Skip to content

Commit 08e2f80

Browse files
committed
POC: using MutatingAdmissionPolicy to schedule CS pods
This change documents a proof-of-concept of configuring the node placement of Config Sync system pods using MutatingAdmissionPolicy. This works out of the box, but is currently an alpha api and is behind a feature gate. It is targeted for beta in k8s 1.34.
1 parent f2ada25 commit 08e2f80

File tree

6 files changed

+201
-1
lines changed

6 files changed

+201
-1
lines changed

e2e/nomostest/clusters/kind.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"sync"
2323
"time"
2424

25+
"k8s.io/apiserver/pkg/features"
2526
"kpt.dev/configsync/e2e"
2627
"kpt.dev/configsync/e2e/nomostest/docker"
2728
"kpt.dev/configsync/e2e/nomostest/taskgroup"
@@ -155,6 +156,12 @@ func createKindCluster(p *cluster.Provider, name, kcfgPath string) error {
155156
fmt.Sprintf(`[plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:%d"]
156157
endpoint = ["http://%s:%d"]`, docker.RegistryPort, docker.RegistryName, docker.RegistryPort),
157158
},
159+
FeatureGates: map[string]bool{
160+
string(features.MutatingAdmissionPolicy): true,
161+
},
162+
RuntimeConfig: map[string]string{
163+
"admissionregistration.k8s.io/v1alpha1": "true",
164+
},
158165
// Enable ValidatingAdmissionWebhooks in the Kind cluster, as these
159166
// are disabled by default.
160167
// Also mount etcd to tmpfs for memory-backed storage.
@@ -166,7 +173,7 @@ etcd:
166173
dataDir: /tmp/etcd
167174
apiServer:
168175
extraArgs:
169-
"enable-admission-plugins": "ValidatingAdmissionWebhook"`,
176+
"enable-admission-plugins": "ValidatingAdmissionWebhook,MutatingAdmissionPolicy,ValidatingAdmissionPolicy"`,
170177
},
171178
}),
172179
// Retain nodes for debugging logs.

e2e/nomostest/config_sync.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,6 +1398,23 @@ func podHasReadyCondition(conditions []corev1.PodCondition) bool {
13981398
return false
13991399
}
14001400

1401+
// ValidatePodByLabel validates the all Pods matching the provided label pass the
1402+
// provided list of predicates.
1403+
func ValidatePodByLabel(nt *NT, label, value string, predicates ...testpredicates.Predicate) error {
1404+
newPods := &corev1.PodList{}
1405+
if err := nt.KubeClient.List(newPods, client.InNamespace(configmanagement.ControllerNamespace), client.MatchingLabels{label: value}); err != nil {
1406+
return err
1407+
}
1408+
tg := taskgroup.New()
1409+
for _, pod := range newPods.Items {
1410+
po := pod
1411+
tg.Go(func() error {
1412+
return nt.Validate(po.Name, po.Namespace, &corev1.Pod{}, predicates...)
1413+
})
1414+
}
1415+
return tg.Wait()
1416+
}
1417+
14011418
// NewPodReady checks if the new created pods are ready.
14021419
// It also checks if the new children pods that are managed by the pods are ready.
14031420
func NewPodReady(nt *NT, labelName, currentLabel, childLabel string, oldCurrentPods, oldChildPods []corev1.Pod) error {

e2e/nomostest/testpredicates/predicates.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,28 @@ func HasExactlyLabelKeys(wantKeys ...string) Predicate {
224224
}
225225
}
226226

