Skip to content

Commit 7ee1a6b

Browse files
authored
Merge pull request #36 from jaypipes/jaypipes/set-based-label-selectors
support set-based label selectors
2 parents 093df10 + de15fa6 commit 7ee1a6b

File tree

6 files changed

+361
-70
lines changed

6 files changed

+361
-70
lines changed

README.md

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,16 @@ matches some expectation:
151151
* `kube`: (optional) an object containing actions and assertions the test takes
152152
against the Kubernetes API server.
153153
* `kube.get`: (optional) string or object containing a resource identifier
154-
(e.g. `pods`, `po/nginx` or label selector for resources that will be read
155-
from the Kubernetes API server.
154+
(e.g. `pods`, `po/nginx` or [label selector](#using-label-selectors) for
155+
resources that will be read from the Kubernetes API server.
156156
* `kube.create`: (optional) string containing either a file path to a YAML
157157
manifest or a string of raw YAML containing the resource(s) to create.
158158
* `kube.apply`: (optional) string containing either a file path to a YAML
159159
manifest or a string of raw YAML containing the resource(s) for which
160160
`gdt-kube` will perform a Kubernetes Apply call.
161161
* `kube.delete`: (optional) string or object containing either a resource
162162
identifier (e.g. `pods`, `po/nginx` , a file path to a YAML manifest, or a
163-
label selector for resources that will be deleted.
163+
[label selector](#using-label-selectors) for resources that will be deleted.
164164
* `var`: (optional) an object describing variables that can have
165165
values saved and referred to by subsequent test specs. Each key in the `var`
166166
object is the name of the variable to define.
@@ -347,6 +347,88 @@ tests:
347347
- kube.delete: pods/nginx
348348
```
349349

350+
### Using label selectors
351+
352+
When selecting objects from the Kubernetes API, you can use a label selector in
353+
the `kube.get` and `kube.delete` test spec actions. This label selector
354+
functionality is incredibly flexible. You can use a `kubectl`-style label
355+
selector string, like so:
356+
357+
```yaml
358+
- name: select pods that have the "app=argo" label but do NOT have the "app=argo-rollouts" or "app=argorollouts" label
359+
kube:
360+
get:
361+
type: pod
362+
labels: app in (argo),app notin (argo-rollouts,argorollouts)
363+
```
364+
365+
The `kube.get.labels` field can also be a map of string to string, which is
366+
more aligned with how `gdt`'s YAML syntax is structured. This example selects
367+
pods that have **both** the `app=myapp` **and** the `region=myregion` label:
368+
369+
```yaml
370+
- name: select pods that have BOTH the app=myapp AND region=myregion label
371+
kube:
372+
get:
373+
type: pod
374+
labels:
375+
app: myapp
376+
region: myregion
377+
```
378+
379+
The `kube.get.labels-in` field is a map of string to slice of strings and gets
380+
translated into a "label IN (val1, val2)" expression. This example selects pods
381+
that have **either** the `app=myapp` label **or** the `app=test` label:
382+
383+
```yaml
384+
- name: select pods that have EITHER the app=myapp OR app=test label
385+
kube:
386+
get:
387+
type: pod
388+
labels-in:
389+
app:
390+
- myapp
391+
- test
392+
```
393+
394+
The `kube.get.labels-not-in` field is also a map of string to slice of strings
395+
and gets translated into a `label NOTIN (val1, val2)` expression. This example
396+
selects pods that **do not** have either the `app=myapp` or the `app=test`
397+
label.
398+
399+
```yaml
400+
- name: select pods that have DON'T HAVE the app=myapp OR app=test label
401+
kube:
402+
get:
403+
type: pod
404+
labels-not-in:
405+
app:
406+
- myapp
407+
- test
408+
```
409+
410+
You can combine the `kube.get.labels`, `kube.get.labels-in` and
411+
`kube.get.labels-not-in` fields to create complex querying expressions. This
412+
example selects pods that have the `app=myapp` label **and** have **either**
413+
the `category=test` or `category=staging` label **and** do **not** have a
414+
`personal=true` label:
415+
416+
```yaml
417+
- 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
418+
kube:
419+
get:
420+
type: pod
421+
labels:
422+
app: myapp
423+
labels-in:
424+
category:
425+
- test
426+
- staging
427+
labels-not-in:
428+
personal:
429+
- true
430+
```
431+
350432
### Passing variables to subsequent test specs
351433

352434
A `gdt` test scenario is comprised of a list of test specs. These test specs

action.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"github.com/gdt-dev/core/parse"
2020
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2121
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
22-
"k8s.io/apimachinery/pkg/labels"
2322
"k8s.io/apimachinery/pkg/runtime/schema"
2423
"k8s.io/apimachinery/pkg/util/yaml"
2524
)
@@ -169,10 +168,10 @@ func (a *Action) doList(
169168
resName := res.Resource
170169
labelSelString := ""
171170
opts := metav1.ListOptions{}
172-
withlabels := a.Get.Labels
173-
if withlabels != nil {
171+
sel := a.Get.LabelSelector
172+
if sel != nil {
174173
// We already validated the label selector during parse-time
175-
labelsStr := labels.Set(withlabels).String()
174+
labelsStr := sel.String()
176175
labelSelString = fmt.Sprintf(" (labels: %s)", labelsStr)
177176
opts.LabelSelector = labelsStr
178177
}
@@ -484,11 +483,11 @@ func (a *Action) doDeleteCollection(
484483
ns string,
485484
) error {
486485
opts := metav1.ListOptions{}
487-
withlabels := a.Delete.Labels
488486
labelSelString := ""
489-
if withlabels != nil {
487+
sel := a.Delete.LabelSelector
488+
if sel != nil {
490489
// We already validated the label selector during parse-time
491-
labelsStr := labels.Set(withlabels).String()
490+
labelsStr := sel.String()
492491
labelSelString = fmt.Sprintf(" (labels: %s)", labelsStr)
493492
opts.LabelSelector = labelsStr
494493
}

identifier.go

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package kube
66

77
import (
88
"path/filepath"
9+
10+
kubelabels "k8s.io/apimachinery/pkg/labels"
911
)
1012

1113
// resourceIdentifierWithSelector is the full long-form resource identifier as
@@ -19,15 +21,22 @@ type resourceIdentifierWithSelector struct {
1921
// Labels is a map, keyed by metadata Label, of Label values to select a
2022
// resource by
2123
Labels map[string]string `yaml:"labels,omitempty"`
24+
// LabelsIn is a map, keyed by metadata Label, of slices of string label
25+
// values to select a resource with an IN() selector.
26+
LabelsIn map[string][]string `yaml:"labels-in,omitempty"`
27+
// LabelsNotIn is a map, keyed by metadata Label, of slices of string label
28+
// values to select a resource with an NOT IN() selector.
29+
LabelsNotIn map[string][]string `yaml:"labels-not-in,omitempty"`
30+
LabelSelector kubelabels.Selector `yaml:"-"`
2231
}
2332

2433
// ResourceIdentifier is a struct used to parse an interface{} that can be
2534
// either a string or a struct containing a selector with things like a label
2635
// key/value map.
2736
type ResourceIdentifier struct {
28-
Arg string `yaml:"-"`
29-
Name string `yaml:"-"`
30-
Labels map[string]string `yaml:"-"`
37+
Arg string `yaml:"-"`
38+
Name string `yaml:"-"`
39+
LabelSelector kubelabels.Selector `yaml:"-"`
3140
}
3241

3342
// Title returns the resource identifier's kind and name, if present
@@ -42,22 +51,33 @@ func NewResourceIdentifier(
4251
arg string,
4352
name string,
4453
labels map[string]string,
45-
) *ResourceIdentifier {
46-
return &ResourceIdentifier{
47-
Arg: arg,
48-
Name: name,
49-
Labels: labels,
54+
) (*ResourceIdentifier, error) {
55+
sel := kubelabels.Everything()
56+
if len(labels) > 0 {
57+
ls, err := kubelabels.ValidatedSelectorFromSet(labels)
58+
if err != nil {
59+
return nil, err
60+
}
61+
sel = ls
62+
}
63+
ri := &ResourceIdentifier{
64+
Arg: arg,
65+
Name: name,
66+
}
67+
if !sel.Empty() {
68+
ri.LabelSelector = sel.DeepCopySelector()
5069
}
70+
return ri, nil
5171
}
5272

5373
// ResourceIdentifierOrFile is a struct used to parse an interface{} that can
5474
// be either a string, a filepath or a struct containing a selector with things
5575
// like a label key/value map.
5676
type ResourceIdentifierOrFile struct {
57-
fp string `yaml:"-"`
58-
Arg string `yaml:"-"`
59-
Name string `yaml:"-"`
60-
Labels map[string]string `yaml:"-"`
77+
fp string `yaml:"-"`
78+
Arg string `yaml:"-"`
79+
Name string `yaml:"-"`
80+
LabelSelector kubelabels.Selector `yaml:"-"`
6181
}
6282

6383
// FilePath returns the resource identifier's file path, if present
@@ -82,11 +102,22 @@ func NewResourceIdentifierOrFile(
82102
arg string,
83103
name string,
84104
labels map[string]string,
85-
) *ResourceIdentifierOrFile {
86-
return &ResourceIdentifierOrFile{
87-
fp: fp,
88-
Arg: arg,
89-
Name: name,
90-
Labels: labels,
105+
) (*ResourceIdentifierOrFile, error) {
106+
sel := kubelabels.Everything()
107+
if len(labels) > 0 {
108+
ls, err := kubelabels.ValidatedSelectorFromSet(labels)
109+
if err != nil {
110+
return nil, err
111+
}
112+
sel = ls
113+
}
114+
ri := &ResourceIdentifierOrFile{
115+
fp: fp,
116+
Arg: arg,
117+
Name: name,
118+
}
119+
if !sel.Empty() {
120+
ri.LabelSelector = sel.DeepCopySelector()
91121
}
122+
return ri, nil
92123
}

parse.go

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import (
1616
"github.com/samber/lo"
1717
"github.com/theory/jsonpath"
1818
"gopkg.in/yaml.v3"
19-
"k8s.io/apimachinery/pkg/labels"
19+
kubelabels "k8s.io/apimachinery/pkg/labels"
20+
kubeselection "k8s.io/apimachinery/pkg/selection"
2021
)
2122

2223
// EitherShortcutOrKubeSpecAt returns a parse error indicating the test author
@@ -561,13 +562,9 @@ func (r *ResourceIdentifier) UnmarshalYAML(node *yaml.Node) error {
561562
if err := node.Decode(&ri); err != nil {
562563
return err
563564
}
564-
_, err := labels.ValidatedSelectorFromSet(ri.Labels)
565-
if err != nil {
566-
return InvalidWithLabelsAt(err, node)
567-
}
568565
r.Arg = ri.Type
569566
r.Name = ri.Name
570-
r.Labels = ri.Labels
567+
r.LabelSelector = ri.LabelSelector
571568
return nil
572569
}
573570

@@ -603,13 +600,107 @@ func (r *ResourceIdentifierOrFile) UnmarshalYAML(node *yaml.Node) error {
603600
if err := node.Decode(&ri); err != nil {
604601
return err
605602
}
606-
_, err := labels.ValidatedSelectorFromSet(ri.Labels)
607-
if err != nil {
608-
return InvalidWithLabelsAt(err, node)
609-
}
610603
r.Arg = ri.Type
611604
r.Name = ri.Name
612-
r.Labels = ri.Labels
605+
r.LabelSelector = ri.LabelSelector
606+
return nil
607+
}
608+
609+
func (r *resourceIdentifierWithSelector) UnmarshalYAML(node *yaml.Node) error {
610+
if node.Kind != yaml.MappingNode {
611+
return parse.ExpectedMapAt(node)
612+
}
613+
sel := kubelabels.Everything()
614+
// maps/structs are stored in a top-level Node.Content field which is a
615+
// concatenated slice of Node pointers in pairs of key/values.
616+
for i := 0; i < len(node.Content); i += 2 {
617+
keyNode := node.Content[i]
618+
if keyNode.Kind != yaml.ScalarNode {
619+
return parse.ExpectedScalarAt(keyNode)
620+
}
621+
key := keyNode.Value
622+
valNode := node.Content[i+1]
623+
switch key {
624+
case "type":
625+
if valNode.Kind != yaml.ScalarNode {
626+
return parse.ExpectedScalarAt(valNode)
627+
}
628+
r.Type = valNode.Value
629+
case "name":
630+
if valNode.Kind != yaml.ScalarNode {
631+
return parse.ExpectedScalarAt(valNode)
632+
}
633+
r.Name = valNode.Value
634+
case "labels", "labels-all", "labels_all":
635+
if valNode.Kind != yaml.ScalarNode && valNode.Kind != yaml.MappingNode {
636+
return parse.ExpectedScalarOrMapAt(valNode)
637+
}
638+
if valNode.Kind == yaml.ScalarNode {
639+
s, err := kubelabels.Parse(valNode.Value)
640+
if err != nil {
641+
return InvalidWithLabelsAt(err, valNode)
642+
}
643+
// NOTE(jaypipes): If the `labels` key was found and the value
644+
// was a string that successfully parsed according to the
645+
// kubectl labels selector format, ignore any other
646+
// labels-in/labels-not-in keys.
647+
r.LabelSelector = s.DeepCopySelector()
648+
return nil
649+
} else {
650+
var m map[string]string
651+
if err := valNode.Decode(&m); err != nil {
652+
return err
653+
}
654+
s, err := kubelabels.ValidatedSelectorFromSet(m)
655+
if err != nil {
656+
return InvalidWithLabelsAt(err, valNode)
657+
}
658+
newReqs, _ := s.Requirements()
659+
for _, req := range newReqs {
660+
sel = sel.Add(req)
661+
}
662+
}
663+
case "labels-in", "labels_in", "labels-any", "labels_any":
664+
if valNode.Kind != yaml.MappingNode {
665+
return parse.ExpectedMapAt(valNode)
666+
}
667+
var m map[string][]string
668+
if err := valNode.Decode(&m); err != nil {
669+
return err
670+
}
671+
for k, vals := range m {
672+
req, err := kubelabels.NewRequirement(
673+
k, kubeselection.In, vals,
674+
)
675+
if err != nil {
676+
return InvalidWithLabelsAt(err, valNode)
677+
}
678+
sel = sel.Add(*req)
679+
}
680+
case "labels-not-in", "labels_not_in", "labels-not-any", "labels_not_any":
681+
if valNode.Kind != yaml.MappingNode {
682+
return parse.ExpectedMapAt(valNode)
683+
}
684+
var m map[string][]string
685+
if err := valNode.Decode(&m); err != nil {
686+
return err
687+
}
688+
for k, vals := range m {
689+
req, err := kubelabels.NewRequirement(
690+
k, kubeselection.NotIn, vals,
691+
)
692+
if err != nil {
693+
return InvalidWithLabelsAt(err, valNode)
694+
}
695+
sel = sel.Add(*req)
696+
}
697+
default:
698+
return parse.UnknownFieldAt(key, keyNode)
699+
}
700+
}
701+
if !sel.Empty() {
702+
r.LabelSelector = sel.DeepCopySelector()
703+
}
613704
return nil
614705
}
615706

0 commit comments

Comments
 (0)