Skip to content

Commit d2c7dfe

Browse files
authored
Merge pull request #11 from gdt-dev/placement
add placement spread assertions
2 parents cd9bc31 + 064e0e3 commit d2c7dfe

16 files changed

+609
-441
lines changed

.github/workflows/gate-tests.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
test-skip-kind:
1414
strategy:
1515
matrix:
16-
go: ['1.19', '1.20', '1.21']
16+
go: ['1.22']
1717
os: [macos-latest, windows-latest]
1818
runs-on: ${{ matrix.os }}
1919
steps:
@@ -42,7 +42,7 @@ jobs:
4242
test-all:
4343
strategy:
4444
matrix:
45-
go: ['1.19', '1.20', '1.21']
45+
go: ['1.22']
4646
os: [ubuntu-latest]
4747
runs-on: ${{ matrix.os }}
4848
steps:

README.md

+106
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ matches some expectation:
184184
`ConditionType` should have
185185
* `reason` which is the exact string that should be present in the
186186
`Condition` with the `ConditionType`
187+
* `assert.placement`: (optional) an object describing assertions to make about
188+
the placement (scheduling outcome) of Pods returned in the `kube.get` result.
189+
* `assert.placement.spread`: (optional) an single string or array of strings
190+
for topology keys that the Pods returned in the `kube.get` result should be
191+
spread evenly across, e.g. `topology.kubernetes.io/zone` or
192+
`kubernetes.io/hostname`.
193+
* `assert.placement.pack`: (optional) an single string or array of strings for
194+
topology keys that the Pods returned in the `kube.get` result should be
195+
bin-packed within, e.g. `topology.kubernetes.io/zone` or
196+
`kubernetes.io/hostname`.
187197
* `assert.json`: (optional) object describing the assertions to make about
188198
resource(s) returned from the `kube.get` call to the Kubernetes API server.
189199
* `assert.json.len`: (optional) integer representing the number of bytes in the
@@ -450,6 +460,102 @@ tests:
450460
reason: NewReplicaSetAvailable
451461
```
452462

463+
### Asserting scheduling outcomes using `assert.placement`
464+
465+
The `assert.placement` field of a `gdt-kube` test Spec allows a test author to
466+
specify the expected scheduling outcome for a set of Pods returned by the
467+
Kubernetes API server from the result of a `kube.get` call.
468+
469+
#### Asserting even spread of Pods across a topology
470+
471+
Suppose you have a Deployment resource with a `TopologySpreadConstraints` that
472+
specifies the Pods in the Deployment must land on different hosts:
473+
474+
```yaml
475+
apiVersion: apps/v1
476+
kind: Deployment
477+
metadata:
478+
name: nginx-deployment
479+
labels:
480+
app: nginx
481+
spec:
482+
replicas: 3
483+
selector:
484+
matchLabels:
485+
app: nginx
486+
template:
487+
metadata:
488+
labels:
489+
app: nginx
490+
spec:
491+
containers:
492+
- name: nginx
493+
image: nginx:latest
494+
ports:
495+
- containerPort: 80
496+
topologySpreadConstraints:
497+
- maxSkew: 1
498+
topologyKey: kubernetes.io/hostname
499+
whenUnsatisfiable: DoNotSchedule
500+
labelSelector:
501+
matchLabels:
502+
app: nginx
503+
```
504+
505+
You can create a `gdt-kube` test case that verifies that your `nginx`
506+
Deployment's Pods are evenly spread across all available hosts:
507+
508+
```yaml
509+
tests:
510+
- kube:
511+
get: deployments/nginx
512+
assert:
513+
placement:
514+
spread: kubernetes.io/hostname
515+
```
516+
517+
If there are more hosts than the `spec.replicas` in the Deployment, `gdt-kube`
518+
will ensure that each Pod landed on a unique host. If there are fewer hosts
519+
than the `spec.replicas` in the Deployment, `gdt-kube` will ensure that there
520+
is an even spread of Pods to hosts, with any host having no more than one more
521+
Pod than any other.
522+
523+
#### Asserting bin-packing of Pods
524+
525+
Suppose you have configured your Kubernetes scheduler to bin-pack Pods onto
526+
hosts by scheduling Pods to hosts with the most allocated CPU resources:
527+
528+
```yaml
529+
apiVersion: kubescheduler.config.k8s.io/v1
530+
kind: KubeSchedulerConfiguration
531+
profiles:
532+
- pluginConfig:
533+
- args:
534+
scoringStrategy:
535+
resources:
536+
- name: cpu
537+
weight: 100
538+
type: MostAllocated
539+
name: NodeResourcesFit
540+
```
541+
542+
You can create a `gdt-kube` test case that verifies that your `nginx`
543+
Deployment's Pods are packed onto the fewest unique hosts:
544+
545+
```yaml
546+
tests:
547+
- kube:
548+
get: deployments/nginx
549+
assert:
550+
placement:
551+
pack: kubernetes.io/hostname
552+
```
553+
554+
`gdt-kube` will examine the total number of hosts that meet the nginx
555+
Deployment's scheduling and resource constraints and then assert that the
556+
number of hosts the Deployment's Pods landed on is the minimum number that
557+
would fit the total requested resources.
558+
453559
### Asserting resource fields using `assert.json`
454560

455561
The `assert.json` field of a `gdt-kube` test Spec allows a test author to

action.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (a *Action) Do(
100100
) error {
101101
cmd := a.getCommand()
102102

103-
debug.Println(ctx, t, "kube: %s [ns: %s]", cmd, ns)
103+
debug.Println(ctx, "kube: %s [ns: %s]", cmd, ns)
104104
switch cmd {
105105
case "get":
106106
return a.get(ctx, t, c, ns, out)

assertions.go

+48-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package kube
66

77
import (
8+
"context"
89
"encoding/json"
910
"errors"
1011
"fmt"
@@ -158,6 +159,8 @@ type Expect struct {
158159
// reason: NewReplicaSetAvailable
159160
// ```
160161
Conditions map[string]*ConditionMatch `yaml:"conditions,omitempty"`
162+
// Placement describes expected Pod scheduling spread or pack outcomes.
163+
Placement *PlacementAssertion `yaml:"placement,omitempty"`
161164
}
162165

