Repository: GoogleContainerTools/skaffold
Pull request #7699 (2022-07-28 03:32:08)

fix: multiple renderers clean up isue
shd merge after #7698 

In this PR, 
Instead of having a `hasDeployed` to indicate if a deployed happened, code now uses a `deployManifests` which stores the last deployed manifests. 

Cleanup will use this in `dev` cycle to clean up all the manifests deployed by last iteration. 

Fixes Multiple Render clean up issue 
```
➜  multiple-renderers git:(fix_sig_and_dev_cleanup) ✗ ../../../out/skaffold dev -d gcr.io/tejal-gke1
Listing files to watch...
 - go-guestbook-backend
 - go-guestbook-frontend
Generating tags...
 - go-guestbook-backend -> gcr.io/tejal-gke1/go-guestbook-backend:v1.37.2-127-g465de3403-dirty
 - go-guestbook-frontend -> gcr.io/tejal-gke1/go-guestbook-frontend:v1.37.2-127-g465de3403-dirty
Checking cache...
 - go-guestbook-backend: Found. Tagging
 - go-guestbook-frontend: Found. Tagging
Tags used in deployment:
 - go-guestbook-backend -> gcr.io/tejal-gke1/go-guestbook-backend:v1.37.2-127-g465de3403-dirty@sha256:1aa0519da1663092bc55b4b64a7e673af164c4b9812c189959b58534e26e1ef7
 - go-guestbook-frontend -> gcr.io/tejal-gke1/go-guestbook-frontend:v1.37.2-127-g465de3403-dirty@sha256:c1a4b85cb88f8f4da283911c04fa854c86b92775731e00e5a47b5678c8b9aa48
Starting deploy...
 - deployment.apps/frontend configured
 - service/frontend configured
 - service/go-guestbook-mongodb configured
 - service/backend configured
 - deployment.apps/backend configured
 - deployment.apps/go-guestbook-mongodb configured
Waiting for deployments to stabilize...
 - deployment/go-guestbook-mongodb is ready. [2/3 deployment(s) still pending]
 - deployment/frontend is ready. [1/3 deployment(s) still pending]
 - deployment/backend is ready.
Deployments stabilized in 6.938 seconds
Press Ctrl+C to exit
Watching for changes...
[backend] 2022/07/28 02:33:48 backend server listening on port 8081
[frontend] 2022/07/28 02:33:47 frontend server listening on port 27017
[backend] 2022/07/28 02:34:00 backend server listening on port 8081
^CCleaning up...
 - deployment.apps "frontend" deleted
 - service "frontend" deleted
 - service "go-guestbook-mongodb" deleted
 - service "backend" deleted
 - deployment.apps "backend" deleted
 - deployment.apps "go-guestbook-mongodb" deleted
```

