Repository: GoogleContainerTools/kpt
Pull request #3362 (2022-07-12 21:21:53)

support nerdctl as a fn runtime
The CI test part has not been done yet. It will be done soon.



Patch:
diff --git a/internal/docs/generated/fndocs/docs.go b/internal/docs/generated/fndocs/docs.go
index 5f419978..efdcbfc0 100644
--- a/internal/docs/generated/fndocs/docs.go
+++ b/internal/docs/generated/fndocs/docs.go
@@ -21,6 +21,11 @@ Flags:
     Container image of the function e.g. ` + "`" + `gcr.io/kpt-fn/set-namespace:v0.1` + "`" + `.
     For convenience, if full image path is not specified, ` + "`" + `gcr.io/kpt-fn/` + "`" + ` is added as default prefix.
     e.g. instead of passing ` + "`" + `gcr.io/kpt-fn/set-namespace:v0.1` + "`" + ` you can pass ` + "`" + `set-namespace:v0.1` + "`" + `.
+
+Environment Variables:
+
+  KPT_FN_RUNTIME:
+    The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
 `
 var DocExamples = `
   # display the documentation for image set-namespace:v0.1.1
@@ -144,7 +149,7 @@ Flags:
 Environment Variables:
 
   KPT_FN_RUNTIME:
-    The runtime to run kpt functions. It must be one of "docker" or "podman".
+    The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
 `
 var EvalExamples = `
   # execute container my-fn on the resources in DIR directory and
@@ -292,7 +297,7 @@ Flags:
 Environment Variables:
 
   KPT_FN_RUNTIME:
-    The runtime to run kpt functions. It must be one of "docker" or "podman".
+    The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
 `
 var RenderExamples = `
   # Render the package in current directory
@@ -318,7 +323,7 @@ var RenderExamples = `
   | kpt fn eval - -i gcr.io/kpt-fn/set-annotations:v0.1.3 -o path/to/dir  -- foo=bar
 
   # Render my-package-dir with podman as runtime for functions
-  $ KPT_FN_RUNTIME=podman kpt fn render my-package-dir --runtime podman
+  $ KPT_FN_RUNTIME=podman kpt fn render my-package-dir
 `
 
 var SinkShort = `Write resources to a local directory`
