Repository: linkerd/linkerd2
Pull request #8137 (2022-03-24 15:20:07)

Add nodeAffinity support to assign linkerd pods to a group of nodes.
Subject
Add nodeAffinity support to assign linkerd pods to a group of nodes.

Problem
Spread linkerd pods among different node pools. Described deeply in this [Slack](https://linkerd.slack.com/archives/C89RTCWJF/p1647855153313799) thread.

Solution
Introduce `nodeAffinity` helm value that will be rendered in specification of linkerd pods. The value was introduced in a smiliar way as `tolerations`

Validation
Not sure how to add a test. What would you expect to be added? A golang test?

Fixes [8136](https://github.com/linkerd/linkerd2/issues/8136)

Signed-off-by: Michal Romanowski <michal.rom089@gmail.com>


michalrom089: @alpeb When running unit-test update I encountered this error:
```bash
FAIL    github.com/linkerd/linkerd2/viz/tap/pkg 1.540s
?       github.com/linkerd/linkerd2/web [no test files]
flag provided but not defined: -update
Usage of /tmp/go-build4090075835/b1377/srv.test:
```

I can see that `.golden` files has been updated. 
alpeb: > @alpeb When running unit-test update I encountered this error:
> 
> ```shell
> FAIL    github.com/linkerd/linkerd2/viz/tap/pkg 1.540s
> ?       github.com/linkerd/linkerd2/web [no test files]
> flag provided but not defined: -update
> Usage of /tmp/go-build4090075835/b1377/srv.test:
> ```
> 
> I can see that `.golden` files has been updated.

You can disregard that error. Also please keep an eye on #8135 in case it merges first because it'll generate conflicts with this PR.
michalrom089: > Everything looks good to me. Thanks a lot for the work you've done so far!
> 
> I have tried to use the `nodeAffinity` value when installing the control plane, the value seems to be rendered a bit odd. Perhaps I'm doing something wrong?
> 
> Input:
> 
> ```yaml
> requiredDuringSchedulingIgnoredDuringExecution:
> nodeSelectorTerms:
> - matchExpressions:
>   - key: kubernetes.io/os
>     operator: In
>     values:
>     - linux
> ```
> 
> Install:
> 
> ```shell
> $ bin/linkerd install --set-file "nodeAffinity=<affinity-values>.yaml" > rendered.yaml
> 
> $ grep 'nodeAffinity' -A 5 rendered.yaml
> 
> 1211:    nodeAffinity: |
> 1212-      requiredDuringSchedulingIgnoredDuringExecution:
> 1213-      nodeSelectorTerms:
> 1214-      - matchExpressions:
> 1215-        - key: kubernetes.io/os
> 1216-          operator: In
> --
> 1470:        nodeAffinity:
> 1471-          |
> 1472-            requiredDuringSchedulingIgnoredDuringExecution:
> 1473-            nodeSelectorTerms:
> 1474-            - matchExpressions:
> 1475-              - key: kubernetes.io/os
> --
> 1819:        nodeAffinity:
> 1820-          |
> 1821-            requiredDuringSchedulingIgnoredDuringExecution:
> 1822-            nodeSelectorTerms:
> 1823-            - matchExpressions:
> 1824-              - key: kubernetes.io/os
> --
> 2191:        nodeAffinity:
> 2192-          |
> 2193-            requiredDuringSchedulingIgnoredDuringExecution:
> 2194-            nodeSelectorTerms:
> 2195-            - matchExpressions:
> 2196-              - key: kubernetes.io/os
> ```
> 
> From `rendered.yaml`
> 
> ```yaml
>  name: linkerd-destination
>   namespace: linkerd
> spec:
>   replicas: 1
>   selector:
>     matchLabels:
>       linkerd.io/control-plane-component: destination
>       linkerd.io/control-plane-ns: linkerd
>       linkerd.io/proxy-deployment: linkerd-destination
>   template:
>     metadata:
>       annotations:
>         linkerd.io/created-by: linkerd/cli dev-4da6fa1f-matei
>         linkerd.io/proxy-version: dev-4da6fa1f-matei
>         config.linkerd.io/default-inbound-policy: "all-unauthenticated"
>       labels:
>         linkerd.io/control-plane-component: destination
>         linkerd.io/control-plane-ns: linkerd
>         linkerd.io/workload-ns: linkerd
>         linkerd.io/proxy-deployment: linkerd-destination
>     spec:
>       nodeSelector:
>         kubernetes.io/os: linux
>       affinity:
>         nodeAffinity:
>           |
>             requiredDuringSchedulingIgnoredDuringExecution:
>             nodeSelectorTerms:
>             - matchExpressions:
>               - key: kubernetes.io/os
>                 operator: In
>                 values:
>                 - linuxcontainers:
> ```
> 
> Similarly, the identity component has the same format and it cannot be applied.
> 
> ```yaml
> apiVersion: apps/v1
> kind: Deployment
> metadata:
>   annotations:
>     linkerd.io/created-by: linkerd/cli dev-4da6fa1f-matei
>   labels:
>     app.kubernetes.io/name: identity
>     app.kubernetes.io/part-of: Linkerd
>     app.kubernetes.io/version: dev-4da6fa1f-matei
>     linkerd.io/control-plane-component: identity
>     linkerd.io/control-plane-ns: linkerd
>   name: linkerd-identity
>   namespace: linkerd
> spec:
>   replicas: 1
>   selector:
>     matchLabels:
>       linkerd.io/control-plane-component: identity
>       linkerd.io/control-plane-ns: linkerd
>       linkerd.io/proxy-deployment: linkerd-identity
>   template:
>     metadata:
>       annotations:
>         linkerd.io/created-by: linkerd/cli dev-4da6fa1f-matei
>         linkerd.io/proxy-version: dev-4da6fa1f-matei
>         config.linkerd.io/default-inbound-policy: "all-unauthenticated"
>       labels:
>         linkerd.io/control-plane-component: identity
>         linkerd.io/control-plane-ns: linkerd
>         linkerd.io/workload-ns: linkerd
>         linkerd.io/proxy-deployment: linkerd-identity
>     spec:
>       nodeSelector:
>         kubernetes.io/os: linux
>       affinity:
>         nodeAffinity:
>           |
>             requiredDuringSchedulingIgnoredDuringExecution:
>             nodeSelectorTerms:
>             - matchExpressions:
>               - key: kubernetes.io/os
>                 operator: In
>                 values:
>                 - linuxcontainers:
>       - args:
>         - identity
>         - -log-level=info
>         - -log-format=plain
>         - -controller-namespace=linkerd
>         - -identity-trust-domain=cluster.local
>         - -identity-issuance-lifetime=24h0m0s
>         - -identity-clock-skew-allowance=20s
>         - -identity-scheme=linkerd.io/tls
>         - -enable-pprof=false
>         env:
>         - name: LINKERD_DISABLED
>           value: "linkerd-await cannot block the identity controller"
>         image: cr.l5d.io/linkerd/controller:dev-4da6fa1f-matei
>         imagePullPolicy: IfNotPresent
>         livenessProbe:
>           httpGet:
>             path: /ping
>             port: 9990
>           initialDelaySeconds: 10
>         name: identity
>         ports:
>         - containerPort: 8080
>           name: grpc
>         - containerPort: 9990
>           name: admin-http
>         readinessProbe:
>           failureThreshold: 7
>           httpGet:
>             path: /ready
>             port: 9990
>         securityContext:
>           runAsUser: 2103
>           allowPrivilegeEscalation: false
>         volumeMounts:
>         - mountPath: /var/run/linkerd/identity/issuer
>           name: identity-issuer
>         - mountPath: /var/run/linkerd/identity/trust-roots/
>           name: trust-roots
>       - env:
>         - name: _pod_name
>           valueFrom:
>             fieldRef:
>               fieldPath: metadata.name
>         - name: _pod_ns
>           valueFrom:
>             fieldRef:
>               fieldPath: metadata.namespace
>         - name: _pod_nodeName
>           valueFrom:
>             fieldRef:
>               fieldPath: spec.nodeName
>         - name: LINKERD2_PROXY_INBOUND_PORTS_REQUIRE_TLS
>           value: "8080"
>         - name: LINKERD2_PROXY_LOG
>           value: "warn,linkerd=info"
>         - name: LINKERD2_PROXY_LOG_FORMAT
>           value: "plain"
>         - name: LINKERD2_PROXY_DESTINATION_SVC_ADDR
>           value: linkerd-dst-headless.linkerd.svc.cluster.local.:8086
>         - name: LINKERD2_PROXY_DESTINATION_PROFILE_NETWORKS
>           value: "10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16"
>         - name: LINKERD2_PROXY_POLICY_SVC_ADDR
>           value: linkerd-policy.linkerd.svc.cluster.local.:8090
>         - name: LINKERD2_PROXY_POLICY_WORKLOAD
>           value: "$(_pod_ns):$(_pod_name)"
>         - name: LINKERD2_PROXY_INBOUND_DEFAULT_POLICY
>           value: all-unauthenticated
>         - name: LINKERD2_PROXY_POLICY_CLUSTER_NETWORKS
>           value: "10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16"
>         - name: LINKERD2_PROXY_INBOUND_CONNECT_TIMEOUT
>           value: "100ms"
>         - name: LINKERD2_PROXY_OUTBOUND_CONNECT_TIMEOUT
>           value: "1000ms"
>         - name: LINKERD2_PROXY_CONTROL_LISTEN_ADDR
>           value: 0.0.0.0:4190
>         - name: LINKERD2_PROXY_ADMIN_LISTEN_ADDR
>           value: 0.0.0.0:4191
>         - name: LINKERD2_PROXY_OUTBOUND_LISTEN_ADDR
>           value: 127.0.0.1:4140
>         - name: LINKERD2_PROXY_INBOUND_LISTEN_ADDR
>           value: 0.0.0.0:4143
>         - name: LINKERD2_PROXY_INBOUND_IPS
>           valueFrom:
>             fieldRef:
>               fieldPath: status.podIPs
>         - name: LINKERD2_PROXY_INBOUND_PORTS
>           value: "8080,9990"
>         - name: LINKERD2_PROXY_DESTINATION_PROFILE_SUFFIXES
>           value: svc.cluster.local.
>         - name: LINKERD2_PROXY_INBOUND_ACCEPT_KEEPALIVE
>           value: 10000ms
>         - name: LINKERD2_PROXY_OUTBOUND_CONNECT_KEEPALIVE
>           value: 10000ms
>         - name: LINKERD2_PROXY_INBOUND_PORTS_DISABLE_PROTOCOL_DETECTION
>           value: "25,587,3306,4444,5432,6379,9300,11211"
>         - name: LINKERD2_PROXY_DESTINATION_CONTEXT
>           value: |
>             {"ns":"$(_pod_ns)", "nodeName":"$(_pod_nodeName)"}
>         - name: _pod_sa
>           valueFrom:
>             fieldRef:
>               fieldPath: spec.serviceAccountName
>         - name: LINKERD2_PROXY_IDENTITY_DIR
>           value: /var/run/linkerd/identity/end-entity
>         - name: LINKERD2_PROXY_IDENTITY_TRUST_ANCHORS
>           valueFrom:
>             configMapKeyRef:
>               name: linkerd-identity-trust-roots
>               key: ca-bundle.crt
>         - name: LINKERD2_PROXY_IDENTITY_TOKEN_FILE
>           value: /var/run/secrets/tokens/linkerd-identity-token
>         - name: LINKERD2_PROXY_IDENTITY_SVC_ADDR
>           value: localhost.:8080
>         - name: LINKERD2_PROXY_IDENTITY_LOCAL_NAME
>           value: $(_pod_sa).$(_pod_ns).serviceaccount.identity.linkerd.cluster.local
>         - name: LINKERD2_PROXY_IDENTITY_SVC_NAME
>           value: linkerd-identity.linkerd.serviceaccount.identity.linkerd.cluster.local
>         - name: LINKERD2_PROXY_DESTINATION_SVC_NAME
>           value: linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local
>         - name: LINKERD2_PROXY_POLICY_SVC_NAME
>           value: linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local
>         image: cr.l5d.io/linkerd/proxy:dev-4da6fa1f-matei
>         imagePullPolicy: IfNotPresent
>         livenessProbe:
>           httpGet:
>             path: /live
>             port: 4191
>           initialDelaySeconds: 10
>         name: linkerd-proxy
>         ports:
>         - containerPort: 4143
>           name: linkerd-proxy
>         - containerPort: 4191
>           name: linkerd-admin
>         readinessProbe:
>           httpGet:
>             path: /ready
>             port: 4191
>           initialDelaySeconds: 2
>         resources:
>         securityContext:
>           allowPrivilegeEscalation: false
>           readOnlyRootFilesystem: true
>           runAsUser: 2102
>         terminationMessagePolicy: FallbackToLogsOnError
>         volumeMounts:
>         - mountPath: /var/run/linkerd/identity/end-entity
>           name: linkerd-identity-end-entity
>         - mountPath: /var/run/secrets/tokens
>           name: linkerd-identity-token
>       initContainers:
>       - args:
>         - --incoming-proxy-port
>         - "4143"
>         - --outgoing-proxy-port
>         - "4140"
>         - --proxy-uid
>         - "2102"
>         - --inbound-ports-to-ignore
>         - "4190,4191,4567,4568"
>         - --outbound-ports-to-ignore
>         - "443"
>         image: cr.l5d.io/linkerd/proxy-init:v1.5.3
>         imagePullPolicy: IfNotPresent
>         name: linkerd-init
>         resources:
>           limits:
>             cpu: "100m"
>             memory: "20Mi"
>           requests:
>             cpu: "100m"
>             memory: "20Mi"
>         securityContext:
>           allowPrivilegeEscalation: false
>           capabilities:
>             add:
>             - NET_ADMIN
>             - NET_RAW
>           privileged: false
>           runAsNonRoot: true
>           readOnlyRootFilesystem: true
>         terminationMessagePolicy: FallbackToLogsOnError
>         volumeMounts:
>         - mountPath: /run
>           name: linkerd-proxy-init-xtables-lock
>       serviceAccountName: linkerd-identity
>       volumes:
>       - name: identity-issuer
>         secret:
>           secretName: linkerd-identity-issuer
>       - configMap:
>           name: linkerd-identity-trust-roots
>         name: trust-roots
>       - emptyDir: {}
>         name: linkerd-proxy-init-xtables-lock
>       - name: linkerd-identity-token
>         projected:
>           sources:
>           - serviceAccountToken:
>               path: linkerd-identity-token
>               expirationSeconds: 86400
>               audience: identity.l5d.io
>       - emptyDir:
>           medium: Memory
>         name: linkerd-identity-end-entity
> ```
> 
> `$ kubectl apply -f id.yaml` --
> 
> ```
> error: error parsing id.yaml: error converting YAML to JSON: yaml: line 44: did not find expected key
> 
> $ awk '{if (NR==44) print $0}' id.yaml
>                 - linuxcontainers:
> ```
> 
> It seems the next field (containers) is rolled into this one? Adding a newline in my manifest didn't seem to do anything, once I added a newline, this is the output I got:
> 
> ```
> 2192:        nodeAffinity:
> 2193-          |+
> 2194-            requiredDuringSchedulingIgnoredDuringExecution:
> 2195-            nodeSelectorTerms:
> 2196-            - matchExpressions:
> 2197-              - key: kubernetes.io/os
> 2198-                operator: In
> 2199-                values:
> 2200-                - linuxcontainers:
> ```

I don't think you can use `--set-file` like that. It is used to set a file content to a specific property (e.g. assign a whole file to a [ConfigMap data](https://kubernetes.io/docs/concepts/configuration/configmap/#configmaps-and-pods). That's why you have that leading `|` character.

If you want to load affinity from a separate file, you can do:
```yaml
# affinity.yml
nodeAffinity:
  requiredDuringSchedulingIgnoredDuringExecution:
    nodeSelectorTerms:
    - matchExpressions:
      - key: kubernetes.io/os
        operator: In
        values:
         - linux
```
and then:
```bash
bin/linkerd install -f affinity.yml > rendered.yaml
```
michalrom089: > You'll notice a lot of the golden files have empty newlines added when there is no affinity set. We can avoid these being added by stripping whitespace after the templated values.
> 
> For example in `destination.yaml` we can add a `-` at the end which ensures we strip whitespace to the right as well: `{{- include "linkerd.affinity" $tree | nindent 6 -}}`.
> 
> Remember this will change the golden files so you'll need to run `go test ./... --update` but we should see all the empty newlines disappear.

@kleimkuhler It seems that adding `-` at the end of include broke the yaml manifests. For some reason it's wraping the next line:
```yaml
    spec:
      {{- if .Values.tolerations -}}
      {{- include "linkerd.tolerations" . | nindent 6 }}
      {{- end -}}
      {{- include "linkerd.node-selector" . | nindent 6 }}
      {{- $_ := set $tree "component" "identity" -}}
      {{- include "linkerd.affinity" $tree | nindent 6 -}}
      containers:
```

produces
```
    spec:containers:
```

Before my change, the last Helm function didn't have the trailing `-` character - [link](https://github.com/linkerd/linkerd2/blob/98fe879d56b2953308425a3991ba743e651a14e2/charts/linkerd-control-plane/templates/destination.yaml#L169-L171).

In that situation, I imagine two possible solutions:

1. Moving the `{{- if or .Values.enablePodAntiAffinity .Values.nodeAffinity -}}` logic from the `_affinity.tpl` to each manifest that use `affinity` so we can remove the trailing `-` from the last helm function before 'containers'
2.  Accept those empty lines in the `.golden` files.

Which one do you prefer?
michalrom089: @kleimkuhler @mateiidavid Could you please take a look at the comments above 🙏 ?
mateiidavid: @michalrom089 sorry to keep you waiting. Thanks a lot for the example above, makes it easier to test stuff. I'm ok with accepting the empty lines in `*.golden` if it saves us from using conditionals in all of our charts. Not sure if @kleimkuhler has a different opinion though.
michalrom089: Ok, I've implemented the second option. Are we ready to merge it?

@kleimkuhler @mateiidavid 
mateiidavid: @michalrom089 this is good to merge as soon as it passes CI. I think a commit wasn't signed, do you mind squashing your commits and signing that off? As soon as the DCO check passes we can merge this in :) 
michalrom089: @mateiidavid I've made a rebase --signoff + force push

Patch:
diff --git a/.github/workflows/policy_controller.yml b/.github/workflows/policy_controller.yml
index 7c9395529..b13751bb1 100644
--- a/.github/workflows/policy_controller.yml
+++ b/.github/workflows/policy_controller.yml
@@ -20,7 +20,7 @@ env:
   CARGO_ACTION_FMT_VERSION: v0.1.3
   CARGO_INCREMENTAL: 0
   CARGO_NET_RETRY: 10
-  K3D_VERSION: v5.3.0
+  K3D_VERSION: v5.4.1
   PROTOC_NO_VENDOR: 1
   PROXY_INIT_VERSION: v1.5.3
   RUST_BACKTRACE: short
@@ -131,7 +131,7 @@ jobs:
 
           exit $ex
 
-  docker_build:
+  docker-build:
     runs-on: ubuntu-20.04
     strategy:
       matrix:
@@ -163,9 +163,52 @@ jobs:
           name: image-archives
           path: /home/runner/archives
 
+  integration-build:
+    runs-on: ubuntu-20.04
+    name: Integration build
+    timeout-minutes: 30
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
+
+      - name: Install rust
+        run: |
+          rm -rf $HOME/.cargo
+          bin/scurl -v https://sh.rustup.rs | sh -s -- -y --default-toolchain $(cat rust-toolchain)
+          source $HOME/.cargo/env
+          echo "PATH=$PATH" >> $GITHUB_ENV
+          bin/scurl -o /usr/local/bin/cargo-action-fmt "https://github.com/olix0r/cargo-action-fmt/releases/download/release%2F${CARGO_ACTION_FMT_VERSION}/cargo-action-fmt-x86_64-unknown-linux-gnu"
+          chmod 755 /usr/local/bin/cargo-action-fmt
+          bin/scurl -L https://get.nexte.st/latest/linux | tar xzf - -C /usr/local/bin
+          cargo version
+          cargo-action-fmt --version
+          cargo nextest --version
+
+      - run: mkdir target
+      - run: cargo fetch --locked
+      - run: cargo metadata --format-version 1 >target/metadata.json
+      - run: cargo nextest list --frozen --package=linkerd-policy-test --list-type=binaries-only --message-format=json >target/nextest.json
+      - name: Create tarball
+        run: |
+          mkdir -p /home/runner/archives
+          jq -r '.["rust-binaries"] | to_entries | .[].value | .["binary-path"]' target/nextest.json | sed -e "s,^$PWD/,," \
+            | xargs tar -czvf /home/runner/archives/nextest.tar.gz target/nextest.json target/metadata.json
+      - name: Upload artifact
+        uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
+        with:
+          name: image-archives
+          path: /home/runner/archives
+
+
   integration:
-    needs: [docker_build]
-    name: Policy controller integration
+    strategy:
+      matrix:
+        k8s:
+          - v1.20
+          - v1.23
+    name: Policy controller integration (k8s ${{ matrix.k8s }})
+
+    needs: [docker-build, integration-build]
     runs-on: ubuntu-20.04
     timeout-minutes: 20
     env:
@@ -182,26 +225,23 @@ jobs:
           echo "PATH=$PATH" >> $GITHUB_ENV
           bin/scurl -o /usr/local/bin/cargo-action-fmt "https://github.com/olix0r/cargo-action-fmt/releases/download/release%2F${CARGO_ACTION_FMT_VERSION}/cargo-action-fmt-x86_64-unknown-linux-gnu"
           chmod 755 /usr/local/bin/cargo-action-fmt
+          bin/scurl -L https://get.nexte.st/latest/linux | tar xzf - -C /usr/local/bin
           cargo version
           cargo-action-fmt --version
+          cargo nextest --version
 
       - run: bin/scurl -v https://raw.githubusercontent.com/k3d-io/k3d/${K3D_VERSION}/install.sh | bash
       - run: k3d --version
-      - run: k3d cluster create --no-lb --k3s-arg "--no-deploy=local-storage,traefik,servicelb,metrics-server@server:*"
+      - run: k3d cluster create --no-lb --k3s-arg "--no-deploy=local-storage,traefik,servicelb,metrics-server@server:*" --image +${{ matrix.k8s }}
       - run: kubectl version
 
-      # Build the tests before installing Linkerd and running the tests. This gives the cluster a
-      # chance to settle down before loading images, etc. It also ensures that the tests compile
-      # before doing extra work.
-      - run: cargo fetch --locked
-      - name: Build tests
-        run: cargo test -p linkerd-policy-test --frozen --no-run | cargo-action-fmt
-
       - name: Download image archives
         uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
         with:
           name: image-archives
           path: image-archives
+      - name: Load test binaries
+        run: tar -xzvf image-archives/nextest.tar.gz
       - name: Load images
         run: |
           docker pull -q docker.io/bitnami/kubectl:latest &
@@ -241,5 +281,6 @@ jobs:
 
       # Run the tests. We disable parallelism to avoid spurious timeouts caused
       # by resource contention.
-      - name: Run cargo test
-        run: cargo test -p linkerd-policy-test --frozen -- --test-threads=1
+      - run: cargo nextest run --cargo-metadata=target/metadata.json --binaries-metadata=target/nextest.json --test-threads=1
+        env:
+          NEXTEST_EXPERIMENTAL_REUSE_BUILD: 1
diff --git a/charts/linkerd-control-plane/templates/destination.yaml b/charts/linkerd-control-plane/templates/destination.yaml
index 334843b56..4b94da89e 100644
--- a/charts/linkerd-control-plane/templates/destination.yaml
+++ b/charts/linkerd-control-plane/templates/destination.yaml
@@ -164,10 +164,8 @@ spec:
       {{- include "linkerd.tolerations" . | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "destination" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "destination" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
       {{- if not (empty .Values.destinationProxyResources) }}
       {{- $r := merge .Values.destinationProxyResources .Values.proxy.resources }}
diff --git a/charts/linkerd-control-plane/templates/identity.yaml b/charts/linkerd-control-plane/templates/identity.yaml
index c4ed9761b..ec6193762 100644
--- a/charts/linkerd-control-plane/templates/identity.yaml
+++ b/charts/linkerd-control-plane/templates/identity.yaml
@@ -137,10 +137,8 @@ spec:
       {{- include "linkerd.tolerations" . | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "identity" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "identity" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
       - args:
         - identity
diff --git a/charts/linkerd-control-plane/templates/proxy-injector.yaml b/charts/linkerd-control-plane/templates/proxy-injector.yaml
index 8a473ab7f..b7c00a4ad 100644
--- a/charts/linkerd-control-plane/templates/proxy-injector.yaml
+++ b/charts/linkerd-control-plane/templates/proxy-injector.yaml
@@ -50,10 +50,8 @@ spec:
       {{- include "linkerd.tolerations" . | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "proxy-injector" "label" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "proxy-injector" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
       {{- if not (empty .Values.proxyInjectorProxyResources) }}
       {{- $r := merge .Values.proxyInjectorProxyResources .Values.proxy.resources }}
diff --git a/charts/linkerd-control-plane/values-ha.yaml b/charts/linkerd-control-plane/values-ha.yaml
index c309faeb5..0df6aec52 100644
--- a/charts/linkerd-control-plane/values-ha.yaml
+++ b/charts/linkerd-control-plane/values-ha.yaml
@@ -4,6 +4,8 @@
 
 enablePodAntiAffinity: true
 
+# nodeAffinity: 
+
 # proxy configuration
 proxy:
   resources:
diff --git a/charts/linkerd-control-plane/values.yaml b/charts/linkerd-control-plane/values.yaml
index 2185dc7d3..8f411cd6f 100644
--- a/charts/linkerd-control-plane/values.yaml
+++ b/charts/linkerd-control-plane/values.yaml
@@ -455,3 +455,8 @@ nodeSelector:
 # [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)
 # for more information
 #tolerations:
+
+# -|- NodeAffinity section, See the
+# [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity)
+# for more information
+#nodeAffinity:
diff --git a/charts/linkerd2-cni/templates/cni-plugin.yaml b/charts/linkerd2-cni/templates/cni-plugin.yaml
index 265aeea33..17756fd30 100644
--- a/charts/linkerd2-cni/templates/cni-plugin.yaml
+++ b/charts/linkerd2-cni/templates/cni-plugin.yaml
@@ -195,6 +195,10 @@ spec:
       {{- end }}
       nodeSelector:
         kubernetes.io/os: linux
+      {{- if .Values.nodeAffinity }}
+      affinity:
+      {{- include "linkerd.node-affinity" . | nindent 8 }}
+      {{- end }}
       hostNetwork: true
       serviceAccountName: linkerd-cni
       {{- if .Values.priorityClassName }}
diff --git a/charts/linkerd2-cni/values.yaml b/charts/linkerd2-cni/values.yaml
index ae6312b5d..256a039ff 100644
--- a/charts/linkerd2-cni/values.yaml
+++ b/charts/linkerd2-cni/values.yaml
@@ -41,6 +41,11 @@ privileged: false
 # for more information
 #tolerations:
 
+# -|- NodeAffinity section, See the
+# [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity)
+# for more information
+#nodeAffinity:
+
 #
 ## For Private docker registries, authentication is needed.
 # If the control plane service images are pulled from a
diff --git a/charts/partials/templates/_affinity.tpl b/charts/partials/templates/_affinity.tpl
index 926a4eea5..cec0db8ea 100644
--- a/charts/partials/templates/_affinity.tpl
+++ b/charts/partials/templates/_affinity.tpl
@@ -1,22 +1,38 @@
 {{ define "linkerd.pod-affinity" -}}
-affinity:
-  podAntiAffinity:
-    preferredDuringSchedulingIgnoredDuringExecution:
-    - podAffinityTerm:
-        labelSelector:
-          matchExpressions:
-          - key: {{ default "linkerd.io/control-plane-component" .label }}
-            operator: In
-            values:
-            - {{ .component }}
-        topologyKey: failure-domain.beta.kubernetes.io/zone
-      weight: 100
-    requiredDuringSchedulingIgnoredDuringExecution:
-    - labelSelector:
+podAntiAffinity:
+  preferredDuringSchedulingIgnoredDuringExecution:
+  - podAffinityTerm:
+      labelSelector:
         matchExpressions:
         - key: {{ default "linkerd.io/control-plane-component" .label }}
           operator: In
           values:
           - {{ .component }}
-      topologyKey: kubernetes.io/hostname
+      topologyKey: failure-domain.beta.kubernetes.io/zone
+    weight: 100
+  requiredDuringSchedulingIgnoredDuringExecution:
+  - labelSelector:
+      matchExpressions:
+      - key: {{ default "linkerd.io/control-plane-component" .label }}
+        operator: In
+        values:
+        - {{ .component }}
+    topologyKey: kubernetes.io/hostname
+{{- end }}
+
+{{ define "linkerd.node-affinity" -}}
+nodeAffinity:
+{{- toYaml .Values.nodeAffinity | trim | nindent 2 }}
+{{- end }}
+
+{{ define "linkerd.affinity" -}}
+{{- if or .Values.enablePodAntiAffinity .Values.nodeAffinity -}}
+affinity:
+{{- end }}
+{{- if .Values.enablePodAntiAffinity -}}
+{{- include "linkerd.pod-affinity" . | nindent 2 }}
+{{- end }}
+{{- if .Values.nodeAffinity -}}
+{{- include "linkerd.node-affinity" . | nindent 2 }}
+{{- end }}
 {{- end }}
diff --git a/multicluster/charts/linkerd-multicluster/templates/gateway.yaml b/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
index a89d6c02d..82f7c339d 100644
--- a/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
+++ b/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
@@ -1,5 +1,6 @@
 {{if .Values.gateway.enabled -}}
 ---
+{{- $tree := deepCopy . }}
 apiVersion: apps/v1
 kind: Deployment
 metadata:
@@ -34,10 +35,9 @@ spec:
       labels:
         app: {{.Values.gateway.name}}
     spec:
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "label" "app" "component" .Values.gateway.name -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" .Values.gateway.name -}}
+      {{- $_ := set $tree "label" "app" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
         - name: pause
           image: gcr.io/google_containers/pause:3.2
diff --git a/multicluster/charts/linkerd-multicluster/values-ha.yaml b/multicluster/charts/linkerd-multicluster/values-ha.yaml
index fc787e0dc..e5146ad7d 100644
--- a/multicluster/charts/linkerd-multicluster/values-ha.yaml
+++ b/multicluster/charts/linkerd-multicluster/values-ha.yaml
@@ -2,3 +2,5 @@ gateway:
   replicas: 3
 
 enablePodAntiAffinity: true
+
+# nodeAffinity:
diff --git a/multicluster/charts/linkerd-multicluster/values.yaml b/multicluster/charts/linkerd-multicluster/values.yaml
index fb543289a..e149ceb18 100644
--- a/multicluster/charts/linkerd-multicluster/values.yaml
+++ b/multicluster/charts/linkerd-multicluster/values.yaml
@@ -50,3 +50,8 @@ enablePSP: false
 # across hosts and zones for High Availability.
 # Enable this only when you have multiple replicas of components.
 enablePodAntiAffinity: false
+
+# -- NodeAffinity section, See the
+# [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity)
+# for more information
+# nodeAffinity:
diff --git a/pkg/charts/linkerd2/values.go b/pkg/charts/linkerd2/values.go
index 85877a6d9..fa4c38bd0 100644
--- a/pkg/charts/linkerd2/values.go
+++ b/pkg/charts/linkerd2/values.go
@@ -31,6 +31,7 @@ type (
 		ControllerUID                int64               `json:"controllerUID"`
 		EnableH2Upgrade              bool                `json:"enableH2Upgrade"`
 		EnablePodAntiAffinity        bool                `json:"enablePodAntiAffinity"`
+		NodeAffinity                 map[string]string   `json:"nodeAffinity"`
 		WebhookFailurePolicy         string              `json:"webhookFailurePolicy"`
 		DisableHeartBeat             bool                `json:"disableHeartBeat"`
 		HeartbeatSchedule            string              `json:"heartbeatSchedule"`
diff --git a/viz/charts/linkerd-viz/README.md b/viz/charts/linkerd-viz/README.md
index 98ac7b684..550cc7052 100644
--- a/viz/charts/linkerd-viz/README.md
+++ b/viz/charts/linkerd-viz/README.md
@@ -94,7 +94,7 @@ Kubernetes: `>=1.20.0-0`
 | defaultLogLevel | string | `"info"` | Log level for all the viz components |
 | defaultRegistry | string | `"cr.l5d.io/linkerd"` | Docker registry for all viz components |
 | defaultUID | int | `2103` | UID for all the viz components |
-| enablePSP | bool | `false` | Create Roles and RoleBindings to associate this extension's ServiceAccounts to the control plane PSP resource. This requires that `enabledPSP` is set to true on the control plane install. Note PSP has been deprecated since k8s v1.21 |
+| enablePSP | bool | `false` | NodeAffinity section, See the [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity) for more information nodeAffinity: -- Create Roles and RoleBindings to associate this extension's ServiceAccounts to the control plane PSP resource. This requires that `enabledPSP` is set to true on the control plane install. Note PSP has been deprecated since k8s v1.21 |
 | enablePodAntiAffinity | bool | `false` | Enables Pod Anti Affinity logic to balance the placement of replicas across hosts and zones for High Availability. Enable this only when you have multiple replicas of components. |
 | grafana.externalUrl | string | `nil` | url of a Grafana instance hosted off-cluster. Cannot be set if grafana.url is set. The reverse proxy will not be used for this URL. |
 | grafana.uidPrefix | string | `nil` | prefix for Grafana dashboard UID's, used when grafana.externalUrl is set. |
diff --git a/viz/charts/linkerd-viz/templates/metrics-api.yaml b/viz/charts/linkerd-viz/templates/metrics-api.yaml
index 909c064e2..7de102474 100644
--- a/viz/charts/linkerd-viz/templates/metrics-api.yaml
+++ b/viz/charts/linkerd-viz/templates/metrics-api.yaml
@@ -23,6 +23,7 @@ spec:
     port: 8085
     targetPort: 8085
 ---
+{{- $tree := deepCopy . }}
 apiVersion: apps/v1
 kind: Deployment
 metadata:
@@ -65,10 +66,9 @@ spec:
       {{- include "linkerd.tolerations" (dict "Values" .Values.metricsAPI) | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" (dict "Values" .Values.metricsAPI) | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "metrics-api" "label" "component" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "metrics-api" -}}
+      {{- $_ := set $tree "label" "component" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 -}}
       containers:
       - args:
         - -controller-namespace={{.Values.linkerdNamespace}}
diff --git a/viz/charts/linkerd-viz/templates/tap-injector.yaml b/viz/charts/linkerd-viz/templates/tap-injector.yaml
index 7aa24acfa..f1940e8a7 100644
--- a/viz/charts/linkerd-viz/templates/tap-injector.yaml
+++ b/viz/charts/linkerd-viz/templates/tap-injector.yaml
@@ -23,6 +23,7 @@ spec:
     port: 443
     targetPort: tap-injector
 ---
+{{- $tree := deepCopy . }}
 kind: Deployment
 apiVersion: apps/v1
 metadata:
@@ -68,10 +69,9 @@ spec:
       {{- include "linkerd.tolerations" . | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "tap-injector" "label" "component" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "tap-injector" -}}
+      {{- $_ := set $tree "label" "component" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
       - args:
         - injector
diff --git a/viz/charts/linkerd-viz/templates/tap.yaml b/viz/charts/linkerd-viz/templates/tap.yaml
index 586d7f2ab..4741c09f3 100644
--- a/viz/charts/linkerd-viz/templates/tap.yaml
+++ b/viz/charts/linkerd-viz/templates/tap.yaml
@@ -27,6 +27,7 @@ spec:
     port: 443
     targetPort: apiserver
 ---
+{{- $tree := deepCopy . }}
 kind: Deployment
 apiVersion: apps/v1
 metadata:
@@ -77,10 +78,9 @@ spec:
       {{- include "linkerd.tolerations" . | nindent 6 }}
       {{- end -}}
       {{- include "linkerd.node-selector" . | nindent 6 }}
-      {{- if .Values.enablePodAntiAffinity -}}
-      {{- $local := dict "component" "tap" "label" "component" -}}
-      {{- include "linkerd.pod-affinity" $local | nindent 6 -}}
-      {{- end }}
+      {{- $_ := set $tree "component" "tap" -}}
+      {{- $_ := set $tree "label" "component" -}}
+      {{- include "linkerd.affinity" $tree | nindent 6 }}
       containers:
       - args:
         - api
diff --git a/viz/charts/linkerd-viz/values-ha.yaml b/viz/charts/linkerd-viz/values-ha.yaml
index 03365d2b6..65ebcc450 100644
--- a/viz/charts/linkerd-viz/values-ha.yaml
+++ b/viz/charts/linkerd-viz/values-ha.yaml
@@ -4,6 +4,8 @@
 
 enablePodAntiAffinity: true
 
+# nodeAffinity:
+
 resources: &ha_resources
   cpu: &ha_resources_cpu
     limit: ""
diff --git a/viz/charts/linkerd-viz/values.yaml b/viz/charts/linkerd-viz/values.yaml
index 5fe00effa..3c57f5d20 100644
--- a/viz/charts/linkerd-viz/values.yaml
+++ b/viz/charts/linkerd-viz/values.yaml
@@ -46,6 +46,11 @@ tolerations: &default_tolerations
 # Enable this only when you have multiple replicas of components.
 enablePodAntiAffinity: false
 
+# -- NodeAffinity section, See the
+# [K8S documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity)
+# for more information
+# nodeAffinity:
+
 # -- Create Roles and RoleBindings to associate this extension's
 # ServiceAccounts to the control plane PSP resource. This requires that
 # `enabledPSP` is set to true on the control plane install. Note PSP has been


Test patch:
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index 87311047c..db6682b8a 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1981,6 +1983,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2345,6 +2348,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 6c5045f51..a0ae2af3c 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index aeb2cf1ad..45ed8b700 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 6c5045f51..a0ae2af3c 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index 131b9d276..7ae4b6ce2 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index e804ee914..f67cbe874 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1971,6 +1973,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2325,6 +2328,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 67d48a3f0..357d4eb78 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -1416,6 +1416,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: null
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index 5bd80212a..61433392b 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -1416,6 +1416,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: null
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 314726735..ac2acc80b 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -1320,6 +1320,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1571,6 +1572,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1911,6 +1913,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2224,6 +2227,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_helm_control_plane_output.golden b/cli/cmd/testdata/install_helm_control_plane_output.golden
index 336a660c5..893a55479 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output.golden
@@ -437,6 +437,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: linkerd-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -682,6 +683,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1025,6 +1027,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -1393,6 +1396,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
index 6e4c69b13..cb3de3b5b 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
@@ -464,6 +464,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: null
     linkerdVersion: linkerd-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 806fb4881..ab11768b7 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -464,6 +464,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: null
     linkerdVersion: linkerd-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations:
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index d80cb19c3..38aa7a86d 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -459,6 +459,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: null
     linkerdVersion: linkerd-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 0fb425305..697432c40 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1943,6 +1945,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2269,6 +2272,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 2f56e271f..6b4359e23 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -1386,6 +1386,7 @@ data:
     imagePullPolicy: ImagePullPolicy
     imagePullSecrets: null
     linkerdVersion: ""
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1637,6 +1638,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2353,6 +2356,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index 6035ec380..a0beb1f6a 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 0dbd0374c..4c2d2699d 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -1389,6 +1389,7 @@ data:
     imagePullPolicy: IfNotPresent
     imagePullSecrets: []
     linkerdVersion: install-control-plane-version
+    nodeAffinity: null
     nodeSelector:
       kubernetes.io/os: linux
     podAnnotations: {}
@@ -1640,6 +1641,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - identity
@@ -1980,6 +1982,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
@@ -2343,6 +2346,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/multicluster/cmd/testdata/install_default.golden b/multicluster/cmd/testdata/install_default.golden
index 4f77cb988..bf04b327f 100644
--- a/multicluster/cmd/testdata/install_default.golden
+++ b/multicluster/cmd/testdata/install_default.golden
@@ -34,6 +34,7 @@ spec:
       labels:
         app: linkerd-gateway
     spec:
+      
       containers:
         - name: pause
           image: gcr.io/google_containers/pause:3.2
diff --git a/multicluster/cmd/testdata/install_psp.golden b/multicluster/cmd/testdata/install_psp.golden
index 53e0dfc46..625cebc5c 100644
--- a/multicluster/cmd/testdata/install_psp.golden
+++ b/multicluster/cmd/testdata/install_psp.golden
@@ -34,6 +34,7 @@ spec:
       labels:
         app: linkerd-gateway
     spec:
+      
       containers:
         - name: pause
           image: gcr.io/google_containers/pause:3.2
diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go
index 29230f2ed..0cccdc6ae 100644
--- a/pkg/healthcheck/healthcheck_test.go
+++ b/pkg/healthcheck/healthcheck_test.go
@@ -2227,6 +2227,7 @@ data:
     disableHeartBeat: false
     enableH2Upgrade: true
     enablePodAntiAffinity: false
+    nodeAffinity: null
     cliVersion: CliVersion
     clusterDomain: cluster.local
     clusterNetworks: ClusterNetworks
diff --git a/policy-test/src/curl.rs b/policy-test/src/curl.rs
index 1adead4b7..bc5694a15 100644
--- a/policy-test/src/curl.rs
+++ b/policy-test/src/curl.rs
@@ -23,7 +23,10 @@ impl Runner {
             namespace: ns.to_string(),
             client: client.clone(),
         };
-        runner.create_rbac().await;
+        tokio::time::timeout(tokio::time::Duration::from_secs(60), runner.create_rbac())
+            .await
+            .expect("must create RBAC within a minute");
+
         runner
     }
 
@@ -92,6 +95,7 @@ impl Runner {
             },
         )
         .await;
+        super::await_service_account(&self.client, &self.namespace, "curl").await;
 
         create(
             &self.client,
diff --git a/policy-test/src/lib.rs b/policy-test/src/lib.rs
index b05521d45..85c7460c8 100644
--- a/policy-test/src/lib.rs
+++ b/policy-test/src/lib.rs
@@ -128,6 +128,12 @@ where
     )
     .await;
     tracing::trace!(?ns);
+    tokio::time::timeout(
+        tokio::time::Duration::from_secs(60),
+        await_service_account(&client, &ns.name(), "default"),
+    )
+    .await
+    .expect("Timed out waiting for a serviceaccount");
 
     tracing::trace!("Spawning");
     let test = test(client.clone(), ns.name());
@@ -163,6 +169,70 @@ where
     }
 }
 
+pub async fn await_service_account(client: &kube::Client, ns: &str, name: &str) {
+    use futures::StreamExt;
+    let secret_name = await_service_account_secret(client, ns, name).await;
+
+    tracing::trace!(name = %secret_name, "Waiting for secret");
+    tokio::pin! {
+        let secrets = kube::runtime::watcher(
+            kube::Api::<k8s::api::core::v1::Secret>::namespaced(client.clone(), ns),
+            kube::api::ListParams::default().fields(&format!("metadata.name={}", secret_name)),
+        );
+    }
+    loop {
+        match secrets
+            .next()
+            .await
+            .expect("secret watch must not end")
+            .expect("secret watch must not fail")
+        {
+            kube::runtime::watcher::Event::Restarted(secrets) if !secrets.is_empty() => break,
+            kube::runtime::watcher::Event::Applied(_) => break,
+            _ => {}
+        }
+    }
+}
+
+async fn await_service_account_secret(client: &kube::Client, ns: &str, name: &str) -> String {
+    use futures::StreamExt;
+
+    tracing::trace!(%name, "Waiting for serviceaccount");
+    tokio::pin! {
+        let sas = kube::runtime::watcher(
+            kube::Api::<k8s::ServiceAccount>::namespaced(client.clone(), ns),
+            kube::api::ListParams::default().fields(&format!("metadata.name={}", name)),
+        );
+    }
+    loop {
+        let ev = sas
+            .next()
+            .await
+            .expect("serviceaccounts watch must not end")
+            .expect("serviceaccounts watch must not fail");
+        tracing::info!(?ev);
+        match ev {
+            kube::runtime::watcher::Event::Restarted(sas) => {
+                if let Some(sa) = sas.get(0) {
+                    if let Some(sec) = sa.secrets.iter().flatten().next() {
+                        if let Some(name) = sec.name.as_ref() {
+                            return name.clone();
+                        }
+                    }
+                }
+            }
+            kube::runtime::watcher::Event::Applied(sa) => {
+                if let Some(sec) = sa.secrets.iter().flatten().next() {
+                    if let Some(name) = sec.name.as_ref() {
+                        return name.clone();
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
 pub fn random_suffix(len: usize) -> String {
     use rand::Rng;
 
diff --git a/viz/cmd/testdata/install_default.golden b/viz/cmd/testdata/install_default.golden
index 2d874134c..e6ae044ea 100644
--- a/viz/cmd/testdata/install_default.golden
+++ b/viz/cmd/testdata/install_default.golden
@@ -890,6 +890,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - api
@@ -1085,6 +1086,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - injector
diff --git a/viz/cmd/testdata/install_default_overrides.golden b/viz/cmd/testdata/install_default_overrides.golden
index 7c10db3c7..824d93942 100644
--- a/viz/cmd/testdata/install_default_overrides.golden
+++ b/viz/cmd/testdata/install_default_overrides.golden
@@ -890,6 +890,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - api
@@ -1085,6 +1086,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - injector
diff --git a/viz/cmd/testdata/install_prometheus_disabled.golden b/viz/cmd/testdata/install_prometheus_disabled.golden
index 89fca7b0e..4711036d8 100644
--- a/viz/cmd/testdata/install_prometheus_disabled.golden
+++ b/viz/cmd/testdata/install_prometheus_disabled.golden
@@ -609,6 +609,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - api
@@ -804,6 +805,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - injector
diff --git a/viz/cmd/testdata/install_prometheus_loglevel_from_args.golden b/viz/cmd/testdata/install_prometheus_loglevel_from_args.golden
index a70dc5e84..d65c67cdd 100644
--- a/viz/cmd/testdata/install_prometheus_loglevel_from_args.golden
+++ b/viz/cmd/testdata/install_prometheus_loglevel_from_args.golden
@@ -890,6 +890,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - api
@@ -1085,6 +1086,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - injector
diff --git a/viz/cmd/testdata/install_proxy_resources.golden b/viz/cmd/testdata/install_proxy_resources.golden
index 47df59595..e1043187d 100644
--- a/viz/cmd/testdata/install_proxy_resources.golden
+++ b/viz/cmd/testdata/install_proxy_resources.golden
@@ -898,6 +898,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - api
@@ -1093,6 +1094,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
+      
       containers:
       - args:
         - injector