codecov[bot]: # [Codecov](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) Report
> Merging [#7699](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (1fdb75c) into [refactor_ptr](https://codecov.io/gh/GoogleContainerTools/skaffold/commit/8267eb86e47ee6e062fb8813aaebe2affec2a3bb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) (8267eb8) will **decrease** coverage by `0.07%`.
> The diff coverage is `50.00%`.

```diff
@@               Coverage Diff                @@
##           refactor_ptr    #7699      +/-   ##
================================================
- Coverage         66.59%   66.51%   -0.08%     
================================================
  Files               587      587              
  Lines             28107    28121      +14     
================================================
- Hits              18717    18706      -11     
- Misses             8031     8046      +15     
- Partials           1359     1369      +10     
```


| [Impacted Files](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | Coverage Δ | |
|---|---|---|
| [pkg/skaffold/render/renderer/noop/noop.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3JlbmRlci9yZW5kZXJlci9ub29wL25vb3AuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/skaffold/runner/v2/apply.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci92Mi9hcHBseS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/skaffold/runner/v2/cleanup.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci92Mi9jbGVhbnVwLmdv) | `0.00% <ø> (ø)` | |
| [pkg/skaffold/runner/v2/render.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci92Mi9yZW5kZXIuZ28=) | `47.61% <ø> (ø)` | |
| [pkg/skaffold/runner/v2/runner.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci92Mi9ydW5uZXIuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/skaffold/runner/v2/verify.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci92Mi92ZXJpZnkuZ28=) | `0.00% <ø> (ø)` | |
| [pkg/skaffold/render/renderer/renderer.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3JlbmRlci9yZW5kZXJlci9yZW5kZXJlci5nbw==) | `33.33% <35.00%> (-3.04%)` | :arrow_down: |
| [cmd/skaffold/app/cmd/dev.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-Y21kL3NrYWZmb2xkL2FwcC9jbWQvZGV2Lmdv) | `80.95% <100.00%> (ø)` | |
| [pkg/skaffold/kubernetes/manifest/manifests.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL2t1YmVybmV0ZXMvbWFuaWZlc3QvbWFuaWZlc3RzLmdv) | `94.80% <100.00%> (+0.21%)` | :arrow_up: |
| [pkg/skaffold/runner/renderer.go](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools#diff-cGtnL3NrYWZmb2xkL3J1bm5lci9yZW5kZXJlci5nbw==) | `77.77% <100.00%> (ø)` | |
| ... and [8 more](https://codecov.io/gh/GoogleContainerTools/skaffold/pull/7699/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools) | |

:mega: Codecov can now indicate which changes are the most critical in Pull Requests. [Learn more](https://about.codecov.io/product/feature/runtime-insights/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleContainerTools)


Patch:
diff --git a/cmd/skaffold/app/cmd/dev.go b/cmd/skaffold/app/cmd/dev.go
index 8c00af02a..638bff8f1 100644
--- a/cmd/skaffold/app/cmd/dev.go
+++ b/cmd/skaffold/app/cmd/dev.go
@@ -72,7 +72,7 @@ func runDev(ctx context.Context, out io.Writer) error {
 				}
 				err := r.Dev(ctx, out, artifacts)
 
-				if r.HasDeployed() {
+				if r.DeployManifests().String() != "" {
 					cleanup = func() {
 						manifestsByConfig, err := r.Render(ctx, io.Discard, []graph.Artifact{}, false)
 						if err != nil {
diff --git a/cmd/skaffold/app/cmd/flags.go b/cmd/skaffold/app/cmd/flags.go
index 5a1f48097..4a73b9960 100644
--- a/cmd/skaffold/app/cmd/flags.go
+++ b/cmd/skaffold/app/cmd/flags.go
@@ -644,6 +644,14 @@ var flagRegistry = []Flag{
 		FlagAddMethod: "StringVar",
 		DefinedOn:     []string{"verify"},
 	},
+	{
+		Name:          "enable-platform-node-affinity",
+		Usage:         "If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms",
+		Value:         &opts.EnablePlatformNodeAffinity,
+		DefValue:      false,
+		FlagAddMethod: "BoolVar",
+		DefinedOn:     []string{"dev", "render", "run", "debug", "deploy"},
+	},
 }
 
 func methodNameByType(v reflect.Value) string {
diff --git a/docs/content/en/docs/references/cli/_index.md b/docs/content/en/docs/references/cli/_index.md
index 08c39c783..44c60df29 100644
--- a/docs/content/en/docs/references/cli/_index.md
+++ b/docs/content/en/docs/references/cli/_index.md
@@ -408,6 +408,7 @@ Options:
   -c, --config='': File for global configurations (defaults to $HOME/.skaffold/config)
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -466,6 +467,7 @@ Env vars:
 * `SKAFFOLD_CONFIG` (same as `--config`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -580,6 +582,7 @@ Options:
   -c, --config='': File for global configurations (defaults to $HOME/.skaffold/config)
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -626,6 +629,7 @@ Env vars:
 * `SKAFFOLD_CONFIG` (same as `--config`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -678,6 +682,7 @@ Options:
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -736,6 +741,7 @@ Env vars:
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
@@ -945,6 +951,7 @@ Options:
       --cache-artifacts=true: Set to false to disable default caching of artifacts
   -d, --default-repo='': Default repository value (overrides global config)
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
   -i, --images=: A list of pre-built images to deploy, either tagged images or NAME=TAG pairs
@@ -976,6 +983,7 @@ Env vars:
 * `SKAFFOLD_CACHE_ARTIFACTS` (same as `--cache-artifacts`)
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
 * `SKAFFOLD_IMAGES` (same as `--images`)
@@ -1019,6 +1027,7 @@ Options:
   -d, --default-repo='': Default repository value (overrides global config)
       --detect-minikube=true: Use heuristics to detect a minikube cluster
       --digest-source='': Set to 'remote' to skip builds and resolve the digest of images by tag from the remote registry. Set to 'local' to build images locally and use digests from built images. Set to 'tag' to use tags directly from the build. Set to 'none' to use tags directly from the Kubernetes manifests. If unspecified, defaults to 'remote' for remote clusters, and 'tag' for local clusters like kind or minikube.
+      --enable-platform-node-affinity=false: If true, skaffold will add platform (os/arch) node affinity to rendered manifests based on the image platforms
   -f, --filename='skaffold.yaml': Path or URL to the Skaffold config file
       --force=false: Recreate Kubernetes resources if necessary for deployment, warning: might cause downtime!
       --hydration-dir='.kpt-pipeline': The directory to where the (kpt) hydration takes place. Default to a hidden directory .kpt-pipeline.
@@ -1072,6 +1081,7 @@ Env vars:
 * `SKAFFOLD_DEFAULT_REPO` (same as `--default-repo`)
 * `SKAFFOLD_DETECT_MINIKUBE` (same as `--detect-minikube`)
 * `SKAFFOLD_DIGEST_SOURCE` (same as `--digest-source`)
+* `SKAFFOLD_ENABLE_PLATFORM_NODE_AFFINITY` (same as `--enable-platform-node-affinity`)
 * `SKAFFOLD_FILENAME` (same as `--filename`)
 * `SKAFFOLD_FORCE` (same as `--force`)
 * `SKAFFOLD_HYDRATION_DIR` (same as `--hydration-dir`)
diff --git a/pkg/skaffold/config/options.go b/pkg/skaffold/config/options.go
index abceeb6bf..2b92a83ef 100644
--- a/pkg/skaffold/config/options.go
+++ b/pkg/skaffold/config/options.go
@@ -32,74 +32,75 @@ type WaitForDeletions struct {
 
 // SkaffoldOptions are options that are set by command line arguments not included in the config file itself
 type SkaffoldOptions struct {
-	Apply                 bool
-	AutoBuild             bool
-	AutoCreateConfig      bool
-	AutoDeploy            bool
-	AutoSync              bool
-	AssumeYes             bool
-	CacheArtifacts        bool
-	ContainerDebugging    bool
-	Cleanup               bool
-	DetectMinikube        bool
-	DryRun                bool
-	EnableRPC             bool
-	Force                 bool
-	ForceLoadImages       bool
-	IterativeStatusCheck  bool
-	FastFailStatusCheck   bool
-	Notification          bool
-	NoPrune               bool
-	NoPruneChildren       bool
-	ProfileAutoActivation bool
-	PropagateProfiles     bool
-	RenderOnly            bool
-	SkipTests             bool
-	SkipConfigDefaults    bool
-	Tail                  bool
-	WaitForConnection     bool
-	MakePathsAbsolute     *bool
-	MultiLevelRepo        *bool
-	ConfigurationFile     string
-	HydrationDir          string
-	InventoryNamespace    string
-	InventoryID           string
-	InventoryName         string
-	GlobalConfig          string
-	EventLogFile          string
-	RenderOutput          string
-	User                  string
-	CustomTag             string
-	Namespace             string
-	CacheFile             string
-	Trigger               string
-	KubeContext           string
-	KubeConfig            string
-	LastLogFile           string
-	DigestSource          string
-	Command               string
-	MinikubeProfile       string
-	RepoCacheDir          string
-	TransformRulesFile    string
-	VerifyDockerNetwork   string
-	CustomLabels          []string
-	TargetImages          []string
-	Profiles              []string
-	InsecureRegistries    []string
-	ConfigurationFilter   []string
-	HydratedManifests     []string
-	Platforms             []string
-	BuildConcurrency      int
-	WatchPollInterval     int
-	StatusCheck           BoolOrUndefined
-	PushImages            BoolOrUndefined
-	RPCPort               IntOrUndefined
-	RPCHTTPPort           IntOrUndefined
-	Muted                 Muted
-	PortForward           PortForwardOptions
-	DefaultRepo           StringOrUndefined
-	SyncRemoteCache       SyncRemoteCacheOption
-	WaitForDeletions      WaitForDeletions
+	Apply                      bool
+	AutoBuild                  bool
+	AutoCreateConfig           bool
+	AutoDeploy                 bool
+	AutoSync                   bool
+	AssumeYes                  bool
+	CacheArtifacts             bool
+	ContainerDebugging         bool
+	Cleanup                    bool
+	DetectMinikube             bool
+	DryRun                     bool
+	EnableRPC                  bool
+	Force                      bool
+	ForceLoadImages            bool
+	IterativeStatusCheck       bool
+	FastFailStatusCheck        bool
+	Notification               bool
+	NoPrune                    bool
+	NoPruneChildren            bool
+	ProfileAutoActivation      bool
+	PropagateProfiles          bool
+	RenderOnly                 bool
+	SkipTests                  bool
+	SkipConfigDefaults         bool
+	Tail                       bool
+	WaitForConnection          bool
+	EnablePlatformNodeAffinity bool
+	MakePathsAbsolute          *bool
+	MultiLevelRepo             *bool
+	ConfigurationFile          string
+	HydrationDir               string
+	InventoryNamespace         string
+	InventoryID                string
+	InventoryName              string
+	GlobalConfig               string
+	EventLogFile               string
+	RenderOutput               string
+	User                       string
+	CustomTag                  string
+	Namespace                  string
+	CacheFile                  string
+	Trigger                    string
+	KubeContext                string
+	KubeConfig                 string
+	LastLogFile                string
+	DigestSource               string
+	Command                    string
+	MinikubeProfile            string
+	RepoCacheDir               string
+	TransformRulesFile         string
+	VerifyDockerNetwork        string
+	CustomLabels               []string
+	TargetImages               []string
+	Profiles                   []string
+	InsecureRegistries         []string
+	ConfigurationFilter        []string
+	HydratedManifests          []string
+	Platforms                  []string
+	BuildConcurrency           int
+	WatchPollInterval          int
+	StatusCheck                BoolOrUndefined
+	PushImages                 BoolOrUndefined
+	RPCPort                    IntOrUndefined
+	RPCHTTPPort                IntOrUndefined
+	Muted                      Muted
+	PortForward                PortForwardOptions
+	DefaultRepo                StringOrUndefined
+	SyncRemoteCache            SyncRemoteCacheOption
+	WaitForDeletions           WaitForDeletions
 }
 
 type RunMode string
diff --git a/pkg/skaffold/deploy/kubectl/cli.go b/pkg/skaffold/deploy/kubectl/cli.go
index 7ddc73902..41af0a5f7 100644
--- a/pkg/skaffold/deploy/kubectl/cli.go
+++ b/pkg/skaffold/deploy/kubectl/cli.go
@@ -61,6 +61,7 @@ type Config interface {
 	Tail() bool
 	PipelineForImage(imageName string) (latest.Pipeline, bool)
 	JSONParseConfig() latest.JSONParseConfig
+	EnablePlatformNodeAffinityInRenderedManifests() bool
 }
 
 func NewCLI(cfg Config, flags latest.KubectlFlags, defaultNamespace string) CLI {
diff --git a/pkg/skaffold/docker/platform.go b/pkg/skaffold/docker/platform.go
new file mode 100644
index 000000000..f04394aad
--- /dev/null
+++ b/pkg/skaffold/docker/platform.go
@@ -0,0 +1,96 @@
+/*
+Copyright 2019 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package docker
+
+import (
+	"fmt"
+
+	"github.com/google/go-containerregistry/pkg/name"
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	"github.com/google/go-containerregistry/pkg/v1/types"
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/util"
+)
+
+// GetPlatforms returns the platforms of the provided image.
+func GetPlatforms(image string) ([]spec.Platform, error) {
+	i, err := getImage(image)
+	if err != nil {
+		return nil, err
+	}
+
+	mt, err := i.MediaType()
+	if err != nil {
+		return nil, err
+	}
+	var p []spec.Platform
+	switch mt {
+	case types.OCIImageIndex, types.DockerManifestList:
+		ix, ok := i.(v1.ImageIndex)
+		if !ok {
+			return nil, fmt.Errorf("failed to interpret %q as index: %v", image, i)
+		}
+		manifests, err := ix.IndexManifest()
+		if err != nil {
+			return nil, err
+		}
+		for _, m := range manifests.Manifests {
+			if m.Platform == nil {
+				continue
+			}
+			p = append(p, util.ConvertFromV1Platform(*m.Platform))
+		}
+	case types.OCIManifestSchema1, types.DockerManifestSchema2:
+		im, ok := i.(v1.Image)
+		if !ok {
+			return nil, fmt.Errorf("failed to interpret %q as image: %v", image, im)
+		}
+		cf, err := im.ConfigFile()
+		if err != nil {
+			return nil, err
+		}
+		p = append(p, spec.Platform{OS: cf.OS, Architecture: cf.Architecture})
+	default:
+		return nil, fmt.Errorf("image media type: %s", mt)
+	}
+	if err != nil {
+		return nil, err
+	}
+	return p, nil
+}
+
+func getImage(image string) (ImageRef, error) {
+	ref, err := name.ParseReference(image, name.WeakValidation)
+	if err != nil {
+		return nil, err
+	}
+	desc, err := remote.Get(ref, remote.WithAuthFromKeychain(primaryKeychain))
+	if err != nil {
+		return nil, err
+	}
+	if desc.MediaType.IsIndex() {
+		return desc.ImageIndex()
+	}
+	return desc.Image()
+}
+
+// ImageRef represents a v1.Image or v1.ImageIndex.
+type ImageRef interface {
+	MediaType() (types.MediaType, error)
+}
diff --git a/pkg/skaffold/kubernetes/manifest/affinity.go b/pkg/skaffold/kubernetes/manifest/affinity.go
new file mode 100644
index 000000000..1c12a0ab7
--- /dev/null
+++ b/pkg/skaffold/kubernetes/manifest/affinity.go
@@ -0,0 +1,259 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifest
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+	v1 "k8s.io/api/core/v1"
+	apimachinery "k8s.io/apimachinery/pkg/runtime/schema"
+
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/docker"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/output/log"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/platform"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/yaml"
+)
+
+// for testing
+var getPlatforms = docker.GetPlatforms
+
+const (
+	specField     = "spec"
+	affinityField = "affinity"
+
+	// kubernetes.io/arch and kubernetes.io/os are known node labels. See https://kubernetes.io/docs/reference/labels-annotations-taints/
+	nodeOperatingSystemLabel = "kubernetes.io/os"
+	nodeArchitectureLabel    = "kubernetes.io/arch"
+)
+
+type ResourceSelectorAffinity struct {
+	allowlist map[apimachinery.GroupKind]latest.ResourceFilter
+	denylist  map[apimachinery.GroupKind]latest.ResourceFilter
+}
+
+func NewResourceSelectorAffinity(allowlist map[apimachinery.GroupKind]latest.ResourceFilter, denylist map[apimachinery.GroupKind]latest.ResourceFilter) *ResourceSelectorAffinity {
+	return &ResourceSelectorAffinity{
+		allowlist: allowlist,
+		denylist:  denylist,
+	}
+}
+
+func (rsa *ResourceSelectorAffinity) allowByGroupKind(gk apimachinery.GroupKind) bool {
+	if _, allowed := rsa.allowlist[gk]; allowed {
+		if rf, disallowed := rsa.denylist[gk]; disallowed {
+			for _, s := range rf.Labels {
+				if s == ".*" {
+					return false
+				}
+			}
+			for _, s := range rf.Image {
+				if s == ".*" {
+					return false
+				}
+			}
+			for _, s := range rf.Affinity {
+				if s == ".*" {
+					return false
+				}
+			}
+		}
+		return true
+	}
+	return false
+}
+
+//nolint:unparam
+func (rsa *ResourceSelectorAffinity) allowByNavpath(gk apimachinery.GroupKind, navpath string, k string) (string, bool) {
+	for _, w := range ConfigConnectorResourceSelector {
+		if w.Matches(gk.Group, gk.Kind) {
+			return "", false
+		}
+	}
+
+	if rf, ok := rsa.denylist[gk]; ok {
+		for _, denypath := range rf.Affinity {
+			if denypath == ".*" {
+				return "", false
+			}
+			if navpath == denypath {
+				return "", false
+			}
+		}
+	}
+
+	if rf, ok := rsa.allowlist[gk]; ok {
+		for _, allowpath := range rf.Affinity {
+			if allowpath == ".*" {
+				if k != specField {
+					return "", false
+				}
+				return "", true
+			}
+			if navpath == allowpath {
+				return "", true
+			}
+		}
+	}
+	return "", false
+}
+
+// SetPlatformNodeAffinity adds `nodeAffinity` definitions specific to the target platform (os/architecture) for each built image.
+func (l *ManifestList) SetPlatformNodeAffinity(ctx context.Context, rs ResourceSelector, platforms PodPlatforms) (ManifestList, error) {
+	r := &nodeAffinitySetter{ctx: ctx, platforms: platforms}
+	return l.Visit(r, rs)
+}
+
+func (l *ManifestList) GetImagePlatforms(ctx context.Context, rs ResourceSelector) (PodPlatforms, error) {
+	s := &imagePlatformSaver{ctx: ctx, platforms: make(PodPlatforms)}
+	_, err := l.Visit(s, rs)
+	return s.platforms, parseImagesInManifestErr(err)
+}
+
+// PodPlatforms maps the pod spec json path to the list of common platforms for all containers in that pod.
+type PodPlatforms map[string][]spec.Platform
+
+type imagePlatformSaver struct {
+	ctx       context.Context
+	platforms PodPlatforms
+}
+
+func (r *imagePlatformSaver) Visit(gk apimachinery.GroupKind, navpath string, o map[string]interface{}, k string, v interface{}, rs ResourceSelector) bool {
+	if k != imageField {
+		return true
+	}
+	image, ok := v.(string)
+	if !ok {
+		return true
+	}
+	platforms, err := getPlatforms(image)
+	if err != nil {
+		log.Entry(r.ctx).Debugf("Couldn't get target platforms for image %q: %s", image, err.Error())
+		return false
+	}
+	specPath := strings.TrimSuffix(navpath, ".containers.image")
+	if pl, ok := r.platforms[specPath]; ok && len(pl) > 0 {
+		// only keep common platforms for all containers in a pod spec.
+		// for example, if a pod spec has two containers where the first image is a manifest list supporting `linux/arm64` and `linux/amd64`,
+		// and the second image only supports `linux/amd64`, then we only save `linux/amd64` platform for that pod spec.
+		r.platforms[specPath] = platform.Matcher{Platforms: pl}.Intersect(platform.Matcher{Platforms: platforms}).Platforms
+	} else {
+		r.platforms[specPath] = platforms
+	}
+	return false
+}
+
+type nodeAffinitySetter struct {
+	ctx       context.Context
+	platforms PodPlatforms
+}
+
+func (s *nodeAffinitySetter) Visit(gk apimachinery.GroupKind, navpath string, o map[string]interface{}, k string, v interface{}, rs ResourceSelector) bool {
+	if _, ok := rs.allowByNavpath(gk, navpath, k); !ok {
+		return true
+	}
+
+	if len(s.platforms) == 0 {
+		return false
+	}
+	platforms, ok := s.platforms[navpath]
+	if !ok || len(platforms) == 0 {
+		return true
+	}
+
+	spec, ok := v.(map[string]interface{})
+	if !ok {
+		return true
+	}
+	affinity, err := updateAffinity(spec[affinityField], platforms)
+	if err != nil {
+		log.Entry(s.ctx).Debugf("failed to update affinity definition: %s", err.Error())
+		return true
+	}
+	spec[affinityField] = affinity
+	return false
+}
+
+func updateAffinity(data interface{}, platforms []spec.Platform) (map[string]interface{}, error) {
+	var affinity v1.Affinity
+	if data == nil {
+		affinity = v1.Affinity{}
+	} else {
+		data, err := json.Marshal(data)
+		if err != nil {
+			return nil, err
+		}
+		if err = json.Unmarshal(data, &affinity); err != nil {
+			return nil, err
+		}
+	}
+
+	if affinity.NodeAffinity == nil {
+		affinity.NodeAffinity = &v1.NodeAffinity{}
+	}
+	if affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil {
+		affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution = &v1.NodeSelector{}
+	}
+
+	for _, term := range affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
+		for _, exp := range term.MatchExpressions {
+			if exp.Key == nodeOperatingSystemLabel || exp.Key == nodeArchitectureLabel {
+				return nil, fmt.Errorf("found existing node affinity for os/arch: %s", exp)
+			}
+		}
+	}
+
+	if len(affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms) == 0 {
+		affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = append(affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms, v1.NodeSelectorTerm{})
+	}
+
+	var terms []v1.NodeSelectorTerm
+	for _, pl := range platforms {
+		for _, term := range affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
+			t := term.DeepCopy()
+			if pl.OS != "" {
+				t.MatchExpressions = append(t.MatchExpressions, v1.NodeSelectorRequirement{
+					Key:      nodeOperatingSystemLabel,
+					Operator: v1.NodeSelectorOpIn,
+					Values:   []string{pl.OS},
+				})
+			}
+			if pl.Architecture != "" {
+				t.MatchExpressions = append(t.MatchExpressions, v1.NodeSelectorRequirement{
+					Key:      nodeArchitectureLabel,
+					Operator: v1.NodeSelectorOpIn,
+					Values:   []string{pl.Architecture},
+				})
+			}
+			terms = append(terms, *t)
+		}
+	}
+	affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = terms
+	bytes, err := json.Marshal(affinity)
+	if err != nil {
+		return nil, err
+	}
+	m := make(map[string]interface{})
+	if err := yaml.Unmarshal(bytes, &m); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
diff --git a/pkg/skaffold/kubernetes/manifest/manifests.go b/pkg/skaffold/kubernetes/manifest/manifests.go
index 185d89a1c..92d56de81 100644
--- a/pkg/skaffold/kubernetes/manifest/manifests.go
+++ b/pkg/skaffold/kubernetes/manifest/manifests.go
@@ -46,8 +46,12 @@ func NewManifestListByConfig() ManifestListByConfig {
 }
 
 func (ml *ManifestListByConfig) Add(configName string, manifest ManifestList) {
-	ml.configNames = append(ml.configNames, configName)
-	ml.manifests[configName] = manifest
+	if _, ok := ml.manifests[configName]; !ok {
+		ml.configNames = append(ml.configNames, configName)
+		ml.manifests[configName] = manifest
+		return
+	}
+	ml.manifests[configName] = append(ml.manifests[configName], manifest...)
 }
 
 func (ml ManifestListByConfig) GetForConfig(configName string) ManifestList {
diff --git a/pkg/skaffold/kubernetes/manifest/visitor.go b/pkg/skaffold/kubernetes/manifest/visitor.go
index 7ca3ffe85..7dca96d89 100644
--- a/pkg/skaffold/kubernetes/manifest/visitor.go
+++ b/pkg/skaffold/kubernetes/manifest/visitor.go
@@ -38,57 +38,69 @@ var TransformAllowlist = map[apimachinery.GroupKind]latest.ResourceFilter{
 		GroupKind: "Pod",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "", Kind: "Service"}: {
 		GroupKind: "Service",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "DaemonSet"}: {
 		GroupKind: "DaemonSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "Deployment"}: {
 		GroupKind: "Deployment.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "ReplicaSet"}: {
 		GroupKind: "ReplicaSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "apps", Kind: "StatefulSet"}: {
 		GroupKind: "StatefulSet.apps",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "batch", Kind: "CronJob"}: {
 		GroupKind: "CronJob.batch",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "batch", Kind: "Job"}: {
 		GroupKind: "Job.batch",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "DaemonSet"}: {
 		GroupKind: "DaemonSet.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "Deployment"}: {
 		GroupKind: "Deployment.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
 	{Group: "extensions", Kind: "ReplicaSet"}: {
 		GroupKind: "ReplicaSet.extensions",
 		Image:     []string{".*"},
 		Labels:    []string{".*"},
+		Affinity:  []string{".*"},
 	},
+	// TODO: Investigate exact requirements for adding `affinity` definitions for the following custom resource kinds
 	{Group: "serving.knative.dev", Kind: "Service"}: {
 		GroupKind: "Service.serving.knative.dev",
 		Image:     []string{".*"},
diff --git a/pkg/skaffold/render/config.go b/pkg/skaffold/render/config.go
index 1f22d944c..6e6b363a9 100644
--- a/pkg/skaffold/render/config.go
+++ b/pkg/skaffold/render/config.go
@@ -30,17 +30,19 @@ type Config interface {
 	TransformAllowList() []latest.ResourceFilter
 	TransformDenyList() []latest.ResourceFilter
 	Mode() config.RunMode
+	EnablePlatformNodeAffinityInRenderedManifests() bool
 }
 
 type MockConfig struct {
 	WorkingDir string
 }
 
-func (mc MockConfig) GetWorkingDir() string                       { return mc.WorkingDir }
-func (mc MockConfig) TransformAllowList() []latest.ResourceFilter { return nil }
-func (mc MockConfig) TransformDenyList() []latest.ResourceFilter  { return nil }
-func (mc MockConfig) TransformRulesFile() string                  { return "" }
-func (mc MockConfig) ConfigurationFile() string                   { return "" }
-func (mc MockConfig) GetKubeConfig() string                       { return "" }
-func (mc MockConfig) GetKubeContext() string                      { return "" }
-func (mc MockConfig) Mode() config.RunMode                        { return "" }
+func (mc MockConfig) GetWorkingDir() string                               { return mc.WorkingDir }
+func (mc MockConfig) TransformAllowList() []latest.ResourceFilter         { return nil }
+func (mc MockConfig) TransformDenyList() []latest.ResourceFilter          { return nil }
+func (mc MockConfig) TransformRulesFile() string                          { return "" }
+func (mc MockConfig) ConfigurationFile() string                           { return "" }
+func (mc MockConfig) GetKubeConfig() string                               { return "" }
+func (mc MockConfig) GetKubeContext() string                              { return "" }
+func (mc MockConfig) Mode() config.RunMode                                { return "" }
+func (mc MockConfig) EnablePlatformNodeAffinityInRenderedManifests() bool { return true }
diff --git a/pkg/skaffold/render/renderer/kpt/kpt.go b/pkg/skaffold/render/renderer/kpt/kpt.go
index 54981e9e3..ba0ec1042 100644
--- a/pkg/skaffold/render/renderer/kpt/kpt.go
+++ b/pkg/skaffold/render/renderer/kpt/kpt.go
@@ -44,6 +44,7 @@ import (
 )
 
 type Kpt struct {
+	cfg        render.Config
 	configName string
 
 	generate.Generator
@@ -88,6 +89,7 @@ func New(cfg render.Config, rCfg latest.RenderConfig, hydrationDir string, label
 		transformer, _ = transform.NewTransformer([]latest.Transformer{})
 	}
 	return &Kpt{
+		cfg:                cfg,
 		configName:         configName,
 		Generator:          generator,
 		Validator:          validator,
@@ -146,7 +148,12 @@ func (r *Kpt) Render(ctx context.Context, out io.Writer, builds []graph.Artifact
 		return ml, errors.DeleteKptfileError(err, r.hydrationDir)
 	}
 	endTrace()
-	manifests, errH := rUtil.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, r.transformAllowlist, r.transformDenylist)
+	opts := rUtil.GenerateHydratedManifestsOptions{
+		TransformAllowList:         r.transformAllowlist,
+		TransformDenylist:          r.transformDenylist,
+		EnablePlatformNodeAffinity: r.cfg.EnablePlatformNodeAffinityInRenderedManifests(),
+	}
+	manifests, errH := rUtil.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, opts)
 	if err != nil {
 		return ml, errH
 	}
diff --git a/pkg/skaffold/render/renderer/kubectl/kubectl.go b/pkg/skaffold/render/renderer/kubectl/kubectl.go
index 324e6e193..34e2c4ff6 100644
--- a/pkg/skaffold/render/renderer/kubectl/kubectl.go
+++ b/pkg/skaffold/render/renderer/kubectl/kubectl.go
@@ -33,6 +33,8 @@ import (
 )
 
 type Kubectl struct {
+	cfg render.Config
+
 	configName string
 
 	generate.Generator
@@ -49,6 +51,7 @@ func New(cfg render.Config, rCfg latest.RenderConfig, labels map[string]string,
 		return Kubectl{}, err
 	}
 	return Kubectl{
+		cfg:        cfg,
 		configName: configName,
 		Generator:  generator,
 		labels:     labels,
@@ -64,7 +67,12 @@ func (r Kubectl) Render(ctx context.Context, out io.Writer, builds []graph.Artif
 	instrumentation.AddAttributesToCurrentSpanFromContext(ctx, map[string]string{
 		"RendererType": "kubectl",
 	})
-	manifests, err := util.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, r.transformAllowlist, r.transformDenylist)
+	opts := util.GenerateHydratedManifestsOptions{
+		TransformAllowList:         r.transformAllowlist,
+		TransformDenylist:          r.transformDenylist,
+		EnablePlatformNodeAffinity: r.cfg.EnablePlatformNodeAffinityInRenderedManifests(),
+	}
+	manifests, err := util.GenerateHydratedManifests(ctx, out, builds, r.Generator, r.labels, opts)
 	endTrace()
 	manifestListByConfig := manifest.NewManifestListByConfig()
 	manifestListByConfig.Add(r.configName, manifests)
diff --git a/pkg/skaffold/render/renderer/noop/noop.go b/pkg/skaffold/render/renderer/noop/noop.go
index 661b3ebf3..377d1f86d 100644
--- a/pkg/skaffold/render/renderer/noop/noop.go
+++ b/pkg/skaffold/render/renderer/noop/noop.go
@@ -30,8 +30,8 @@ import (
 // TODO(marlongamez): add Helm renderer which uses helm template and can feed into deployers
 type Noop struct{}
 
-func New(_ latest.RenderConfig, _, _ string, _ map[string]string) (Noop, error) {
-	return Noop{}, nil
+func New(_ latest.RenderConfig, _, _ string, _ map[string]string) Noop {
+	return Noop{}
 }
 
 func (r Noop) Render(ctx context.Context, out io.Writer, artifacts []graph.Artifact, offline bool) (manifest.ManifestListByConfig, error) {
diff --git a/pkg/skaffold/render/renderer/render_mux.go b/pkg/skaffold/render/renderer/render_mux.go
index 8b1eb3ebd..c3d55f6e7 100644
--- a/pkg/skaffold/render/renderer/render_mux.go
+++ b/pkg/skaffold/render/renderer/render_mux.go
@@ -30,14 +30,17 @@ import (
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/util/stringset"
 )
 
+// GroupRenderer maintains the slice of all `Renderer`s defined in a single Skaffold config.
+type GroupRenderer []Renderer
+
 // RenderMux forwards all method calls to the renderers it contains.
 // When encountering an error, it aborts and returns the error. Otherwise,
 // it collects the results and returns all the manifests.
 type RenderMux struct {
-	renderers []Renderer
+	renderers GroupRenderer
 }
 
-func NewRenderMux(renderers []Renderer) Renderer {
+func NewRenderMux(renderers GroupRenderer) Renderer {
 	return RenderMux{renderers: renderers}
 }
 
diff --git a/pkg/skaffold/render/renderer/renderer.go b/pkg/skaffold/render/renderer/renderer.go
index ceaaffd20..43fde13d0 100644
--- a/pkg/skaffold/render/renderer/renderer.go
+++ b/pkg/skaffold/render/renderer/renderer.go
@@ -39,18 +39,32 @@ type Renderer interface {
 }
 
 // New creates a new Renderer object from the latestV2 API schema.
-func New(cfg render.Config, renderCfg latest.RenderConfig, hydrationDir string, labels map[string]string, usingLegacyHelmDeploy bool, command string, configName string) (Renderer, error) {
+func New(ctx context.Context, cfg render.Config, renderCfg latest.RenderConfig, hydrationDir string, labels map[string]string, usingLegacyHelmDeploy bool, command string, configName string) (GroupRenderer, error) {
 	if usingLegacyHelmDeploy && command != "render" {
-		return noop.New(renderCfg, cfg.GetWorkingDir(), hydrationDir, labels)
+		return GroupRenderer{noop.New(renderCfg, cfg.GetWorkingDir(), hydrationDir, labels)}, nil
 	}
-	if renderCfg.Validate == nil && renderCfg.Transform == nil && renderCfg.Helm != nil {
-		log.Entry(context.TODO()).Debug("setting up helm renderer")
-		return helm.New(cfg, renderCfg, labels, configName)
+	if renderCfg.Validate != nil && renderCfg.Transform != nil {
+		r, err := kpt.New(cfg, renderCfg, hydrationDir, labels, configName)
+		if err != nil {
+			return nil, err
+		}
+		log.Entry(ctx).Infof("setting up kpt renderer")
+		return GroupRenderer{r}, nil
 	}
-	if renderCfg.Validate == nil && renderCfg.Transform == nil && renderCfg.Kpt == nil {
-		log.Entry(context.TODO()).Debug("setting up kubectl renderer")
-		return kubectl.New(cfg, renderCfg, labels, configName)
+	var rs GroupRenderer
+	var r Renderer
+	var err error
+	if renderCfg.RawK8s != nil || renderCfg.Kustomize != nil {
+		if r, err = kubectl.New(cfg, renderCfg, labels, configName); err != nil {
+			return nil, err
+		}
+		rs = append(rs, r)
 	}
-	log.Entry(context.TODO()).Infof("setting up kpt renderer")
-	return kpt.New(cfg, renderCfg, hydrationDir, labels, configName)
+	if renderCfg.Helm != nil {
+		if r, err = helm.New(cfg, renderCfg, labels, configName); err != nil {
+			return nil, err
+		}
+		rs = append(rs, r)
+	}
+	return rs, nil
 }
diff --git a/pkg/skaffold/render/renderer/util/util.go b/pkg/skaffold/render/renderer/util/util.go
index 1af31e68a..03aead69c 100644
--- a/pkg/skaffold/render/renderer/util/util.go
+++ b/pkg/skaffold/render/renderer/util/util.go
@@ -33,7 +33,13 @@ import (
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/yaml"
 )
 
-func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []graph.Artifact, g generate.Generator, labels map[string]string, transformAllowlist, transformDenylist map[apim.GroupKind]latest.ResourceFilter) (manifest.ManifestList, error) {
+type GenerateHydratedManifestsOptions struct {
+	TransformAllowList         map[apim.GroupKind]latest.ResourceFilter
+	TransformDenylist          map[apim.GroupKind]latest.ResourceFilter
+	EnablePlatformNodeAffinity bool
+}
+
+func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []graph.Artifact, g generate.Generator, labels map[string]string, opts GenerateHydratedManifestsOptions) (manifest.ManifestList, error) {
 	// Generate manifests.
 	rCtx, endTrace := instrumentation.StartTrace(ctx, "Render_generateManifest")
 	manifests, err := g.Generate(rCtx, out)
@@ -45,12 +51,25 @@ func GenerateHydratedManifests(ctx context.Context, out io.Writer, builds []grap
 	// Update image labels.renderer_test.go
 	rCtx, endTrace = instrumentation.StartTrace(ctx, "Render_setSkaffoldLabels")
 	// TODO(aaron-prindle) wire proper transform allow/deny list args when going to V2
-	manifests, err = manifests.ReplaceImages(rCtx, builds, manifest.NewResourceSelectorImages(transformAllowlist, transformDenylist))
+	manifests, err = manifests.ReplaceImages(rCtx, builds, manifest.NewResourceSelectorImages(opts.TransformAllowList, opts.TransformDenylist))
 	if err != nil {
 		return nil, err
 	}
 	// TODO(aaron-prindle) wire proper transform allow/deny list args when going to V2
-	if manifests, err = manifests.SetLabels(labels, manifest.NewResourceSelectorLabels(transformAllowlist, transformDenylist)); err != nil {
+	if manifests, err = manifests.SetLabels(labels, manifest.NewResourceSelectorLabels(opts.TransformAllowList, opts.TransformDenylist)); err != nil {
+		return nil, err
+	}
+	endTrace()
+
+	if !opts.EnablePlatformNodeAffinity {
+		return manifests, nil
+	}
+	rCtx, endTrace = instrumentation.StartTrace(ctx, "Render_setPlatformNodeAffinity")
+	platforms, err := manifests.GetImagePlatforms(rCtx, manifest.NewResourceSelectorImages(opts.TransformAllowList, opts.TransformDenylist))
+	if err != nil {
+		return nil, err
+	}
+	if manifests, err = manifests.SetPlatformNodeAffinity(rCtx, manifest.NewResourceSelectorAffinity(opts.TransformAllowList, opts.TransformDenylist), platforms); err != nil {
 		return nil, err
 	}
 	endTrace()
@@ -120,6 +139,18 @@ func ConsolidateTransformConfiguration(cfg render.Config) (map[apim.GroupKind]la
 func ConvertJSONPathIndex(rf latest.ResourceFilter) latest.ResourceFilter {
 	nrf := latest.ResourceFilter{}
 	nrf.GroupKind = rf.GroupKind
+	if len(rf.Affinity) > 0 {
+		naffinity := []string{}
+		for _, str := range rf.Affinity {
+			if str == ".*" {
+				naffinity = append(naffinity, str)
+				continue
+			}
+			nstr := strings.ReplaceAll(str, ".*", "")
+			naffinity = append(naffinity, nstr)
+		}
+		nrf.Affinity = naffinity
+	}
 
 	if len(rf.Labels) > 0 {
 		nlabels := []string{}
diff --git a/pkg/skaffold/runner/renderer.go b/pkg/skaffold/runner/renderer.go
index 20b23a269..3ff54bccf 100644
--- a/pkg/skaffold/runner/renderer.go
+++ b/pkg/skaffold/runner/renderer.go
@@ -27,13 +27,13 @@ import (
 func GetRenderer(ctx context.Context, runCtx *runcontext.RunContext, hydrationDir string, labels map[string]string, usingLegacyHelmDeploy bool) (renderer.Renderer, error) {
 	ps := runCtx.Pipelines.AllByConfigNames()
 
-	var renderers []renderer.Renderer
+	var renderers renderer.GroupRenderer
 	for configName, p := range ps {
-		r, err := renderer.New(runCtx, p.Render, hydrationDir, labels, usingLegacyHelmDeploy, runCtx.Opts.Command, configName)
+		rs, err := renderer.New(ctx, runCtx, p.Render, hydrationDir, labels, usingLegacyHelmDeploy, runCtx.Opts.Command, configName)
 		if err != nil {
 			return nil, err
 		}
-		renderers = append(renderers, r)
+		renderers = append(renderers, rs...)
 	}
 	return renderer.NewRenderMux(renderers), nil
 }
diff --git a/pkg/skaffold/runner/runcontext/v2/context.go b/pkg/skaffold/runner/runcontext/v2/context.go
index 22ae7ae40..94bae7814 100644
--- a/pkg/skaffold/runner/runcontext/v2/context.go
+++ b/pkg/skaffold/runner/runcontext/v2/context.go
@@ -279,6 +279,9 @@ func (rc *RunContext) VerifyDockerNetwork() string                   { return rc
 func (rc *RunContext) JSONParseConfig() latest.JSONParseConfig {
 	return rc.DefaultPipeline().Deploy.Logs.JSONParse
 }
+func (rc *RunContext) EnablePlatformNodeAffinityInRenderedManifests() bool {
+	return rc.Opts.EnablePlatformNodeAffinity
+}
 
 // GetRenderConfig returns the top tier RenderConfig.
 // TODO: design how to support multi-module.
diff --git a/pkg/skaffold/runner/runner.go b/pkg/skaffold/runner/runner.go
index 7c9296f64..a9a1d89fe 100644
--- a/pkg/skaffold/runner/runner.go
+++ b/pkg/skaffold/runner/runner.go
@@ -42,7 +42,7 @@ type Runner interface {
 	DeployAndLog(context.Context, io.Writer, []graph.Artifact, manifest.ManifestListByConfig) error
 	GeneratePipeline(context.Context, io.Writer, []util.VersionedConfig, []string, string) error
 	HasBuilt() bool
-	HasDeployed() bool
+	DeployManifests() manifest.ManifestListByConfig
 	Prune(context.Context, io.Writer) error
 
 	Render(ctx context.Context, out io.Writer, builds []graph.Artifact, offline bool) (manifest.ManifestListByConfig, error)
diff --git a/pkg/skaffold/runner/v2/apply.go b/pkg/skaffold/runner/v2/apply.go
index 9549a73d3..6d3746382 100644
--- a/pkg/skaffold/runner/v2/apply.go
+++ b/pkg/skaffold/runner/v2/apply.go
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 package v2
 
 import (
@@ -68,7 +69,7 @@ func (r *SkaffoldRunner) applyResources(ctx context.Context, out io.Writer, arti
 		endTrace(instrumentation.TraceEndError(err))
 		return err
 	}
-	r.hasDeployed = true
+	r.deployManifests = list
 	event.DeployComplete()
 	return nil
 }
diff --git a/pkg/skaffold/runner/v2/cleanup.go b/pkg/skaffold/runner/v2/cleanup.go
index 7210c9a38..264855667 100644
--- a/pkg/skaffold/runner/v2/cleanup.go
+++ b/pkg/skaffold/runner/v2/cleanup.go
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 package v2
 
 import (
diff --git a/pkg/skaffold/runner/v2/deploy.go b/pkg/skaffold/runner/v2/deploy.go
index d8698d67e..1e5edfe31 100644
--- a/pkg/skaffold/runner/v2/deploy.go
+++ b/pkg/skaffold/runner/v2/deploy.go
@@ -110,7 +110,7 @@ See https://skaffold.dev/docs/pipeline-stages/taggers/#how-tagging-works`)
 
 	r.deployer.RegisterLocalImages(localAndBuiltImages)
 	err = r.deployer.Deploy(ctx, deployOut, artifacts, list)
-	r.hasDeployed = true // set even if deploy may have failed, because we want to cleanup any partially created resources
+	r.deployManifests = list // set even if deploy may have failed, because we want to cleanup any partially created resources
 	postDeployFn()
 	if err != nil {
 		event.DeployFailed(err)
diff --git a/pkg/skaffold/runner/v2/dev.go b/pkg/skaffold/runner/v2/dev.go
index 9fa2e26ac..37985d2a0 100644
--- a/pkg/skaffold/runner/v2/dev.go
+++ b/pkg/skaffold/runner/v2/dev.go
@@ -186,6 +186,7 @@ func (r *SkaffoldRunner) doDev(ctx context.Context, out io.Writer) error {
 			endTrace(instrumentation.TraceEndError(err))
 			return nil
 		}
+		r.deployManifests = manifests
 
 		if err := r.Deploy(childCtx, out, r.Builds, manifests); err != nil {
 			log.Entry(ctx).Warn("Skipping deploy due to error:", err)
@@ -348,6 +349,7 @@ func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*la
 
 	// First render
 	manifests, err := r.Render(ctx, out, r.Builds, true)
+	r.deployManifests = manifests
 	if err != nil {
 		event.DevLoopFailedInPhase(r.devIteration, constants.Render, err)
 		eventV2.TaskFailed(constants.DevLoop, err)
diff --git a/pkg/skaffold/runner/v2/render.go b/pkg/skaffold/runner/v2/render.go
index 0eed61d18..f3b222d8e 100644
--- a/pkg/skaffold/runner/v2/render.go
+++ b/pkg/skaffold/runner/v2/render.go
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 package v2
 
 import (
diff --git a/pkg/skaffold/runner/v2/runner.go b/pkg/skaffold/runner/v2/runner.go
index 5cd7b7ce8..89a5a8833 100644
--- a/pkg/skaffold/runner/v2/runner.go
+++ b/pkg/skaffold/runner/v2/runner.go
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 package v2
 
 import (
@@ -22,6 +23,7 @@ import (
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/deploy/label"
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/filemon"
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/graph"
+	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/kubernetes/manifest"
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/platform"
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/render/renderer"
 	"github.com/GoogleContainerTools/skaffold/pkg/skaffold/runner"
@@ -50,13 +52,13 @@ type SkaffoldRunner struct {
 	sourceDependencies graph.SourceDependenciesCache
 	platforms          platform.Resolver
 
-	devIteration int
-	isLocalImage func(imageName string) (bool, error)
-	hasDeployed  bool
-	intents      *runner.Intents
+	devIteration    int
+	isLocalImage    func(imageName string) (bool, error)
+	deployManifests manifest.ManifestListByConfig
+	intents         *runner.Intents
 }
 
-// HasDeployed returns true if this runner has deployed something.
-func (r *SkaffoldRunner) HasDeployed() bool {
-	return r.hasDeployed
+// DeployManifests returns a list of manifest if this runner has deployed something.
+func (r *SkaffoldRunner) DeployManifests() manifest.ManifestListByConfig {
+	return r.deployManifests
 }
diff --git a/pkg/skaffold/runner/v2/verify.go b/pkg/skaffold/runner/v2/verify.go
index 2a1ceefac..cb455cce9 100644
--- a/pkg/skaffold/runner/v2/verify.go
+++ b/pkg/skaffold/runner/v2/verify.go
@@ -103,7 +103,6 @@ See https://skaffold.dev/docs/pipeline-stages/taggers/#how-tagging-works`)
 
 	r.verifier.RegisterLocalImages(localAndBuiltImages)
 	err = r.verifier.Verify(ctx, deployOut, artifacts)
-	r.hasDeployed = true // set even if deploy may have failed, because we want to cleanup any partially created resources
 	postDeployFn()
 	if err != nil {
 		eventV2.TaskFailed(constants.Verify, err)
diff --git a/pkg/skaffold/util/platform.go b/pkg/skaffold/util/platform.go
index 3355c66ef..f760b2e62 100644
--- a/pkg/skaffold/util/platform.go
+++ b/pkg/skaffold/util/platform.go
@@ -24,3 +24,7 @@ import (
 func ConvertToV1Platform(platform specs.Platform) v1.Platform {
 	return v1.Platform{Architecture: platform.Architecture, OS: platform.OS, OSVersion: platform.OSVersion, OSFeatures: platform.OSFeatures, Variant: platform.Variant}
 }
+
+func ConvertFromV1Platform(platform v1.Platform) specs.Platform {
+	return specs.Platform{Architecture: platform.Architecture, OS: platform.OS, OSVersion: platform.OSVersion, OSFeatures: platform.OSFeatures, Variant: platform.Variant}
+}


Test patch:
diff --git a/cmd/skaffold/app/cmd/dev_test.go b/cmd/skaffold/app/cmd/dev_test.go
index 574dcd596..092aa62d6 100644
--- a/cmd/skaffold/app/cmd/dev_test.go
+++ b/cmd/skaffold/app/cmd/dev_test.go
@@ -34,10 +34,9 @@ import (
 type mockDevRunner struct {
 	runner.Runner
 	hasBuilt          bool
-	hasDeployed       bool
 	errDev            error
 	calls             []string
-	manifestsByConfig manifest.ManifestListByConfig
+	deployedManifests manifest.ManifestListByConfig
 }
 
 func (r *mockDevRunner) Dev(context.Context, io.Writer, []*latest.Artifact) error {
@@ -50,9 +49,9 @@ func (r *mockDevRunner) HasBuilt() bool {
 	return r.hasBuilt
 }
 
-func (r *mockDevRunner) HasDeployed() bool {
-	r.calls = append(r.calls, "HasDeployed")
-	return r.hasDeployed
+func (r *mockDevRunner) DeployManifests() manifest.ManifestListByConfig {
+	r.calls = append(r.calls, "DeployManifests")
+	return r.deployedManifests
 }
 
 func (r *mockDevRunner) Prune(context.Context, io.Writer) error {
@@ -67,42 +66,44 @@ func (r *mockDevRunner) Cleanup(context.Context, io.Writer, bool, manifest.Manif
 
 func (r *mockDevRunner) Render(ctx context.Context, out io.Writer, builds []graph.Artifact, offline bool) (manifest.ManifestListByConfig, error) {
 	r.calls = append(r.calls, "Render")
-	return r.manifestsByConfig, nil
+	return r.deployedManifests, nil
 }
 
 func TestDoDev(t *testing.T) {
 	tests := []struct {
-		description   string
-		hasBuilt      bool
-		hasDeployed   bool
-		expectedCalls []string
+		description       string
+		hasBuilt          bool
+		deployedManifests manifest.ManifestList
+		expectedCalls     []string
 	}{
 		{
-			description:   "cleanup and then prune",
-			hasBuilt:      true,
-			hasDeployed:   true,
-			expectedCalls: []string{"Dev", "HasDeployed", "HasBuilt", "Render", "Cleanup", "Prune"},
+			description:       "cleanup and then prune",
+			hasBuilt:          true,
+			deployedManifests: manifest.ManifestList{[]byte("dummy")},
+			expectedCalls:     []string{"Dev", "DeployManifests", "HasBuilt", "Render", "Cleanup", "Prune"},
 		},
 		{
-			description:   "hasn't deployed",
-			hasBuilt:      true,
-			hasDeployed:   false,
-			expectedCalls: []string{"Dev", "HasDeployed", "HasBuilt", "Prune"},
+			description:       "hasn't deployed",
+			hasBuilt:          true,
+			deployedManifests: manifest.ManifestList{},
+			expectedCalls:     []string{"Dev", "DeployManifests", "HasBuilt", "Prune"},
 		},
 		{
-			description:   "hasn't built",
-			hasBuilt:      false,
-			hasDeployed:   false,
-			expectedCalls: []string{"Dev", "HasDeployed", "HasBuilt"},
+			description:       "hasn't built",
+			hasBuilt:          false,
+			deployedManifests: manifest.ManifestList{},
+			expectedCalls:     []string{"Dev", "DeployManifests", "HasBuilt"},
 		},
 	}
 
 	for _, test := range tests {
 		testutil.Run(t, test.description, func(t *testutil.T) {
+			dm := manifest.NewManifestListByConfig()
+			dm.Add("test", test.deployedManifests)
 			mockRunner := &mockDevRunner{
-				hasBuilt:    test.hasBuilt,
-				hasDeployed: test.hasDeployed,
-				errDev:      context.Canceled,
+				hasBuilt:          test.hasBuilt,
+				deployedManifests: dm,
+				errDev:            context.Canceled,
 			}
 			t.Override(&createRunner, func(context.Context, io.Writer, config.SkaffoldOptions) (runner.Runner, []util.VersionedConfig, *runcontext.RunContext, error) {
 				return mockRunner, []util.VersionedConfig{&latest.SkaffoldConfig{}}, nil, nil
@@ -138,8 +139,8 @@ func (m *mockConfigChangeRunner) HasBuilt() bool {
 	return true
 }
 
-func (m *mockConfigChangeRunner) HasDeployed() bool {
-	return true
+func (m *mockConfigChangeRunner) DeployManifests() manifest.ManifestListByConfig {
+	return manifest.ManifestListByConfig{}
 }
 
 func (m *mockConfigChangeRunner) Prune(context.Context, io.Writer) error {
diff --git a/pkg/skaffold/deploy/kustomize/kustomize_test.go b/pkg/skaffold/deploy/kustomize/kustomize_test.go
index f2e3b926e..7c65c3b36 100644
--- a/pkg/skaffold/deploy/kustomize/kustomize_test.go
+++ b/pkg/skaffold/deploy/kustomize/kustomize_test.go
@@ -292,7 +292,7 @@ func TestKustomizeCleanup(t *testing.T) {
 					Namespace: kubectl.TestNamespace}},
 			}, &label.DefaultLabeller{}, &test.kustomize, "default")
 			t.RequireNoError(err)
-			err = k.Cleanup(context.Background(), io.Discard, test.dryRun, manifest.ManifestListByConfig{})
+			err = k.Cleanup(context.Background(), io.Discard, test.dryRun, manifest.NewManifestListByConfig())
 
 			t.CheckError(test.shouldErr, err)
 		})
diff --git a/pkg/skaffold/docker/platform_test.go b/pkg/skaffold/docker/platform_test.go
new file mode 100644
index 000000000..95cf4fadf
--- /dev/null
+++ b/pkg/skaffold/docker/platform_test.go
@@ -0,0 +1,130 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package docker
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/random"
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestGetPlatformsForImage(t *testing.T) {
+	idx, err := random.Image(1024, 1)
+	testutil.CheckError(t, false, err)
+	expectedRepo := "foo/bar"
+	manifestPath := fmt.Sprintf("/v2/%s/manifests/latest", expectedRepo)
+	manifest, err := idx.Manifest()
+	testutil.CheckError(t, false, err)
+
+	configFile, err := idx.ConfigFile()
+	testutil.CheckError(t, false, err)
+	// Update image platform to match test
+	configFile.OS = "linux"
+	configFile.Architecture = "arm64"
+	rawConfigFile, err := json.Marshal(configFile)
+	testutil.CheckError(t, false, err)
+	manifest.Config.Data = rawConfigFile
+	cfgHash, cfgSize, err := v1.SHA256(bytes.NewReader(rawConfigFile))
+	testutil.CheckError(t, false, err)
+	manifest.Config.Digest = cfgHash
+	manifest.Config.Size = cfgSize
+
+	rawManifest, err := json.Marshal(manifest)
+	testutil.CheckError(t, false, err)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v2/":
+			w.WriteHeader(http.StatusOK)
+		case manifestPath:
+			if r.Method != http.MethodGet {
+				t.Errorf("Method; got %v, want %v", r.Method, http.MethodGet)
+			}
+			mt, err := idx.MediaType()
+			testutil.CheckError(t, false, err)
+			w.Header().Set("Content-Type", string(mt))
+			w.Write(rawManifest)
+		default:
+			t.Fatalf("Unexpected path: %v", r.URL.Path)
+		}
+	}))
+	defer server.Close()
+	u, err := url.Parse(server.URL)
+	testutil.CheckError(t, false, err)
+	tag := fmt.Sprintf("%s/%s:latest", u.Host, expectedRepo)
+	platforms, err := GetPlatforms(tag)
+	testutil.CheckError(t, false, err)
+
+	expectedPlatforms := []spec.Platform{
+		{Architecture: "arm64", OS: "linux"},
+	}
+	testutil.CheckDeepEqual(t, expectedPlatforms, platforms)
+}
+
+func TestGetPlatformsForIndex(t *testing.T) {
+	idx, err := random.Index(1024, 1, 3)
+	testutil.CheckError(t, false, err)
+	expectedRepo := "foo/bar"
+	manifestPath := fmt.Sprintf("/v2/%s/manifests/latest", expectedRepo)
+	manifest, err := idx.IndexManifest()
+	testutil.CheckError(t, false, err)
+
+	// Update image platform to match test
+	manifest.Manifests[0].Platform = &v1.Platform{Architecture: "arm64", OS: "linux"}
+	manifest.Manifests[1].Platform = &v1.Platform{Architecture: "amd64", OS: "linux"}
+	rawManifest, err := json.Marshal(manifest)
+	testutil.CheckError(t, false, err)
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/v2/":
+			w.WriteHeader(http.StatusOK)
+		case manifestPath:
+			if r.Method != http.MethodGet {
+				t.Errorf("Method; got %v, want %v", r.Method, http.MethodGet)
+			}
+			mt, err := idx.MediaType()
+			testutil.CheckError(t, false, err)
+			w.Header().Set("Content-Type", string(mt))
+			w.Write(rawManifest)
+		default:
+			t.Fatalf("Unexpected path: %v", r.URL.Path)
+		}
+	}))
+	defer server.Close()
+	u, err := url.Parse(server.URL)
+	testutil.CheckError(t, false, err)
+	tag := fmt.Sprintf("%s/%s:latest", u.Host, expectedRepo)
+	platforms, err := GetPlatforms(tag)
+	testutil.CheckError(t, false, err)
+
+	expectedPlatforms := []spec.Platform{
+		{Architecture: "arm64", OS: "linux"},
+		{Architecture: "amd64", OS: "linux"},
+	}
+	testutil.CheckDeepEqual(t, expectedPlatforms, platforms)
+}
diff --git a/pkg/skaffold/kubernetes/manifest/affinity_test.go b/pkg/skaffold/kubernetes/manifest/affinity_test.go
new file mode 100644
index 000000000..d0634953d
--- /dev/null
+++ b/pkg/skaffold/kubernetes/manifest/affinity_test.go
@@ -0,0 +1,437 @@
+/*
+Copyright 2019 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifest
+
+import (
+	"context"
+	"testing"
+
+	spec "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestGetImagePlatforms(t *testing.T) {
+	tests := []struct {
+		description string
+		manifest    string
+		platforms   PodPlatforms
+		expected    PodPlatforms
+	}{
+		{
+			description: "single container in Pod",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+		},
+		{
+			description: "multiple containers in Pod with overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example1:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+				"gcr.io/k8s-skaffold/example2:latest": {{Architecture: "arm64", OS: "linux"}, {Architecture: "arm", OS: "freebsd"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": {{Architecture: "arm64", OS: "linux"}},
+			},
+		},
+		{
+			description: "multiple containers in Pod with no overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example1:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+				"gcr.io/k8s-skaffold/example2:latest": {{Architecture: "arm", OS: "freebsd"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec": nil,
+			},
+		},
+		{
+			description: "deployment manifest",
+			manifest: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: getting-started
+  labels:
+    app: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      containers:
+      - name: example
+        image: gcr.io/k8s-skaffold/example:latest`,
+			platforms: map[string][]spec.Platform{
+				"gcr.io/k8s-skaffold/example:latest": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: map[string][]spec.Platform{
+				".spec.template.spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			t.Override(&getPlatforms, func(image string) ([]spec.Platform, error) {
+				return test.platforms[image], nil
+			})
+			ml := ManifestList{[]byte(test.manifest)}
+			pl, err := ml.GetImagePlatforms(context.Background(), NewResourceSelectorImages(TransformAllowlist, TransformDenylist))
+			t.CheckNoError(err)
+			t.CheckMapsMatch(test.expected, pl)
+		})
+	}
+}
+
+func TestSetPlatformNodeAffinity(t *testing.T) {
+	tests := []struct {
+		description string
+		manifest    string
+		platforms   PodPlatforms
+		expected    string
+	}{
+		{
+			description: "single container in Pod",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+		},
+		{
+			description: "multiple containers in Pod with overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+		},
+		{
+			description: "multiple containers in Pod with no overlapping platforms",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+			platforms: map[string][]spec.Platform{
+				".spec": nil,
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  containers:
+  - image: gcr.io/k8s-skaffold/example1:latest
+    name: example1
+  - image: gcr.io/k8s-skaffold/example2:latest
+    name: example2`,
+		},
+		{
+			description: "deployment manifest",
+			manifest: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: getting-started
+  labels:
+    app: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      containers:
+      - name: example
+        image: gcr.io/k8s-skaffold/example:latest`,
+			platforms: map[string][]spec.Platform{
+				".spec.template.spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    app: getting-started
+  name: getting-started
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: getting-started
+  template:
+    metadata:
+      labels:
+        app: getting-started
+    spec:
+      affinity:
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            nodeSelectorTerms:
+            - matchExpressions:
+              - key: kubernetes.io/os
+                operator: In
+                values:
+                - linux
+              - key: kubernetes.io/arch
+                operator: In
+                values:
+                - amd64
+            - matchExpressions:
+              - key: kubernetes.io/os
+                operator: In
+                values:
+                - linux
+              - key: kubernetes.io/arch
+                operator: In
+                values:
+                - arm64
+      containers:
+      - image: gcr.io/k8s-skaffold/example:latest
+        name: example`,
+		},
+		{
+			description: "existing node affinity",
+			manifest: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+			platforms: map[string][]spec.Platform{
+				".spec": {{Architecture: "amd64", OS: "linux"}, {Architecture: "arm64", OS: "linux"}},
+			},
+			expected: `
+apiVersion: v1
+kind: Pod
+metadata:
+  name: getting-started
+spec:
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - amd64
+        - matchExpressions:
+          - key: foo1
+            operator: Equals
+            values:
+            - bar1
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+        - matchExpressions:
+          - key: foo2
+            operator: Equals
+            values:
+            - bar2
+          - key: kubernetes.io/os
+            operator: In
+            values:
+            - linux
+          - key: kubernetes.io/arch
+            operator: In
+            values:
+            - arm64
+  containers:
+  - image: gcr.io/k8s-skaffold/example:latest
+    name: example`,
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			t.Override(&getPlatforms, func(image string) ([]spec.Platform, error) {
+				return test.platforms[image], nil
+			})
+			ml := ManifestList{[]byte(test.manifest)}
+			m, err := ml.SetPlatformNodeAffinity(context.Background(), NewResourceSelectorAffinity(TransformAllowlist, TransformDenylist), test.platforms)
+			t.CheckNoError(err)
+			expected := ManifestList{[]byte(test.expected)}
+			t.CheckDeepEqual(expected.String(), m.String())
+		})
+	}
+}
diff --git a/pkg/skaffold/kubernetes/manifest/manifests_test.go b/pkg/skaffold/kubernetes/manifest/manifests_test.go
index d23351701..1b1524fa6 100644
--- a/pkg/skaffold/kubernetes/manifest/manifests_test.go
+++ b/pkg/skaffold/kubernetes/manifest/manifests_test.go
@@ -162,3 +162,57 @@ func TestAppendServiceAndRoleBinding(t *testing.T) {
 	testutil.CheckDeepEqual(t, service, string(manifests[1]))
 	testutil.CheckDeepEqual(t, manifests.String(), roleBinding+"\n---\n"+service)
 }
+
+func TestManifestListByConfigAdd(t *testing.T) {
+	tests := []struct {
+		description string
+		mlbc        ManifestListByConfig
+		config      string
+		ml          ManifestList
+		expected    ManifestListByConfig
+	}{
+		{
+			description: "same config name appends to original list",
+			mlbc: ManifestListByConfig{
+				manifests: map[string]ManifestList{
+					"config-a": {[]byte(pod1)},
+				},
+				configNames: []string{"config-a"},
+			},
+			config: "config-a",
+			ml:     ManifestList{[]byte(pod2)},
+			expected: ManifestListByConfig{
+				manifests: map[string]ManifestList{
+					"config-a": {[]byte(pod1), []byte(pod2)},
+				},
+				configNames: []string{"config-a"},
+			},
+		},
+		{
+			description: "different config name appends to original list",
+			mlbc: ManifestListByConfig{
+				manifests: map[string]ManifestList{
+					"config-a": {[]byte(pod1)},
+				},
+				configNames: []string{"config-a"},
+			},
+			config: "config-b",
+			ml:     ManifestList{[]byte(pod2)},
+			expected: ManifestListByConfig{
+				manifests: map[string]ManifestList{
+					"config-a": {[]byte(pod1)},
+					"config-b": {[]byte(pod2)},
+				},
+				configNames: []string{"config-a", "config-b"},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		testutil.Run(t, test.description, func(t *testutil.T) {
+			test.mlbc.Add(test.config, test.ml)
+			t.CheckDeepEqual(test.expected.configNames, test.mlbc.configNames)
+			t.CheckDeepEqual(test.expected.manifests, test.mlbc.manifests)
+		})
+	}
+}
diff --git a/pkg/skaffold/render/renderer/render_mux_test.go b/pkg/skaffold/render/renderer/render_mux_test.go
index 5c95e70c4..6fe72af4b 100644
--- a/pkg/skaffold/render/renderer/render_mux_test.go
+++ b/pkg/skaffold/render/renderer/render_mux_test.go
@@ -33,14 +33,14 @@ import (
 func TestRenderMux_Render(t *testing.T) {
 	tests := []struct {
 		name         string
-		renderers    []Renderer
+		renderers    GroupRenderer
 		expected     string
 		expectedDeps []string
 		shouldErr    bool
 	}{
 		{
 			name: "concatenates render results with separator",
-			renderers: []Renderer{
+			renderers: GroupRenderer{
 				mock{configName: "config1", manifests: "manifest-1", deps: []string{"file1.txt", "file2.txt"}},
 				mock{configName: "config2", manifests: "manifest-2", deps: []string{"file2.txt", "file3.txt"}}},
 			expected:     "manifest-1\n---\nmanifest-2",
@@ -48,7 +48,7 @@ func TestRenderMux_Render(t *testing.T) {
 		},
 		{
 			name: "returns empty string when any call fails",
-			renderers: []Renderer{
+			renderers: GroupRenderer{
 				mock{manifests: "manifest-1", deps: []string{"file1.txt"}},
 				mock{deps: []string{"file2.txt"}, shouldErr: true}},
 			expectedDeps: []string{"file1.txt", "file2.txt"},
@@ -56,7 +56,7 @@ func TestRenderMux_Render(t *testing.T) {
 		},
 		{
 			name: "short-circuits when first call fails",
-			renderers: []Renderer{
+			renderers: GroupRenderer{
 				mock{deps: []string{"file1.txt"}, shouldErr: true},
 				mock{manifests: "manifest-2", deps: []string{"file2.txt"}}},
 			expectedDeps: []string{"file1.txt", "file2.txt"},
diff --git a/pkg/skaffold/schema/latest/config.go b/pkg/skaffold/schema/latest/config.go
index 4f317d3e0..afec3de5a 100644
--- a/pkg/skaffold/schema/latest/config.go
+++ b/pkg/skaffold/schema/latest/config.go
@@ -1560,8 +1560,10 @@ type ResourceFilter struct {
 	GroupKind string `yaml:"groupKind" yamltags:"required"`
 	// Image is an optional slice of JSON-path-like paths of where to rewrite images.
 	Image []string `yaml:"image,omitempty"`
-	// Labels is an optional slide of JSON-path-like paths of where to add a labels block if missing.
+	// Labels is an optional slice of JSON-path-like paths of where to add a labels block if missing.
 	Labels []string `yaml:"labels,omitempty"`
+	// Affinity is an optional slice of JSON-path-like paths of where to add `affinity` definitions for scheduling Pods.
+	Affinity []string `yaml:"affinity,omitempty"`
 }
 
 // UnmarshalYAML provides a custom unmarshaller to deal with
diff --git a/pkg/skaffold/util/platform_test.go b/pkg/skaffold/util/platform_test.go
new file mode 100644
index 000000000..6ff74074d
--- /dev/null
+++ b/pkg/skaffold/util/platform_test.go
@@ -0,0 +1,32 @@
+/*
+Copyright 2021 The Skaffold Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package util
+
+import (
+	"testing"
+
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+
+	"github.com/GoogleContainerTools/skaffold/testutil"
+)
+
+func TestConvert(t *testing.T) {
+	pl1 := v1.Platform{Architecture: "arch", OS: "os", OSVersion: "os_ver", Variant: "variant", OSFeatures: []string{"os_feature"}}
+	pl2 := ConvertFromV1Platform(pl1)
+	pl3 := ConvertToV1Platform(pl2)
+	testutil.CheckDeepEqual(t, pl1, pl3)
+}