163166
// conditionMatch is a struct with fields that we will match a resource's
@@ -196,8 +199,21 @@ func (m *ConditionMatch) UnmarshalYAML(node *yaml.Node) error {
196199
return nil
197200
}
198201

202+
// PlacementAssertion describes an expectation for Pod scheduling outcomes.
203+
type PlacementAssertion struct {
204+
// Spread contains zero or more topology keys that gdt-kube will assert an
205+
// even spread across.
206+
Spread *gdttypes.FlexStrings `yaml:"spread,omitempty"`
207+
// Pack contains zero or more topology keys that gdt-kube will assert
208+
// bin-packing of resources within.
209+
Pack *gdttypes.FlexStrings `yaml:"pack,omitempty"`
210+
}
211+
199212
// assertions contains all assertions made for the exec test
200213
type assertions struct {
214+
// c is the connection to the Kubernetes API for when the assertions needs
215+
// to query for things like placement outcomes or Node resources.
216+
c *connection
201217
// failures contains the set of error messages for failed assertions
202218
failures []error
203219
// exp contains the expected conditions to assert against
@@ -226,7 +242,7 @@ func (a *assertions) Failures() []error {
226242

227243
// OK checks all the assertions against the supplied arguments and returns true
228244
// if all assertions pass.
229-
func (a *assertions) OK() bool {
245+
func (a *assertions) OK(ctx context.Context) bool {
230246
exp := a.exp
231247
if exp == nil {
232248
if a.err != nil {
@@ -247,7 +263,10 @@ func (a *assertions) OK() bool {
247263
if !a.conditionsOK() {
248264
return false
249265
}
250-
if !a.jsonOK() {
266+
if !a.jsonOK(ctx) {
267+
return false
268+
}
269+
if !a.placementOK(ctx) {
251270
return false
252271
}
253272
return true
@@ -426,7 +445,7 @@ func (a *assertions) conditionsOK() bool {
426445

427446
// jsonOK returns true if the subject matches the JSON conditions, false
428447
// otherwise
429-
func (a *assertions) jsonOK() bool {
448+
func (a *assertions) jsonOK(ctx context.Context) bool {
430449
exp := a.exp
431450
if exp.JSON != nil && a.hasSubject() {
432451
var err error
@@ -438,7 +457,7 @@ func (a *assertions) jsonOK() bool {
438457
}
439458
}
440459
ja := gdtjson.New(exp.JSON, b)
441-
if !ja.OK() {
460+
if !ja.OK(ctx) {
442461
for _, f := range ja.Failures() {
443462
a.Fail(f)
444463
}
@@ -448,6 +467,29 @@ func (a *assertions) jsonOK() bool {
448467
return true
449468
}
450469

470+
// placementOK returns true if the subject matches the Placement conditions,
471+
// false otherwise
472+
func (a *assertions) placementOK(ctx context.Context) bool {
473+
exp := a.exp
474+
if exp.Placement != nil && a.hasSubject() {
475+
// TODO(jaypipes): Handle list returns...
476+
res, ok := a.r.(*unstructured.Unstructured)
477+
if !ok {
478+
panic("expected result to be unstructured.Unstructured")
479+
}
480+
spread := exp.Placement.Spread
481+
if spread != nil {
482+
ok = a.placementSpreadOK(ctx, res, spread.Values())
483+
}
484+
pack := exp.Placement.Pack
485+
if pack != nil {
486+
ok = ok && a.placementPackOK(ctx, res, pack.Values())
487+
}
488+
return ok
489+
}
490+
return true
491+
}
492+
451493
// hasSubject returns true if the assertions `r` field (which contains the
452494
// subject of which we inspect) is not `nil`.
453495
func (a *assertions) hasSubject() bool {
@@ -465,11 +507,13 @@ func (a *assertions) hasSubject() bool {
465507
// newAssertions returns an assertions object populated with the supplied http
466508
// spec assertions
467509
func newAssertions(
510+
c *connection,
468511
exp *Expect,
469512
err error,
470513
r interface{},
471514
) gdttypes.Assertions {
472515
return &assertions{
516+
c: c,
473517
failures: []error{},
474518
exp: exp,
475519
err: err,

connect.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package kube
77
import (
88
"context"
99
"fmt"
10+
"os"
1011

1112
gdtcontext "github.com/gdt-dev/gdt/context"
1213
"k8s.io/apimachinery/pkg/api/meta"
@@ -191,7 +192,7 @@ func (s *Spec) connect(ctx context.Context) (*connection, error) {
191192
}
192193
disco := discocached.NewMemCacheClient(discoverer)
193194
mapper := restmapper.NewDeferredDiscoveryRESTMapper(disco)
194-
expander := restmapper.NewShortcutExpander(mapper, disco)
195+
expander := restmapper.NewShortcutExpander(mapper, disco, func(s string) { fmt.Fprint(os.Stderr, s) })
195196

196197
return &connection{
197198
mapper: expander,

eval.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ func (s *Spec) Eval(ctx context.Context, t *testing.T) *result.Result {
6565
return result.New(result.WithRuntimeError(err))
6666
}
6767
}
68-
a = newAssertions(s.Assert, err, out)
69-
success = a.OK()
68+
a = newAssertions(c, s.Assert, err, out)
69+
success = a.OK(ctx)
7070
debug.Println(
71-
ctx, t, "%s (try %d after %s) ok: %v",
71+
ctx, "%s (try %d after %s) ok: %v",
7272
s.Title(), attempts, after, success,
7373
)
7474
if success {
@@ -77,7 +77,7 @@ func (s *Spec) Eval(ctx context.Context, t *testing.T) *result.Result {
7777
}
7878
for _, f := range a.Failures() {
7979
debug.Println(
80-
ctx, t, "%s (try %d after %s) failure: %s",
80+
ctx, "%s (try %d after %s) failure: %s",
8181
s.Title(), attempts, after, f,
8282
)
8383
}

0 commit comments

Comments
 (0)