Switch 'kubectl edit' to use unstructured objects, handle schemaless objects

mark --output-version as deprecated, add example for fully-qualifying version to edit

Add 'kubectl edit' testcase for editing schemaed and schemaless data together

Add 'kubectl edit' testcase for editing unknown version of known group/kind
pull/6/head
Jordan Liggitt 2017-02-12 19:31:06 -05:00
parent 5b805bc18a
commit ec271f5c09
No known key found for this signature in database
GPG Key ID: 24E7ADF9A3B42012
25 changed files with 622 additions and 133 deletions

View File

@ -46,6 +46,7 @@ import (
"k8s.io/kubernetes/pkg/util/crlf" "k8s.io/kubernetes/pkg/util/crlf"
"k8s.io/kubernetes/pkg/util/i18n" "k8s.io/kubernetes/pkg/util/i18n"
jsonpatch "github.com/evanphx/json-patch"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -61,9 +62,12 @@ var (
accepts filenames as well as command line arguments, although the files you point to must accepts filenames as well as command line arguments, although the files you point to must
be previously saved versions of resources. be previously saved versions of resources.
The files to edit will be output in the default API version, or a version specified Editing is done with the API version used to fetch the resource.
by --output-version. The default format is YAML - if you would like to edit in JSON To edit using a specific API version, fully-qualify the resource, version, and group.
pass -o json. The flag --windows-line-endings can be used to force Windows line endings,
The default format is YAML. To edit in JSON, specify "-o json".
The flag --windows-line-endings can be used to force Windows line endings,
otherwise the default for your operating system will be used. otherwise the default for your operating system will be used.
In the event an error occurs while updating, a temporary file will be created on disk In the event an error occurs while updating, a temporary file will be created on disk
@ -79,8 +83,8 @@ var (
# Use an alternative editor # Use an alternative editor
KUBE_EDITOR="nano" kubectl edit svc/docker-registry KUBE_EDITOR="nano" kubectl edit svc/docker-registry
# Edit the service 'docker-registry' in JSON using the v1 API format: # Edit the job 'myjob' in JSON using the v1 API format:
kubectl edit svc/docker-registry --output-version=v1 -o json`) kubectl edit job.v1.batch/myjob -o json`)
) )
func NewCmdEdit(f cmdutil.Factory, out, errOut io.Writer) *cobra.Command { func NewCmdEdit(f cmdutil.Factory, out, errOut io.Writer) *cobra.Command {
@ -113,7 +117,10 @@ func NewCmdEdit(f cmdutil.Factory, out, errOut io.Writer) *cobra.Command {
cmdutil.AddFilenameOptionFlags(cmd, options, usage) cmdutil.AddFilenameOptionFlags(cmd, options, usage)
cmdutil.AddValidateFlags(cmd) cmdutil.AddValidateFlags(cmd)
cmd.Flags().StringP("output", "o", "yaml", "Output format. One of: yaml|json.") cmd.Flags().StringP("output", "o", "yaml", "Output format. One of: yaml|json.")
cmd.Flags().String("output-version", "", "Output the formatted object with the given group version (for ex: 'extensions/v1beta1').") cmd.Flags().String("output-version", "", "DEPRECATED: To edit using a specific API version, fully-qualify the resource, version, and group (for example: 'jobs.v1.batch/myjob').")
cmd.Flags().MarkDeprecated("output-version", "editing is now done using the resource exactly as fetched from the API. To edit using a specific API version, fully-qualify the resource, version, and group (for example: 'jobs.v1.batch/myjob').")
cmd.Flags().MarkHidden("output-version")
cmd.Flags().Bool("windows-line-endings", gruntime.GOOS == "windows", "Use Windows line-endings (default Unix line-endings)") cmd.Flags().Bool("windows-line-endings", gruntime.GOOS == "windows", "Use Windows line-endings (default Unix line-endings)")
cmdutil.AddApplyAnnotationFlags(cmd) cmdutil.AddApplyAnnotationFlags(cmd)
cmdutil.AddRecordFlag(cmd) cmdutil.AddRecordFlag(cmd)
@ -125,6 +132,10 @@ func RunEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
return runEdit(f, out, errOut, cmd, args, options, NormalEditMode) return runEdit(f, out, errOut, cmd, args, options, NormalEditMode)
} }
// runEdit performs an interactive edit on the resources specified by filename or resource builder args.
// in NormalEditMode, all resources are edited as a single list.
// in CreateEditMode, resources are edited one-by-one.
// TODO: refactor runEdit and editFn into smaller simpler chunks
func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args []string, options *resource.FilenameOptions, editMode EditMode) error { func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args []string, options *resource.FilenameOptions, editMode EditMode) error {
o, err := getPrinter(cmd) o, err := getPrinter(cmd)
if err != nil { if err != nil {
@ -136,22 +147,14 @@ func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
return err return err
} }
clientConfig, err := f.ClientConfig()
if err != nil {
return err
}
encoder := f.JSONEncoder() encoder := f.JSONEncoder()
defaultVersion, err := cmdutil.OutputVersion(cmd, clientConfig.GroupVersion)
if err != nil {
return err
}
var ( var (
windowsLineEndings = cmdutil.GetFlagBool(cmd, "windows-line-endings") windowsLineEndings = cmdutil.GetFlagBool(cmd, "windows-line-endings")
edit = editor.NewDefaultEditor(f.EditorEnvs()) edit = editor.NewDefaultEditor(f.EditorEnvs())
) )
// editFn is invoked for each edit session (once with a list for normal edit, once for each individual resource in a edit-on-create invocation)
editFn := func(infos []*resource.Info) error { editFn := func(infos []*resource.Info) error {
var ( var (
results = editResults{} results = editResults{}
@ -161,13 +164,27 @@ func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
) )
containsError := false containsError := false
for {
originalObj, err := resource.AsVersionedObject(infos, false, defaultVersion, encoder)
if err != nil {
return err
}
objToEdit := originalObj // loop until we succeed or cancel editing
for {
// get the object we're going to serialize as input to the editor
var originalObj runtime.Object
switch len(infos) {
case 1:
originalObj = infos[0].Object
default:
l := &unstructured.UnstructuredList{
Object: map[string]interface{}{
"kind": "List",
"apiVersion": "v1",
"metadata": map[string]interface{}{},
},
}
for _, info := range infos {
l.Items = append(l.Items, info.Object.(*unstructured.Unstructured))
}
originalObj = l
}
// generate the file to edit // generate the file to edit
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
@ -181,7 +198,7 @@ func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
} }
if !containsError { if !containsError {
if err := o.printer.PrintObj(objToEdit, w); err != nil { if err := o.printer.PrintObj(originalObj, w); err != nil {
return preservedFile(err, results.file, errOut) return preservedFile(err, results.file, errOut)
} }
original = buf.Bytes() original = buf.Bytes()
@ -245,39 +262,34 @@ func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
} }
// parse the edited file // parse the edited file
var updatedVisitor resource.Visitor updatedInfos, err := updatedResultsGetter(edited).Infos()
if updatedInfos, err := updatedResultsGetter(edited).Infos(); err != nil { if err != nil {
// syntax error // syntax error
containsError = true containsError = true
results.header.reasons = append(results.header.reasons, editReason{head: fmt.Sprintf("The edited file had a syntax error: %v", err)}) results.header.reasons = append(results.header.reasons, editReason{head: fmt.Sprintf("The edited file had a syntax error: %v", err)})
continue continue
} else {
updatedVisitor = resource.InfoListVisitor(updatedInfos)
} }
// not a syntax error as it turns out... // not a syntax error as it turns out...
containsError = false containsError = false
updatedVisitor := resource.InfoListVisitor(updatedInfos)
namespaceVisitor := updatedVisitor
// need to make sure the original namespace wasn't changed while editing // need to make sure the original namespace wasn't changed while editing
if err = namespaceVisitor.Visit(resource.RequireNamespace(cmdNamespace)); err != nil { if err := updatedVisitor.Visit(resource.RequireNamespace(cmdNamespace)); err != nil {
return preservedFile(err, file, errOut) return preservedFile(err, file, errOut)
} }
// iterate through all items to apply annotations // iterate through all items to apply annotations
annotationVisitor := updatedVisitor if err := visitAnnotation(cmd, f, updatedVisitor, encoder); err != nil {
if _, err := visitAnnotation(cmd, f, annotationVisitor, encoder); err != nil {
return preservedFile(err, file, errOut) return preservedFile(err, file, errOut)
} }
switch editMode { switch editMode {
case NormalEditMode: case NormalEditMode:
patchVisitor := updatedVisitor err = visitToPatch(infos, updatedVisitor, mapper, encoder, out, errOut, &results, file)
err = visitToPatch(originalObj, patchVisitor, mapper, encoder, out, errOut, defaultVersion, &results, file)
case EditBeforeCreateMode: case EditBeforeCreateMode:
createVisitor := updatedVisitor err = visitToCreate(updatedVisitor, mapper, out, errOut, &results, file)
err = visitToCreate(createVisitor, mapper, out, errOut, defaultVersion, &results, file)
default: default:
err = fmt.Errorf("Not supported edit mode %q", editMode) err = fmt.Errorf("Unsupported edit mode %q", editMode)
} }
if err != nil { if err != nil {
return preservedFile(err, results.file, errOut) return preservedFile(err, results.file, errOut)
@ -326,7 +338,7 @@ func runEdit(f cmdutil.Factory, out, errOut io.Writer, cmd *cobra.Command, args
return editFn([]*resource.Info{info}) return editFn([]*resource.Info{info})
}) })
default: default:
return fmt.Errorf("Not supported edit mode %q", editMode) return fmt.Errorf("Unsupported edit mode %q", editMode)
} }
} }
@ -352,36 +364,30 @@ func getPrinter(cmd *cobra.Command) (*editPrinterOptions, error) {
type resultGetter func([]byte) *resource.Result type resultGetter func([]byte) *resource.Result
// getMapperAndResult obtains the initial set of resources to edit, and returns:
// * mapper: restmapper used for printing objects
// * result: initial set of resources to edit. contains latest versions from the server when in normal editing mode
// * resultGetter: function that returns a set of resources parsed from user input. used to get resources from edited file.
// * cmdNamespace: namespace the edit was invoked with. used to verify namespaces don't change during editing.
// * error: any error that occurs fetching initial resources or building results.
func getMapperAndResult(f cmdutil.Factory, args []string, options *resource.FilenameOptions, editMode EditMode) (meta.RESTMapper, *resource.Result, resultGetter, string, error) { func getMapperAndResult(f cmdutil.Factory, args []string, options *resource.FilenameOptions, editMode EditMode) (meta.RESTMapper, *resource.Result, resultGetter, string, error) {
if editMode != NormalEditMode && editMode != EditBeforeCreateMode {
return nil, nil, nil, "", fmt.Errorf("Unsupported edit mode %q", editMode)
}
cmdNamespace, enforceNamespace, err := f.DefaultNamespace() cmdNamespace, enforceNamespace, err := f.DefaultNamespace()
if err != nil { if err != nil {
return nil, nil, nil, "", err return nil, nil, nil, "", err
} }
var mapper meta.RESTMapper mapper, typer, err := f.UnstructuredObject()
var typer runtime.ObjectTyper
switch editMode {
case NormalEditMode:
mapper, typer = f.Object()
case EditBeforeCreateMode:
mapper, typer, err = f.UnstructuredObject()
default:
return nil, nil, nil, "", fmt.Errorf("Not supported edit mode %q", editMode)
}
if err != nil { if err != nil {
return nil, nil, nil, "", err return nil, nil, nil, "", err
} }
// resource builder to read objects from the original file or arg invocation b := resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.UnstructuredClientForMapping), unstructured.UnstructuredJSONScheme)
var b *resource.Builder if editMode == NormalEditMode {
switch editMode { // if in normal mode, also read from args, and fetch latest from the server
case NormalEditMode: b = b.ResourceTypeOrNameArgs(true, args...).Latest()
b = resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.ClientForMapping), f.Decoder(true)).
ResourceTypeOrNameArgs(true, args...).
Latest()
case EditBeforeCreateMode:
b = resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.UnstructuredClientForMapping), unstructured.UnstructuredJSONScheme)
default:
return nil, nil, nil, "", fmt.Errorf("Not supported edit mode %q", editMode)
} }
originalResult := b.NamespaceParam(cmdNamespace).DefaultNamespace(). originalResult := b.NamespaceParam(cmdNamespace).DefaultNamespace().
@ -395,64 +401,48 @@ func getMapperAndResult(f cmdutil.Factory, args []string, options *resource.File
} }
updatedResultGetter := func(data []byte) *resource.Result { updatedResultGetter := func(data []byte) *resource.Result {
// resource builder to read objects from the original file or arg invocation // resource builder to read objects from edited data
var b *resource.Builder return resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.UnstructuredClientForMapping), unstructured.UnstructuredJSONScheme).
switch editMode { Stream(bytes.NewReader(data), "edited-file").
case NormalEditMode: ContinueOnError().
b = resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.ClientForMapping), f.Decoder(true)) Flatten().
case EditBeforeCreateMode: Do()
b = resource.NewBuilder(mapper, typer, resource.ClientMapperFunc(f.UnstructuredClientForMapping), unstructured.UnstructuredJSONScheme)
}
return b.Stream(bytes.NewReader(data), "edited-file").ContinueOnError().Flatten().Do()
} }
return mapper, originalResult, updatedResultGetter, cmdNamespace, err return mapper, originalResult, updatedResultGetter, cmdNamespace, err
} }
func visitToPatch( func visitToPatch(
originalObj runtime.Object, originalInfos []*resource.Info,
patchVisitor resource.Visitor, patchVisitor resource.Visitor,
mapper meta.RESTMapper, mapper meta.RESTMapper,
encoder runtime.Encoder, encoder runtime.Encoder,
out, errOut io.Writer, out, errOut io.Writer,
defaultVersion schema.GroupVersion,
results *editResults, results *editResults,
file string, file string,
) error { ) error {
err := patchVisitor.Visit(func(info *resource.Info, incomingErr error) error { err := patchVisitor.Visit(func(info *resource.Info, incomingErr error) error {
currOriginalObj := originalObj
// if we're editing a list, then navigate the list to find the item that we're currently trying to edit
if meta.IsListType(originalObj) {
currOriginalObj = nil
editObjUID, err := meta.NewAccessor().UID(info.Object) editObjUID, err := meta.NewAccessor().UID(info.Object)
if err != nil { if err != nil {
return err return err
} }
listItems, err := meta.ExtractList(originalObj) var originalInfo *resource.Info
if err != nil { for _, i := range originalInfos {
return err originalObjUID, err := meta.NewAccessor().UID(i.Object)
}
// iterate through the list to find the item with the matching UID
for i := range listItems {
originalObjUID, err := meta.NewAccessor().UID(listItems[i])
if err != nil { if err != nil {
return err return err
} }
if editObjUID == originalObjUID { if editObjUID == originalObjUID {
currOriginalObj = listItems[i] originalInfo = i
break break
} }
} }
if currOriginalObj == nil { if originalInfo == nil {
return fmt.Errorf("no original object found for %#v", info.Object) return fmt.Errorf("no original object found for %#v", info.Object)
} }
} originalSerialization, err := runtime.Encode(encoder, originalInfo.Object)
originalSerialization, err := runtime.Encode(encoder, currOriginalObj)
if err != nil { if err != nil {
return err return err
} }
@ -478,9 +468,37 @@ func visitToPatch(
return nil return nil
} }
preconditions := []mergepatch.PreconditionFunc{mergepatch.RequireKeyUnchanged("apiVersion"), preconditions := []mergepatch.PreconditionFunc{
mergepatch.RequireKeyUnchanged("kind"), mergepatch.RequireMetadataKeyUnchanged("name")} mergepatch.RequireKeyUnchanged("apiVersion"),
patch, err := strategicpatch.CreateTwoWayMergePatch(originalJS, editedJS, currOriginalObj, preconditions...) mergepatch.RequireKeyUnchanged("kind"),
mergepatch.RequireMetadataKeyUnchanged("name"),
}
// Create the versioned struct from the type defined in the mapping
// (which is the API version we'll be submitting the patch to)
versionedObject, err := api.Scheme.New(info.Mapping.GroupVersionKind)
var patchType types.PatchType
var patch []byte
switch {
case runtime.IsNotRegisteredError(err):
// fall back to generic JSON merge patch
patchType = types.MergePatchType
patch, err = jsonpatch.CreateMergePatch(originalJS, editedJS)
if err != nil {
glog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err)
return err
}
for _, precondition := range preconditions {
if !precondition(patch) {
glog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err)
return fmt.Errorf("%s", "At least one of apiVersion, kind and name was changed")
}
}
case err != nil:
return err
default:
patchType = types.StrategicMergePatchType
patch, err = strategicpatch.CreateTwoWayMergePatch(originalJS, editedJS, versionedObject, preconditions...)
if err != nil { if err != nil {
glog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err) glog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err)
if mergepatch.IsPreconditionFailed(err) { if mergepatch.IsPreconditionFailed(err) {
@ -488,9 +506,9 @@ func visitToPatch(
} }
return err return err
} }
}
results.version = defaultVersion patched, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, patchType, patch)
patched, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch)
if err != nil { if err != nil {
fmt.Fprintln(errOut, results.addError(err, info)) fmt.Fprintln(errOut, results.addError(err, info))
return nil return nil
@ -502,9 +520,8 @@ func visitToPatch(
return err return err
} }
func visitToCreate(createVisitor resource.Visitor, mapper meta.RESTMapper, out, errOut io.Writer, defaultVersion schema.GroupVersion, results *editResults, file string) error { func visitToCreate(createVisitor resource.Visitor, mapper meta.RESTMapper, out, errOut io.Writer, results *editResults, file string) error {
err := createVisitor.Visit(func(info *resource.Info, incomingErr error) error { err := createVisitor.Visit(func(info *resource.Info, incomingErr error) error {
results.version = defaultVersion
if err := createAndRefresh(info); err != nil { if err := createAndRefresh(info); err != nil {
return err return err
} }
@ -514,8 +531,7 @@ func visitToCreate(createVisitor resource.Visitor, mapper meta.RESTMapper, out,
return err return err
} }
func visitAnnotation(cmd *cobra.Command, f cmdutil.Factory, annotationVisitor resource.Visitor, encoder runtime.Encoder) ([]runtime.Object, error) { func visitAnnotation(cmd *cobra.Command, f cmdutil.Factory, annotationVisitor resource.Visitor, encoder runtime.Encoder) error {
mutatedObjects := []runtime.Object{}
// iterate through all items to apply annotations // iterate through all items to apply annotations
err := annotationVisitor.Visit(func(info *resource.Info, incomingErr error) error { err := annotationVisitor.Visit(func(info *resource.Info, incomingErr error) error {
// put configuration annotation in "updates" // put configuration annotation in "updates"
@ -527,12 +543,9 @@ func visitAnnotation(cmd *cobra.Command, f cmdutil.Factory, annotationVisitor re
return err return err
} }
} }
mutatedObjects = append(mutatedObjects, info.Object)
return nil return nil
}) })
return mutatedObjects, err return err
} }
type EditMode string type EditMode string

View File

@ -205,22 +205,8 @@ func TestEdit(t *testing.T) {
t.Fatalf("%s: %v", name, err) t.Fatalf("%s: %v", name, err)
} }
f, tf, _, ns := cmdtesting.NewAPIFactory() f, tf, _, _ := cmdtesting.NewAPIFactory()
tf.Printer = &testPrinter{} tf.Printer = &testPrinter{}
tf.ClientForMappingFunc = func(mapping *meta.RESTMapping) (resource.RESTClient, error) {
versionedAPIPath := ""
if mapping.GroupVersionKind.Group == "" {
versionedAPIPath = "/api/" + mapping.GroupVersionKind.Version
} else {
versionedAPIPath = "/apis/" + mapping.GroupVersionKind.Group + "/" + mapping.GroupVersionKind.Version
}
return &fake.RESTClient{
APIRegistry: api.Registry,
VersionedAPIPath: versionedAPIPath,
NegotiatedSerializer: ns, //unstructuredSerializer,
Client: fake.CreateHTTPClient(reqResp),
}, nil
}
tf.UnstructuredClientForMappingFunc = func(mapping *meta.RESTMapping) (resource.RESTClient, error) { tf.UnstructuredClientForMappingFunc = func(mapping *meta.RESTMapping) (resource.RESTClient, error) {
versionedAPIPath := "" versionedAPIPath := ""
if mapping.GroupVersionKind.Group == "" { if mapping.GroupVersionKind.Group == "" {

View File

@ -0,0 +1,32 @@
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "kubernetes",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/services/kubernetes",
"uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87",
"resourceVersion": "16953",
"creationTimestamp": "2017-02-12T20:11:19Z",
"labels": {
"component": "apiserver",
"provider": "kubernetes"
}
},
"spec": {
"ports": [
{
"name": "https",
"protocol": "TCP",
"port": 443,
"targetPort": 443
}
],
"clusterIP": "10.0.0.1",
"type": "ClusterIP",
"sessionAffinity": "ClientIP"
},
"status": {
"loadBalancer": {}
}
}

View File

@ -0,0 +1,16 @@
{
"apiVersion": "company.com/v1",
"kind": "Bar",
"metadata": {
"name": "test",
"namespace": "default",
"selfLink": "/apis/company.com/v1/namespaces/default/bars/test",
"uid": "fd16c23d-f185-11e6-b041-acbc32c1ca87",
"resourceVersion": "16954",
"creationTimestamp": "2017-02-13T00:47:26Z"
},
"some-field": "field1",
"third-field": {
"sub-field": "bar2"
}
}

View File

@ -0,0 +1,21 @@
{
"apiVersion": "company.com/v1",
"field1": "value1",
"field2": true,
"field3": [
1
],
"field4": {
"a": true,
"b": false
},
"kind": "Bar",
"metadata": {
"name": "test2",
"namespace": "default",
"selfLink": "/apis/company.com/v1/namespaces/default/bars/test2",
"uid": "5ef5b446-f186-11e6-b041-acbc32c1ca87",
"resourceVersion": "16955",
"creationTimestamp": "2017-02-13T00:50:10Z"
}
}

View File

@ -0,0 +1,62 @@
# 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: v1
items:
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: 2017-02-12T20:11:19Z
labels:
component: apiserver
provider: kubernetes
new-label: new-value
name: kubernetes
namespace: default
resourceVersion: "16953"
selfLink: /api/v1/namespaces/default/services/kubernetes
uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87
spec:
clusterIP: 10.0.0.1
ports:
- name: https
port: 443
protocol: TCP
targetPort: 443
sessionAffinity: ClientIP
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: company.com/v1
kind: Bar
metadata:
creationTimestamp: 2017-02-13T00:47:26Z
name: test
namespace: default
resourceVersion: "16954"
selfLink: /apis/company.com/v1/namespaces/default/bars/test
uid: fd16c23d-f185-11e6-b041-acbc32c1ca87
some-field: field1
other-field: other-value
third-field:
sub-field: bar2
- apiVersion: company.com/v1
field1: value1
field2: true
field3:
- 1
- 2
field4:
a: true
b: false
kind: Bar
metadata:
creationTimestamp: 2017-02-13T00:50:10Z
name: test2
namespace: default
resourceVersion: "16955"
selfLink: /apis/company.com/v1/namespaces/default/bars/test2
uid: 5ef5b446-f186-11e6-b041-acbc32c1ca87
kind: List
metadata: {}

View File

@ -0,0 +1,59 @@
# 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: v1
items:
- apiVersion: v1
kind: Service
metadata:
creationTimestamp: 2017-02-12T20:11:19Z
labels:
component: apiserver
provider: kubernetes
name: kubernetes
namespace: default
resourceVersion: "16953"
selfLink: /api/v1/namespaces/default/services/kubernetes
uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87
spec:
clusterIP: 10.0.0.1
ports:
- name: https
port: 443
protocol: TCP
targetPort: 443
sessionAffinity: ClientIP
type: ClusterIP
status:
loadBalancer: {}
- apiVersion: company.com/v1
kind: Bar
metadata:
creationTimestamp: 2017-02-13T00:47:26Z
name: test
namespace: default
resourceVersion: "16954"
selfLink: /apis/company.com/v1/namespaces/default/bars/test
uid: fd16c23d-f185-11e6-b041-acbc32c1ca87
some-field: field1
third-field:
sub-field: bar2
- apiVersion: company.com/v1
field1: value1
field2: true
field3:
- 1
field4:
a: true
b: false
kind: Bar
metadata:
creationTimestamp: 2017-02-13T00:50:10Z
name: test2
namespace: default
resourceVersion: "16955"
selfLink: /apis/company.com/v1/namespaces/default/bars/test2
uid: 5ef5b446-f186-11e6-b041-acbc32c1ca87
kind: List
metadata: {}

View File

@ -0,0 +1,7 @@
{
"metadata": {
"labels": {
"new-label": "new-value"
}
}
}

View File

@ -0,0 +1,33 @@
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "kubernetes",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/services/kubernetes",
"uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87",
"resourceVersion": "17087",
"creationTimestamp": "2017-02-12T20:11:19Z",
"labels": {
"component": "apiserver",
"new-label": "new-value",
"provider": "kubernetes"
}
},
"spec": {
"ports": [
{
"name": "https",
"protocol": "TCP",
"port": 443,
"targetPort": 443
}
],
"clusterIP": "10.0.0.1",
"type": "ClusterIP",
"sessionAffinity": "ClientIP"
},
"status": {
"loadBalancer": {}
}
}

View File

@ -0,0 +1,3 @@
{
"other-field": "other-value"
}

View File

@ -0,0 +1,17 @@
{
"apiVersion": "company.com/v1",
"kind": "Bar",
"metadata": {
"name": "test",
"namespace": "default",
"selfLink": "/apis/company.com/v1/namespaces/default/bars/test",
"uid": "fd16c23d-f185-11e6-b041-acbc32c1ca87",
"resourceVersion": "17088",
"creationTimestamp": "2017-02-13T00:47:26Z"
},
"other-field": "other-value",
"some-field": "field1",
"third-field": {
"sub-field": "bar2"
}
}

View File

@ -0,0 +1,6 @@
{
"field3": [
1,
2
]
}

View File

@ -0,0 +1,22 @@
{
"apiVersion": "company.com/v1",
"field1": "value1",
"field2": true,
"field3": [
1,
2
],
"field4": {
"a": true,
"b": false
},
"kind": "Bar",
"metadata": {
"name": "test2",
"namespace": "default",
"selfLink": "/apis/company.com/v1/namespaces/default/bars/test2",
"uid": "5ef5b446-f186-11e6-b041-acbc32c1ca87",
"resourceVersion": "17089",
"creationTimestamp": "2017-02-13T00:50:10Z"
}
}

View File

@ -0,0 +1,55 @@
description: edit a mix of schema and schemaless data
mode: edit
args:
- service/kubernetes
- bars/test
- bars/test2
namespace: default
expectedStdout:
- "service \"kubernetes\" edited"
- "bar \"test\" edited"
- "bar \"test2\" edited"
expectedExitCode: 0
steps:
- type: request
expectedMethod: GET
expectedPath: /api/v1/namespaces/default/services/kubernetes
expectedInput: 0.request
resultingStatusCode: 200
resultingOutput: 0.response
- type: request
expectedMethod: GET
expectedPath: /apis/company.com/v1/namespaces/default/bars/test
expectedInput: 1.request
resultingStatusCode: 200
resultingOutput: 1.response
- type: request
expectedMethod: GET
expectedPath: /apis/company.com/v1/namespaces/default/bars/test2
expectedInput: 2.request
resultingStatusCode: 200
resultingOutput: 2.response
- type: edit
expectedInput: 3.original
resultingOutput: 3.edited
- type: request
expectedMethod: PATCH
expectedPath: /api/v1/namespaces/default/services/kubernetes
expectedContentType: application/strategic-merge-patch+json
expectedInput: 4.request
resultingStatusCode: 200
resultingOutput: 4.response
- type: request
expectedMethod: PATCH
expectedPath: /apis/company.com/v1/namespaces/default/bars/test
expectedContentType: application/merge-patch+json
expectedInput: 5.request
resultingStatusCode: 200
resultingOutput: 5.response
- type: request
expectedMethod: PATCH
expectedPath: /apis/company.com/v1/namespaces/default/bars/test2
expectedContentType: application/merge-patch+json
expectedInput: 6.request
resultingStatusCode: 200
resultingOutput: 6.response

View File

@ -0,0 +1,22 @@
{
"kind": "StorageClass",
"apiVersion": "storage.k8s.io/v0",
"metadata": {
"name": "foo",
"selfLink": "/apis/storage.k8s.io/v0/storageclassesfoo",
"uid": "b2287558-f190-11e6-b041-acbc32c1ca87",
"resourceVersion": "21388",
"creationTimestamp": "2017-02-13T02:04:04Z",
"labels": {
"label1": "value1"
}
},
"provisioner": "foo",
"parameters": {
"baz": "qux",
"foo": "bar"
},
"extraField": {
"otherData": true
}
}

View File

@ -0,0 +1,22 @@
# 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: storage.k8s.io/v0
extraField:
otherData: true
addedData: "foo"
kind: StorageClass
metadata:
creationTimestamp: 2017-02-13T02:04:04Z
labels:
label1: value1
label2: value2
name: foo
resourceVersion: "21388"
selfLink: /apis/storage.k8s.io/v0/storageclassesfoo
uid: b2287558-f190-11e6-b041-acbc32c1ca87
parameters:
baz: qux
foo: bar
provisioner: foo

View File

@ -0,0 +1,20 @@
# 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: storage.k8s.io/v0
extraField:
otherData: true
kind: StorageClass
metadata:
creationTimestamp: 2017-02-13T02:04:04Z
labels:
label1: value1
name: foo
resourceVersion: "21388"
selfLink: /apis/storage.k8s.io/v0/storageclassesfoo
uid: b2287558-f190-11e6-b041-acbc32c1ca87
parameters:
baz: qux
foo: bar
provisioner: foo

View File

@ -0,0 +1,11 @@
{
"extraField": {
"addedData": "foo"
},
"metadata": {
"labels": {
"label2": "value2"
},
"namespace": ""
}
}

View File

@ -0,0 +1,24 @@
{
"kind": "StorageClass",
"apiVersion": "storage.k8s.io/v0",
"metadata": {
"name": "foo",
"selfLink": "/apis/storage.k8s.io/v0/storageclassesfoo",
"uid": "b2287558-f190-11e6-b041-acbc32c1ca87",
"resourceVersion": "21431",
"creationTimestamp": "2017-02-13T02:04:04Z",
"labels": {
"label1": "value1",
"label2": "value2"
}
},
"provisioner": "foo",
"parameters": {
"baz": "qux",
"foo": "bar"
},
"extraField": {
"otherData": true,
"addedData": true
}
}

View File

@ -0,0 +1,25 @@
description: edit an unknown version of a known group/kind
mode: edit
args:
- storageclasses.v0.storage.k8s.io/foo
namespace: default
expectedStdout:
- "storageclass \"foo\" edited"
expectedExitCode: 0
steps:
- type: request
expectedMethod: GET
expectedPath: /apis/storage.k8s.io/v0/storageclasses/foo
expectedInput: 0.request
resultingStatusCode: 200
resultingOutput: 0.response
- type: edit
expectedInput: 1.original
resultingOutput: 1.edited
- type: request
expectedMethod: PATCH
expectedPath: /apis/storage.k8s.io/v0/storageclasses/foo
expectedContentType: application/merge-patch+json
expectedInput: 2.request
resultingStatusCode: 200
resultingOutput: 2.response

View File

@ -719,5 +719,38 @@ func testDynamicResources() []*discovery.APIGroupResources {
}, },
}, },
}, },
{
Group: metav1.APIGroup{
Name: "storage.k8s.io",
Versions: []metav1.GroupVersionForDiscovery{
{Version: "v1beta1"},
{Version: "v0"},
},
PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1beta1"},
},
VersionedResources: map[string][]metav1.APIResource{
"v1beta1": {
{Name: "storageclasses", Namespaced: false, Kind: "StorageClass"},
},
// bogus version of a known group/version/resource to make sure kubectl falls back to generic object mode
"v0": {
{Name: "storageclasses", Namespaced: false, Kind: "StorageClass"},
},
},
},
{
Group: metav1.APIGroup{
Name: "company.com",
Versions: []metav1.GroupVersionForDiscovery{
{Version: "v1"},
},
PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"},
},
VersionedResources: map[string][]metav1.APIResource{
"v1": {
{Name: "bars", Namespaced: true, Kind: "Bar"},
},
},
},
} }
} }