Skip to content

Commit 8c80c6b

Browse files
authored
test: using MutatingAdmissionPolicy to schedule CS pods (#1777)
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 1b13cb4 commit 8c80c6b

File tree

7 files changed

+242
-2
lines changed

7 files changed

+242
-2
lines changed

e2e/nomostest/clusters/kind.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/docker"
2727
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/taskgroup"
2828
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/testing"
29+
"k8s.io/apiserver/pkg/features"
2930
"sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
3031
"sigs.k8s.io/kind/pkg/cluster"
3132
)
@@ -155,6 +156,15 @@ 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+
// MutatingAdmissionPolicy is currently off by default at the time of writing.
160+
// It is enabled so that it can be tested with Config Sync.
161+
// The feature gate can be removed once MutatingAdmissionPolicy enabled by default.
162+
FeatureGates: map[string]bool{
163+
string(features.MutatingAdmissionPolicy): true,
164+
},
165+
RuntimeConfig: map[string]string{
166+
"admissionregistration.k8s.io/v1beta1": "true",
167+
},
158168
// Enable ValidatingAdmissionWebhooks in the Kind cluster, as these
159169
// are disabled by default.
160170
// Also mount etcd to tmpfs for memory-backed storage.
@@ -166,7 +176,7 @@ etcd:
166176
dataDir: /tmp/etcd
167177
apiServer:
168178
extraArgs:
169-
"enable-admission-plugins": "ValidatingAdmissionWebhook"`,
179+
"enable-admission-plugins": "ValidatingAdmissionWebhook,MutatingAdmissionPolicy,ValidatingAdmissionPolicy"`,
170180
},
171181
}),
172182
// Retain nodes for debugging logs.

e2e/nomostest/config_sync.go

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