227+
// HasExactlyNodeAffinity ensures the Pod has the provided nodeAffinity
228+
func HasExactlyNodeAffinity(nodeAffinity *corev1.NodeAffinity) Predicate {
229+
return func(o client.Object) error {
230+
if o == nil {
231+
return ErrObjectNotFound
232+
}
233+
pod, ok := o.(*corev1.Pod)
234+
if !ok {
235+
return WrongTypeErr(pod, &corev1.Pod{})
236+
}
237+
gotAffinity := &corev1.NodeAffinity{}
238+
if pod.Spec.Affinity != nil && pod.Spec.Affinity.NodeAffinity != nil {
239+
gotAffinity = pod.Spec.Affinity.NodeAffinity
240+
}
241+
if !equality.Semantic.DeepEqual(nodeAffinity, gotAffinity) {
242+
return fmt.Errorf("expected %s to have spec.affinity.nodeAffinity: %s, but got %s",
243+
kinds.ObjectSummary(pod), log.AsJSON(nodeAffinity), log.AsJSON(gotAffinity))
244+
}
245+
return nil
246+
}
247+
}
248+
227249
// HasExactlyImage ensures a container has the expected image.
228250
func HasExactlyImage(containerName, expectImageName, expectImageTag, expectImageDigest string) Predicate {
229251
return func(o client.Object) error {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package e2e
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
8+
corev1 "k8s.io/api/core/v1"
9+
"kpt.dev/configsync/e2e/nomostest"
10+
"kpt.dev/configsync/e2e/nomostest/ntopts"
11+
"kpt.dev/configsync/e2e/nomostest/syncsource"
12+
nomostesting "kpt.dev/configsync/e2e/nomostest/testing"
13+
"kpt.dev/configsync/e2e/nomostest/testpredicates"
14+
"kpt.dev/configsync/pkg/api/configsync"
15+
"kpt.dev/configsync/pkg/core/k8sobjects"
16+
"kpt.dev/configsync/pkg/kinds"
17+
"kpt.dev/configsync/pkg/reconcilermanager"
18+
)
19+
20+
// This test currently requires KinD because MutatingAdmissionPolicy is alpha
21+
// and requires a feature gate.
22+
func TestMutatingAdmissionPolicy(t *testing.T) {
23+
nt := nomostest.New(t, nomostesting.Reconciliation2,
24+
ntopts.SyncWithGitSource(nomostest.DefaultRootSyncID, ntopts.Unstructured),
25+
ntopts.RequireKind(t))
26+
rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(nomostest.DefaultRootSyncID)
27+
28+
mapFile := filepath.Join(".", "..", "..", "examples", "mutating-admission-policies", "config-sync-node-placement.yaml")
29+
nt.T.Cleanup(func() {
30+
nt.Must(nt.Shell.Kubectl("delete", "--ignore-not-found", "-f", mapFile))
31+
})
32+
nt.Must(nt.Shell.Kubectl("apply", "-f", mapFile))
33+
// TODO: is there a way to wait for MutatingAdmissionPolicy readiness? (it doesn't appear so)
34+
// sleep hack to give time to propagate
35+
time.Sleep(5 * time.Second)
36+
37+
// expected nodeAffinity from the example MutatingAdmissionPolicy yaml
38+
exampleNodeAffinity := &corev1.NodeAffinity{
39+
PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{
40+
{
41+
Weight: 1,
42+
Preference: corev1.NodeSelectorTerm{
43+
MatchExpressions: []corev1.NodeSelectorRequirement{
44+
{
45+
Key: "another-node-label-key",
46+
Operator: corev1.NodeSelectorOpIn,
47+
Values: []string{"another-node-label-value"},
48+
},
49+
},
50+
},
51+
},
52+
},
53+
}
54+
55+
// bounce reconciler-manager Pod and verify the nodeAffinity is applied by MAP
56+
nt.Must(nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.ManagerName,
57+
testpredicates.HasExactlyNodeAffinity(&corev1.NodeAffinity{})))
58+
nt.T.Log("Replacing the reconciler-manager Pod to validate nodeAffinity is added")
59+
nomostest.DeletePodByLabel(nt, "app", reconcilermanager.ManagerName, false)
60+
nt.Must(nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.ManagerName,
61+
testpredicates.HasExactlyNodeAffinity(exampleNodeAffinity)))
62+
63+
// update the RootSync to trigger a Deployment change, verify new reconciler Pod has nodeAffinity
64+
rootSync := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, nomostest.DefaultRootSyncID.Name)
65+
rootSync.Spec.Git.Dir = "foo"
66+
nt.Must(nt.KubeClient.Apply(rootSync))
67+
nt.Must(rootSyncGitRepo.Add("foo/ns.yaml", k8sobjects.NamespaceObject("test-map-ns")))
68+
nt.Must(rootSyncGitRepo.CommitAndPush("add foo-ns under foo/ dir"))
69+
nt.Must(nt.WatchForSync(kinds.RootSyncV1Beta1(), rootSync.Name, configsync.ControllerNamespace,
70+
&syncsource.GitSyncSource{
71+
ExpectedCommit: rootSyncGitRepo.MustHash(t),
72+
ExpectedDirectory: "foo",
73+
}))
74+
nt.Must(nt.Validate("test-map-ns", "", &corev1.Namespace{}))
75+
nt.Must(nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.Reconciler,
76+
testpredicates.HasExactlyNodeAffinity(exampleNodeAffinity)))
77+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Scheduling Config Sync system pods using MutatingAdmissionPolicies
2+
3+
[MutatingAdmissionPolicy] can be used to configure the way the Config Sync's
4+
system Pods are scheduled.
5+
6+
The [example in this directory](./config-sync-node-placement.yaml) demonstrates
7+
how to inject `nodeAffinity` into all Pods in the `config-management-system` Namespace.
8+
9+
Note that this is just a demonstrative example, and different `matchConstraints` and
10+
`mutations` can be applied depending on the use case.
11+
12+
Caveats:
13+
14+
- MutatingAdmissionPolicy is currently in alpha and requires feature gate enablement. It is [targeted to enter beta in k8s 1.34].
15+
- MutatingAdmissionPolicy does not update existing Pods. Pods created before the policy was applied must be updated/recreated.
16+
17+
18+
19+
20+
[MutatingAdmissionPolicy]: https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/
21+
[targeted to enter beta in k8s 1.34]: https://github.com/kubernetes/enhancements/issues/3962
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
apiVersion: admissionregistration.k8s.io/v1alpha1
2+
kind: MutatingAdmissionPolicy
3+
metadata:
4+
name: "configsync-nodeplacement"
5+
spec:
6+
# This example uniformly applies the nodeAffinity to *all* Pods in config-management-system.
7+
# Different matchConstraints can be used for more granular mutation.
8+
matchConstraints:
9+
namespaceSelector:
10+
matchLabels:
11+
kubernetes.io/metadata.name: config-management-system
12+
resourceRules:
13+
- apiGroups: [""]
14+
apiVersions: ["v1"]
15+
operations: ["CREATE", "UPDATE"]
16+
resources: ["pods"]
17+
failurePolicy: Fail
18+
reinvocationPolicy: IfNeeded
19+
mutations:
20+
# Simple example of adding nodeAffinity: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
21+
# Similar mutations can be applied for nodeSelector/tolerations/etc
22+
- patchType: "JSONPatch"
23+
jsonPatch:
24+
expression: >
25+
[
26+
JSONPatch{
27+
op: "add", path: "/spec/affinity",
28+
value: Object.spec.affinity{
29+
nodeAffinity: Object.spec.affinity.nodeAffinity{
30+
preferredDuringSchedulingIgnoredDuringExecution: [
31+
Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution{
32+
weight: 1,
33+
preference: Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.preference{
34+
matchExpressions: [
35+
Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.preference.matchExpressions{
36+
key: "another-node-label-key",
37+
operator: "In",
38+
values: [
39+
"another-node-label-value"
40+
]
41+
}
42+
]
43+
}
44+
}
45+
]
46+
}
47+
}
48+
}
49+
]
50+
---
51+
apiVersion: admissionregistration.k8s.io/v1alpha1
52+
kind: MutatingAdmissionPolicyBinding
53+
metadata:
54+
name: "configsync-nodeplacement"
55+
spec:
56+
policyName: "configsync-nodeplacement"

0 commit comments

Comments
 (0)