diff --git a/README.md b/README.md index 1b7a293..0ac84e1 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,8 @@ matches some expectation: * `kube`: (optional) an object containing actions and assertions the test takes against the Kubernetes API server. * `kube.get`: (optional) string or object containing a resource identifier - (e.g. `pods`, `po/nginx` or label selector for resources that will be read - from the Kubernetes API server. + (e.g. `pods`, `po/nginx` or [label selector](#using-label-selectors) for + resources that will be read from the Kubernetes API server. * `kube.create`: (optional) string containing either a file path to a YAML manifest or a string of raw YAML containing the resource(s) to create. * `kube.apply`: (optional) string containing either a file path to a YAML @@ -160,7 +160,7 @@ matches some expectation: `gdt-kube` will perform a Kubernetes Apply call. * `kube.delete`: (optional) string or object containing either a resource identifier (e.g. `pods`, `po/nginx` , a file path to a YAML manifest, or a - label selector for resources that will be deleted. + [label selector](#using-label-selectors) for resources that will be deleted. * `var`: (optional) an object describing variables that can have values saved and referred to by subsequent test specs. Each key in the `var` object is the name of the variable to define. @@ -347,6 +347,88 @@ tests: - kube.delete: pods/nginx ``` +### Using label selectors + +When selecting objects from the Kubernetes API, you can use a label selector in +the `kube.get` and `kube.delete` test spec actions. This label selector +functionality is incredibly flexible. You can use a `kubectl`-style label +selector string, like so: + +```yaml + - name: select pods that have the "app=argo" label but do NOT have the "app=argo-rollouts" or "app=argorollouts" label + kube: + get: + type: pod + labels: app in (argo),app notin (argo-rollouts,argorollouts) +``` + +The `kube.get.labels` field can also be a map of string to string, which is +more aligned with how `gdt`'s YAML syntax is structured. This example selects +pods that have **both** the `app=myapp` **and** the `region=myregion` label: + +```yaml + - name: select pods that have BOTH the app=myapp AND region=myregion label + kube: + get: + type: pod + labels: + app: myapp + region: myregion +``` + +The `kube.get.labels-in` field is a map of string to slice of strings and gets +translated into a "label IN (val1, val2)" expression. This example selects pods +that have **either** the `app=myapp` label **or** the `app=test` label: + +```yaml + - name: select pods that have EITHER the app=myapp OR app=test label + kube: + get: + type: pod + labels-in: + app: + - myapp + - test +``` + +The `kube.get.labels-not-in` field is also a map of string to slice of strings +and gets translated into a `label NOTIN (val1, val2)` expression. This example +selects pods that **do not** have either the `app=myapp` or the `app=test` +label. + +```yaml + - name: select pods that have DON'T HAVE the app=myapp OR app=test label + kube: + get: + type: pod + labels-not-in: + app: + - myapp + - test +``` + +You can combine the `kube.get.labels`, `kube.get.labels-in` and +`kube.get.labels-not-in` fields to create complex querying expressions. This +example selects pods that have the `app=myapp` label **and** have **either** +the `category=test` or `category=staging` label **and** do **not** have a +`personal=true` label: + +```yaml + - name: select pods that have have an app=myapp label AND have either a category=test or category=staging label AND do not have the personal=true label + kube: + get: + type: pod + labels: + app: myapp + labels-in: + category: + - test + - staging + labels-not-in: + personal: + - true +``` + ### Passing variables to subsequent test specs A `gdt` test scenario is comprised of a list of test specs. These test specs diff --git a/action.go b/action.go index 33a328c..5ea8128 100644 --- a/action.go +++ b/action.go @@ -19,7 +19,6 @@ import ( "github.com/gdt-dev/core/parse" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/yaml" ) @@ -169,10 +168,10 @@ func (a *Action) doList( resName := res.Resource labelSelString := "" opts := metav1.ListOptions{} - withlabels := a.Get.Labels - if withlabels != nil { + sel := a.Get.LabelSelector + if sel != nil { // We already validated the label selector during parse-time - labelsStr := labels.Set(withlabels).String() + labelsStr := sel.String() labelSelString = fmt.Sprintf(" (labels: %s)", labelsStr) opts.LabelSelector = labelsStr } @@ -484,11 +483,11 @@ func (a *Action) doDeleteCollection( ns string, ) error { opts := metav1.ListOptions{} - withlabels := a.Delete.Labels labelSelString := "" - if withlabels != nil { + sel := a.Delete.LabelSelector + if sel != nil { // We already validated the label selector during parse-time - labelsStr := labels.Set(withlabels).String() + labelsStr := sel.String() labelSelString = fmt.Sprintf(" (labels: %s)", labelsStr) opts.LabelSelector = labelsStr } diff --git a/identifier.go b/identifier.go index c9c99b6..e07cbb6 100644 --- a/identifier.go +++ b/identifier.go @@ -6,6 +6,8 @@ package kube import ( "path/filepath" + + kubelabels "k8s.io/apimachinery/pkg/labels" ) // resourceIdentifierWithSelector is the full long-form resource identifier as @@ -19,15 +21,22 @@ type resourceIdentifierWithSelector struct { // Labels is a map, keyed by metadata Label, of Label values to select a // resource by Labels map[string]string `yaml:"labels,omitempty"` + // LabelsIn is a map, keyed by metadata Label, of slices of string label + // values to select a resource with an IN() selector. + LabelsIn map[string][]string `yaml:"labels-in,omitempty"` + // LabelsNotIn is a map, keyed by metadata Label, of slices of string label + // values to select a resource with an NOT IN() selector. + LabelsNotIn map[string][]string `yaml:"labels-not-in,omitempty"` + LabelSelector kubelabels.Selector `yaml:"-"` } // ResourceIdentifier is a struct used to parse an interface{} that can be // either a string or a struct containing a selector with things like a label // key/value map. type ResourceIdentifier struct { - Arg string `yaml:"-"` - Name string `yaml:"-"` - Labels map[string]string `yaml:"-"` + Arg string `yaml:"-"` + Name string `yaml:"-"` + LabelSelector kubelabels.Selector `yaml:"-"` } // Title returns the resource identifier's kind and name, if present @@ -42,22 +51,33 @@ func NewResourceIdentifier( arg string, name string, labels map[string]string, -) *ResourceIdentifier { - return &ResourceIdentifier{ - Arg: arg, - Name: name, - Labels: labels, +) (*ResourceIdentifier, error) { + sel := kubelabels.Everything() + if len(labels) > 0 { + ls, err := kubelabels.ValidatedSelectorFromSet(labels) + if err != nil { + return nil, err + } + sel = ls + } + ri := &ResourceIdentifier{ + Arg: arg, + Name: name, + } + if !sel.Empty() { + ri.LabelSelector = sel.DeepCopySelector() } + return ri, nil } // ResourceIdentifierOrFile is a struct used to parse an interface{} that can // be either a string, a filepath or a struct containing a selector with things // like a label key/value map. type ResourceIdentifierOrFile struct { - fp string `yaml:"-"` - Arg string `yaml:"-"` - Name string `yaml:"-"` - Labels map[string]string `yaml:"-"` + fp string `yaml:"-"` + Arg string `yaml:"-"` + Name string `yaml:"-"` + LabelSelector kubelabels.Selector `yaml:"-"` } // FilePath returns the resource identifier's file path, if present @@ -82,11 +102,22 @@ func NewResourceIdentifierOrFile( arg string, name string, labels map[string]string, -) *ResourceIdentifierOrFile { - return &ResourceIdentifierOrFile{ - fp: fp, - Arg: arg, - Name: name, - Labels: labels, +) (*ResourceIdentifierOrFile, error) { + sel := kubelabels.Everything() + if len(labels) > 0 { + ls, err := kubelabels.ValidatedSelectorFromSet(labels) + if err != nil { + return nil, err + } + sel = ls + } + ri := &ResourceIdentifierOrFile{ + fp: fp, + Arg: arg, + Name: name, + } + if !sel.Empty() { + ri.LabelSelector = sel.DeepCopySelector() } + return ri, nil } diff --git a/parse.go b/parse.go index 2bead9e..1dcd303 100644 --- a/parse.go +++ b/parse.go @@ -16,7 +16,8 @@ import ( "github.com/samber/lo" "github.com/theory/jsonpath" "gopkg.in/yaml.v3" - "k8s.io/apimachinery/pkg/labels" + kubelabels "k8s.io/apimachinery/pkg/labels" + kubeselection "k8s.io/apimachinery/pkg/selection" ) // EitherShortcutOrKubeSpecAt returns a parse error indicating the test author @@ -561,13 +562,9 @@ func (r *ResourceIdentifier) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&ri); err != nil { return err } - _, err := labels.ValidatedSelectorFromSet(ri.Labels) - if err != nil { - return InvalidWithLabelsAt(err, node) - } r.Arg = ri.Type r.Name = ri.Name - r.Labels = ri.Labels + r.LabelSelector = ri.LabelSelector return nil } @@ -603,13 +600,107 @@ func (r *ResourceIdentifierOrFile) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&ri); err != nil { return err } - _, err := labels.ValidatedSelectorFromSet(ri.Labels) - if err != nil { - return InvalidWithLabelsAt(err, node) - } r.Arg = ri.Type r.Name = ri.Name - r.Labels = ri.Labels + r.LabelSelector = ri.LabelSelector + return nil +} + +func (r *resourceIdentifierWithSelector) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return parse.ExpectedMapAt(node) + } + sel := kubelabels.Everything() + // maps/structs are stored in a top-level Node.Content field which is a + // concatenated slice of Node pointers in pairs of key/values. + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + if keyNode.Kind != yaml.ScalarNode { + return parse.ExpectedScalarAt(keyNode) + } + key := keyNode.Value + valNode := node.Content[i+1] + switch key { + case "type": + if valNode.Kind != yaml.ScalarNode { + return parse.ExpectedScalarAt(valNode) + } + r.Type = valNode.Value + case "name": + if valNode.Kind != yaml.ScalarNode { + return parse.ExpectedScalarAt(valNode) + } + r.Name = valNode.Value + case "labels", "labels-all", "labels_all": + if valNode.Kind != yaml.ScalarNode && valNode.Kind != yaml.MappingNode { + return parse.ExpectedScalarOrMapAt(valNode) + } + if valNode.Kind == yaml.ScalarNode { + s, err := kubelabels.Parse(valNode.Value) + if err != nil { + return InvalidWithLabelsAt(err, valNode) + } + // NOTE(jaypipes): If the `labels` key was found and the value + // was a string that successfully parsed according to the + // kubectl labels selector format, ignore any other + // labels-in/labels-not-in keys. + r.LabelSelector = s.DeepCopySelector() + return nil + } else { + var m map[string]string + if err := valNode.Decode(&m); err != nil { + return err + } + s, err := kubelabels.ValidatedSelectorFromSet(m) + if err != nil { + return InvalidWithLabelsAt(err, valNode) + } + newReqs, _ := s.Requirements() + for _, req := range newReqs { + sel = sel.Add(req) + } + } + case "labels-in", "labels_in", "labels-any", "labels_any": + if valNode.Kind != yaml.MappingNode { + return parse.ExpectedMapAt(valNode) + } + var m map[string][]string + if err := valNode.Decode(&m); err != nil { + return err + } + for k, vals := range m { + req, err := kubelabels.NewRequirement( + k, kubeselection.In, vals, + ) + if err != nil { + return InvalidWithLabelsAt(err, valNode) + } + sel = sel.Add(*req) + } + case "labels-not-in", "labels_not_in", "labels-not-any", "labels_not_any": + if valNode.Kind != yaml.MappingNode { + return parse.ExpectedMapAt(valNode) + } + var m map[string][]string + if err := valNode.Decode(&m); err != nil { + return err + } + for k, vals := range m { + req, err := kubelabels.NewRequirement( + k, kubeselection.NotIn, vals, + ) + if err != nil { + return InvalidWithLabelsAt(err, valNode) + } + sel = sel.Add(*req) + } + default: + return parse.UnknownFieldAt(key, keyNode) + } + } + if !sel.Empty() { + r.LabelSelector = sel.DeepCopySelector() + } return nil } diff --git a/parse_test.go b/parse_test.go index fb3ae34..829f39f 100644 --- a/parse_test.go +++ b/parse_test.go @@ -305,6 +305,42 @@ func TestFailureBadVarJSONPath(t *testing.T) { require.Nil(s) } +func TestParseLabelSelector(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + fp := filepath.Join("testdata", "parse-label-selector.yaml") + f, err := os.Open(fp) + require.Nil(err) + defer f.Close() // nolint:errcheck + + s, err := scenario.FromReader(f, scenario.WithPath(fp)) + require.Nil(err) + require.NotNil(s) + + expSelectors := []string{ + "app=nginx", + "app=nginx,version=1", + // NOTE(jaypipes): kubelabels.Requirements are sorted alphanumerically, + // which is why this is actually different than the order that is + // specified in the parse-label-selector.yaml file. + "app in (argo-rollouts,argorollouts)", + "app notin (argo-rollouts,argorollouts)", + "app in (argo),app notin (argo-rollouts,argorollouts)", + "app in (argo),app notin (argo-rollouts,argorollouts)", + } + require.Len(s.Tests, len(expSelectors)) + for x, st := range s.Tests { + expSelector := expSelectors[x] + stk := st.(*gdtkube.Spec).Kube + get := stk.Get + require.NotNil(get) + sel := get.LabelSelector + require.NotNil(sel) + assert.Equal(expSelector, sel.String()) + } +} + func TestParse(t *testing.T) { assert := assert.New(t) require := require.New(t) @@ -320,6 +356,26 @@ func TestParse(t *testing.T) { require.Nil(err) require.NotNil(s) + podFileIdent, _ := gdtkube.NewResourceIdentifierOrFile( + "manifests/nginx-pod.yaml", + "", "", nil, + ) + podNameIdent, _ := gdtkube.NewResourceIdentifier( + "pods", "name", nil, + ) + appLabelIdent, _ := gdtkube.NewResourceIdentifier( + "pods", "", map[string]string{ + "app": "nginx", + }, + ) + podFooIdent, _ := gdtkube.NewResourceIdentifier( + "pods", "foo", nil, + ) + podVarSubIdent, _ := gdtkube.NewResourceIdentifier( + "pods", + "$POD", // $$POD is replaced with $POD after envvar substitution... + nil, + ) podYAML := `apiVersion: v1 kind: Pod metadata: @@ -376,10 +432,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Delete: gdtkube.NewResourceIdentifierOrFile( - "manifests/nginx-pod.yaml", - "", "", nil, - ), + Delete: podFileIdent, }, }, }, @@ -391,9 +444,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "name", nil, - ), + Get: podNameIdent, }, }, }, @@ -405,9 +456,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "name", nil, - ), + Get: podNameIdent, }, }, }, @@ -419,11 +468,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "", map[string]string{ - "app": "nginx", - }, - ), + Get: appLabelIdent, }, }, }, @@ -435,11 +480,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "", map[string]string{ - "app": "nginx", - }, - ), + Get: appLabelIdent, }, }, }, @@ -451,9 +492,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "foo", nil, - ), + Get: podFooIdent, }, }, Assert: &gdtkube.Expect{ @@ -468,9 +507,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", "name", nil, - ), + Get: podNameIdent, }, }, Var: gdtkube.Variables{ @@ -487,11 +524,7 @@ spec: }, Kube: &gdtkube.KubeSpec{ Action: gdtkube.Action{ - Get: gdtkube.NewResourceIdentifier( - "pods", - "$POD", // $$POD is replaced with $POD after envvar substitution... - nil, - ), + Get: podVarSubIdent, }, }, }, diff --git a/testdata/parse-label-selector.yaml b/testdata/parse-label-selector.yaml new file mode 100644 index 0000000..9bae72d --- /dev/null +++ b/testdata/parse-label-selector.yaml @@ -0,0 +1,55 @@ +name: parse-label-selector +description: a scenario with several well-formed kube/get specs with label selectors + +tests: + + - name: label selector, one label only + kube: + get: + type: pod + labels: + app: nginx + + - name: label selector, multiple labels + kube: + get: + type: pod + labels: + app: nginx + version: 1 + + - name: label selector, IN expression + kube: + get: + type: pod + labels-in: + app: + - argorollouts + - argo-rollouts + + - name: label selector, NOT IN expression + kube: + get: + type: pod + labels-not-in: + app: + - argorollouts + - argo-rollouts + + - name: label selector, AND'ed expression with IN and NOT IN expressions + kube: + get: + type: pod + labels-in: + app: + - argo + labels-not-in: + app: + - argo-rollouts + - argorollouts + + - name: label selector, string complex label selector expression + kube: + get: + type: pod + labels: app in (argo),app notin (argo-rollouts,argorollouts)