Repository: linkerd/linkerd2
Pull request #7335 (2021-11-22 11:05:30)

Add mc crd to codegen
Currently, the MC `Link` CRD is being handled using the dynamic k8s client. It would be useful for consumers of this API if there was a typed API for this CRD.

The solution is to update the codegen script to generate this code.

Signed-off-by: Zahari Dichev <zaharidichev@gmail.com>



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index ff9c9bd53..5dd1f2eef 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13,9 +13,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.47"
+version = "1.0.48"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38d9ff5d688f1c13395289f67db01d4826b46dd694e7580accdc3e8430f2d98e"
+checksum = "62e1f47f7dc0422027a4e370dd4548d4d66b26782e513e98dca1e689e058a80e"
 
 [[package]]
 name = "async-stream"
@@ -318,9 +318,9 @@ checksum = "2022715d62ab30faffd124d40b76f4134a550a87792276512b18d63272333394"
 
 [[package]]
 name = "futures"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a12aa0eb539080d55c3f2d45a67c3b58b6b0773c1a3ca2dfec66d58c97fd66ca"
+checksum = "8cd0210d8c325c245ff06fd95a3b13689a1a276ac8cfa8e8720cb840bfb84b9e"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -333,9 +333,9 @@ dependencies = [
 
 [[package]]
 name = "futures-channel"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5da6ba8c3bb3c165d3c7319fc1cc8304facf1fb8db99c5de877183c08a273888"
+checksum = "7fc8cd39e3dbf865f7340dce6a2d401d24fd37c6fe6c4f0ee0de8bfca2252d27"
 dependencies = [
  "futures-core",
  "futures-sink",
@@ -343,15 +343,15 @@ dependencies = [
 
 [[package]]
 name = "futures-core"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d"
+checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45025be030969d763025784f7f355043dc6bc74093e4ecc5000ca4dc50d8745c"
+checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -360,18 +360,16 @@ dependencies = [
 
 [[package]]
 name = "futures-io"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "522de2a0fe3e380f1bc577ba0474108faf3f6b18321dbf60b3b9c39a75073377"
+checksum = "e481354db6b5c353246ccf6a728b0c5511d752c08da7260546fc0933869daa11"
 
 [[package]]
 name = "futures-macro"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18e4a4b95cea4b4ccbcf1c5675ca7c4ee4e9e75eb79944d07defde18068f79bb"
+checksum = "a89f17b21645bc4ed773c69af9c9a0effd4a3f1a3876eadd453469f8854e7fdd"
 dependencies = [
- "autocfg",
- "proc-macro-hack",
  "proc-macro2",
  "quote",
  "syn",
@@ -379,23 +377,22 @@ dependencies = [
 
 [[package]]
 name = "futures-sink"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36ea153c13024fe480590b3e3d4cad89a0cfacecc24577b68f86c6ced9c2bc11"
+checksum = "996c6442437b62d21a32cd9906f9c41e7dc1e19a9579843fad948696769305af"
 
 [[package]]
 name = "futures-task"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99"
+checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12"
 
 [[package]]
 name = "futures-util"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481"
+checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e"
 dependencies = [
- "autocfg",
  "futures-channel",
  "futures-core",
  "futures-io",
@@ -405,8 +402,6 @@ dependencies = [
  "memchr",
  "pin-project-lite",
  "pin-utils",
- "proc-macro-hack",
- "proc-macro-nested",
  "slab",
 ]
 
@@ -1237,18 +1232,6 @@ dependencies = [
  "version_check",
 ]
 
-[[package]]
-name = "proc-macro-hack"
-version = "0.5.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5"
-
-[[package]]
-name = "proc-macro-nested"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086"
-
 [[package]]
 name = "proc-macro2"
 version = "1.0.32"
@@ -2036,9 +2019,9 @@ dependencies = [
 
 [[package]]
 name = "tracing-subscriber"
-version = "0.3.1"
+version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80a4ddde70311d8da398062ecf6fc2c309337de6b0f77d6c27aff8d53f6fca52"
+checksum = "7507ec620f809cdf07cccb5bc57b13069a88031b795efd4079b1c71b66c1613d"
 dependencies = [
  "lazy_static",
  "matchers",
diff --git a/bin/_os.sh b/bin/_os.sh
index 635e2e9b7..32e7207d9 100644
--- a/bin/_os.sh
+++ b/bin/_os.sh
@@ -4,6 +4,32 @@ set -eu
 
 export OS_ARCH_ALL="linux-amd64 linux-arm64 linux-arm darwin darwin-arm64 windows"
 
+architecture() {
+  arch=$(uname -m)
+  case $arch in
+    x86_64)
+      arch=amd64
+      ;;
+    armv8*)
+      arch=arm64
+      ;;
+    aarch64*)
+      arch=arm64
+      ;;
+    armv*)
+      arch=arm
+      ;;
+    amd64|arm64)
+      arch=$arch
+      ;;
+    *)
+      echo "unsupported architecture: $arch" >&2
+      exit 1
+      ;;
+  esac
+  echo "$arch"
+}
+
 os() {
   os=$(uname -s)
   arch=""
@@ -16,28 +42,7 @@ os() {
       ;;
     Linux)
       os=linux
-      arch=$(uname -m)
-      case $arch in
-        x86_64)
-          arch=amd64
-          ;;
-        armv8*)
-          arch=arm64
-          ;;
-        aarch64*)
-          arch=arm64
-          ;;
-        armv*)
-          arch=arm
-          ;;
-        amd64|arm64)
-          arch=$arch
-          ;;
-        *)
-          echo "unsupported architecture: $arch" >&2
-          exit 1
-          ;;
-      esac
+      arch=$(architecture)
       ;;
     *)
       echo "unsupported os: $os" >&2
diff --git a/bin/docker-build-policy-controller b/bin/docker-build-policy-controller
index f5873f898..51364847f 100755
--- a/bin/docker-build-policy-controller
+++ b/bin/docker-build-policy-controller
@@ -13,6 +13,8 @@ bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
 . "$bindir"/_docker.sh
 # shellcheck source=_tag.sh
 . "$bindir"/_tag.sh
+# shellcheck source=_os.sh
+. "$bindir"/_os.sh
 
 if [ "$DOCKER_TARGET" = 'multi-arch' ]; then
     echo "DOCKER_TARGET may not be set to 'multi-arch' with $0" >&2
@@ -20,4 +22,5 @@ if [ "$DOCKER_TARGET" = 'multi-arch' ]; then
 fi
 
 dir=$( cd "$bindir"/../policy-controller && pwd )
-docker_build policy-controller "${TAG:-$(head_root_tag)}" "$dir/amd64.dockerfile"
+arch=$(architecture)
+docker_build policy-controller "${TAG:-$(head_root_tag)}" "$dir/$arch.dockerfile"
diff --git a/bin/lint b/bin/lint
index a849bab71..f27464c1a 100755
--- a/bin/lint
+++ b/bin/lint
@@ -2,7 +2,7 @@
 
 set -eu
 