1413+
// ValidatePodByLabel validates that all Pods matching the provided label pass the
1414+
// provided list of predicates.
1415+
func ValidatePodByLabel(nt *NT, label, value string, predicates ...testpredicates.Predicate) error {
1416+
newPods := &corev1.PodList{}
1417+
if err := nt.KubeClient.List(newPods, client.InNamespace(configmanagement.ControllerNamespace), client.MatchingLabels{label: value}); err != nil {
1418+
return err
1419+
}
1420+
tg := taskgroup.New()
1421+
for _, pod := range newPods.Items {
1422+
po := pod
1423+
tg.Go(func() error {
1424+
return nt.Validate(po.Name, po.Namespace, &corev1.Pod{}, predicates...)
1425+
})
1426+
}
1427+
return tg.Wait()
1428+
}
1429+
14131430
// NewPodReady checks if the new created pods are ready.
14141431
// It also checks if the new children pods that are managed by the pods are ready.
14151432
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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package e2e
16+
17+
import (
18+
"path/filepath"
19+
"testing"
20+
"time"
21+
22+
"github.com/GoogleContainerTools/config-sync/e2e/nomostest"
23+
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/ntopts"
24+
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/syncsource"
25+
nomostesting "github.com/GoogleContainerTools/config-sync/e2e/nomostest/testing"
26+
"github.com/GoogleContainerTools/config-sync/e2e/nomostest/testpredicates"
27+
"github.com/GoogleContainerTools/config-sync/pkg/api/configsync"
28+
"github.com/GoogleContainerTools/config-sync/pkg/core/k8sobjects"
29+
"github.com/GoogleContainerTools/config-sync/pkg/kinds"
30+
"github.com/GoogleContainerTools/config-sync/pkg/reconcilermanager"
31+
corev1 "k8s.io/api/core/v1"
32+
)
33+
34+
// This test currently requires KinD because MutatingAdmissionPolicy is alpha
35+
// and requires a feature gate.
36+
func TestMutatingAdmissionPolicy(t *testing.T) {
37+
nt := nomostest.New(t, nomostesting.Reconciliation2,
38+
ntopts.SyncWithGitSource(nomostest.DefaultRootSyncID, ntopts.Unstructured),
39+
ntopts.RequireKind(t))
40+
rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(nomostest.DefaultRootSyncID)
41+
42+
mapFile := filepath.Join(".", "..", "..", "examples", "mutating-admission-policies", "config-sync-node-placement.yaml")
43+
nt.T.Cleanup(func() {
44+
nt.Must(nt.Shell.Kubectl("delete", "--ignore-not-found", "-f", mapFile))
45+
nt.Must(nomostest.Wait(nt.T, "reconciler-manager has no nodeAffinity", time.Minute, func() error {
46+
if err := nomostest.DeletePodByLabel(nt, "app", reconcilermanager.ManagerName, false); err != nil {
47+
return err
48+
}
49+
return nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.ManagerName,
50+
testpredicates.HasExactlyNodeAffinity(&corev1.NodeAffinity{}))
51+
}))
52+
})
53+
nt.Must(nt.Shell.Kubectl("apply", "-f", mapFile))
54+
55+
// expected nodeAffinity from the example MutatingAdmissionPolicy yaml
56+
exampleNodeAffinity := &corev1.NodeAffinity{
57+
PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{
58+
{
59+
Weight: 1,
60+
Preference: corev1.NodeSelectorTerm{
61+
MatchExpressions: []corev1.NodeSelectorRequirement{
62+
{
63+
Key: "another-node-label-key",
64+
Operator: corev1.NodeSelectorOpIn,
65+
Values: []string{"another-node-label-value"},
66+
},
67+
},
68+
},
69+
},
70+
},
71+
}
72+
73+
// bounce reconciler-manager Pod and verify the nodeAffinity is applied by MAP
74+
nt.Must(nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.ManagerName,
75+
testpredicates.HasExactlyNodeAffinity(&corev1.NodeAffinity{})))
76+
nt.T.Log("Replacing the reconciler-manager Pod to validate nodeAffinity is added")
77+
// MutatingAdmissionPolicy does not surface a readiness status, so need to retry.
78+
nt.Must(nomostest.Wait(nt.T, "reconciler-manager has mutated nodeAffinity", time.Minute, func() error {
79+
if err := nomostest.DeletePodByLabel(nt, "app", reconcilermanager.ManagerName, false); err != nil {
80+
return err
81+
}
82+
return nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.ManagerName,
83+
testpredicates.HasExactlyNodeAffinity(exampleNodeAffinity))
84+
}))
85+
86+
// update the RootSync to trigger a Deployment change, verify new reconciler Pod has nodeAffinity
87+
rootSync := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, nomostest.DefaultRootSyncID.Name)
88+
rootSync.Spec.Git.Dir = "foo"
89+
nt.Must(nt.KubeClient.Apply(rootSync))
90+
nt.Must(rootSyncGitRepo.Add("foo/ns.yaml", k8sobjects.NamespaceObject("test-map-ns")))
91+
nt.Must(rootSyncGitRepo.CommitAndPush("add foo-ns under foo/ dir"))
92+
nt.Must(nt.WatchForSync(kinds.RootSyncV1Beta1(), rootSync.Name, configsync.ControllerNamespace,
93+
&syncsource.GitSyncSource{
94+
ExpectedCommit: rootSyncGitRepo.MustHash(t),
95+
ExpectedDirectory: "foo",
96+
}))
97+
nt.Must(nt.Validate("test-map-ns", "", &corev1.Namespace{}))
98+
nt.Must(nomostest.ValidatePodByLabel(nt, "app", reconcilermanager.Reconciler,
99+
testpredicates.HasExactlyNodeAffinity(exampleNodeAffinity)))
100+
}
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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
apiVersion: admissionregistration.k8s.io/v1beta1
16+
kind: MutatingAdmissionPolicy
17+
metadata:
18+
name: "configsync-nodeplacement"
19+
spec:
20+
# This example uniformly applies the nodeAffinity to *all* Pods in config-management-system.
21+
# Different matchConstraints can be used for more granular mutation.
22+
matchConstraints:
23+
namespaceSelector:
24+
matchLabels:
25+
kubernetes.io/metadata.name: config-management-system
26+
resourceRules:
27+
- apiGroups: [""]
28+
apiVersions: ["v1"]
29+
operations: ["CREATE", "UPDATE"]
30+
resources: ["pods"]
31+
failurePolicy: Fail
32+
reinvocationPolicy: IfNeeded
33+
mutations:
34+
# Simple example of adding nodeAffinity: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
35+
# Similar mutations can be applied for nodeSelector/tolerations/etc
36+
- patchType: "JSONPatch"
37+
jsonPatch:
38+
expression: >
39+
[
40+
JSONPatch{
41+
op: "add", path: "/spec/affinity",
42+
value: Object.spec.affinity{
43+
nodeAffinity: Object.spec.affinity.nodeAffinity{
44+
preferredDuringSchedulingIgnoredDuringExecution: [
45+
Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution{
46+
weight: 1,
47+
preference: Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.preference{
48+
matchExpressions: [
49+
Object.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution.preference.matchExpressions{
50+
key: "another-node-label-key",
51+
operator: "In",
52+
values: [
53+
"another-node-label-value"
54+
]
55+
}
56+
]
57+
}
58+
}
59+
]
60+
}
61+
}
62+
}
63+
]
64+
---
65+
apiVersion: admissionregistration.k8s.io/v1beta1
66+
kind: MutatingAdmissionPolicyBinding
67+
metadata:
68+
name: "configsync-nodeplacement"
69+
spec:
70+
policyName: "configsync-nodeplacement"

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ require (
4343
k8s.io/api v0.34.1
4444
k8s.io/apiextensions-apiserver v0.34.1
4545
k8s.io/apimachinery v0.34.1
46+
k8s.io/apiserver v0.34.1
4647
k8s.io/cli-runtime v0.34.1
4748
k8s.io/client-go v0.34.1
4849
k8s.io/cluster-registry v0.0.6
@@ -173,7 +174,6 @@ require (
173174
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
174175
gopkg.in/inf.v0 v0.9.1 // indirect
175176
gopkg.in/yaml.v2 v2.4.0 // indirect
176-
k8s.io/apiserver v0.34.1 // indirect
177177
k8s.io/component-base v0.34.1 // indirect
178178
k8s.io/component-helpers v0.34.1 // indirect
179179
k8s.io/controller-manager v0.34.0 // indirect

0 commit comments

Comments
 (0)