diff --git a/internal/fnruntime/container.go b/internal/fnruntime/container.go
index 3cfaa8d9..d0d5d3ed 100644
--- a/internal/fnruntime/container.go
+++ b/internal/fnruntime/container.go
@@ -46,12 +46,13 @@ type containerNetworkName string
 const (
 	networkNameNone           containerNetworkName = "none"
 	networkNameHost           containerNetworkName = "host"
-	defaultLongTimeout        time.Duration        = 5 * time.Minute
-	dockerVersionTimeout      time.Duration        = 5 * time.Second
+	defaultLongTimeout                             = 5 * time.Minute
+	versionCommandTimeout                          = 5 * time.Second
 	minSupportedDockerVersion string               = "v20.10.0"
 
-	dockerBin string = "docker"
-	podmanBin string = "podman"
+	dockerBin  string = "docker"
+	podmanBin  string = "podman"
+	nerdctlBin string = "nerdctl"
 
 	AlwaysPull       ImagePullPolicy = "Always"
 	IfNotPresentPull ImagePullPolicy = "IfNotPresent"
@@ -59,8 +60,9 @@ const (
 
 	ContainerRuntimeEnv = "KPT_FN_RUNTIME"
 
-	Docker ContainerRuntime = "docker"
-	Podman ContainerRuntime = "podman"
+	Docker  ContainerRuntime = "docker"
+	Podman  ContainerRuntime = "podman"
+	Nerdctl ContainerRuntime = "nerdctl"
 )
 
 type ImagePullPolicy string
@@ -105,6 +107,8 @@ func (r ContainerRuntime) GetBin() string {
 	switch r {
 	case Podman:
 		return podmanBin
+	case Nerdctl:
+		return nerdctlBin
 	default:
 		return dockerBin
 	}
@@ -130,6 +134,8 @@ func (f *ContainerFn) Run(reader io.Reader, writer io.Writer) error {
 	switch runtime {
 	case Podman:
 		return f.runCLI(reader, writer, podmanBin, filterPodmanCLIOutput)
+	case Nerdctl:
+		return f.runCLI(reader, writer, nerdctlBin, filterNerdctlCLIOutput)
 	default:
 		return f.runCLI(reader, writer, dockerBin, filterDockerCLIOutput)
 	}
@@ -162,8 +168,8 @@ func (f *ContainerFn) runCLI(reader io.Reader, writer io.Writer, bin string, fil
 	return nil
 }
 
-// getCmd assembles a command for docker or podman. The input binName is expected
-// to be either "docker" or "podman".
+// getCmd assembles a command for docker, podman or nerdctl. The input binName
+// is expected to be one of "docker", "podman" and "nerdctl".
 func (f *ContainerFn) getCmd(binName string) (*exec.Cmd, context.CancelFunc) {
 	network := networkNameNone
 	if f.Perm.AllowNetwork {
@@ -176,7 +182,6 @@ func (f *ContainerFn) getCmd(binName string) (*exec.Cmd, context.CancelFunc) {
 
 	args := []string{
 		"run", "--rm", "-i",
-		"-a", "STDIN", "-a", "STDOUT", "-a", "STDERR",
 		"--network", string(network),
 		"--user", uidgid,
 		"--security-opt=no-new-privileges",
@@ -348,12 +353,54 @@ func isPodmanCLIoutput(s string) bool {
 	return false
 }
 
+// filterNerdctlCLIOutput filters out nerdctl CLI messages
+// from the given buffer.
+func filterNerdctlCLIOutput(in io.Reader) string {
+	s := bufio.NewScanner(in)
+	var lines []string
+
+	for s.Scan() {
+		txt := s.Text()
+		if !isNerdctlCLIoutput(txt) {
+			lines = append(lines, txt)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+// isNerdctlCLIoutput is helper method to determine if
+// the given string is a nerdctl CLI output message.
+// Example nerdctl output:
+// docker.io/library/hello-world:latest:                                             resolving      |--------------------------------------|
+// docker.io/library/hello-world:latest:                                             resolved       |++++++++++++++++++++++++++++++++++++++|
+// index-sha256:13e367d31ae85359f42d637adf6da428f76d75dc9afeb3c21faea0d976f5c651:    done           |++++++++++++++++++++++++++++++++++++++|
+// manifest-sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4: done           |++++++++++++++++++++++++++++++++++++++|
+// config-sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412:   done           |++++++++++++++++++++++++++++++++++++++|
+// layer-sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54:    done           |++++++++++++++++++++++++++++++++++++++|
+// elapsed: 2.4 s                                                                    total:  4.4 Ki (1.9 KiB/s)
+func isNerdctlCLIoutput(s string) bool {
+	if strings.Contains(s, "index-sha256:") ||
+		strings.Contains(s, "Copying blob sha256:") ||
+		strings.Contains(s, "manifest-sha256:") ||
+		strings.Contains(s, "config-sha256:") ||
+		strings.Contains(s, "layer-sha256:") ||
+		strings.Contains(s, "elapsed:") ||
+		strings.Contains(s, "++++++++++++++++++++++++++++++++++++++") ||
+		strings.Contains(s, "--------------------------------------") ||
+		sha256Matcher.MatchString(s) {
+		return true
+	}
+	return false
+}
+
 func StringToContainerRuntime(v string) (ContainerRuntime, error) {
 	switch strings.ToLower(v) {
 	case string(Docker):
 		return Docker, nil
 	case string(Podman):
 		return Podman, nil
+	case string(Nerdctl):
+		return Nerdctl, nil
 	case "":
 		return Docker, nil
 	default:
@@ -367,6 +414,8 @@ func ContainerRuntimeAvailable(runtime ContainerRuntime) error {
 		return dockerCmdAvailable()
 	case Podman:
 		return podmanCmdAvailable()
+	case Nerdctl:
+		return nerdctlCmdAvailable()
 	default:
 		return dockerCmdAvailable()
 	}
@@ -381,9 +430,9 @@ To install docker, follow the instructions at https://docs.docker.com/get-docker
 `
 	cmdOut := &bytes.Buffer{}
 
-	ctx, cancel := context.WithTimeout(context.Background(), dockerVersionTimeout)
+	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
 	defer cancel()
-	cmd := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Client.Version}}")
+	cmd := exec.CommandContext(ctx, dockerBin, "version", "--format", "{{.Client.Version}}")
 	cmd.Stdout = cmdOut
 	err := cmd.Run()
 	if err != nil || cmdOut.String() == "" {
@@ -413,12 +462,27 @@ func podmanCmdAvailable() error {
 To install podman, follow the instructions at https://podman.io/getting-started/installation.
 `
 
-	ctx, cancel := context.WithTimeout(context.Background(), dockerVersionTimeout)
+	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
 	defer cancel()
-	cmd := exec.CommandContext(ctx, "podman", "version")
+	cmd := exec.CommandContext(ctx, podmanBin, "version")
 	err := cmd.Run()
 	if err != nil {
 		return fmt.Errorf("%s", suggestedText)
 	}
 	return nil
 }
+
+func nerdctlCmdAvailable() error {
+	suggestedText := `nerdctl must be installed.
+To install nerdctl, follow the instructions at https://github.com/containerd/nerdctl#install.
+`
+
+	ctx, cancel := context.WithTimeout(context.Background(), versionCommandTimeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, nerdctlBin, "version")
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("%v\n%s", err, suggestedText)
+	}
+	return nil
+}
diff --git a/internal/util/addmergecomment/addmergecomment.go b/internal/util/addmergecomment/addmergecomment.go
index 215aea51..2b4a3903 100644
--- a/internal/util/addmergecomment/addmergecomment.go
+++ b/internal/util/addmergecomment/addmergecomment.go
@@ -23,9 +23,22 @@ import (
 	"github.com/GoogleContainerTools/kpt/internal/util/merge"
 	"sigs.k8s.io/kustomize/kyaml/copyutil"
 	"sigs.k8s.io/kustomize/kyaml/kio"
+	"sigs.k8s.io/kustomize/kyaml/resid"
 	kyaml "sigs.k8s.io/kustomize/kyaml/yaml"
 )
 
+// TODO(yuwenma): Those const vars are defined in kpt-functions-sdk/go/fn v0.0.0-20220706221933-7181f451a663+
+// we cannot import go/fn directly because the porch/set-namespace uses an older go/fn version. Bumping kpt module alone fails
+// kpt CI.
+// We should update porch/set-namespace once https://github.com/GoogleContainerTools/kpt-functions-catalog/pull/885 is released.
+// and cleanup the const vars below
+const (
+	upstreamIdentifierFmt = "%s|%s|%s|%s"
+	upstreamIdentifier    = "internal.kpt.dev/upstream-identifier"
+	unknownNamespace      = "~C"
+	defaultNamespace      = "default"
+)
+
 // AddMergeComment adds merge comments with format "kpt-merge: namespace/name"
 // to all resources in the package
 type AddMergeComment struct{}
@@ -49,6 +62,38 @@ func Process(paths ...string) error {
 	return nil
 }
 
+// addUpstreamAnnotation adds internal.kpt.dev/upstream-identifier annotation to resource.
+// In a 3 level package chain (root -> branch -> deployable), the downstream package uses the upstream package meta GKNN
+// as its upstream origin, not the upstream its own origin. For example
+// root: No `upstreamIdentifier` annotation
+// branch: `upstream-identifier=rootGKNN`
+// deployable: `upstream-identifier=branchGKNN`
+// One known caveat is that upstream meta change can cause downstream origin mismatch. This potentially causes the 3-way merge
+// to fail in pkg update step.
+func addUpstreamAnnotation(object *kyaml.RNode, mergeComment string) error {
+	annotations := object.GetAnnotations()
+	group, _ := resid.ParseGroupVersion(object.GetApiVersion())
+	var name, namespace string
+	if strings.Contains(mergeComment, merge.MergeCommentPrefix) {
+		nsAndName := merge.NsAndNameForMerge(mergeComment)
+		namespace = nsAndName[0]
+		name = nsAndName[1]
+	} else {
+		namespace = object.GetNamespace()
+		name = object.GetName()
+	}
+	// Convert namespace to follow the upstream identifier convention, where
+	// - empty string is treated as "default"
+	// - unknown custom resource or cluster scoped resource use placeholder "~C"
+	if namespace == "" {
+		namespace = defaultNamespace
+	} else if object.GetNamespace() == resid.TotallyNotANamespace {
+		namespace = unknownNamespace
+	}
+	annotations[upstreamIdentifier] = fmt.Sprintf(upstreamIdentifierFmt, group, object.GetKind(), namespace, name)
+	return object.SetAnnotations(annotations)
+}
+
 // Filter implements kyaml.Filter
 // this filter adds merge comment with format "kpt-merge: namespace/name" to
 // the input resource, if the namespace field doesn't exist on the resource,
@@ -60,15 +105,33 @@ func (amc *AddMergeComment) Filter(object *kyaml.RNode) (*kyaml.RNode, error) {
 		return object, nil
 	}
 	mf := object.Field(kyaml.MetadataField)
-	if mf.IsNilOrEmpty() {
-		// skip adding merge comment if empty metadata
+	if object.GetName() == "" && object.GetNamespace() == "" && len(object.GetLabels()) == 0 {
+		// skip adding merge comment if empty metadata. Since the intermediate annotations always exist,
+		// mf.IsNilOrEmpty cannot tell whether it's empty meta or not.
+		// e.g. Empty meta with internal annotations.
+		//kind: MyKind
+		//spec:
+		//  replicas: 3
+		//metadata:
+		//  annotations:
+		//    config.kubernetes.io/index: '0'
+		//    config.kubernetes.io/path: 'k8s-cli-982798852.yaml'
+		//    internal.config.kubernetes.io/index: '0'
+		//    internal.config.kubernetes.io/path: 'k8s-cli-982798852.yaml'
+		//    internal.config.kubernetes.io/seqindent: 'compact'
+		//    internal.config.kubernetes.io/annotations-migration-resource-id: '0'
 		return object, nil
 	}
-	if strings.Contains(mf.Key.YNode().LineComment, merge.MergeCommentPrefix) {
-		// skip adding merge comment if merge comment is already present
+
+	// Only add merge comment if merge comment does not present
+	if !strings.Contains(mf.Key.YNode().LineComment, merge.MergeCommentPrefix) {
+		mf.Key.YNode().LineComment = fmt.Sprintf("%s %s/%s", merge.MergeCommentPrefix, rm.Namespace, rm.Name)
+	}
+	// We will migrate kpt-merge comment to upstream-identifier annotation. As an intermediate stage, this filter
+	// preserves the mergeComment behavior to guarantee the backward compatibility.
+	if err := addUpstreamAnnotation(object, mf.Key.YNode().LineComment); err != nil {
 		return object, nil
 	}
-	mf.Key.YNode().LineComment = fmt.Sprintf("%s %s/%s", merge.MergeCommentPrefix, rm.Namespace, rm.Name)
 	return object, nil
 }
 
diff --git a/internal/util/merge/merge3.go b/internal/util/merge/merge3.go
index effdf27b..836d6367 100644
--- a/internal/util/merge/merge3.go
+++ b/internal/util/merge/merge3.go
@@ -247,7 +247,7 @@ func resolveGroup(meta yaml.ResourceMeta) string {
 // resolveNamespace resolves the namespace which should be used for merging resources
 // uses namespace from comment on metadata field if present, falls back to resource namespace
 func resolveNamespace(meta yaml.ResourceMeta, metadataComment string) string {
-	nsName := nsAndNameForMerge(metadataComment)
+	nsName := NsAndNameForMerge(metadataComment)
 	if nsName == nil {
 		return meta.Namespace
 	}
@@ -257,17 +257,17 @@ func resolveNamespace(meta yaml.ResourceMeta, metadataComment string) string {
 // resolveName resolves the name which should be used for merging resources
 // uses name from comment on metadata field if present, falls back to resource name
 func resolveName(meta yaml.ResourceMeta, metadataComment string) string {
-	nsName := nsAndNameForMerge(metadataComment)
+	nsName := NsAndNameForMerge(metadataComment)
 	if nsName == nil {
 		return meta.Name
 	}
 	return nsName[1]
 }
 
-// nsAndNameForMerge returns the namespace and name for merge
+// NsAndNameForMerge returns the namespace and name for merge
 // from the line comment on the metadata field
 // e.g. metadata: # kpt-merge: default/foo returns [default, foo]
-func nsAndNameForMerge(metadataComment string) []string {
+func NsAndNameForMerge(metadataComment string) []string {
 	comment := strings.TrimPrefix(metadataComment, "#")
 	comment = strings.TrimSpace(comment)
 	if !strings.HasPrefix(comment, MergeCommentPrefix) {
diff --git a/site/reference/cli/fn/doc/README.md b/site/reference/cli/fn/doc/README.md
index a914c635..0fd7d88c 100644
--- a/site/reference/cli/fn/doc/README.md
+++ b/site/reference/cli/fn/doc/README.md
@@ -31,6 +31,13 @@ kpt fn doc --image=IMAGE
   e.g. instead of passing `gcr.io/kpt-fn/set-namespace:v0.1` you can pass `set-namespace:v0.1`.
 ```
 
+#### Environment Variables
+
+```
+KPT_FN_RUNTIME:
+  The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
+```
+
 <!--mdtogo-->
 
 ### Examples
diff --git a/site/reference/cli/fn/eval/README.md b/site/reference/cli/fn/eval/README.md
index 233a06d6..2c1463d8 100644
--- a/site/reference/cli/fn/eval/README.md
+++ b/site/reference/cli/fn/eval/README.md
@@ -146,7 +146,7 @@ fn-args:
 
 ```
 KPT_FN_RUNTIME:
-  The runtime to run kpt functions. It must be one of "docker" or "podman".
+  The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
 ```
 
 <!--mdtogo-->
diff --git a/site/reference/cli/fn/render/README.md b/site/reference/cli/fn/render/README.md
index a18f5fc2..d6936083 100644
--- a/site/reference/cli/fn/render/README.md
+++ b/site/reference/cli/fn/render/README.md
@@ -75,7 +75,7 @@ PKG_PATH:
 
 ```
 KPT_FN_RUNTIME:
-  The runtime to run kpt functions. It must be one of "docker" or "podman".
+  The runtime to run kpt functions. It must be one of "docker", "podman" and "nerdctl".
 ```
 
 <!--mdtogo-->
@@ -120,7 +120,7 @@ $ kpt fn render -o stdout \
 
 ```shell
 # Render my-package-dir with podman as runtime for functions
-$ KPT_FN_RUNTIME=podman kpt fn render my-package-dir --runtime podman
+$ KPT_FN_RUNTIME=podman kpt fn render my-package-dir
 ```
 
 <!--mdtogo-->


Test patch:
diff --git a/e2e/testdata/porch/rpkg-clone/config.yaml b/e2e/testdata/porch/rpkg-clone/config.yaml
index 43034098..5594e47b 100644
--- a/e2e/testdata/porch/rpkg-clone/config.yaml
+++ b/e2e/testdata/porch/rpkg-clone/config.yaml
@@ -60,6 +60,7 @@ commands:
             config.kubernetes.io/path: Kptfile
             internal.config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/path: Kptfile
+            internal.kpt.dev/upstream-identifier: kpt.dev|Kptfile|default|basens-clone
           name: basens-clone
         upstream:
           git:
@@ -82,6 +83,7 @@ commands:
             config.kubernetes.io/path: namespace.yaml
             internal.config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/path: namespace.yaml
+            internal.kpt.dev/upstream-identifier: '|Namespace|default|example'
           name: example
       kind: ResourceList
     yaml: true
@@ -103,6 +105,7 @@ commands:
             config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/path: Kptfile
+            internal.kpt.dev/upstream-identifier: kpt.dev|Kptfile|default|empty-clone
           name: empty-clone
         upstream:
           git:
diff --git a/e2e/testdata/porch/rpkg-copy/config.yaml b/e2e/testdata/porch/rpkg-copy/config.yaml
index 64c7eeb8..b6af4dec 100644
--- a/e2e/testdata/porch/rpkg-copy/config.yaml
+++ b/e2e/testdata/porch/rpkg-copy/config.yaml
@@ -58,6 +58,7 @@ commands:
             config.kubernetes.io/path: Kptfile
             internal.config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/path: Kptfile
+            internal.kpt.dev/upstream-identifier: kpt.dev|Kptfile|default|basens-edit
           name: basens-edit
         upstream:
           git:
@@ -80,6 +81,7 @@ commands:
             config.kubernetes.io/path: namespace.yaml
             internal.config.kubernetes.io/index: "0"
             internal.config.kubernetes.io/path: namespace.yaml
+            internal.kpt.dev/upstream-identifier: '|Namespace|default|example'
           name: example
       kind: ResourceList
     yaml: true
diff --git a/internal/util/addmergecomment/addmergecomment_test.go b/internal/util/addmergecomment/addmergecomment_test.go
index d2c78500..d40f414e 100644
--- a/internal/util/addmergecomment/addmergecomment_test.go
+++ b/internal/util/addmergecomment/addmergecomment_test.go
@@ -46,6 +46,8 @@ kind: Deployment
 metadata: # kpt-merge: my-space/nginx-deployment
   name: nginx-deployment
   namespace: my-space
+  annotations:
+    internal.kpt.dev/upstream-identifier: apps|Deployment|my-space|nginx-deployment
 spec:
   replicas: 3
  `,
@@ -67,6 +69,8 @@ kind: Deployment
 metadata: # kpt-merge: default/nginx-deployment
   name: nginx-deployment
   namespace: default
+  annotations:
+    internal.kpt.dev/upstream-identifier: apps|Deployment|default|nginx-deployment
 spec:
   replicas: 3
  `,
@@ -86,6 +90,8 @@ apiVersion: apps/v1
 kind: Deployment
 metadata: # kpt-merge: /nginx-deployment
   name: nginx-deployment
+  annotations:
+    internal.kpt.dev/upstream-identifier: apps|Deployment|default|nginx-deployment
 spec:
   replicas: 3
  `,
@@ -105,12 +111,14 @@ apiVersion: apps/v1
 kind: Deployment
 metadata: # kpt-merge: my-space/nginx-deployment
   name: nginx-deployment-new
+  annotations:
+    internal.kpt.dev/upstream-identifier: apps|Deployment|my-space|nginx-deployment
 spec:
   replicas: 3
  `,
 		},
 		{
-			name: "Skip adding kpt merge comment if already present",
+			name: "Skip adding kpt merge comment if skip meta",
 			input: `
 apiVersion: apps/v1
 kind: MyKind
diff --git a/internal/util/diff/diff_test.go b/internal/util/diff/diff_test.go
index ae4b4269..fdd1d0ea 100644
--- a/internal/util/diff/diff_test.go
+++ b/internal/util/diff/diff_test.go
@@ -72,11 +72,11 @@ func TestCommand_Diff(t *testing.T) {
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-39c39
+41c41
 <             - containerPort: 80
 ---
 >             - containerPort: 8081
-25,27c25,27
+27,29c27,29
 <     - name: "80"
 <       port: 80
 <       targetPort: 80
@@ -112,11 +112,11 @@ func TestCommand_Diff(t *testing.T) {
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-39c39
+41c41
 <             - containerPort: 80
 ---
 >             - containerPort: 8081
-25,27c25,27
+27,29c27,29
 <     - name: "80"
 <       port: 80
 <       targetPort: 80
@@ -153,11 +153,11 @@ func TestCommand_Diff(t *testing.T) {
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-39c39
+41c41
 <             - containerPort: 8081
 ---
 >             - containerPort: 80
-25,27c25,27
+27,29c27,29
 <     - name: "8081"
 <       port: 8081
 <       targetPort: 8081
@@ -199,7 +199,7 @@ func TestCommand_Diff(t *testing.T) {
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-7c7
+9c9
 <   replicas: 5
 ---
 >   replicas: 3
@@ -250,7 +250,7 @@ locally changed: foo
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-7c7
+9c9
 <   replicas: 5
 ---
 >   replicas: 3
@@ -305,7 +305,7 @@ locally changed: foo
 			diffTool: "diff",
 			diffOpts: "-r -i -w",
 			expDiff: `
-7c7
+9c9
 <   replicas: 3
 ---
 >   replicas: 5