-lintversion=1.25.1
+lintversion=1.43.0
 
 cd "$(pwd -P)"
 
diff --git a/bin/update-codegen.sh b/bin/update-codegen.sh
index 83cb9db9b..2be40dc3b 100755
--- a/bin/update-codegen.sh
+++ b/bin/update-codegen.sh
@@ -10,7 +10,7 @@ codegen_pkg=${GOPATH}/pkg/mod/k8s.io/code-generator@${gen_ver}
 # ROOT_PACKAGE :: the package that is the target for code generation
 ROOT_PACKAGE=github.com/linkerd/linkerd2
 
-crds=(serviceprofile:v1alpha2 server:v1beta1 serverauthorization:v1beta1)
+crds=(serviceprofile:v1alpha2 server:v1beta1 serverauthorization:v1beta1 link:v1alpha1)
 
 # remove previously generated code
 rm -rf "${rootdir}/controller/gen/client"
@@ -38,3 +38,4 @@ cp -R "${GOPATH}/src/${ROOT_PACKAGE}/controller/gen" 'controller/'
 # Temporary fix for https://github.com/kubernetes/code-generator/issues/135
 sed -i 's/Group: \"server\"/Group: \"policy.linkerd.io\"/g' "${rootdir}/controller/gen/client/clientset/versioned/typed/server/v1beta1/fake/fake_server.go"
 sed -i 's/Group: \"serverauthorization\"/Group: \"policy.linkerd.io\"/g' "${rootdir}/controller/gen/client/clientset/versioned/typed/serverauthorization/v1beta1/fake/fake_serverauthorization.go"
+sed -i 's/Group: \"link\"/Group: \"multicluster.linkerd.io\"/g' "${rootdir}/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link.go"
diff --git a/charts/linkerd2/templates/policy-crd.yaml b/charts/linkerd2/templates/policy-crd.yaml
index f18954046..bd43842ba 100644
--- a/charts/linkerd2/templates/policy-crd.yaml
+++ b/charts/linkerd2/templates/policy-crd.yaml
@@ -19,6 +19,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -162,6 +164,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/install.go b/cli/cmd/install.go
index 7059169db..02ec77d7e 100644
--- a/cli/cmd/install.go
+++ b/cli/cmd/install.go
@@ -216,6 +216,10 @@ A full list of configurable values can be found at https://www.github.com/linker
 
 func newCmdInstall() *cobra.Command {
 	values, err := l5dcharts.NewValues()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(1)
+	}
 	var options valuespkg.Options
 
 	allStageFlags, allStageFlagSet := makeAllStageFlags(values)
diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go
index 53d53b80d..504ebb8df 100644
--- a/cli/cmd/uninstall.go
+++ b/cli/cmd/uninstall.go
@@ -55,7 +55,7 @@ This command provides all Kubernetes namespace-scoped and cluster-scoped resourc
 						extensionNames = append(extensionNames, fmt.Sprintf("* %s", v))
 					}
 
-					fmt.Fprintln(os.Stderr, fmt.Sprintf("Please uninstall the following extensions before uninstalling the control-plane:\n\t%s", strings.Join(extensionNames, "\n\t")))
+					fmt.Fprintf(os.Stderr, "Please uninstall the following extensions before uninstalling the control-plane:\n\t%s\n", strings.Join(extensionNames, "\n\t"))
 					fail = true
 				}
 
@@ -73,7 +73,7 @@ This command provides all Kubernetes namespace-scoped and cluster-scoped resourc
 				}
 
 				if len(injectedPods) > 0 {
-					fmt.Fprintln(os.Stderr, fmt.Sprintf("Please uninject the following pods before uninstalling the control-plane:\n\t%s", strings.Join(injectedPods, "\n\t")))
+					fmt.Fprintf(os.Stderr, "Please uninject the following pods before uninstalling the control-plane:\n\t%s\n", strings.Join(injectedPods, "\n\t"))
 					fail = true
 				}
 
