From 7e6321347841176d29684b808577e2964906cbf0 Mon Sep 17 00:00:00 2001 From: Janet Kuo Date: Thu, 23 Jul 2015 15:43:48 -0700 Subject: [PATCH] Implement kubectl annotation update command. Refactor kubectl annotate to decouple command framework from business logic. --- contrib/completions/bash/kubectl | 27 + docs/man/man1/.files_generated | 1 + docs/man/man1/kubectl-annotate.1 | 197 ++++++++ docs/man/man1/kubectl.1 | 2 +- docs/user-guide/kubectl/.files_generated | 1 + docs/user-guide/kubectl/kubectl.md | 1 + docs/user-guide/kubectl/kubectl_annotate.md | 128 +++++ pkg/kubectl/cmd/annotation.go | 278 +++++++++++ pkg/kubectl/cmd/annotation_test.go | 515 ++++++++++++++++++++ pkg/kubectl/cmd/cmd.go | 1 + pkg/kubectl/cmd/label.go | 35 +- pkg/kubectl/cmd/label_test.go | 6 +- pkg/kubectl/cmd/util/helpers.go | 21 + 13 files changed, 1182 insertions(+), 31 deletions(-) create mode 100644 docs/man/man1/kubectl-annotate.1 create mode 100644 docs/user-guide/kubectl/kubectl_annotate.md create mode 100644 pkg/kubectl/cmd/annotation.go create mode 100644 pkg/kubectl/cmd/annotation_test.go diff --git a/contrib/completions/bash/kubectl b/contrib/completions/bash/kubectl index 6799502026c..5f58d3f9dc1 100644 --- a/contrib/completions/bash/kubectl +++ b/contrib/completions/bash/kubectl @@ -735,6 +735,32 @@ _kubectl_label() must_have_one_noun=() } +_kubectl_annotate() +{ + last_command="kubectl_annotate" + commands=() + + flags=() + two_word_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--all") + flags+=("--help") + flags+=("-h") + flags+=("--no-headers") + flags+=("--output=") + two_word_flags+=("-o") + flags+=("--output-version=") + flags+=("--overwrite") + flags+=("--resource-version=") + flags+=("--template=") + two_word_flags+=("-t") + + must_have_one_flag=() + must_have_one_noun=() +} + _kubectl_config_view() { last_command="kubectl_config_view" @@ -978,6 +1004,7 @@ _kubectl() commands+=("stop") commands+=("expose") commands+=("label") + commands+=("annotate") commands+=("config") commands+=("cluster-info") commands+=("api-versions") diff --git a/docs/man/man1/.files_generated b/docs/man/man1/.files_generated index 76219f56480..01dd18c9c15 100644 --- a/docs/man/man1/.files_generated +++ b/docs/man/man1/.files_generated @@ -1,3 +1,4 @@ +kubectl-annotate.1 kubectl-api-versions.1 kubectl-attach.1 kubectl-cluster-info.1 diff --git a/docs/man/man1/kubectl-annotate.1 b/docs/man/man1/kubectl-annotate.1 new file mode 100644 index 00000000000..a671cba6a4d --- /dev/null +++ b/docs/man/man1/kubectl-annotate.1 @@ -0,0 +1,197 @@ +.TH "KUBERNETES" "1" " kubernetes User Manuals" "Eric Paris" "Jan 2015" "" + + +.SH NAME +.PP +kubectl annotate \- Update the annotations on a resource + + +.SH SYNOPSIS +.PP +\fBkubectl annotate\fP [OPTIONS] + + +.SH DESCRIPTION +.PP +Update the annotations on one or more resources. + +.PP +An annotation is a key/value pair that can hold larger (compared to a label), and possibly not human\-readable, data. +It is intended to store non\-identifying auxiliary data, especially data manipulated by tools and system extensions. +If \-\-overwrite is true, then existing annotations can be overwritten, otherwise attempting to overwrite an annotation will result in an error. +If \-\-resource\-version is specified, then updates will use this resource version, otherwise the existing resource\-version will be used. + +.PP +Possible resources include (case insensitive): pods (po), services (svc), +replicationcontrollers (rc), nodes (no), events (ev), componentstatuses (cs), +limitranges (limits), persistentvolumes (pv), persistentvolumeclaims (pvc), +resourcequotas (quota) or secrets. + + +.SH OPTIONS +.PP +\fB\-\-all\fP=false + select all resources in the namespace of the specified resource types + +.PP +\fB\-h\fP, \fB\-\-help\fP=false + help for annotate + +.PP +\fB\-\-no\-headers\fP=false + When using the default output, don't print headers. + +.PP +\fB\-o\fP, \fB\-\-output\fP="" + Output format. One of: json|yaml|template|templatefile|wide. + +.PP +\fB\-\-output\-version\fP="" + Output the formatted object with the given version (default api\-version). + +.PP +\fB\-\-overwrite\fP=false + If true, allow annotations to be overwritten, otherwise reject annotation updates that overwrite existing annotations. + +.PP +\fB\-\-resource\-version\fP="" + If non\-empty, the annotation update will only succeed if this is the current resource\-version for the object. Only valid when specifying a single resource. + +.PP +\fB\-t\fP, \fB\-\-template\fP="" + Template string or path to template file to use when \-o=template or \-o=templatefile. The template format is golang templates [ +\[la]http://golang.org/pkg/text/template/#pkg-overview\[ra]] + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +.PP +\fB\-\-alsologtostderr\fP=false + log to standard error as well as files + +.PP +\fB\-\-api\-version\fP="" + The API version to use when talking to the server + +.PP +\fB\-\-certificate\-authority\fP="" + Path to a cert. file for the certificate authority. + +.PP +\fB\-\-client\-certificate\fP="" + Path to a client key file for TLS. + +.PP +\fB\-\-client\-key\fP="" + Path to a client key file for TLS. + +.PP +\fB\-\-cluster\fP="" + The name of the kubeconfig cluster to use + +.PP +\fB\-\-context\fP="" + The name of the kubeconfig context to use + +.PP +\fB\-\-insecure\-skip\-tls\-verify\fP=false + If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure. + +.PP +\fB\-\-kubeconfig\fP="" + Path to the kubeconfig file to use for CLI requests. + +.PP +\fB\-\-log\-backtrace\-at\fP=:0 + when logging hits line file:N, emit a stack trace + +.PP +\fB\-\-log\-dir\fP="" + If non\-empty, write log files in this directory + +.PP +\fB\-\-log\-flush\-frequency\fP=5s + Maximum number of seconds between log flushes + +.PP +\fB\-\-logtostderr\fP=true + log to standard error instead of files + +.PP +\fB\-\-match\-server\-version\fP=false + Require server version to match client version + +.PP +\fB\-\-namespace\fP="" + If present, the namespace scope for this CLI request. + +.PP +\fB\-\-password\fP="" + Password for basic authentication to the API server. + +.PP +\fB\-s\fP, \fB\-\-server\fP="" + The address and port of the Kubernetes API server + +.PP +\fB\-\-stderrthreshold\fP=2 + logs at or above this threshold go to stderr + +.PP +\fB\-\-token\fP="" + Bearer token for authentication to the API server. + +.PP +\fB\-\-user\fP="" + The name of the kubeconfig user to use + +.PP +\fB\-\-username\fP="" + Username for basic authentication to the API server. + +.PP +\fB\-\-v\fP=0 + log level for V logs + +.PP +\fB\-\-validate\fP=false + If true, use a schema to validate the input before sending it + +.PP +\fB\-\-vmodule\fP= + comma\-separated list of pattern=N settings for file\-filtered logging + + +.SH EXAMPLE +.PP +.RS + +.nf +# Update pod 'foo' with the annotation 'description' and the value 'my frontend'. +# If the same annotation is set multiple times, only the last value will be applied +$ kubectl annotate pods foo description='my frontend' + +# Update pod 'foo' with the annotation 'description' and the value 'my frontend running nginx', overwriting any existing value. +$ kubectl annotate \-\-overwrite pods foo description='my frontend running nginx' + +# Update all pods in the namespace +$ kubectl annotate pods \-\-all description='my frontend running nginx' + +# Update pod 'foo' only if the resource is unchanged from version 1. +$ kubectl annotate pods foo description='my frontend running nginx' \-\-resource\-version=1 + +# Update pod 'foo' by removing an annotation named 'description' if it exists. +# Does not require the \-\-overwrite flag. +$ kubectl annotate pods foo description\- + +.fi +.RE + + +.SH SEE ALSO +.PP +\fBkubectl(1)\fP, + + +.SH HISTORY +.PP +January 2015, Originally compiled by Eric Paris (eparis at redhat dot com) based on the kubernetes source material, but hopefully they have been automatically generated since! diff --git a/docs/man/man1/kubectl.1 b/docs/man/man1/kubectl.1 index e2dd746bd6d..83f5fcdf128 100644 --- a/docs/man/man1/kubectl.1 +++ b/docs/man/man1/kubectl.1 @@ -124,7 +124,7 @@ Find more information at .SH SEE ALSO .PP -\fBkubectl\-get(1)\fP, \fBkubectl\-describe(1)\fP, \fBkubectl\-create(1)\fP, \fBkubectl\-replace(1)\fP, \fBkubectl\-patch(1)\fP, \fBkubectl\-delete(1)\fP, \fBkubectl\-namespace(1)\fP, \fBkubectl\-logs(1)\fP, \fBkubectl\-rolling\-update(1)\fP, \fBkubectl\-scale(1)\fP, \fBkubectl\-attach(1)\fP, \fBkubectl\-exec(1)\fP, \fBkubectl\-port\-forward(1)\fP, \fBkubectl\-proxy(1)\fP, \fBkubectl\-run(1)\fP, \fBkubectl\-stop(1)\fP, \fBkubectl\-expose(1)\fP, \fBkubectl\-label(1)\fP, \fBkubectl\-config(1)\fP, \fBkubectl\-cluster\-info(1)\fP, \fBkubectl\-api\-versions(1)\fP, \fBkubectl\-version(1)\fP, +\fBkubectl\-get(1)\fP, \fBkubectl\-describe(1)\fP, \fBkubectl\-create(1)\fP, \fBkubectl\-replace(1)\fP, \fBkubectl\-patch(1)\fP, \fBkubectl\-delete(1)\fP, \fBkubectl\-namespace(1)\fP, \fBkubectl\-logs(1)\fP, \fBkubectl\-rolling\-update(1)\fP, \fBkubectl\-scale(1)\fP, \fBkubectl\-attach(1)\fP, \fBkubectl\-exec(1)\fP, \fBkubectl\-port\-forward(1)\fP, \fBkubectl\-proxy(1)\fP, \fBkubectl\-run(1)\fP, \fBkubectl\-stop(1)\fP, \fBkubectl\-expose(1)\fP, \fBkubectl\-label(1)\fP, \fBkubectl\-annotate(1)\fP, \fBkubectl\-config(1)\fP, \fBkubectl\-cluster\-info(1)\fP, \fBkubectl\-api\-versions(1)\fP, \fBkubectl\-version(1)\fP, .SH HISTORY diff --git a/docs/user-guide/kubectl/.files_generated b/docs/user-guide/kubectl/.files_generated index 73e9a345778..815195aca62 100644 --- a/docs/user-guide/kubectl/.files_generated +++ b/docs/user-guide/kubectl/.files_generated @@ -1,4 +1,5 @@ kubectl.md +kubectl_annotate.md kubectl_api-versions.md kubectl_attach.md kubectl_cluster-info.md diff --git a/docs/user-guide/kubectl/kubectl.md b/docs/user-guide/kubectl/kubectl.md index 55206067620..ffeac431eb0 100644 --- a/docs/user-guide/kubectl/kubectl.md +++ b/docs/user-guide/kubectl/kubectl.md @@ -78,6 +78,7 @@ kubectl ### SEE ALSO +* [kubectl annotate](kubectl_annotate.md) - Update the annotations on a resource * [kubectl api-versions](kubectl_api-versions.md) - Print available API versions. * [kubectl attach](kubectl_attach.md) - Attach to a running container. * [kubectl cluster-info](kubectl_cluster-info.md) - Display cluster info diff --git a/docs/user-guide/kubectl/kubectl_annotate.md b/docs/user-guide/kubectl/kubectl_annotate.md new file mode 100644 index 00000000000..bb820a6d2cf --- /dev/null +++ b/docs/user-guide/kubectl/kubectl_annotate.md @@ -0,0 +1,128 @@ + + + + +WARNING +WARNING +WARNING +WARNING +WARNING + +

PLEASE NOTE: This document applies to the HEAD of the source tree

+ +If you are using a released version of Kubernetes, you should +refer to the docs that go with that version. + + +The latest 1.0.x release of this document can be found +[here](http://releases.k8s.io/release-1.0/docs/user-guide/kubectl/kubectl_annotate.md). + +Documentation for other releases can be found at +[releases.k8s.io](http://releases.k8s.io). + +-- + + + + + +## kubectl annotate + +Update the annotations on a resource + +### Synopsis + + +Update the annotations on one or more resources. + +An annotation is a key/value pair that can hold larger (compared to a label), and possibly not human-readable, data. +It is intended to store non-identifying auxiliary data, especially data manipulated by tools and system extensions. +If --overwrite is true, then existing annotations can be overwritten, otherwise attempting to overwrite an annotation will result in an error. +If --resource-version is specified, then updates will use this resource version, otherwise the existing resource-version will be used. + +Possible resources include (case insensitive): pods (po), services (svc), +replicationcontrollers (rc), nodes (no), events (ev), componentstatuses (cs), +limitranges (limits), persistentvolumes (pv), persistentvolumeclaims (pvc), +resourcequotas (quota) or secrets. + +``` +kubectl annotate [--overwrite] RESOURCE NAME KEY_1=VAL_1 ... KEY_N=VAL_N [--resource-version=version] +``` + +### Examples + +``` +# Update pod 'foo' with the annotation 'description' and the value 'my frontend'. +# If the same annotation is set multiple times, only the last value will be applied +$ kubectl annotate pods foo description='my frontend' + +# Update pod 'foo' with the annotation 'description' and the value 'my frontend running nginx', overwriting any existing value. +$ kubectl annotate --overwrite pods foo description='my frontend running nginx' + +# Update all pods in the namespace +$ kubectl annotate pods --all description='my frontend running nginx' + +# Update pod 'foo' only if the resource is unchanged from version 1. +$ kubectl annotate pods foo description='my frontend running nginx' --resource-version=1 + +# Update pod 'foo' by removing an annotation named 'description' if it exists. +# Does not require the --overwrite flag. +$ kubectl annotate pods foo description- +``` + +### Options + +``` + --all=false: select all resources in the namespace of the specified resource types + -h, --help=false: help for annotate + --no-headers=false: When using the default output, don't print headers. + -o, --output="": Output format. One of: json|yaml|template|templatefile|wide. + --output-version="": Output the formatted object with the given version (default api-version). + --overwrite=false: If true, allow annotations to be overwritten, otherwise reject annotation updates that overwrite existing annotations. + --resource-version="": If non-empty, the annotation update will only succeed if this is the current resource-version for the object. Only valid when specifying a single resource. + -t, --template="": Template string or path to template file to use when -o=template or -o=templatefile. The template format is golang templates [http://golang.org/pkg/text/template/#pkg-overview] +``` + +### Options inherited from parent commands + +``` + --alsologtostderr=false: log to standard error as well as files + --api-version="": The API version to use when talking to the server + --certificate-authority="": Path to a cert. file for the certificate authority. + --client-certificate="": Path to a client key file for TLS. + --client-key="": Path to a client key file for TLS. + --cluster="": The name of the kubeconfig cluster to use + --context="": The name of the kubeconfig context to use + --insecure-skip-tls-verify=false: If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure. + --kubeconfig="": Path to the kubeconfig file to use for CLI requests. + --log-backtrace-at=:0: when logging hits line file:N, emit a stack trace + --log-dir=: If non-empty, write log files in this directory + --log-flush-frequency=5s: Maximum number of seconds between log flushes + --logtostderr=true: log to standard error instead of files + --match-server-version=false: Require server version to match client version + --namespace="": If present, the namespace scope for this CLI request. + --password="": Password for basic authentication to the API server. + -s, --server="": The address and port of the Kubernetes API server + --stderrthreshold=2: logs at or above this threshold go to stderr + --token="": Bearer token for authentication to the API server. + --user="": The name of the kubeconfig user to use + --username="": Username for basic authentication to the API server. + --v=0: log level for V logs + --validate=false: If true, use a schema to validate the input before sending it + --vmodule=: comma-separated list of pattern=N settings for file-filtered logging +``` + +### SEE ALSO + +* [kubectl](kubectl.md) - kubectl controls the Kubernetes cluster manager + +###### Auto generated by spf13/cobra at 2015-08-03 21:33:00.41118358 +0000 UTC + + +[![Analytics](https://kubernetes-site.appspot.com/UA-36037335-10/GitHub/docs/user-guide/kubectl/kubectl_annotate.md?pixel)]() + diff --git a/pkg/kubectl/cmd/annotation.go b/pkg/kubectl/cmd/annotation.go new file mode 100644 index 00000000000..f988a06a41b --- /dev/null +++ b/pkg/kubectl/cmd/annotation.go @@ -0,0 +1,278 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + cmdutil "github.com/GoogleCloudPlatform/kubernetes/pkg/kubectl/cmd/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/kubectl/resource" + "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" + "github.com/spf13/cobra" +) + +// AnnotateOptions have the data required to perform the annotate operation +type AnnotateOptions struct { + out io.Writer + resources []string + newAnnotations map[string]string + removeAnnotations []string + builder *resource.Builder + + overwrite bool + all bool + resourceVersion string +} + +const ( + annotate_long = `Update the annotations on one or more resources. + +An annotation is a key/value pair that can hold larger (compared to a label), and possibly not human-readable, data. +It is intended to store non-identifying auxiliary data, especially data manipulated by tools and system extensions. +If --overwrite is true, then existing annotations can be overwritten, otherwise attempting to overwrite an annotation will result in an error. +If --resource-version is specified, then updates will use this resource version, otherwise the existing resource-version will be used. + +Possible resources include (case insensitive): pods (po), services (svc), +replicationcontrollers (rc), nodes (no), events (ev), componentstatuses (cs), +limitranges (limits), persistentvolumes (pv), persistentvolumeclaims (pvc), +resourcequotas (quota) or secrets.` + annotate_example = `# Update pod 'foo' with the annotation 'description' and the value 'my frontend'. +# If the same annotation is set multiple times, only the last value will be applied +$ kubectl annotate pods foo description='my frontend' + +# Update pod 'foo' with the annotation 'description' and the value 'my frontend running nginx', overwriting any existing value. +$ kubectl annotate --overwrite pods foo description='my frontend running nginx' + +# Update all pods in the namespace +$ kubectl annotate pods --all description='my frontend running nginx' + +# Update pod 'foo' only if the resource is unchanged from version 1. +$ kubectl annotate pods foo description='my frontend running nginx' --resource-version=1 + +# Update pod 'foo' by removing an annotation named 'description' if it exists. +# Does not require the --overwrite flag. +$ kubectl annotate pods foo description-` +) + +func NewCmdAnnotate(f *cmdutil.Factory, out io.Writer) *cobra.Command { + options := &AnnotateOptions{} + + cmd := &cobra.Command{ + Use: "annotate [--overwrite] RESOURCE NAME KEY_1=VAL_1 ... KEY_N=VAL_N [--resource-version=version]", + Short: "Update the annotations on a resource", + Long: annotate_long, + Example: annotate_example, + Run: func(cmd *cobra.Command, args []string) { + if err := options.Complete(f, args, out); err != nil { + cmdutil.CheckErr(err) + } + if err := options.Validate(args); err != nil { + cmdutil.CheckErr(cmdutil.UsageError(cmd, err.Error())) + } + if err := options.RunAnnotate(); err != nil { + cmdutil.CheckErr(err) + } + }, + } + cmdutil.AddPrinterFlags(cmd) + cmd.Flags().BoolVar(&options.overwrite, "overwrite", false, "If true, allow annotations to be overwritten, otherwise reject annotation updates that overwrite existing annotations.") + cmd.Flags().BoolVar(&options.all, "all", false, "select all resources in the namespace of the specified resource types") + cmd.Flags().StringVar(&options.resourceVersion, "resource-version", "", "If non-empty, the annotation update will only succeed if this is the current resource-version for the object. Only valid when specifying a single resource.") + return cmd +} + +// Complete adapts from the command line args and factory to the data required. +func (o *AnnotateOptions) Complete(f *cmdutil.Factory, args []string, out io.Writer) (err error) { + namespace, _, err := f.DefaultNamespace() + if err != nil { + return err + } + + // retrieves resource and annotation args from args + // also checks args to verify that all resources are specified before annotations + annotationArgs := []string{} + metAnnotaionArg := false + for _, s := range args { + isAnnotation := strings.Contains(s, "=") || strings.HasSuffix(s, "-") + switch { + case !metAnnotaionArg && isAnnotation: + metAnnotaionArg = true + fallthrough + case metAnnotaionArg && isAnnotation: + annotationArgs = append(annotationArgs, s) + case !metAnnotaionArg && !isAnnotation: + o.resources = append(o.resources, s) + case metAnnotaionArg && !isAnnotation: + return fmt.Errorf("all resources must be specified before annotation changes: %s", s) + } + } + if len(o.resources) < 1 { + return fmt.Errorf("one or more resources must be specified as or /") + } + if len(annotationArgs) < 1 { + return fmt.Errorf("at least one annotation update is required") + } + + if o.newAnnotations, o.removeAnnotations, err = parseAnnotations(annotationArgs); err != nil { + return err + } + + mapper, typer := f.Object() + o.builder = resource.NewBuilder(mapper, typer, f.ClientMapperForCommand()). + ContinueOnError(). + NamespaceParam(namespace).DefaultNamespace(). + ResourceTypeOrNameArgs(o.all, o.resources...). + Flatten(). + Latest() + + return nil +} + +// Validate checks to the AnnotateOptions to see if there is sufficient information run the command. +func (o AnnotateOptions) Validate(args []string) error { + if err := validateAnnotations(o.removeAnnotations, o.newAnnotations); err != nil { + return err + } + + // only apply resource version locking on a single resource + if len(o.resources) > 1 && len(o.resourceVersion) > 0 { + return fmt.Errorf("--resource-version may only be used with a single resource") + } + + return nil +} + +// RunAnnotate does the work +func (o AnnotateOptions) RunAnnotate() error { + r := o.builder.Do() + if err := r.Err(); err != nil { + return err + } + return r.Visit(func(info *resource.Info) error { + _, err := cmdutil.UpdateObject(info, func(obj runtime.Object) error { + err := o.updateAnnotations(obj) + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return nil + }) +} + +// parseAnnotations retrieves new and remove annotations from annotation args +func parseAnnotations(annotationArgs []string) (map[string]string, []string, error) { + var invalidBuf bytes.Buffer + newAnnotations := map[string]string{} + removeAnnotations := []string{} + for _, annotationArg := range annotationArgs { + if strings.Index(annotationArg, "=") != -1 { + parts := strings.SplitN(annotationArg, "=", 2) + if len(parts) != 2 || len(parts[1]) == 0 { + if invalidBuf.Len() > 0 { + invalidBuf.WriteString(", ") + } + invalidBuf.WriteString(fmt.Sprintf(annotationArg)) + } else { + newAnnotations[parts[0]] = parts[1] + } + } else if strings.HasSuffix(annotationArg, "-") { + removeAnnotations = append(removeAnnotations, annotationArg[:len(annotationArg)-1]) + } else { + if invalidBuf.Len() > 0 { + invalidBuf.WriteString(", ") + } + invalidBuf.WriteString(fmt.Sprintf(annotationArg)) + } + } + if invalidBuf.Len() > 0 { + return newAnnotations, removeAnnotations, fmt.Errorf("invalid annotation format: %s", invalidBuf.String()) + } + + return newAnnotations, removeAnnotations, nil +} + +// validateAnnotations checks the format of annotation args and checks removed annotations aren't in the new annotations map +func validateAnnotations(removeAnnotations []string, newAnnotations map[string]string) error { + var modifyRemoveBuf bytes.Buffer + for _, removeAnnotation := range removeAnnotations { + if _, found := newAnnotations[removeAnnotation]; found { + if modifyRemoveBuf.Len() > 0 { + modifyRemoveBuf.WriteString(", ") + } + modifyRemoveBuf.WriteString(fmt.Sprintf(removeAnnotation)) + } + } + if modifyRemoveBuf.Len() > 0 { + return fmt.Errorf("can not both modify and remove the following annotation(s) in the same command: %s", modifyRemoveBuf.String()) + } + + return nil +} + +// validateNoAnnotationOverwrites validates that when overwrite is false, to-be-updated annotations don't exist in the object annotation map (yet) +func validateNoAnnotationOverwrites(meta *api.ObjectMeta, annotations map[string]string) error { + var buf bytes.Buffer + for key := range annotations { + if value, found := meta.Annotations[key]; found { + if buf.Len() > 0 { + buf.WriteString("; ") + } + buf.WriteString(fmt.Sprintf("'%s' already has a value (%s)", key, value)) + } + } + if buf.Len() > 0 { + return fmt.Errorf("--overwrite is false but found the following declared annotation(s): %s", buf.String()) + } + return nil +} + +// updateAnnotations updates annotations of obj +func (o AnnotateOptions) updateAnnotations(obj runtime.Object) error { + meta, err := api.ObjectMetaFor(obj) + if err != nil { + return err + } + if !o.overwrite { + if err := validateNoAnnotationOverwrites(meta, o.newAnnotations); err != nil { + return err + } + } + + if meta.Annotations == nil { + meta.Annotations = make(map[string]string) + } + + for key, value := range o.newAnnotations { + meta.Annotations[key] = value + } + for _, annotation := range o.removeAnnotations { + delete(meta.Annotations, annotation) + } + + if len(o.resourceVersion) != 0 { + meta.ResourceVersion = o.resourceVersion + } + return nil +} diff --git a/pkg/kubectl/cmd/annotation_test.go b/pkg/kubectl/cmd/annotation_test.go new file mode 100644 index 00000000000..5b3718e6899 --- /dev/null +++ b/pkg/kubectl/cmd/annotation_test.go @@ -0,0 +1,515 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/testapi" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" +) + +func TestValidateAnnotationOverwrites(t *testing.T) { + tests := []struct { + meta *api.ObjectMeta + annotations map[string]string + expectErr bool + scenario string + }{ + { + meta: &api.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "b": "B", + }, + }, + annotations: map[string]string{ + "a": "a", + "c": "C", + }, + scenario: "share first annotation", + expectErr: true, + }, + { + meta: &api.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "c": "C", + }, + }, + annotations: map[string]string{ + "b": "B", + "c": "c", + }, + scenario: "share second annotation", + expectErr: true, + }, + { + meta: &api.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "c": "C", + }, + }, + annotations: map[string]string{ + "b": "B", + "d": "D", + }, + scenario: "no overlap", + }, + { + meta: &api.ObjectMeta{}, + annotations: map[string]string{ + "a": "A", + "b": "B", + }, + scenario: "no annotations", + }, + } + for _, test := range tests { + err := validateNoAnnotationOverwrites(test.meta, test.annotations) + if test.expectErr && err == nil { + t.Errorf("%s: unexpected non-error", test.scenario) + } else if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", test.scenario, err) + } + } +} + +func TestParseAnnotations(t *testing.T) { + testURL := "https://test.com/index.htm?id=123#u=user-name" + testJSON := `'{"kind":"SerializedReference","apiVersion":"v1","reference":{"kind":"ReplicationController","namespace":"default","name":"my-nginx","uid":"c544ee78-2665-11e5-8051-42010af0c213","apiVersion":"v1","resourceVersion":"61368"}}'` + tests := []struct { + annotations []string + expected map[string]string + expectedRemove []string + scenario string + expectedErr string + expectErr bool + }{ + { + annotations: []string{"a=b", "c=d"}, + expected: map[string]string{"a": "b", "c": "d"}, + expectedRemove: []string{}, + scenario: "add two annotations", + expectErr: false, + }, + { + annotations: []string{"url=" + testURL, "kubernetes.io/created-by=" + testJSON}, + expected: map[string]string{"url": testURL, "kubernetes.io/created-by": testJSON}, + expectedRemove: []string{}, + scenario: "add annotations with special characters", + expectErr: false, + }, + { + annotations: []string{}, + expected: map[string]string{}, + expectedRemove: []string{}, + scenario: "add no annotations", + expectErr: false, + }, + { + annotations: []string{"a=b", "c=d", "e-"}, + expected: map[string]string{"a": "b", "c": "d"}, + expectedRemove: []string{"e"}, + scenario: "add two annotations, remove one", + expectErr: false, + }, + { + annotations: []string{"ab", "c=d"}, + expectedErr: "invalid annotation format: ab", + scenario: "incorrect annotation input (missing =value)", + expectErr: true, + }, + { + annotations: []string{"a="}, + expectedErr: "invalid annotation format: a=", + scenario: "incorrect annotation input (missing value)", + expectErr: true, + }, + { + annotations: []string{"ab", "a="}, + expectedErr: "invalid annotation format: ab, a=", + scenario: "incorrect multiple annotation input (missing value)", + expectErr: true, + }, + } + for _, test := range tests { + annotations, remove, err := parseAnnotations(test.annotations) + switch { + case test.expectErr && err == nil: + t.Errorf("%s: unexpected non-error, should return %v", test.scenario, test.expectedErr) + case test.expectErr && err.Error() != test.expectedErr: + t.Errorf("%s: unexpected error %v, expected %v", test.scenario, err, test.expectedErr) + case !test.expectErr && err != nil: + t.Errorf("%s: unexpected error %v", test.scenario, err) + case !test.expectErr && !reflect.DeepEqual(annotations, test.expected): + t.Errorf("%s: expected %v, got %v", test.scenario, test.expected, annotations) + case !test.expectErr && !reflect.DeepEqual(remove, test.expectedRemove): + t.Errorf("%s: expected %v, got %v", test.scenario, test.expectedRemove, remove) + } + } +} + +func TestValidateAnnotations(t *testing.T) { + tests := []struct { + removeAnnotations []string + newAnnotations map[string]string + expectedErr string + scenario string + }{ + { + expectedErr: "can not both modify and remove the following annotation(s) in the same command: a", + removeAnnotations: []string{"a"}, + newAnnotations: map[string]string{"a": "b", "c": "d"}, + scenario: "remove an added annotation", + }, + { + expectedErr: "can not both modify and remove the following annotation(s) in the same command: a, c", + removeAnnotations: []string{"a", "c"}, + newAnnotations: map[string]string{"a": "b", "c": "d"}, + scenario: "remove added annotations", + }, + } + for _, test := range tests { + if err := validateAnnotations(test.removeAnnotations, test.newAnnotations); err == nil { + t.Errorf("%s: unexpected non-error", test.scenario) + } else if err.Error() != test.expectedErr { + t.Errorf("%s: expected error %s, got %s", test.scenario, test.expectedErr, err.Error()) + } + } +} + +func TestUpdateAnnotations(t *testing.T) { + tests := []struct { + obj runtime.Object + overwrite bool + version string + annotations map[string]string + remove []string + expected runtime.Object + expectErr bool + }{ + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"a": "b"}, + expectErr: true, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"a": "c"}, + overwrite: true, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "c"}, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"c": "d"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"c": "d"}, + version: "2", + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + ResourceVersion: "2", + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{}, + remove: []string{"a"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + annotations: map[string]string{"e": "f"}, + remove: []string{"a"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + "c": "d", + "e": "f", + }, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + annotations: map[string]string{"e": "f"}, + remove: []string{"g"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + "a": "b", + "c": "d", + "e": "f", + }, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + remove: []string{"e"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + "a": "b", + "c": "d", + }, + }, + }, + }, + { + obj: &api.Pod{ + ObjectMeta: api.ObjectMeta{}, + }, + annotations: map[string]string{"a": "b"}, + expected: &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + }, + } + for _, test := range tests { + options := &AnnotateOptions{ + overwrite: test.overwrite, + newAnnotations: test.annotations, + removeAnnotations: test.remove, + resourceVersion: test.version, + } + err := options.updateAnnotations(test.obj) + if test.expectErr { + if err == nil { + t.Errorf("unexpected non-error: %v", test) + } + continue + } + if !test.expectErr && err != nil { + t.Errorf("unexpected error: %v %v", err, test) + } + if !reflect.DeepEqual(test.obj, test.expected) { + t.Errorf("expected: %v, got %v", test.expected, test.obj) + } + } +} + +func TestAnnotateErrors(t *testing.T) { + testCases := map[string]struct { + args []string + flags map[string]string + errFn func(error) bool + }{ + "no args": { + args: []string{}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "not enough annotations": { + args: []string{"pods"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "at least one annotation update is required") + }, + }, + "no resources remove annotations": { + args: []string{"pods-"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "no resources add annotations": { + args: []string{"pods=bar"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + } + + for k, testCase := range testCases { + f, tf, _ := NewAPIFactory() + tf.Printer = &testPrinter{} + tf.Namespace = "test" + tf.ClientConfig = &client.Config{Version: testapi.Version()} + + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdAnnotate(f, buf) + cmd.SetOutput(buf) + + for k, v := range testCase.flags { + cmd.Flags().Set(k, v) + } + options := &AnnotateOptions{} + err := options.Complete(f, testCase.args, buf) + if !testCase.errFn(err) { + t.Errorf("%s: unexpected error: %v", k, err) + continue + } + if tf.Printer.(*testPrinter).Objects != nil { + t.Errorf("unexpected print to default printer") + } + if buf.Len() > 0 { + t.Errorf("buffer should be empty: %s", string(buf.Bytes())) + } + } +} + +func TestAnnotateObject(t *testing.T) { + pods, _, _ := testData() + + f, tf, codec := NewAPIFactory() + tf.Printer = &testPrinter{} + tf.Client = &client.FakeRESTClient{ + Codec: codec, + Client: client.HTTPClientFunc(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Body: objBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PUT": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Body: objBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.Namespace = "test" + tf.ClientConfig = &client.Config{Version: testapi.Version()} + buf := bytes.NewBuffer([]byte{}) + + options := &AnnotateOptions{} + args := []string{"pods/foo", "a=b", "c-"} + if err := options.Complete(f, args, buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAnnotateMultipleObjects(t *testing.T) { + pods, _, _ := testData() + + f, tf, codec := NewAPIFactory() + tf.Printer = &testPrinter{} + tf.Client = &client.FakeRESTClient{ + Codec: codec, + Client: client.HTTPClientFunc(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/pods": + return &http.Response{StatusCode: 200, Body: objBody(codec, pods)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PUT": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Body: objBody(codec, &pods.Items[0])}, nil + case "/namespaces/test/pods/bar": + return &http.Response{StatusCode: 200, Body: objBody(codec, &pods.Items[1])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.Namespace = "test" + tf.ClientConfig = &client.Config{Version: testapi.Version()} + buf := bytes.NewBuffer([]byte{}) + + options := &AnnotateOptions{} + options.all = true + args := []string{"pods", "a=b", "c-"} + if err := options.Complete(f, args, buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/kubectl/cmd/cmd.go b/pkg/kubectl/cmd/cmd.go index b5b27ceacc5..92e793af1cb 100644 --- a/pkg/kubectl/cmd/cmd.go +++ b/pkg/kubectl/cmd/cmd.go @@ -146,6 +146,7 @@ Find more information at https://github.com/GoogleCloudPlatform/kubernetes.`, cmds.AddCommand(NewCmdExposeService(f, out)) cmds.AddCommand(NewCmdLabel(f, out)) + cmds.AddCommand(NewCmdAnnotate(f, out)) cmds.AddCommand(cmdconfig.NewCmdConfig(cmdconfig.NewDefaultPathOptions(), out)) cmds.AddCommand(NewCmdClusterInfo(f, out)) diff --git a/pkg/kubectl/cmd/label.go b/pkg/kubectl/cmd/label.go index ef943393fa2..36ba179b874 100644 --- a/pkg/kubectl/cmd/label.go +++ b/pkg/kubectl/cmd/label.go @@ -71,25 +71,6 @@ func NewCmdLabel(f *cmdutil.Factory, out io.Writer) *cobra.Command { return cmd } -func updateObject(info *resource.Info, updateFn func(runtime.Object) (runtime.Object, error)) (runtime.Object, error) { - helper := resource.NewHelper(info.Client, info.Mapping) - - obj, err := updateFn(info.Object) - if err != nil { - return nil, err - } - data, err := helper.Codec.Encode(obj) - if err != nil { - return nil, err - } - - _, err = helper.Replace(info.Namespace, info.Name, true, data) - if err != nil { - return nil, err - } - return obj, nil -} - func validateNoOverwrites(meta *api.ObjectMeta, labels map[string]string) error { for key := range labels { if value, found := meta.Labels[key]; found { @@ -123,14 +104,14 @@ func parseLabels(spec []string) (map[string]string, []string, error) { return labels, remove, nil } -func labelFunc(obj runtime.Object, overwrite bool, resourceVersion string, labels map[string]string, remove []string) (runtime.Object, error) { +func labelFunc(obj runtime.Object, overwrite bool, resourceVersion string, labels map[string]string, remove []string) error { meta, err := api.ObjectMetaFor(obj) if err != nil { - return nil, err + return err } if !overwrite { if err := validateNoOverwrites(meta, labels); err != nil { - return nil, err + return err } } @@ -148,7 +129,7 @@ func labelFunc(obj runtime.Object, overwrite bool, resourceVersion string, label if len(resourceVersion) != 0 { meta.ResourceVersion = resourceVersion } - return obj, nil + return nil } func RunLabel(f *cmdutil.Factory, out io.Writer, cmd *cobra.Command, args []string) error { @@ -211,12 +192,12 @@ func RunLabel(f *cmdutil.Factory, out io.Writer, cmd *cobra.Command, args []stri // TODO: support bulk generic output a la Get return r.Visit(func(info *resource.Info) error { - obj, err := updateObject(info, func(obj runtime.Object) (runtime.Object, error) { - outObj, err := labelFunc(obj, overwrite, resourceVersion, labels, remove) + obj, err := cmdutil.UpdateObject(info, func(obj runtime.Object) error { + err := labelFunc(obj, overwrite, resourceVersion, labels, remove) if err != nil { - return nil, err + return err } - return outObj, nil + return nil }) if err != nil { return err diff --git a/pkg/kubectl/cmd/label_test.go b/pkg/kubectl/cmd/label_test.go index eb72e169237..2907788138a 100644 --- a/pkg/kubectl/cmd/label_test.go +++ b/pkg/kubectl/cmd/label_test.go @@ -256,7 +256,7 @@ func TestLabelFunc(t *testing.T) { }, } for _, test := range tests { - out, err := labelFunc(test.obj, test.overwrite, test.version, test.labels, test.remove) + err := labelFunc(test.obj, test.overwrite, test.version, test.labels, test.remove) if test.expectErr { if err == nil { t.Errorf("unexpected non-error: %v", test) @@ -266,8 +266,8 @@ func TestLabelFunc(t *testing.T) { if !test.expectErr && err != nil { t.Errorf("unexpected error: %v %v", err, test) } - if !reflect.DeepEqual(out, test.expected) { - t.Errorf("expected: %v, got %v", test.expected, out) + if !reflect.DeepEqual(test.obj, test.expected) { + t.Errorf("expected: %v, got %v", test.expected, test.obj) } } } diff --git a/pkg/kubectl/cmd/util/helpers.go b/pkg/kubectl/cmd/util/helpers.go index a0e21af7230..6b0cf25e0db 100644 --- a/pkg/kubectl/cmd/util/helpers.go +++ b/pkg/kubectl/cmd/util/helpers.go @@ -34,6 +34,7 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/api/latest" "github.com/GoogleCloudPlatform/kubernetes/pkg/client" "github.com/GoogleCloudPlatform/kubernetes/pkg/client/clientcmd" + "github.com/GoogleCloudPlatform/kubernetes/pkg/kubectl/resource" "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" utilerrors "github.com/GoogleCloudPlatform/kubernetes/pkg/util/errors" @@ -431,3 +432,23 @@ func DumpReaderToFile(reader io.Reader, filename string) error { } return nil } + +// UpdateObject updates resource object with updateFn +func UpdateObject(info *resource.Info, updateFn func(runtime.Object) error) (runtime.Object, error) { + helper := resource.NewHelper(info.Client, info.Mapping) + + err := updateFn(info.Object) + if err != nil { + return nil, err + } + data, err := helper.Codec.Encode(info.Object) + if err != nil { + return nil, err + } + + _, err = helper.Replace(info.Namespace, info.Name, true, data) + if err != nil { + return nil, err + } + return info.Object, nil +}