diff --git a/controller/gen/apis/link/register.go b/controller/gen/apis/link/register.go
new file mode 100644
index 000000000..99746566e
--- /dev/null
+++ b/controller/gen/apis/link/register.go
@@ -0,0 +1,4 @@
+package link
+
+// GroupName identifies the API Group Name for a Server.
+const GroupName = "multicluster.linkerd.io"
diff --git a/controller/gen/apis/link/v1alpha1/doc.go b/controller/gen/apis/link/v1alpha1/doc.go
new file mode 100644
index 000000000..4601689c3
--- /dev/null
+++ b/controller/gen/apis/link/v1alpha1/doc.go
@@ -0,0 +1,3 @@
+// +k8s:deepcopy-gen=package
+
+package v1alpha1
diff --git a/controller/gen/apis/link/v1alpha1/register.go b/controller/gen/apis/link/v1alpha1/register.go
new file mode 100644
index 000000000..191898fde
--- /dev/null
+++ b/controller/gen/apis/link/v1alpha1/register.go
@@ -0,0 +1,48 @@
+package v1alpha1
+
+import (
+	"github.com/linkerd/linkerd2/controller/gen/apis/link"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+var (
+	// SchemeGroupVersion is the identifier for the API which includes the name
+	// of the group and the version of the API.
+	SchemeGroupVersion = schema.GroupVersion{
+		Group:   link.GroupName,
+		Version: "v1alpha1",
+	}
+
+	// SchemeBuilder collects functions that add things to a scheme. It's to
+	// allow code to compile without explicitly referencing generated types.
+	// You should declare one in each package that will have generated deep
+	// copy or conversion functions.
+	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
+
+	// AddToScheme applies all the stored functions to the scheme. A non-nil error
+	// indicates that one function failed and the attempt was abandoned.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
+
+// Kind takes an unqualified kind and returns back a Group qualified GroupKind
+func Kind(kind string) schema.GroupKind {
+	return SchemeGroupVersion.WithKind(kind).GroupKind()
+}
+
+// Resource takes an unqualified resource and returns a Group qualified
+// GroupResource
+func Resource(resource string) schema.GroupResource {
+	return SchemeGroupVersion.WithResource(resource).GroupResource()
+}
+
+// Adds the list of known types to Scheme.
+func addKnownTypes(scheme *runtime.Scheme) error {
+	scheme.AddKnownTypes(SchemeGroupVersion,
+		&Link{},
+		&LinkList{},
+	)
+	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
+	return nil
+}
diff --git a/controller/gen/apis/link/v1alpha1/types.go b/controller/gen/apis/link/v1alpha1/types.go
new file mode 100644
index 000000000..c18d3aed7
--- /dev/null
+++ b/controller/gen/apis/link/v1alpha1/types.go
@@ -0,0 +1,57 @@
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +genclient
+// +genclient:noStatus
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +groupName=multicluster.linkerd.io
+
+type Link struct {
+	// TypeMeta is the metadata for the resource, like kind and apiversion
+	metav1.TypeMeta `json:",inline"`
+
+	// ObjectMeta contains the metadata for the particular object, including
+	// things like...
+	//  - name
+	//  - namespace
+	//  - self link
+	//  - labels
+	//  - ... etc ...
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	// Spec is the custom resource spec
+	Spec LinkSpec `json:"spec"`
+}
+
+// LinkSpec specifies a LinkSpec resource.
+type LinkSpec struct {
+	TargetClusterName             string               `json:"targetClusterName,omitempty"`
+	TargetClusterDomain           string               `json:"targetClusterDomain,omitempty"`
+	TargetClusterLinkerdNamespace string               `json:"targetClusterLinkerdNamespace,omitempty"`
+	ClusterCredentialsSecret      string               `json:"clusterCredentialsSecret,omitempty"`
+	GatewayAddress                string               `json:"gatewayAddress,omitempty"`
+	GatewayPort                   string               `json:"gatewayPort,omitempty"`
+	GatewayIdentity               string               `json:"gatewayIdentity,omitempty"`
+	ProbeSpec                     ProbeSpec            `json:"probeSpec,omitempty"`
+	Selector                      metav1.LabelSelector `json:"selector,omitempty"`
+}
+
+// ProbeSpec for gateway health probe
+type ProbeSpec struct {
+	Path   string `json:"path,omitempty"`
+	Port   string `json:"port,omitempty"`
+	Period string `json:"period,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// LinkList is a list of LinkList resources.
+type LinkList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+
+	Items []Link `json:"items"`
+}
diff --git a/controller/gen/apis/link/v1alpha1/zz_generated.deepcopy.go b/controller/gen/apis/link/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 000000000..aee1096fc
--- /dev/null
+++ b/controller/gen/apis/link/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,120 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by deepcopy-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Link) DeepCopyInto(out *Link) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Link.
+func (in *Link) DeepCopy() *Link {
+	if in == nil {
+		return nil
+	}
+	out := new(Link)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Link) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LinkList) DeepCopyInto(out *LinkList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Link, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkList.
+func (in *LinkList) DeepCopy() *LinkList {
+	if in == nil {
+		return nil
+	}
+	out := new(LinkList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *LinkList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LinkSpec) DeepCopyInto(out *LinkSpec) {
+	*out = *in
+	out.ProbeSpec = in.ProbeSpec
+	in.Selector.DeepCopyInto(&out.Selector)
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkSpec.
+func (in *LinkSpec) DeepCopy() *LinkSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(LinkSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProbeSpec) DeepCopyInto(out *ProbeSpec) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProbeSpec.
+func (in *ProbeSpec) DeepCopy() *ProbeSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ProbeSpec)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/controller/gen/apis/server/v1beta1/zz_generated.deepcopy.go b/controller/gen/apis/server/v1beta1/zz_generated.deepcopy.go
index 994b22e14..9a6fa4257 100644
--- a/controller/gen/apis/server/v1beta1/zz_generated.deepcopy.go
+++ b/controller/gen/apis/server/v1beta1/zz_generated.deepcopy.go
@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
diff --git a/controller/gen/apis/serverauthorization/v1beta1/zz_generated.deepcopy.go b/controller/gen/apis/serverauthorization/v1beta1/zz_generated.deepcopy.go
index a19932884..d723446fc 100644
--- a/controller/gen/apis/serverauthorization/v1beta1/zz_generated.deepcopy.go
+++ b/controller/gen/apis/serverauthorization/v1beta1/zz_generated.deepcopy.go
@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
diff --git a/controller/gen/apis/serviceprofile/v1alpha2/zz_generated.deepcopy.go b/controller/gen/apis/serviceprofile/v1alpha2/zz_generated.deepcopy.go
index d2f9f4626..05e3a6a60 100644
--- a/controller/gen/apis/serviceprofile/v1alpha2/zz_generated.deepcopy.go
+++ b/controller/gen/apis/serviceprofile/v1alpha2/zz_generated.deepcopy.go
@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
diff --git a/controller/gen/client/clientset/versioned/clientset.go b/controller/gen/client/clientset/versioned/clientset.go
index 2480a53ca..3163f25c8 100644
--- a/controller/gen/client/clientset/versioned/clientset.go
+++ b/controller/gen/client/clientset/versioned/clientset.go
@@ -21,6 +21,7 @@ package versioned
 import (
 	"fmt"
 
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/link/v1alpha1"
 	serverv1beta1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/server/v1beta1"
 	serverauthorizationv1beta1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/serverauthorization/v1beta1"
 	linkerdv1alpha2 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/serviceprofile/v1alpha2"
@@ -31,6 +32,7 @@ import (
 
 type Interface interface {
 	Discovery() discovery.DiscoveryInterface
+	LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface
 	ServerV1beta1() serverv1beta1.ServerV1beta1Interface
 	ServerauthorizationV1beta1() serverauthorizationv1beta1.ServerauthorizationV1beta1Interface
 	LinkerdV1alpha2() linkerdv1alpha2.LinkerdV1alpha2Interface
@@ -40,11 +42,17 @@ type Interface interface {
 // version included in a Clientset.
 type Clientset struct {
 	*discovery.DiscoveryClient
+	linkV1alpha1               *linkv1alpha1.LinkV1alpha1Client
 	serverV1beta1              *serverv1beta1.ServerV1beta1Client
 	serverauthorizationV1beta1 *serverauthorizationv1beta1.ServerauthorizationV1beta1Client
 	linkerdV1alpha2            *linkerdv1alpha2.LinkerdV1alpha2Client
 }
 
+// LinkV1alpha1 retrieves the LinkV1alpha1Client
+func (c *Clientset) LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface {
+	return c.linkV1alpha1
+}
+
 // ServerV1beta1 retrieves the ServerV1beta1Client
 func (c *Clientset) ServerV1beta1() serverv1beta1.ServerV1beta1Interface {
 	return c.serverV1beta1
@@ -81,6 +89,10 @@ func NewForConfig(c *rest.Config) (*Clientset, error) {
 	}
 	var cs Clientset
 	var err error
+	cs.linkV1alpha1, err = linkv1alpha1.NewForConfig(&configShallowCopy)
+	if err != nil {
+		return nil, err
+	}
 	cs.serverV1beta1, err = serverv1beta1.NewForConfig(&configShallowCopy)
 	if err != nil {
 		return nil, err
@@ -105,6 +117,7 @@ func NewForConfig(c *rest.Config) (*Clientset, error) {
 // panics if there is an error in the config.
 func NewForConfigOrDie(c *rest.Config) *Clientset {
 	var cs Clientset
+	cs.linkV1alpha1 = linkv1alpha1.NewForConfigOrDie(c)
 	cs.serverV1beta1 = serverv1beta1.NewForConfigOrDie(c)
 	cs.serverauthorizationV1beta1 = serverauthorizationv1beta1.NewForConfigOrDie(c)
 	cs.linkerdV1alpha2 = linkerdv1alpha2.NewForConfigOrDie(c)
@@ -116,6 +129,7 @@ func NewForConfigOrDie(c *rest.Config) *Clientset {
 // New creates a new Clientset for the given RESTClient.
 func New(c rest.Interface) *Clientset {
 	var cs Clientset
+	cs.linkV1alpha1 = linkv1alpha1.New(c)
 	cs.serverV1beta1 = serverv1beta1.New(c)
 	cs.serverauthorizationV1beta1 = serverauthorizationv1beta1.New(c)
 	cs.linkerdV1alpha2 = linkerdv1alpha2.New(c)
diff --git a/controller/gen/client/clientset/versioned/fake/clientset_generated.go b/controller/gen/client/clientset/versioned/fake/clientset_generated.go
index 67d65cb26..ccaef8141 100644
--- a/controller/gen/client/clientset/versioned/fake/clientset_generated.go
+++ b/controller/gen/client/clientset/versioned/fake/clientset_generated.go
@@ -20,6 +20,8 @@ package fake
 
 import (
 	clientset "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned"
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/link/v1alpha1"
+	fakelinkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake"
 	serverv1beta1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/server/v1beta1"
 	fakeserverv1beta1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/server/v1beta1/fake"
 	serverauthorizationv1beta1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/serverauthorization/v1beta1"
@@ -78,7 +80,15 @@ func (c *Clientset) Tracker() testing.ObjectTracker {
 	return c.tracker
 }
 
-var _ clientset.Interface = &Clientset{}
+var (
+	_ clientset.Interface = &Clientset{}
+	_ testing.FakeClient  = &Clientset{}
+)
+
+// LinkV1alpha1 retrieves the LinkV1alpha1Client
+func (c *Clientset) LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface {
+	return &fakelinkv1alpha1.FakeLinkV1alpha1{Fake: &c.Fake}
+}
 
 // ServerV1beta1 retrieves the ServerV1beta1Client
 func (c *Clientset) ServerV1beta1() serverv1beta1.ServerV1beta1Interface {
diff --git a/controller/gen/client/clientset/versioned/fake/register.go b/controller/gen/client/clientset/versioned/fake/register.go
index 53c05792f..60e3651b5 100644
--- a/controller/gen/client/clientset/versioned/fake/register.go
+++ b/controller/gen/client/clientset/versioned/fake/register.go
@@ -19,6 +19,7 @@ limitations under the License.
 package fake
 
 import (
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	serverv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/server/v1beta1"
 	serverauthorizationv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/serverauthorization/v1beta1"
 	linkerdv1alpha2 "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
@@ -33,6 +34,7 @@ var scheme = runtime.NewScheme()
 var codecs = serializer.NewCodecFactory(scheme)
 
 var localSchemeBuilder = runtime.SchemeBuilder{
+	linkv1alpha1.AddToScheme,
 	serverv1beta1.AddToScheme,
 	serverauthorizationv1beta1.AddToScheme,
 	linkerdv1alpha2.AddToScheme,
diff --git a/controller/gen/client/clientset/versioned/scheme/register.go b/controller/gen/client/clientset/versioned/scheme/register.go
index 9a51167aa..3c2f8538f 100644
--- a/controller/gen/client/clientset/versioned/scheme/register.go
+++ b/controller/gen/client/clientset/versioned/scheme/register.go
@@ -19,6 +19,7 @@ limitations under the License.
 package scheme
 
 import (
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	serverv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/server/v1beta1"
 	serverauthorizationv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/serverauthorization/v1beta1"
 	linkerdv1alpha2 "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
@@ -33,6 +34,7 @@ var Scheme = runtime.NewScheme()
 var Codecs = serializer.NewCodecFactory(Scheme)
 var ParameterCodec = runtime.NewParameterCodec(Scheme)
 var localSchemeBuilder = runtime.SchemeBuilder{
+	linkv1alpha1.AddToScheme,
 	serverv1beta1.AddToScheme,
 	serverauthorizationv1beta1.AddToScheme,
 	linkerdv1alpha2.AddToScheme,
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/doc.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/doc.go
new file mode 100644
index 000000000..df51baa4d
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// This package has the automatically generated typed clients.
+package v1alpha1
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/doc.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/doc.go
new file mode 100644
index 000000000..16f443990
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/doc.go
@@ -0,0 +1,20 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+// Package fake has the automatically generated clients.
+package fake
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link.go
new file mode 100644
index 000000000..8382831d7
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link.go
@@ -0,0 +1,130 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	"context"
+
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	testing "k8s.io/client-go/testing"
+)
+
+// FakeLinks implements LinkInterface
+type FakeLinks struct {
+	Fake *FakeLinkV1alpha1
+	ns   string
+}
+
+var linksResource = schema.GroupVersionResource{Group: "multicluster.linkerd.io", Version: "v1alpha1", Resource: "links"}
+
+var linksKind = schema.GroupVersionKind{Group: "multicluster.linkerd.io", Version: "v1alpha1", Kind: "Link"}
+
+// Get takes name of the link, and returns the corresponding link object, and an error if there is any.
+func (c *FakeLinks) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.Link, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(linksResource, c.ns, name), &v1alpha1.Link{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.Link), err
+}
+
+// List takes label and field selectors, and returns the list of Links that match those selectors.
+func (c *FakeLinks) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LinkList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(linksResource, linksKind, c.ns, opts), &v1alpha1.LinkList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1alpha1.LinkList{ListMeta: obj.(*v1alpha1.LinkList).ListMeta}
+	for _, item := range obj.(*v1alpha1.LinkList).Items {
+		if label.Matches(labels.Set(item.Labels)) {
+			list.Items = append(list.Items, item)
+		}
+	}
+	return list, err
+}
+
+// Watch returns a watch.Interface that watches the requested links.
+func (c *FakeLinks) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(linksResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a link and creates it.  Returns the server's representation of the link, and an error, if there is any.
+func (c *FakeLinks) Create(ctx context.Context, link *v1alpha1.Link, opts v1.CreateOptions) (result *v1alpha1.Link, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(linksResource, c.ns, link), &v1alpha1.Link{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.Link), err
+}
+
+// Update takes the representation of a link and updates it. Returns the server's representation of the link, and an error, if there is any.
+func (c *FakeLinks) Update(ctx context.Context, link *v1alpha1.Link, opts v1.UpdateOptions) (result *v1alpha1.Link, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(linksResource, c.ns, link), &v1alpha1.Link{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.Link), err
+}
+
+// Delete takes name of the link and deletes it. Returns an error if one occurs.
+func (c *FakeLinks) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteAction(linksResource, c.ns, name), &v1alpha1.Link{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakeLinks) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(linksResource, c.ns, listOpts)
+
+	_, err := c.Fake.Invokes(action, &v1alpha1.LinkList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched link.
+func (c *FakeLinks) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.Link, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(linksResource, c.ns, name, pt, data, subresources...), &v1alpha1.Link{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.Link), err
+}
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link_client.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link_client.go
new file mode 100644
index 000000000..84a8cf4f8
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/fake/fake_link_client.go
@@ -0,0 +1,40 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/link/v1alpha1"
+	rest "k8s.io/client-go/rest"
+	testing "k8s.io/client-go/testing"
+)
+
+type FakeLinkV1alpha1 struct {
+	*testing.Fake
+}
+
+func (c *FakeLinkV1alpha1) Links(namespace string) v1alpha1.LinkInterface {
+	return &FakeLinks{c, namespace}
+}
+
+// RESTClient returns a RESTClient that is used to communicate
+// with API server by this client implementation.
+func (c *FakeLinkV1alpha1) RESTClient() rest.Interface {
+	var ret *rest.RESTClient
+	return ret
+}
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/generated_expansion.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/generated_expansion.go
new file mode 100644
index 000000000..07b513b1a
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/generated_expansion.go
@@ -0,0 +1,21 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+type LinkExpansion interface{}
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link.go
new file mode 100644
index 000000000..77ce949cf
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link.go
@@ -0,0 +1,178 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"context"
+	"time"
+
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	scheme "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/scheme"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	rest "k8s.io/client-go/rest"
+)
+
+// LinksGetter has a method to return a LinkInterface.
+// A group's client should implement this interface.
+type LinksGetter interface {
+	Links(namespace string) LinkInterface
+}
+
+// LinkInterface has methods to work with Link resources.
+type LinkInterface interface {
+	Create(ctx context.Context, link *v1alpha1.Link, opts v1.CreateOptions) (*v1alpha1.Link, error)
+	Update(ctx context.Context, link *v1alpha1.Link, opts v1.UpdateOptions) (*v1alpha1.Link, error)
+	Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
+	DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
+	Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.Link, error)
+	List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.LinkList, error)
+	Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
+	Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.Link, err error)
+	LinkExpansion
+}
+
+// links implements LinkInterface
+type links struct {
+	client rest.Interface
+	ns     string
+}
+
+// newLinks returns a Links
+func newLinks(c *LinkV1alpha1Client, namespace string) *links {
+	return &links{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the link, and returns the corresponding link object, and an error if there is any.
+func (c *links) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.Link, err error) {
+	result = &v1alpha1.Link{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("links").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of Links that match those selectors.
+func (c *links) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.LinkList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1alpha1.LinkList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("links").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested links.
+func (c *links) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	opts.Watch = true
+	return c.client.Get().
+		Namespace(c.ns).
+		Resource("links").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch(ctx)
+}
+
+// Create takes the representation of a link and creates it.  Returns the server's representation of the link, and an error, if there is any.
+func (c *links) Create(ctx context.Context, link *v1alpha1.Link, opts v1.CreateOptions) (result *v1alpha1.Link, err error) {
+	result = &v1alpha1.Link{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("links").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(link).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Update takes the representation of a link and updates it. Returns the server's representation of the link, and an error, if there is any.
+func (c *links) Update(ctx context.Context, link *v1alpha1.Link, opts v1.UpdateOptions) (result *v1alpha1.Link, err error) {
+	result = &v1alpha1.Link{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("links").
+		Name(link.Name).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(link).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Delete takes name of the link and deletes it. Returns an error if one occurs.
+func (c *links) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("links").
+		Name(name).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *links) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	var timeout time.Duration
+	if listOpts.TimeoutSeconds != nil {
+		timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second
+	}
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("links").
+		VersionedParams(&listOpts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// Patch applies the patch and returns the patched link.
+func (c *links) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.Link, err error) {
+	result = &v1alpha1.Link{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("links").
+		Name(name).
+		SubResource(subresources...).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(data).
+		Do(ctx).
+		Into(result)
+	return
+}
diff --git a/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link_client.go b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link_client.go
new file mode 100644
index 000000000..e5a993a0f
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/link/v1alpha1/link_client.go
@@ -0,0 +1,89 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	"github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/scheme"
+	rest "k8s.io/client-go/rest"
+)
+
+type LinkV1alpha1Interface interface {
+	RESTClient() rest.Interface
+	LinksGetter
+}
+
+// LinkV1alpha1Client is used to interact with features provided by the link group.
+type LinkV1alpha1Client struct {
+	restClient rest.Interface
+}
+
+func (c *LinkV1alpha1Client) Links(namespace string) LinkInterface {
+	return newLinks(c, namespace)
+}
+
+// NewForConfig creates a new LinkV1alpha1Client for the given config.
+func NewForConfig(c *rest.Config) (*LinkV1alpha1Client, error) {
+	config := *c
+	if err := setConfigDefaults(&config); err != nil {
+		return nil, err
+	}
+	client, err := rest.RESTClientFor(&config)
+	if err != nil {
+		return nil, err
+	}
+	return &LinkV1alpha1Client{client}, nil
+}
+
+// NewForConfigOrDie creates a new LinkV1alpha1Client for the given config and
+// panics if there is an error in the config.
+func NewForConfigOrDie(c *rest.Config) *LinkV1alpha1Client {
+	client, err := NewForConfig(c)
+	if err != nil {
+		panic(err)
+	}
+	return client
+}
+
+// New creates a new LinkV1alpha1Client for the given RESTClient.
+func New(c rest.Interface) *LinkV1alpha1Client {
+	return &LinkV1alpha1Client{c}
+}
+
+func setConfigDefaults(config *rest.Config) error {
+	gv := v1alpha1.SchemeGroupVersion
+	config.GroupVersion = &gv
+	config.APIPath = "/apis"
+	config.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
+
+	if config.UserAgent == "" {
+		config.UserAgent = rest.DefaultKubernetesUserAgent()
+	}
+
+	return nil
+}
+
+// RESTClient returns a RESTClient that is used to communicate
+// with API server by this client implementation.
+func (c *LinkV1alpha1Client) RESTClient() rest.Interface {
+	if c == nil {
+		return nil
+	}
+	return c.restClient
+}
diff --git a/controller/gen/client/informers/externalversions/factory.go b/controller/gen/client/informers/externalversions/factory.go
index 5f9b5e62d..1386a602a 100644
--- a/controller/gen/client/informers/externalversions/factory.go
+++ b/controller/gen/client/informers/externalversions/factory.go
@@ -25,6 +25,7 @@ import (
 
 	versioned "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned"
 	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
+	link "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/link"
 	server "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/server"
 	serverauthorization "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/serverauthorization"
 	serviceprofile "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/serviceprofile"
@@ -174,11 +175,16 @@ type SharedInformerFactory interface {
 	ForResource(resource schema.GroupVersionResource) (GenericInformer, error)
 	WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool
 
+	Link() link.Interface
 	Server() server.Interface
 	Serverauthorization() serverauthorization.Interface
 	Linkerd() serviceprofile.Interface
 }
 
+func (f *sharedInformerFactory) Link() link.Interface {
+	return link.New(f, f.namespace, f.tweakListOptions)
+}
+
 func (f *sharedInformerFactory) Server() server.Interface {
 	return server.New(f, f.namespace, f.tweakListOptions)
 }
diff --git a/controller/gen/client/informers/externalversions/generic.go b/controller/gen/client/informers/externalversions/generic.go
index 7166b697f..d96209996 100644
--- a/controller/gen/client/informers/externalversions/generic.go
+++ b/controller/gen/client/informers/externalversions/generic.go
@@ -21,6 +21,7 @@ package externalversions
 import (
 	"fmt"
 
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	v1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/server/v1beta1"
 	serverauthorizationv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/serverauthorization/v1beta1"
 	v1alpha2 "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
@@ -54,7 +55,11 @@ func (f *genericInformer) Lister() cache.GenericLister {
 // TODO extend this to unknown resources with a client pool
 func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {
 	switch resource {
-	// Group=linkerd.io, Version=v1alpha2
+	// Group=link, Version=v1alpha1
+	case v1alpha1.SchemeGroupVersion.WithResource("links"):
+		return &genericInformer{resource: resource.GroupResource(), informer: f.Link().V1alpha1().Links().Informer()}, nil
+
+		// Group=linkerd.io, Version=v1alpha2
 	case v1alpha2.SchemeGroupVersion.WithResource("serviceprofiles"):
 		return &genericInformer{resource: resource.GroupResource(), informer: f.Linkerd().V1alpha2().ServiceProfiles().Informer()}, nil
 
diff --git a/controller/gen/client/informers/externalversions/link/interface.go b/controller/gen/client/informers/externalversions/link/interface.go
new file mode 100644
index 000000000..dcc0c6e05
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/link/interface.go
@@ -0,0 +1,46 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package link
+
+import (
+	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/link/v1alpha1"
+)
+
+// Interface provides access to each of this group's versions.
+type Interface interface {
+	// V1alpha1 provides access to shared informers for resources in V1alpha1.
+	V1alpha1() v1alpha1.Interface
+}
+
+type group struct {
+	factory          internalinterfaces.SharedInformerFactory
+	namespace        string
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+}
+
+// New returns a new Interface.
+func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {
+	return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
+}
+
+// V1alpha1 returns a new v1alpha1.Interface.
+func (g *group) V1alpha1() v1alpha1.Interface {
+	return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions)
+}
diff --git a/controller/gen/client/informers/externalversions/link/v1alpha1/interface.go b/controller/gen/client/informers/externalversions/link/v1alpha1/interface.go
new file mode 100644
index 000000000..5ae9a27e2
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/link/v1alpha1/interface.go
@@ -0,0 +1,45 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
+)
+
+// Interface provides access to all the informers in this group version.
+type Interface interface {
+	// Links returns a LinkInformer.
+	Links() LinkInformer
+}
+
+type version struct {
+	factory          internalinterfaces.SharedInformerFactory
+	namespace        string
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+}
+
+// New returns a new Interface.
+func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {
+	return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
+}
+
+// Links returns a LinkInformer.
+func (v *version) Links() LinkInformer {
+	return &linkInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
+}
diff --git a/controller/gen/client/informers/externalversions/link/v1alpha1/link.go b/controller/gen/client/informers/externalversions/link/v1alpha1/link.go
new file mode 100644
index 000000000..1da580c5b
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/link/v1alpha1/link.go
@@ -0,0 +1,90 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"context"
+	time "time"
+
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	versioned "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned"
+	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/listers/link/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	watch "k8s.io/apimachinery/pkg/watch"
+	cache "k8s.io/client-go/tools/cache"
+)
+
+// LinkInformer provides access to a shared informer and lister for
+// Links.
+type LinkInformer interface {
+	Informer() cache.SharedIndexInformer
+	Lister() v1alpha1.LinkLister
+}
+
+type linkInformer struct {
+	factory          internalinterfaces.SharedInformerFactory
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+	namespace        string
+}
+
+// NewLinkInformer constructs a new informer for Link type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewLinkInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
+	return NewFilteredLinkInformer(client, namespace, resyncPeriod, indexers, nil)
+}
+
+// NewFilteredLinkInformer constructs a new informer for Link type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewFilteredLinkInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {
+	return cache.NewSharedIndexInformer(
+		&cache.ListWatch{
+			ListFunc: func(options v1.ListOptions) (runtime.Object, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.LinkV1alpha1().Links(namespace).List(context.TODO(), options)
+			},
+			WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.LinkV1alpha1().Links(namespace).Watch(context.TODO(), options)
+			},
+		},
+		&linkv1alpha1.Link{},
+		resyncPeriod,
+		indexers,
+	)
+}
+
+func (f *linkInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
+	return NewFilteredLinkInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
+}
+
+func (f *linkInformer) Informer() cache.SharedIndexInformer {
+	return f.factory.InformerFor(&linkv1alpha1.Link{}, f.defaultInformer)
+}
+
+func (f *linkInformer) Lister() v1alpha1.LinkLister {
+	return v1alpha1.NewLinkLister(f.Informer().GetIndexer())
+}
diff --git a/controller/gen/client/listers/link/v1alpha1/expansion_generated.go b/controller/gen/client/listers/link/v1alpha1/expansion_generated.go
new file mode 100644
index 000000000..9a7cdaaf6
--- /dev/null
+++ b/controller/gen/client/listers/link/v1alpha1/expansion_generated.go
@@ -0,0 +1,27 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// LinkListerExpansion allows custom methods to be added to
+// LinkLister.
+type LinkListerExpansion interface{}
+
+// LinkNamespaceListerExpansion allows custom methods to be added to
+// LinkNamespaceLister.
+type LinkNamespaceListerExpansion interface{}
diff --git a/controller/gen/client/listers/link/v1alpha1/link.go b/controller/gen/client/listers/link/v1alpha1/link.go
new file mode 100644
index 000000000..b0c983421
--- /dev/null
+++ b/controller/gen/client/listers/link/v1alpha1/link.go
@@ -0,0 +1,99 @@
+/*
+Copyright The Kubernetes 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.
+*/
+
+// Code generated by lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/client-go/tools/cache"
+)
+
+// LinkLister helps list Links.
+// All objects returned here must be treated as read-only.
+type LinkLister interface {
+	// List lists all Links in the indexer.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*v1alpha1.Link, err error)
+	// Links returns an object that can list and get Links.
+	Links(namespace string) LinkNamespaceLister
+	LinkListerExpansion
+}
+
+// linkLister implements the LinkLister interface.
+type linkLister struct {
+	indexer cache.Indexer
+}
+
+// NewLinkLister returns a new LinkLister.
+func NewLinkLister(indexer cache.Indexer) LinkLister {
+	return &linkLister{indexer: indexer}
+}
+
+// List lists all Links in the indexer.
+func (s *linkLister) List(selector labels.Selector) (ret []*v1alpha1.Link, err error) {
+	err = cache.ListAll(s.indexer, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.Link))
+	})
+	return ret, err
+}
+
+// Links returns an object that can list and get Links.
+func (s *linkLister) Links(namespace string) LinkNamespaceLister {
+	return linkNamespaceLister{indexer: s.indexer, namespace: namespace}
+}
+
+// LinkNamespaceLister helps list and get Links.
+// All objects returned here must be treated as read-only.
+type LinkNamespaceLister interface {
+	// List lists all Links in the indexer for a given namespace.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*v1alpha1.Link, err error)
+	// Get retrieves the Link from the indexer for a given namespace and name.
+	// Objects returned here must be treated as read-only.
+	Get(name string) (*v1alpha1.Link, error)
+	LinkNamespaceListerExpansion
+}
+
+// linkNamespaceLister implements the LinkNamespaceLister
+// interface.
+type linkNamespaceLister struct {
+	indexer   cache.Indexer
+	namespace string
+}
+
+// List lists all Links in the indexer for a given namespace.
+func (s linkNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.Link, err error) {
+	err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.Link))
+	})
+	return ret, err
+}
+
+// Get retrieves the Link from the indexer for a given namespace and name.
+func (s linkNamespaceLister) Get(name string) (*v1alpha1.Link, error) {
+	obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name)
+	if err != nil {
+		return nil, err
+	}
+	if !exists {
+		return nil, errors.NewNotFound(v1alpha1.Resource("link"), name)
+	}
+	return obj.(*v1alpha1.Link), nil
+}
diff --git a/viz/cmd/tap.go b/viz/cmd/tap.go
index 26108c9b2..0bbd1a28c 100644
--- a/viz/cmd/tap.go
+++ b/viz/cmd/tap.go
@@ -437,7 +437,7 @@ func renderTapEventJSON(event *tapPb.TapEvent, _ string) string {
 	if err != nil {
 		return fmt.Sprintf("{\"error marshalling JSON\": \"%s\"}", err)
 	}
-	return fmt.Sprintf("%s", e)
+	return string(e[:])
 }
 
 // Map public API `TapEvent`s to `displayTapEvent`s


Test patch:
diff --git a/bin/_test-helpers.sh b/bin/_test-helpers.sh
index b62714309..bb4245c2d 100644
--- a/bin/_test-helpers.sh
+++ b/bin/_test-helpers.sh
@@ -372,7 +372,7 @@ start_multicluster_test() {
 
 multicluster_link() {
   lbIP=$(kubectl --context="$context" get svc -n kube-system traefik -o 'go-template={{ (index .status.loadBalancer.ingress 0).ip }}')
-  "$linkerd_path" multicluster link --api-server-address "https://${lbIP}:6443" --cluster-name "$1"
+  "$linkerd_path" multicluster link --api-server-address "https://${lbIP}:6443" --cluster-name "$1" --set "enableHeadlessServices=true"
 }
 
 run_test(){
@@ -536,6 +536,9 @@ run_multicluster_test() {
 
   export context="k3d-target"
   run_test "$test_directory/multicluster/target2" --multicluster
+
+  export context="k3d-target"
+  run_test "$test_directory/multicluster/target-statefulset" --multicluster
 }
 
 run_deep_test() {
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index e849281cd..38467bd67 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 08ca14505..769726eda 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index d1a49b532..fa19b88e5 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 9576441ce..3d9d0b1ae 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index e325c4501..ab0ccaddf 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index bfaa7d8e3..a6cc151de 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 118e2790b..a343314ec 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index ca02cb012..2b8208d75 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 9ab28a6f2..959c75cf0 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -266,6 +266,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -409,6 +411,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_helm_output.golden b/cli/cmd/testdata/install_helm_output.golden
index dce315222..d6c35a848 100644
--- a/cli/cmd/testdata/install_helm_output.golden
+++ b/cli/cmd/testdata/install_helm_output.golden
@@ -345,6 +345,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -488,6 +490,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_helm_output_ha.golden b/cli/cmd/testdata/install_helm_output_ha.golden
index 5e8a0cce2..97e6930fa 100644
--- a/cli/cmd/testdata/install_helm_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_output_ha.golden
@@ -345,6 +345,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -488,6 +490,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 6fc766b04..53237dfd1 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -345,6 +345,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -488,6 +490,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index e4d466be5..72050ca32 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -345,6 +345,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -488,6 +490,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 06393ad9a..fdfc467c1 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index f3227bf99..af6a11d5f 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index d143e77d8..36cec2737 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -335,6 +335,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -478,6 +480,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 6093f652a..1887577bc 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -321,6 +321,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta1 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -464,6 +466,8 @@ spec:
     - name: v1alpha1
       served: true
       storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 ServerAuthorization is deprecated; use policy.linkerd.io/v1beta1 ServerAuthorization"
       schema:
         openAPIV3Schema:
           type: object
diff --git a/test/integration/multicluster/source/source_test.go b/test/integration/multicluster/source/source_test.go
index c7ae3a9e0..fd0d8759b 100644
--- a/test/integration/multicluster/source/source_test.go
+++ b/test/integration/multicluster/source/source_test.go
@@ -69,3 +69,21 @@ func TestInstallVoteBot(t *testing.T) {
 		testutil.AnnotatedFatalf(t, "failed to install vote-bot", "failed to install vote-bot: %s\n%s", err, o)
 	}
 }
+
+func TestInstallSlowCooker(t *testing.T) {
+	err := TestHelper.CreateDataPlaneNamespaceIfNotExists(context.Background(), "multicluster-statefulset", nil)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to create multicluster-statefulset namespace", "failed to create multicluster-statefulset namespace: %s", err)
+	}
+
+	slowcooker, err := TestHelper.LinkerdRun("inject", "testdata/slow-cooker.yaml")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to inject slow-cooker manifest", "failed to inject slow-cooker manifest: %s", err)
+	}
+
+	out, err := TestHelper.KubectlApply(slowcooker, "")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to install slow-cooker", "failed to install slow-cooker: %s\ngot: %s", err, out)
+	}
+
+}
diff --git a/test/integration/multicluster/source/testdata/slow-cooker.yaml b/test/integration/multicluster/source/testdata/slow-cooker.yaml
new file mode 100644
index 000000000..347610f9a
--- /dev/null
+++ b/test/integration/multicluster/source/testdata/slow-cooker.yaml
@@ -0,0 +1,27 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: slow-cooker
+  namespace: multicluster-statefulset
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: slow-cooker
+  template:
+    metadata:
+      labels:
+        app: slow-cooker
+    spec:
+      containers:
+      - name: slow-cooker
+        image: buoyantio/slow_cooker:1.3.0
+        command:
+        - "/bin/sh"
+        args:
+        - "-c"
+        - |
+          sleep 15 # wait for pods to start
+          /slow_cooker/slow_cooker -metric-addr 0.0.0.0:9999 http://nginx-statefulset-0.nginx-statefulset-svc-target.multicluster-statefulset.svc.cluster.local:8080
+        ports:
+        - containerPort: 9999
diff --git a/test/integration/multicluster/target-statefulset/target_statefulset_test.go b/test/integration/multicluster/target-statefulset/target_statefulset_test.go
new file mode 100644
index 000000000..d28e3ff89
--- /dev/null
+++ b/test/integration/multicluster/target-statefulset/target_statefulset_test.go
@@ -0,0 +1,97 @@
+package target_statefulset_test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/linkerd/linkerd2/testutil"
+)
+
+var TestHelper *testutil.TestHelper
+
+var (
+	tcpConnRE = regexp.MustCompile(
+		`tcp_open_total\{direction="outbound",peer="dst",target_addr="[0-9\.]+:[0-9]+",target_ip="[0-9\.]+",target_port="[0-9]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
+	)
+	httpReqRE = regexp.MustCompile(
+		`request_total\{direction="outbound",target_addr="[0-9\.]+:8080",target_ip="[0-9\.]+",target_port="[0-9\.]+",tls="true",server_id="default\.multicluster-statefulset\.serviceaccount\.identity\.linkerd\.cluster\.local",dst_control_plane_ns="linkerd",dst_namespace="multicluster-statefulset",dst_pod="nginx-statefulset-0",dst_serviceaccount="default",dst_statefulset="nginx-statefulset"\} [1-9]\d*`,
+	)
+	dgCmd = []string{"diagnostics", "proxy-metrics", "--namespace",
+		"linkerd-multicluster", "deploy/linkerd-gateway"}
+)
+
+func TestMain(m *testing.M) {
+	TestHelper = testutil.NewTestHelper()
+	if !TestHelper.Multicluster() {
+		fmt.Fprintln(os.Stderr, "Multicluster test disabled")
+		os.Exit(0)
+	}
+	os.Exit(m.Run())
+}
+
+/////////////////////
+//  TEST EXECUTION //
+/////////////////////
+
+// TestMulticlusterStatefulSetTargetTraffic will test that a statefulset can be
+// mirrored from a target cluster to a source cluster. The test deploys two
+// workloads: a slow cooker (as a client) in the src, and an nginx statefulset in
+// (as a server) in the tgt. The slow-cooker is configured to send traffic to an
+// nginx endpoint mirror (nginx-statefulset-0). The traffic should be received
+// by the nginx pod in the tgt. To assert this, we get proxy metrics from the
+// gateway to make sure our connections from the source cluster were routed
+// correctly.
+func TestMulticlusterStatefulSetTargetTraffic(t *testing.T) {
+	err := TestHelper.CreateDataPlaneNamespaceIfNotExists(context.Background(),
+		"multicluster-statefulset", nil)
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to create multicluster-statefulset namespace", "failed to create multicluster-statefulset namespace: %s", err)
+	}
+
+	nginx, err := TestHelper.LinkerdRun("inject", "testdata/nginx.yaml")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to inject nginx manifest", "failed to inject nginx manifest: %s", err)
+	}
+
+	out, err := TestHelper.KubectlApply(nginx, "")
+	if err != nil {
+		testutil.AnnotatedFatalf(t, "failed to install nginx", "failed to install nginx: %s\ngot: %s", err, out)
+	}
+
+	// Give enough time for slow-cooker to go live
+	// and send traffic to nginx.
+	time.Sleep(30 * time.Second)
+
+	t.Run("expect open outbound TCP connection from gateway to nginx", func(t *testing.T) {
+		// Check gateway metrics
+		metrics, err := TestHelper.LinkerdRun(dgCmd...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "failed to get metrics for gateway deployment", "failed to get metrics for gateway deployment: %s", err)
+		}
+
+		// If no match, it means there are no open tcp conns from gateway to
+		// nginx pod.
+		if !tcpConnRE.MatchString(metrics) {
+			testutil.AnnotatedFatalf(t, "failed to find expected TCP connection open outbound metric from gateway to nginx", "failed to find expected TCP connection open outbound metric from gateway to nginx\nexpected: %s, got: %s", tcpConnRE, metrics)
+		}
+
+	})
+
+	t.Run("expect non-empty HTTP request metric from gateway to nginx", func(t *testing.T) {
+		// Check gateway metrics
+		metrics, err := TestHelper.LinkerdRun(dgCmd...)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "failed to get metrics for gateway deployment", "failed to get metrics for gateway deployment: %s", err)
+		}
+
+		// If no match, it means there are no outbound HTTP requests from
+		// gateway to nginx pod.
+		if !httpReqRE.MatchString(metrics) {
+			testutil.AnnotatedFatalf(t, "failed to find expected outbound HTTP request metric from gateway to nginx", "failed to find expected outbound HTTP request metric from gateway to nginx\nexpected: %s, got: %s", httpReqRE, metrics)
+		}
+	})
+}
diff --git a/test/integration/multicluster/target-statefulset/testdata/nginx.yaml b/test/integration/multicluster/target-statefulset/testdata/nginx.yaml
new file mode 100644
index 000000000..a7df2731d
--- /dev/null
+++ b/test/integration/multicluster/target-statefulset/testdata/nginx.yaml
@@ -0,0 +1,44 @@
+
+apiVersion: v1
+kind: Service
+metadata:
+  name: nginx-statefulset-svc
+  namespace: multicluster-statefulset
+  labels:
+    mirror.linkerd.io/exported: "true"
+spec:
+  clusterIP: None
+  clusterIPs:
+  - None
+  selector:
+    app: nginx-set
+  sessionAffinity: None
+  type: ClusterIP
+  ports:
+  - name: tcp
+    protocol: TCP
+    port: 80
+  - name: http
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: nginx-statefulset
+  namespace: multicluster-statefulset
+spec:
+  serviceName: nginx-statefulset-svc
+  selector:
+    matchLabels:
+      app: nginx-set
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: nginx-set
+    spec:
+      containers:
+      - name: nginx
+        image: nginx
+---
diff --git a/test/integration/smimetrics/smi-metrics_test.go b/test/integration/smimetrics/smi-metrics_test.go
index 0607ecf5c..6401b9829 100644
--- a/test/integration/smimetrics/smi-metrics_test.go
+++ b/test/integration/smimetrics/smi-metrics_test.go
@@ -14,6 +14,7 @@ import (
 
 var TestHelper *testutil.TestHelper
 
+//nolint // The test that uses this is currently skipped.
 type testCase struct {
 	name string
 	kind string
diff --git a/viz/metrics-api/http_server_test.go b/viz/metrics-api/http_server_test.go
index 90a2cc703..bc7ed5c76 100644
--- a/viz/metrics-api/http_server_test.go
+++ b/viz/metrics-api/http_server_test.go
@@ -106,7 +106,8 @@ func getServerVizClient(t *testing.T) (*mockGrpcServer, pb.ApiClient) {
 		}
 		err := http.Serve(listener, handler)
 		if err != nil {
-			t.Fatalf("Could not start server: %v", err)
+			t.Errorf("Could not start server: %v", err)
+			return
 		}
 	}()