Repository: linkerd/linkerd2
Pull request #11888 (2024-01-05 17:02:02)

Introduce bindings for ExternalWorkload resources
We introduced an ExternalWorkload CRD for mesh expansion. This change follows up by adding bindings for Rust and Go code.

For Go code:
* We add a new schema and ExternalWorkload types
* We also update the code-gen script to generate informers
* We add a new informer type to our abstractions built on-top of client-go, including a function to check if a client has access to the resource.

For Rust code:
* We add ExternalWorkload bindings to the policy controller.

<!--  Thanks for sending a pull request!

If you already have a well-structured git commit message, chances are GitHub
set the title and description of this PR to the git commit message subject and
body, respectively. If so, you may delete these instructions and submit your PR.

If this is your first time, please read our contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md

The title and description of your Pull Request should match the git commit
subject and body, respectively. Git commit messages are structured as follows:

```
Subject

Problem

Solution

Validation

Fixes #[GitHub issue ID]

DCO Sign off
```

Example git commit message:

```
Introduce Pull Request Template

GitHub's community guidelines recommend a pull request template, the repo was
lacking one.

Introduce a `PULL_REQUEST_TEMPLATE.md` file.

Once merged, the
[Community profile checklist](https://github.com/linkerd/linkerd2/community)
should indicate the repo now provides a pull request template.

Fixes #3321

Signed-off-by: Jane Smith <jane.smith@example.com>
```

Note the git commit message subject becomes the pull request title.

For more details around git commits, see the section on Committing in our
contributor guide:
https://github.com/linkerd/linkerd2/blob/main/CONTRIBUTING.md#committing
-->



Patch:
diff --git a/bin/update-codegen.sh b/bin/update-codegen.sh
index 0f6e591ba..522413845 100755
--- a/bin/update-codegen.sh
+++ b/bin/update-codegen.sh
@@ -13,7 +13,7 @@ CODEGEN_PKG=${GOPATH}/pkg/mod/k8s.io/code-generator@${GEN_VER}
 rm -rf "${SCRIPT_ROOT}/controller/gen/client/clientset/*"
 rm -rf "${SCRIPT_ROOT}/controller/gen/client/listeners/*"
 rm -rf "${SCRIPT_ROOT}/controller/gen/client/informers/*"
-crds=(serviceprofile:v1alpha2 server:v1beta1 serverauthorization:v1beta1 link:v1alpha1 policy:v1alpha1 policy:v1beta3)
+crds=(serviceprofile:v1alpha2 server:v1beta1 serverauthorization:v1beta1 link:v1alpha1 policy:v1alpha1 policy:v1beta3 externalworkload:v1alpha1)
 for crd in "${crds[@]}"
 do
   crd_path=$(tr : / <<< "$crd")
diff --git a/charts/linkerd2-cni/README.md b/charts/linkerd2-cni/README.md
index 4724ca9ac..e24dc6e31 100644
--- a/charts/linkerd2-cni/README.md
+++ b/charts/linkerd2-cni/README.md
@@ -31,7 +31,7 @@ Kubernetes: `>=1.22.0-0`
 | ignoreOutboundPorts | string | `""` | Default set of outbound ports to skip via iptables |
 | image.name | string | `"cr.l5d.io/linkerd/cni-plugin"` | Docker image for the CNI plugin |
 | image.pullPolicy | string | `"IfNotPresent"` | Pull policy for the linkerd-cni container |
-| image.version | string | `"v1.2.2"` | Tag for the CNI container Docker image |
+| image.version | string | `"v1.3.0"` | Tag for the CNI container Docker image |
 | imagePullSecrets | list | `[]` |  |
 | inboundProxyPort | int | `4143` | Inbound port for the proxy container |
 | logLevel | string | `"info"` | Log level for the CNI plugin |
@@ -43,7 +43,17 @@ Kubernetes: `>=1.22.0-0`
 | proxyAdminPort | int | `4191` | Admin port for the proxy container |
 | proxyControlPort | int | `4190` | Control port for the proxy container |
 | proxyUID | int | `2102` | User id under which the proxy shall be ran |
-| resources | object | `{"cpu":{"limit":"","request":""},"ephemeral-storage":{"limit":"","request":""},"memory":{"limit":"","request":""}}` | Resource requests and limits for linkerd-cni daemonset containers |
+| repairController.enableSecurityContext | bool | `true` | Include a securityContext in the repair-controller container |
+| repairController.enabled | bool | `false` | Enables the repair-controller container |
+| repairController.logFormat | string | plain | Log format (`plain` or `json`) for the repair-controller container |
+| repairController.logLevel | string | info | Log level for the repair-controller container |
+| repairController.resources.cpu.limit | string | `""` | Maximum amount of CPU units that the repair-controller container can use |
+| repairController.resources.cpu.request | string | `""` | Amount of CPU units that the repair-controller container requests |
+| repairController.resources.ephemeral-storage.limit | string | `""` | Maximum amount of ephemeral storage that the repair-controller container can use |
+| repairController.resources.ephemeral-storage.request | string | `""` | Amount of ephemeral storage that the repair-controller container requests |
+| repairController.resources.memory.limit | string | `""` | Maximum amount of memory that the repair-controller container can use |
+| repairController.resources.memory.request | string | `""` | Amount of memory that the repair-controller container requests |
+| resources | object | `{"cpu":{"limit":"","request":""},"ephemeral-storage":{"limit":"","request":""},"memory":{"limit":"","request":""}}` | Resource requests and limits for linkerd-cni daemonset container |
 | resources.cpu.limit | string | `""` | Maximum amount of CPU units that the cni container can use |
 | resources.cpu.request | string | `""` | Amount of CPU units that the cni container requests |
 | resources.ephemeral-storage.limit | string | `""` | Maximum amount of ephemeral storage that the cni container can use |
diff --git a/charts/linkerd2-cni/templates/cni-plugin.yaml b/charts/linkerd2-cni/templates/cni-plugin.yaml
index 160449ee3..69d3e0e64 100644
--- a/charts/linkerd2-cni/templates/cni-plugin.yaml
+++ b/charts/linkerd2-cni/templates/cni-plugin.yaml
@@ -112,6 +112,14 @@ rules:
 - apiGroups: [""]
   resources: ["pods", "nodes", "namespaces", "services"]
   verbs: ["list", "get", "watch"]
+{{- if .Values.repairController.enabled }}
+- apiGroups: [""]
+  resources: ["pods"]
+  verbs: ["delete"]
+- apiGroups: ["events.k8s.io"]
+  resources: ["events"]
+  verbs: ["create"]
+{{- end }}
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
@@ -274,6 +282,59 @@ spec:
         {{- if .Values.resources }}
         {{- include "partials.resources" .Values.resources | nindent 8 }}
         {{- end }}
+      {{- if .Values.repairController.enabled }}
+      # This container watches over pods whose linkerd-network-validator
+      # container failed, probably because of a race condition while setting up
+      # the CNI plugin chain, and deletes those pods so they can try acquiring a
+      # proper network config again
+      - name: repair-controller
+        image: {{ .Values.image.name -}}:{{- .Values.image.version }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        {{- if .Values.repairController.enableSecurityContext }}
+        env:
+        - name: LINKERD_CNI_REPAIR_CONTROLLER_NODE_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: spec.nodeName
+        - name: LINKERD_CNI_REPAIR_CONTROLLER_POD_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.name
+        command:
+          - /usr/lib/linkerd/linkerd-cni-repair-controller
+        args:
+          - --admin-addr=0.0.0.0:9990
+          - --log-format
+          - {{ .Values.repairController.logFormat }}
+          - --log-level
+          - {{ .Values.repairController.logLevel }}
+        livenessProbe:
+          httpGet:
+            path: /live
+            port: admin-http
+        readinessProbe:
+          failureThreshold: 7
+          httpGet:
+            path: /ready
+            port: admin-http
+          initialDelaySeconds: 10
+        ports:
+        - containerPort: 9990
+          name: admin-http
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            drop:
+            - ALL
+          privileged: false
+          readOnlyRootFilesystem: true
+          seccompProfile:
+            type: RuntimeDefault
+        {{- end }}
+        {{- if .Values.resources }}
+        {{- include "partials.resources" .Values.resources | nindent 8 }}
+        {{- end }}
+      {{- end }}
       volumes:
       {{- if ne .Values.destCNIBinDir .Values.destCNINetDir }}
       - name: cni-bin-dir
diff --git a/charts/linkerd2-cni/values.yaml b/charts/linkerd2-cni/values.yaml
index 977763edb..a9f9e8fd4 100644
--- a/charts/linkerd2-cni/values.yaml
+++ b/charts/linkerd2-cni/values.yaml
@@ -53,7 +53,7 @@ image:
   # -- Docker image for the CNI plugin
   name: "cr.l5d.io/linkerd/cni-plugin"
   # -- Tag for the CNI container Docker image
-  version: "v1.2.2"
+  version: "v1.3.0"
   # -- Pull policy for the linkerd-cni container
   pullPolicy: IfNotPresent
 
@@ -71,22 +71,44 @@ imagePullSecrets: []
 
 # -- Add additional initContainers to the daemonset
 extraInitContainers: []
-# - name: wait-for-other-cni
-#   image: busybox:1.33
-#   command:
-#     - /bin/sh
-#     - -xc
-#     - |
-#       for i in $(seq 1 180); do
-#         test -f /host/etc/cni/net.d/10-aws.conflist && exit 0
-#         sleep 1
-#       done
-#       exit 1
-#   volumeMounts:
-#     - mountPath: /host/etc/cni/net.d
-#       name: cni-net-dir
 
-# -- Resource requests and limits for linkerd-cni daemonset containers
+# The cni-repair-controller scans pods in each node to find those that have
+# been injected by linkerd, and whose linkerd-network-validator container has
+# failed. This is usually caused by a race between linkerd-cni and the CNI
+# plugin used in the cluster. This controller deletes those failed pods so they
+# can restart and rety re-acquiring a proper network config.
+repairController:
+  # -- Enables the repair-controller container
+  enabled: false
+
+  # -- Log level for the repair-controller container
+  # @default -- info
+  logLevel: info
+  # -- Log format (`plain` or `json`) for the repair-controller container
+  # @default -- plain
+  logFormat: plain
+
+  # -- Include a securityContext in the repair-controller container
+  enableSecurityContext: true
+
+  resources:
+    cpu:
+      # -- Maximum amount of CPU units that the repair-controller container can use
+      limit: ""
+      # -- Amount of CPU units that the repair-controller container requests
+      request: ""
+    memory:
+      # -- Maximum amount of memory that the repair-controller container can use
+      limit: ""
+      # -- Amount of memory that the repair-controller container requests
+      request: ""
+    ephemeral-storage:
+      # -- Maximum amount of ephemeral storage that the repair-controller container can use
+      limit: ""
+      # -- Amount of ephemeral storage that the repair-controller container requests
+      request: ""
+
+# -- Resource requests and limits for linkerd-cni daemonset container
 resources:
   cpu:
     # -- Maximum amount of CPU units that the cni container can use
diff --git a/controller/gen/apis/externalworkload/register.go b/controller/gen/apis/externalworkload/register.go
new file mode 100644
index 000000000..514e995e7
--- /dev/null
+++ b/controller/gen/apis/externalworkload/register.go
@@ -0,0 +1,4 @@
+package externalworkload
+
+// GroupName identifies the API Group name for an ExternalWorkload
+const GroupName = "workload.linkerd.io"
diff --git a/controller/gen/apis/externalworkload/v1alpha1/doc.go b/controller/gen/apis/externalworkload/v1alpha1/doc.go
new file mode 100644
index 000000000..4601689c3
--- /dev/null
+++ b/controller/gen/apis/externalworkload/v1alpha1/doc.go
@@ -0,0 +1,3 @@
+// +k8s:deepcopy-gen=package
+
+package v1alpha1
diff --git a/controller/gen/apis/externalworkload/v1alpha1/register.go b/controller/gen/apis/externalworkload/v1alpha1/register.go
new file mode 100644
index 000000000..d47f6e9a3
--- /dev/null
+++ b/controller/gen/apis/externalworkload/v1alpha1/register.go
@@ -0,0 +1,49 @@
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	"github.com/linkerd/linkerd2/controller/gen/apis/externalworkload"
+)
+
+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:   externalworkload.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,
+		&ExternalWorkload{},
+		&ExternalWorkloadList{},
+	)
+	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
+	return nil
+}
diff --git a/controller/gen/apis/externalworkload/v1alpha1/types.go b/controller/gen/apis/externalworkload/v1alpha1/types.go
new file mode 100644
index 000000000..fae321dcc
--- /dev/null
+++ b/controller/gen/apis/externalworkload/v1alpha1/types.go
@@ -0,0 +1,136 @@
+package v1alpha1
+
+import (
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +genclient
+// +genclient:noStatus
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +groupName=workload.linkerd.io
+
+// ExternalWorkload describes a single workload (i.e. a deployable unit,
+// conceptually similar to a Kubernetes Pod) that is running outside of a
+// Kubernetes cluster. An ExternalWorkload should be enrolled in the mesh and
+// typically represents a virtual machine.
+type ExternalWorkload 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 defines the desired state of an external workload instance
+	Spec ExternalWorkloadSpec `json:"spec"`
+
+	// Status defines the current state of an external workload instance
+	Status ExternalWorkloadStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// ExternalWorkloadList contains a list of ExternalWorkload resources.
+type ExternalWorkloadList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata"`
+
+	Items []ExternalWorkload `json:"items"`
+}
+
+// ExternalWorkloadSpec represents the desired state of an external workload
+type ExternalWorkloadSpec struct {
+	// MeshTls describes TLS settings associated with an external workload
+	//
+	// +optional
+	MeshTls MeshTls `json:"meshTls,omitempty"`
+	// Ports describes a set of ports exposed by the workload
+	Ports []PortSpec `json:"ports"`
+	// List of IP addresses that can be used to send traffic to an external
+	// workload
+	WorkloadIPs []WorkloadIP `json:"workloadIPs"`
+}
+
+// MeshTls describes TLS settings associated with an external workload
+type MeshTls struct {
+	// Identity associated with the workload. Used by peers to perform
+	// verification in the mTLS handshake
+	Identity string `json:"identity,omitempty"`
+	// ServerName is the DNS formatted name associated with the workload. Used
+	// to terminate TLS using the SNI extension.
+	ServerName string `json:"serverName,omitempty"`
+}
+
+// PortSpec represents a network port in a single workload.
+type PortSpec struct {
+	// If specified, must be an IANA_SVC_NAME and unique within the exposed
+	// ports set. Each named port must have a unique name. The name may be
+	// referred to by services
+	// +optional
+	Name string `json:"name,omitempty"`
+	// Number of port exposed on the workload's IP address.
+	// Must be a valid port number, i.e. 0 < x < 65536.
+	Port int32 `json:"port"`
+	// Protocol defines network protocols supported. One of UDP, TCP, or SCTP.
+	// Should coincide with Service selecting the workload.
+	// Defaults to "TCP" if unspecified.
+	// +optional
+	// +default="TCP"
+	Protocol v1.Protocol `json:"protocol,omitempty"`
+}
+
+// WorkloadIPs contains a list of IP addresses exposed by an ExternalWorkload
+type WorkloadIP struct {
+	Ip string `json:"ip"`
+}
+
+// WorkloadStatus holds information about the status of an external workload.
+// The status describes the state of the workload.
+type ExternalWorkloadStatus struct {
+	// Current service state of an ExternalWorkload
+	// +optional
+	Conditions []WorkloadCondition `json:"conditions,omitempty"`
+}
+
+// WorkloadCondition represents the service state of an ExternalWorkload
+type WorkloadCondition struct {
+	// Type of the condition
+	// see: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-conditions
+	Type WorkloadConditionType `json:"type"`
+	// Status of the condition.
+	// Can be True, False, Unknown
+	Status WorkloadConditionStatus `json:"status"`
+	// Last time an ExternalWorkload was probed for a condition.
+	// +optional
+	LastProbeTime metav1.Time `json:"lastProbeTime,omitempty"`
+	// Last time a condition transitioned from one status to another.
+	LastTransitionTime metav1.Time `json:"lastTransitionTime"`
+	// Unique one word reason in CamelCase that describes the reason for a
+	// transition.
+	Reason string `json:"reason"`
+	// Human readable message that describes details about last transition.
+	Message string `json:"message"`
+}
+
+// WorkloadConditionType is a value for the type of a condition in an
+// ExternalWorkload's status
+type WorkloadConditionType string
+
+const (
+	// Ready to serve traffic
+	WorkloadReady WorkloadConditionType = "Ready"
+)
+
+// WorkloadConditionStatus
+type WorkloadConditionStatus string
+
+const (
+	ConditionTrue    WorkloadConditionStatus = "True"
+	ConditionFalse   WorkloadConditionStatus = "False"
+	ConditionUnknown WorkloadConditionStatus = "Unknown"
+)
diff --git a/controller/gen/apis/externalworkload/v1alpha1/zz_generated.deepcopy.go b/controller/gen/apis/externalworkload/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 000000000..0c312b71c
--- /dev/null
+++ b/controller/gen/apis/externalworkload/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,203 @@
+//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 *ExternalWorkload) DeepCopyInto(out *ExternalWorkload) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalWorkload.
+func (in *ExternalWorkload) DeepCopy() *ExternalWorkload {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalWorkload)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ExternalWorkload) 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 *ExternalWorkloadList) DeepCopyInto(out *ExternalWorkloadList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ExternalWorkload, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalWorkloadList.
+func (in *ExternalWorkloadList) DeepCopy() *ExternalWorkloadList {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalWorkloadList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ExternalWorkloadList) 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 *ExternalWorkloadSpec) DeepCopyInto(out *ExternalWorkloadSpec) {
+	*out = *in
+	out.MeshTls = in.MeshTls
+	if in.Ports != nil {
+		in, out := &in.Ports, &out.Ports
+		*out = make([]PortSpec, len(*in))
+		copy(*out, *in)
+	}
+	if in.WorkloadIPs != nil {
+		in, out := &in.WorkloadIPs, &out.WorkloadIPs
+		*out = make([]WorkloadIP, len(*in))
+		copy(*out, *in)
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalWorkloadSpec.
+func (in *ExternalWorkloadSpec) DeepCopy() *ExternalWorkloadSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalWorkloadSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExternalWorkloadStatus) DeepCopyInto(out *ExternalWorkloadStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]WorkloadCondition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalWorkloadStatus.
+func (in *ExternalWorkloadStatus) DeepCopy() *ExternalWorkloadStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ExternalWorkloadStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MeshTls) DeepCopyInto(out *MeshTls) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MeshTls.
+func (in *MeshTls) DeepCopy() *MeshTls {
+	if in == nil {
+		return nil
+	}
+	out := new(MeshTls)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PortSpec) DeepCopyInto(out *PortSpec) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortSpec.
+func (in *PortSpec) DeepCopy() *PortSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(PortSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WorkloadCondition) DeepCopyInto(out *WorkloadCondition) {
+	*out = *in
+	in.LastProbeTime.DeepCopyInto(&out.LastProbeTime)
+	in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadCondition.
+func (in *WorkloadCondition) DeepCopy() *WorkloadCondition {
+	if in == nil {
+		return nil
+	}
+	out := new(WorkloadCondition)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WorkloadIP) DeepCopyInto(out *WorkloadIP) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadIP.
+func (in *WorkloadIP) DeepCopy() *WorkloadIP {
+	if in == nil {
+		return nil
+	}
+	out := new(WorkloadIP)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/controller/gen/client/clientset/versioned/clientset.go b/controller/gen/client/clientset/versioned/clientset.go
index 3d669131b..ae1460eb1 100644
--- a/controller/gen/client/clientset/versioned/clientset.go
+++ b/controller/gen/client/clientset/versioned/clientset.go
@@ -22,6 +22,7 @@ import (
 	"fmt"
 	"net/http"
 
+	externalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1"
 	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/link/v1alpha1"
 	policyv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/policy/v1alpha1"
 	policyv1beta3 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/policy/v1beta3"
@@ -35,6 +36,7 @@ import (
 
 type Interface interface {
 	Discovery() discovery.DiscoveryInterface
+	ExternalworkloadV1alpha1() externalworkloadv1alpha1.ExternalworkloadV1alpha1Interface
 	LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface
 	PolicyV1alpha1() policyv1alpha1.PolicyV1alpha1Interface
 	PolicyV1beta3() policyv1beta3.PolicyV1beta3Interface
@@ -46,6 +48,7 @@ type Interface interface {
 // Clientset contains the clients for groups.
 type Clientset struct {
 	*discovery.DiscoveryClient
+	externalworkloadV1alpha1   *externalworkloadv1alpha1.ExternalworkloadV1alpha1Client
 	linkV1alpha1               *linkv1alpha1.LinkV1alpha1Client
 	policyV1alpha1             *policyv1alpha1.PolicyV1alpha1Client
 	policyV1beta3              *policyv1beta3.PolicyV1beta3Client
@@ -54,6 +57,11 @@ type Clientset struct {
 	linkerdV1alpha2            *linkerdv1alpha2.LinkerdV1alpha2Client
 }
 
+// ExternalworkloadV1alpha1 retrieves the ExternalworkloadV1alpha1Client
+func (c *Clientset) ExternalworkloadV1alpha1() externalworkloadv1alpha1.ExternalworkloadV1alpha1Interface {
+	return c.externalworkloadV1alpha1
+}
+
 // LinkV1alpha1 retrieves the LinkV1alpha1Client
 func (c *Clientset) LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface {
 	return c.linkV1alpha1
@@ -128,6 +136,10 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset,
 
 	var cs Clientset
 	var err error
+	cs.externalworkloadV1alpha1, err = externalworkloadv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient)
+	if err != nil {
+		return nil, err
+	}
 	cs.linkV1alpha1, err = linkv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient)
 	if err != nil {
 		return nil, err
@@ -173,6 +185,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.externalworkloadV1alpha1 = externalworkloadv1alpha1.New(c)
 	cs.linkV1alpha1 = linkv1alpha1.New(c)
 	cs.policyV1alpha1 = policyv1alpha1.New(c)
 	cs.policyV1beta3 = policyv1beta3.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 12118b595..3a7b8d51f 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"
+	externalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1"
+	fakeexternalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake"
 	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"
 	policyv1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/typed/policy/v1alpha1"
@@ -89,6 +91,11 @@ var (
 	_ testing.FakeClient  = &Clientset{}
 )
 
+// ExternalworkloadV1alpha1 retrieves the ExternalworkloadV1alpha1Client
+func (c *Clientset) ExternalworkloadV1alpha1() externalworkloadv1alpha1.ExternalworkloadV1alpha1Interface {
+	return &fakeexternalworkloadv1alpha1.FakeExternalworkloadV1alpha1{Fake: &c.Fake}
+}
+
 // LinkV1alpha1 retrieves the LinkV1alpha1Client
 func (c *Clientset) LinkV1alpha1() linkv1alpha1.LinkV1alpha1Interface {
 	return &fakelinkv1alpha1.FakeLinkV1alpha1{Fake: &c.Fake}
diff --git a/controller/gen/client/clientset/versioned/fake/register.go b/controller/gen/client/clientset/versioned/fake/register.go
index 3690f3b14..4271e8af8 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 (
+	externalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1alpha1"
 	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	policyv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1alpha1"
 	policyv1beta3 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1beta3"
@@ -36,6 +37,7 @@ var scheme = runtime.NewScheme()
 var codecs = serializer.NewCodecFactory(scheme)
 
 var localSchemeBuilder = runtime.SchemeBuilder{
+	externalworkloadv1alpha1.AddToScheme,
 	linkv1alpha1.AddToScheme,
 	policyv1alpha1.AddToScheme,
 	policyv1beta3.AddToScheme,
diff --git a/controller/gen/client/clientset/versioned/scheme/register.go b/controller/gen/client/clientset/versioned/scheme/register.go
index f2f32b645..e266dcec9 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 (
+	externalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1alpha1"
 	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	policyv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1alpha1"
 	policyv1beta3 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1beta3"
@@ -36,6 +37,7 @@ var Scheme = runtime.NewScheme()
 var Codecs = serializer.NewCodecFactory(Scheme)
 var ParameterCodec = runtime.NewParameterCodec(Scheme)
 var localSchemeBuilder = runtime.SchemeBuilder{
+	externalworkloadv1alpha1.AddToScheme,
 	linkv1alpha1.AddToScheme,
 	policyv1alpha1.AddToScheme,
 	policyv1beta3.AddToScheme,
diff --git a/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/doc.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/doc.go
new file mode 100644
index 000000000..df51baa4d
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/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/externalworkload/v1alpha1/externalworkload.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/externalworkload.go
new file mode 100644
index 000000000..ac57766a6
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/externalworkload.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/externalworkload/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"
+)
+
+// ExternalWorkloadsGetter has a method to return a ExternalWorkloadInterface.
+// A group's client should implement this interface.
+type ExternalWorkloadsGetter interface {
+	ExternalWorkloads(namespace string) ExternalWorkloadInterface
+}
+
+// ExternalWorkloadInterface has methods to work with ExternalWorkload resources.
+type ExternalWorkloadInterface interface {
+	Create(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.CreateOptions) (*v1alpha1.ExternalWorkload, error)
+	Update(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.UpdateOptions) (*v1alpha1.ExternalWorkload, 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.ExternalWorkload, error)
+	List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.ExternalWorkloadList, 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.ExternalWorkload, err error)
+	ExternalWorkloadExpansion
+}
+
+// externalWorkloads implements ExternalWorkloadInterface
+type externalWorkloads struct {
+	client rest.Interface
+	ns     string
+}
+
+// newExternalWorkloads returns a ExternalWorkloads
+func newExternalWorkloads(c *ExternalworkloadV1alpha1Client, namespace string) *externalWorkloads {
+	return &externalWorkloads{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the externalWorkload, and returns the corresponding externalWorkload object, and an error if there is any.
+func (c *externalWorkloads) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	result = &v1alpha1.ExternalWorkload{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of ExternalWorkloads that match those selectors.
+func (c *externalWorkloads) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.ExternalWorkloadList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1alpha1.ExternalWorkloadList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested externalWorkloads.
+func (c *externalWorkloads) 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("externalworkloads").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch(ctx)
+}
+
+// Create takes the representation of a externalWorkload and creates it.  Returns the server's representation of the externalWorkload, and an error, if there is any.
+func (c *externalWorkloads) Create(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.CreateOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	result = &v1alpha1.ExternalWorkload{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(externalWorkload).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Update takes the representation of a externalWorkload and updates it. Returns the server's representation of the externalWorkload, and an error, if there is any.
+func (c *externalWorkloads) Update(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.UpdateOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	result = &v1alpha1.ExternalWorkload{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		Name(externalWorkload.Name).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(externalWorkload).
+		Do(ctx).
+		Into(result)
+	return
+}
+
+// Delete takes name of the externalWorkload and deletes it. Returns an error if one occurs.
+func (c *externalWorkloads) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		Name(name).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *externalWorkloads) 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("externalworkloads").
+		VersionedParams(&listOpts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(&opts).
+		Do(ctx).
+		Error()
+}
+
+// Patch applies the patch and returns the patched externalWorkload.
+func (c *externalWorkloads) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.ExternalWorkload, err error) {
+	result = &v1alpha1.ExternalWorkload{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("externalworkloads").
+		Name(name).
+		SubResource(subresources...).
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Body(data).
+		Do(ctx).
+		Into(result)
+	return
+}
diff --git a/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/externalworkload_client.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/externalworkload_client.go
new file mode 100644
index 000000000..0a58f2919
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/externalworkload_client.go
@@ -0,0 +1,107 @@
+/*
+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 (
+	"net/http"
+
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1alpha1"
+	"github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned/scheme"
+	rest "k8s.io/client-go/rest"
+)
+
+type ExternalworkloadV1alpha1Interface interface {
+	RESTClient() rest.Interface
+	ExternalWorkloadsGetter
+}
+
+// ExternalworkloadV1alpha1Client is used to interact with features provided by the externalworkload group.
+type ExternalworkloadV1alpha1Client struct {
+	restClient rest.Interface
+}
+
+func (c *ExternalworkloadV1alpha1Client) ExternalWorkloads(namespace string) ExternalWorkloadInterface {
+	return newExternalWorkloads(c, namespace)
+}
+
+// NewForConfig creates a new ExternalworkloadV1alpha1Client for the given config.
+// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient),
+// where httpClient was generated with rest.HTTPClientFor(c).
+func NewForConfig(c *rest.Config) (*ExternalworkloadV1alpha1Client, error) {
+	config := *c
+	if err := setConfigDefaults(&config); err != nil {
+		return nil, err
+	}
+	httpClient, err := rest.HTTPClientFor(&config)
+	if err != nil {
+		return nil, err
+	}
+	return NewForConfigAndClient(&config, httpClient)
+}
+
+// NewForConfigAndClient creates a new ExternalworkloadV1alpha1Client for the given config and http client.
+// Note the http client provided takes precedence over the configured transport values.
+func NewForConfigAndClient(c *rest.Config, h *http.Client) (*ExternalworkloadV1alpha1Client, error) {
+	config := *c
+	if err := setConfigDefaults(&config); err != nil {
+		return nil, err
+	}
+	client, err := rest.RESTClientForConfigAndClient(&config, h)
+	if err != nil {
+		return nil, err
+	}
+	return &ExternalworkloadV1alpha1Client{client}, nil
+}
+
+// NewForConfigOrDie creates a new ExternalworkloadV1alpha1Client for the given config and
+// panics if there is an error in the config.
+func NewForConfigOrDie(c *rest.Config) *ExternalworkloadV1alpha1Client {
+	client, err := NewForConfig(c)
+	if err != nil {
+		panic(err)
+	}
+	return client
+}
+
+// New creates a new ExternalworkloadV1alpha1Client for the given RESTClient.
+func New(c rest.Interface) *ExternalworkloadV1alpha1Client {
+	return &ExternalworkloadV1alpha1Client{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 *ExternalworkloadV1alpha1Client) RESTClient() rest.Interface {
+	if c == nil {
+		return nil
+	}
+	return c.restClient
+}
diff --git a/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/doc.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/doc.go
new file mode 100644
index 000000000..16f443990
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/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/externalworkload/v1alpha1/fake/fake_externalworkload.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/fake_externalworkload.go
new file mode 100644
index 000000000..7d5212df5
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/fake_externalworkload.go
@@ -0,0 +1,129 @@
+/*
+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/externalworkload/v1alpha1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	testing "k8s.io/client-go/testing"
+)
+
+// FakeExternalWorkloads implements ExternalWorkloadInterface
+type FakeExternalWorkloads struct {
+	Fake *FakeExternalworkloadV1alpha1
+	ns   string
+}
+
+var externalworkloadsResource = v1alpha1.SchemeGroupVersion.WithResource("externalworkloads")
+
+var externalworkloadsKind = v1alpha1.SchemeGroupVersion.WithKind("ExternalWorkload")
+
+// Get takes name of the externalWorkload, and returns the corresponding externalWorkload object, and an error if there is any.
+func (c *FakeExternalWorkloads) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(externalworkloadsResource, c.ns, name), &v1alpha1.ExternalWorkload{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.ExternalWorkload), err
+}
+
+// List takes label and field selectors, and returns the list of ExternalWorkloads that match those selectors.
+func (c *FakeExternalWorkloads) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.ExternalWorkloadList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(externalworkloadsResource, externalworkloadsKind, c.ns, opts), &v1alpha1.ExternalWorkloadList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1alpha1.ExternalWorkloadList{ListMeta: obj.(*v1alpha1.ExternalWorkloadList).ListMeta}
+	for _, item := range obj.(*v1alpha1.ExternalWorkloadList).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 externalWorkloads.
+func (c *FakeExternalWorkloads) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(externalworkloadsResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a externalWorkload and creates it.  Returns the server's representation of the externalWorkload, and an error, if there is any.
+func (c *FakeExternalWorkloads) Create(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.CreateOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(externalworkloadsResource, c.ns, externalWorkload), &v1alpha1.ExternalWorkload{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.ExternalWorkload), err
+}
+
+// Update takes the representation of a externalWorkload and updates it. Returns the server's representation of the externalWorkload, and an error, if there is any.
+func (c *FakeExternalWorkloads) Update(ctx context.Context, externalWorkload *v1alpha1.ExternalWorkload, opts v1.UpdateOptions) (result *v1alpha1.ExternalWorkload, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(externalworkloadsResource, c.ns, externalWorkload), &v1alpha1.ExternalWorkload{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.ExternalWorkload), err
+}
+
+// Delete takes name of the externalWorkload and deletes it. Returns an error if one occurs.
+func (c *FakeExternalWorkloads) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteActionWithOptions(externalworkloadsResource, c.ns, name, opts), &v1alpha1.ExternalWorkload{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakeExternalWorkloads) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(externalworkloadsResource, c.ns, listOpts)
+
+	_, err := c.Fake.Invokes(action, &v1alpha1.ExternalWorkloadList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched externalWorkload.
+func (c *FakeExternalWorkloads) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.ExternalWorkload, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(externalworkloadsResource, c.ns, name, pt, data, subresources...), &v1alpha1.ExternalWorkload{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1alpha1.ExternalWorkload), err
+}
diff --git a/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/fake_externalworkload_client.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/fake_externalworkload_client.go
new file mode 100644
index 000000000..b0c3ba2f9
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/fake/fake_externalworkload_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/externalworkload/v1alpha1"
+	rest "k8s.io/client-go/rest"
+	testing "k8s.io/client-go/testing"
+)
+
+type FakeExternalworkloadV1alpha1 struct {
+	*testing.Fake
+}
+
+func (c *FakeExternalworkloadV1alpha1) ExternalWorkloads(namespace string) v1alpha1.ExternalWorkloadInterface {
+	return &FakeExternalWorkloads{c, namespace}
+}
+
+// RESTClient returns a RESTClient that is used to communicate
+// with API server by this client implementation.
+func (c *FakeExternalworkloadV1alpha1) RESTClient() rest.Interface {
+	var ret *rest.RESTClient
+	return ret
+}
diff --git a/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/generated_expansion.go b/controller/gen/client/clientset/versioned/typed/externalworkload/v1alpha1/generated_expansion.go
new file mode 100644
index 000000000..cbd85d00b
--- /dev/null
+++ b/controller/gen/client/clientset/versioned/typed/externalworkload/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 ExternalWorkloadExpansion interface{}
diff --git a/controller/gen/client/informers/externalversions/externalworkload/interface.go b/controller/gen/client/informers/externalversions/externalworkload/interface.go
new file mode 100644
index 000000000..b4a55f4e7
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/externalworkload/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 externalworkload
+
+import (
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/externalworkload/v1alpha1"
+	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
+)
+
+// 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/externalworkload/v1alpha1/externalworkload.go b/controller/gen/client/informers/externalversions/externalworkload/v1alpha1/externalworkload.go
new file mode 100644
index 000000000..3e748b2ca
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/externalworkload/v1alpha1/externalworkload.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"
+
+	externalworkloadv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/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/externalworkload/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"
+)
+
+// ExternalWorkloadInformer provides access to a shared informer and lister for
+// ExternalWorkloads.
+type ExternalWorkloadInformer interface {
+	Informer() cache.SharedIndexInformer
+	Lister() v1alpha1.ExternalWorkloadLister
+}
+
+type externalWorkloadInformer struct {
+	factory          internalinterfaces.SharedInformerFactory
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+	namespace        string
+}
+
+// NewExternalWorkloadInformer constructs a new informer for ExternalWorkload 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 NewExternalWorkloadInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
+	return NewFilteredExternalWorkloadInformer(client, namespace, resyncPeriod, indexers, nil)
+}
+
+// NewFilteredExternalWorkloadInformer constructs a new informer for ExternalWorkload 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 NewFilteredExternalWorkloadInformer(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.ExternalworkloadV1alpha1().ExternalWorkloads(namespace).List(context.TODO(), options)
+			},
+			WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.ExternalworkloadV1alpha1().ExternalWorkloads(namespace).Watch(context.TODO(), options)
+			},
+		},
+		&externalworkloadv1alpha1.ExternalWorkload{},
+		resyncPeriod,
+		indexers,
+	)
+}
+
+func (f *externalWorkloadInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
+	return NewFilteredExternalWorkloadInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
+}
+
+func (f *externalWorkloadInformer) Informer() cache.SharedIndexInformer {
+	return f.factory.InformerFor(&externalworkloadv1alpha1.ExternalWorkload{}, f.defaultInformer)
+}
+
+func (f *externalWorkloadInformer) Lister() v1alpha1.ExternalWorkloadLister {
+	return v1alpha1.NewExternalWorkloadLister(f.Informer().GetIndexer())
+}
diff --git a/controller/gen/client/informers/externalversions/externalworkload/v1alpha1/interface.go b/controller/gen/client/informers/externalversions/externalworkload/v1alpha1/interface.go
new file mode 100644
index 000000000..1866f95de
--- /dev/null
+++ b/controller/gen/client/informers/externalversions/externalworkload/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 {
+	// ExternalWorkloads returns a ExternalWorkloadInformer.
+	ExternalWorkloads() ExternalWorkloadInformer
+}
+
+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}
+}
+
+// ExternalWorkloads returns a ExternalWorkloadInformer.
+func (v *version) ExternalWorkloads() ExternalWorkloadInformer {
+	return &externalWorkloadInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
+}
diff --git a/controller/gen/client/informers/externalversions/factory.go b/controller/gen/client/informers/externalversions/factory.go
index 171f32bfb..2cdf7b777 100644
--- a/controller/gen/client/informers/externalversions/factory.go
+++ b/controller/gen/client/informers/externalversions/factory.go
@@ -24,6 +24,7 @@ import (
 	time "time"
 
 	versioned "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned"
+	externalworkload "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/externalworkload"
 	internalinterfaces "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/internalinterfaces"
 	link "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/link"
 	policy "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/policy"
@@ -46,6 +47,7 @@ type sharedInformerFactory struct {
 	lock             sync.Mutex
 	defaultResync    time.Duration
 	customResync     map[reflect.Type]time.Duration
+	transform        cache.TransformFunc
 
 	informers map[reflect.Type]cache.SharedIndexInformer
 	// startedInformers is used for tracking which informers have been started.
@@ -84,6 +86,14 @@ func WithNamespace(namespace string) SharedInformerOption {
 	}
 }
 
+// WithTransform sets a transform on all informers.
+func WithTransform(transform cache.TransformFunc) SharedInformerOption {
+	return func(factory *sharedInformerFactory) *sharedInformerFactory {
+		factory.transform = transform
+		return factory
+	}
+}
+
 // NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces.
 func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory {
 	return NewSharedInformerFactoryWithOptions(client, defaultResync)
@@ -188,6 +198,7 @@ func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internal
 	}
 
 	informer = newFunc(f.client, resyncPeriod)
+	informer.SetTransform(f.transform)
 	f.informers[informerType] = informer
 
 	return informer
@@ -247,6 +258,7 @@ type SharedInformerFactory interface {
 	// client.
 	InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer
 
+	Externalworkload() externalworkload.Interface
 	Link() link.Interface
 	Policy() policy.Interface
 	Server() server.Interface
@@ -254,6 +266,10 @@ type SharedInformerFactory interface {
 	Linkerd() serviceprofile.Interface
 }
 
+func (f *sharedInformerFactory) Externalworkload() externalworkload.Interface {
+	return externalworkload.New(f, f.namespace, f.tweakListOptions)
+}
+
 func (f *sharedInformerFactory) Link() link.Interface {
 	return link.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 df19bbe93..cb6821e16 100644
--- a/controller/gen/client/informers/externalversions/generic.go
+++ b/controller/gen/client/informers/externalversions/generic.go
@@ -21,7 +21,8 @@ package externalversions
 import (
 	"fmt"
 
-	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
+	v1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1alpha1"
+	linkv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/link/v1alpha1"
 	policyv1alpha1 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1alpha1"
 	v1beta3 "github.com/linkerd/linkerd2/controller/gen/apis/policy/v1beta3"
 	v1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/server/v1beta1"
@@ -57,8 +58,12 @@ 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=link, Version=v1alpha1
-	case v1alpha1.SchemeGroupVersion.WithResource("links"):
+	// Group=externalworkload, Version=v1alpha1
+	case v1alpha1.SchemeGroupVersion.WithResource("externalworkloads"):
+		return &genericInformer{resource: resource.GroupResource(), informer: f.Externalworkload().V1alpha1().ExternalWorkloads().Informer()}, nil
+
+		// Group=link, Version=v1alpha1
+	case linkv1alpha1.SchemeGroupVersion.WithResource("links"):
 		return &genericInformer{resource: resource.GroupResource(), informer: f.Link().V1alpha1().Links().Informer()}, nil
 
 		// Group=linkerd.io, Version=v1alpha2
diff --git a/controller/gen/client/listers/externalworkload/v1alpha1/expansion_generated.go b/controller/gen/client/listers/externalworkload/v1alpha1/expansion_generated.go
new file mode 100644
index 000000000..5c77b2b63
--- /dev/null
+++ b/controller/gen/client/listers/externalworkload/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
+
+// ExternalWorkloadListerExpansion allows custom methods to be added to
+// ExternalWorkloadLister.
+type ExternalWorkloadListerExpansion interface{}
+
+// ExternalWorkloadNamespaceListerExpansion allows custom methods to be added to
+// ExternalWorkloadNamespaceLister.
+type ExternalWorkloadNamespaceListerExpansion interface{}
diff --git a/controller/gen/client/listers/externalworkload/v1alpha1/externalworkload.go b/controller/gen/client/listers/externalworkload/v1alpha1/externalworkload.go
new file mode 100644
index 000000000..7b39381af
--- /dev/null
+++ b/controller/gen/client/listers/externalworkload/v1alpha1/externalworkload.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/externalworkload/v1alpha1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/client-go/tools/cache"
+)
+
+// ExternalWorkloadLister helps list ExternalWorkloads.
+// All objects returned here must be treated as read-only.
+type ExternalWorkloadLister interface {
+	// List lists all ExternalWorkloads in the indexer.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*v1alpha1.ExternalWorkload, err error)
+	// ExternalWorkloads returns an object that can list and get ExternalWorkloads.
+	ExternalWorkloads(namespace string) ExternalWorkloadNamespaceLister
+	ExternalWorkloadListerExpansion
+}
+
+// externalWorkloadLister implements the ExternalWorkloadLister interface.
+type externalWorkloadLister struct {
+	indexer cache.Indexer
+}
+
+// NewExternalWorkloadLister returns a new ExternalWorkloadLister.
+func NewExternalWorkloadLister(indexer cache.Indexer) ExternalWorkloadLister {
+	return &externalWorkloadLister{indexer: indexer}
+}
+
+// List lists all ExternalWorkloads in the indexer.
+func (s *externalWorkloadLister) List(selector labels.Selector) (ret []*v1alpha1.ExternalWorkload, err error) {
+	err = cache.ListAll(s.indexer, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.ExternalWorkload))
+	})
+	return ret, err
+}
+
+// ExternalWorkloads returns an object that can list and get ExternalWorkloads.
+func (s *externalWorkloadLister) ExternalWorkloads(namespace string) ExternalWorkloadNamespaceLister {
+	return externalWorkloadNamespaceLister{indexer: s.indexer, namespace: namespace}
+}
+
+// ExternalWorkloadNamespaceLister helps list and get ExternalWorkloads.
+// All objects returned here must be treated as read-only.
+type ExternalWorkloadNamespaceLister interface {
+	// List lists all ExternalWorkloads in the indexer for a given namespace.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*v1alpha1.ExternalWorkload, err error)
+	// Get retrieves the ExternalWorkload from the indexer for a given namespace and name.
+	// Objects returned here must be treated as read-only.
+	Get(name string) (*v1alpha1.ExternalWorkload, error)
+	ExternalWorkloadNamespaceListerExpansion
+}
+
+// externalWorkloadNamespaceLister implements the ExternalWorkloadNamespaceLister
+// interface.
+type externalWorkloadNamespaceLister struct {
+	indexer   cache.Indexer
+	namespace string
+}
+
+// List lists all ExternalWorkloads in the indexer for a given namespace.
+func (s externalWorkloadNamespaceLister) List(selector labels.Selector) (ret []*v1alpha1.ExternalWorkload, err error) {
+	err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) {
+		ret = append(ret, m.(*v1alpha1.ExternalWorkload))
+	})
+	return ret, err
+}
+
+// Get retrieves the ExternalWorkload from the indexer for a given namespace and name.
+func (s externalWorkloadNamespaceLister) Get(name string) (*v1alpha1.ExternalWorkload, error) {
+	obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name)
+	if err != nil {
+		return nil, err
+	}
+	if !exists {
+		return nil, errors.NewNotFound(v1alpha1.Resource("externalworkload"), name)
+	}
+	return obj.(*v1alpha1.ExternalWorkload), nil
+}
diff --git a/controller/k8s/api.go b/controller/k8s/api.go
index e3f800359..7003c6d8e 100644
--- a/controller/k8s/api.go
+++ b/controller/k8s/api.go
@@ -11,6 +11,7 @@ import (
 	spv1alpha2 "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
 	l5dcrdclient "github.com/linkerd/linkerd2/controller/gen/client/clientset/versioned"
 	l5dcrdinformer "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions"
+	ewinformers "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/externalworkload/v1alpha1"
 	srvinformers "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/server/v1beta1"
 	spinformers "github.com/linkerd/linkerd2/controller/gen/client/informers/externalversions/serviceprofile/v1alpha2"
 	"github.com/linkerd/linkerd2/pkg/k8s"
@@ -49,6 +50,7 @@ type API struct {
 	ds       appv1informers.DaemonSetInformer
 	endpoint coreinformers.EndpointsInformer
 	es       discoveryinformers.EndpointSliceInformer
+	ew       ewinformers.ExternalWorkloadInformer
 	job      batchv1informers.JobInformer
 	mwc      arinformers.MutatingWebhookConfigurationInformer
 	ns       coreinformers.NamespaceInformer
@@ -67,7 +69,10 @@ type API struct {
 	l5dCrdSharedInformers l5dcrdinformer.SharedInformerFactory
 }
 
-// InitializeAPI creates Kubernetes clients and returns an initialized API wrapper.
+// InitializeAPI creates Kubernetes clients and returns an initialized API
+// wrapper. This creates informers on each one of resources passed, registering
+// metrics on each one; don't forget to call UnregisterGauges() on the returned
+// API reference to clean them up!
 func InitializeAPI(ctx context.Context, kubeConfig string, ensureClusterWideAccess bool, cluster string, resources ...APIResource) (*API, error) {
 	config, err := k8s.GetConfig(kubeConfig, "")
 	if err != nil {
@@ -87,7 +92,10 @@ func InitializeAPI(ctx context.Context, kubeConfig string, ensureClusterWideAcce
 	return initAPI(ctx, k8sClient, dynamicClient, config, ensureClusterWideAccess, cluster, resources...)
 }
 
-// InitializeAPIForConfig creates Kubernetes clients and returns an initialized API wrapper.
+// InitializeAPIForConfig creates Kubernetes clients and returns an initialized
+// API wrapper. This creates informers on each one of resources passed,
+// registering metrics on each one; don't forget to call UnregisterGauges() on
+// the returned API reference to clean them up!
 func InitializeAPIForConfig(ctx context.Context, kubeConfig *rest.Config, ensureClusterWideAccess bool, cluster string, resources ...APIResource) (*API, error) {
 	k8sClient, err := k8s.NewAPIForConfig(kubeConfig, "", []string{}, 0, 0, 0)
 	if err != nil {
@@ -122,6 +130,11 @@ func initAPI(ctx context.Context, k8sClient *k8s.KubernetesAPI, dynamicClient dy
 			if err != nil {
 				return nil, err
 			}
+		case res == ExtWorkload:
+			err := k8s.ExtWorkloadAccess(ctx, k8sClient)
+			if err != nil {
+				return nil, err
+			}
 		default:
 			continue
 		}
@@ -141,7 +154,10 @@ func initAPI(ctx context.Context, k8sClient *k8s.KubernetesAPI, dynamicClient dy
 	return api, nil
 }
 
-// NewClusterScopedAPI takes a Kubernetes client and returns an initialized cluster-wide API.
+// NewClusterScopedAPI takes a Kubernetes client and returns an initialized
+// cluster-wide API. This creates informers on each one of resources passed,
+// registering metrics on each one; don't forget to call UnregisterGauges() on
+// the returned API reference to clean them up!
 func NewClusterScopedAPI(
 	k8sClient kubernetes.Interface,
 	dynamicClient dynamic.Interface,
@@ -153,7 +169,10 @@ func NewClusterScopedAPI(
 	return newAPI(k8sClient, dynamicClient, l5dCrdClient, sharedInformers, cluster, resources...)
 }
 
-// NewNamespacedAPI takes a Kubernetes client and returns an initialized API scoped to namespace.
+// NewNamespacedAPI takes a Kubernetes client and returns an initialized API
+// scoped to namespace. This creates informers on each one of resources passed,
+// registering metrics on each one; don't forget to call UnregisterGauges() on
+// the returned API reference to clean them up!
 func NewNamespacedAPI(
 	k8sClient kubernetes.Interface,
 	dynamicClient dynamic.Interface,
@@ -218,6 +237,13 @@ func newAPI(
 			api.es = sharedInformers.Discovery().V1().EndpointSlices()
 			api.syncChecks = append(api.syncChecks, api.es.Informer().HasSynced)
 			api.promGauges.addInformerSize(k8s.EndpointSlices, informerLabels, api.es.Informer())
+		case ExtWorkload:
+			if l5dCrdSharedInformers == nil {
+				panic("Linkerd CRD shared informer not configured")
+			}
+			api.ew = l5dCrdSharedInformers.Externalworkload().V1alpha1().ExternalWorkloads()
+			api.syncChecks = append(api.syncChecks, api.ew.Informer().HasSynced)
+			api.promGauges.addInformerSize(k8s.ExtWorkload, informerLabels, api.ew.Informer())
 		case Job:
 			api.job = sharedInformers.Batch().V1().Jobs()
 			api.syncChecks = append(api.syncChecks, api.job.Informer().HasSynced)
@@ -374,6 +400,15 @@ func (api *API) ES() discoveryinformers.EndpointSliceInformer {
 	return api.es
 }
 
+// ExtWorkload() provides access to a shared informer and lister for
+// ExternalWorkload CRDs
+func (api *API) ExtWorkload() ewinformers.ExternalWorkloadInformer {
+	if api.ew == nil {
+		panic("ExternalWorkload informer not configured")
+	}
+	return api.ew
+}
+
 // CM provides access to a shared informer and lister for ConfigMaps.
 func (api *API) CM() coreinformers.ConfigMapInformer {
 	if api.cm == nil {
diff --git a/controller/k8s/api_resource.go b/controller/k8s/api_resource.go
index 8681fd64c..af035e9ad 100644
--- a/controller/k8s/api_resource.go
+++ b/controller/k8s/api_resource.go
@@ -30,6 +30,7 @@ const (
 	DS
 	Endpoint
 	ES // EndpointSlice resource
+	ExtWorkload
 	Job
 	MWC
 	NS
diff --git a/multicluster/cmd/service-mirror/main.go b/multicluster/cmd/service-mirror/main.go
index 8e34b40d6..614df0419 100644
--- a/multicluster/cmd/service-mirror/main.go
+++ b/multicluster/cmd/service-mirror/main.go
@@ -303,7 +303,7 @@ func restartClusterWatcher(
 	if err != nil {
 		return fmt.Errorf("unable to parse kube config: %w", err)
 	}
-	clusterWatcher, err = servicemirror.NewRemoteClusterServiceWatcher(
+	cw, err := servicemirror.NewRemoteClusterServiceWatcher(
 		ctx,
 		namespace,
 		controllerK8sAPI,
@@ -317,6 +317,7 @@ func restartClusterWatcher(
 	if err != nil {
 		return fmt.Errorf("unable to create cluster watcher: %w", err)
 	}
+	clusterWatcher = cw
 	err = clusterWatcher.Start(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to start cluster watcher: %w", err)
diff --git a/multicluster/service-mirror/cluster_watcher.go b/multicluster/service-mirror/cluster_watcher.go
index 5fb0f6632..2b52aea6a 100644
--- a/multicluster/service-mirror/cluster_watcher.go
+++ b/multicluster/service-mirror/cluster_watcher.go
@@ -175,6 +175,7 @@ func NewRemoteClusterServiceWatcher(
 	}
 	_, err = remoteAPI.Client.Discovery().ServerVersion()
 	if err != nil {
+		remoteAPI.UnregisterGauges()
 		return nil, fmt.Errorf("cannot connect to api for target cluster %s: %w", clusterName, err)
 	}
 
diff --git a/pkg/charts/cni/values.go b/pkg/charts/cni/values.go
index 6c04d7764..46e29d23d 100644
--- a/pkg/charts/cni/values.go
+++ b/pkg/charts/cni/values.go
@@ -35,6 +35,15 @@ type Resources struct {
 	EphemeralStorage Constraints `json:"ephemeral-storage"`
 }
 
+// RepairController contains the config for the repair-controller container
+type RepairController struct {
+	Image                 Image     `json:"image"`
+	LogLevel              string    `json:"logLevel"`
+	LogFormat             string    `json:"logFormat"`
+	EnableSecurityContext bool      `json:"enableSecurityContext"`
+	Resources             Resources `json:"resources"`
+}
+
 // Values contains the top-level elements in the cni Helm chart
 type Values struct {
 	InboundProxyPort    uint                `json:"inboundProxyPort"`
@@ -60,6 +69,7 @@ type Values struct {
 	EnablePSP           bool                `json:"enablePSP"`
 	Privileged          bool                `json:"privileged"`
 	Resources           Resources           `json:"resources"`
+	RepairController    RepairController    `json:"repairController"`
 }
 
 // NewValues returns a new instance of the Values type.
diff --git a/pkg/k8s/authz.go b/pkg/k8s/authz.go
index 5a9c36b94..3d196815a 100644
--- a/pkg/k8s/authz.go
+++ b/pkg/k8s/authz.go
@@ -128,6 +128,24 @@ func ServersAccess(ctx context.Context, k8sClient kubernetes.Interface) error {
 	return errors.New("Server CRD not found")
 }
 
+// ExtWorkloadAccess checks whether the ExternalWorkload CRD is installed on the
+// cluster and the client is authorized to access ExternalWorkloads
+func ExtWorkloadAccess(ctx context.Context, k8sClient kubernetes.Interface) error {
+	groupVersion := fmt.Sprintf("%s/%s", WorkloadAPIGroup, WorkloadAPIVersion)
+	res, err := k8sClient.Discovery().ServerResourcesForGroupVersion(groupVersion)
+	if err != nil {
+		return err
+	}
+	if res.GroupVersion == groupVersion {
+		for _, apiRes := range res.APIResources {
+			if apiRes.Kind == ExtWorkloadKind {
+				return ResourceAuthz(ctx, k8sClient, "", "list", WorkloadAPIGroup, "", "externalworkloads", "")
+			}
+		}
+	}
+	return errors.New("ExternalWorkload CRD not found")
+}
+
 // EndpointSliceAccess verifies whether the K8s cluster has
 // access to EndpointSlice resources.
 func EndpointSliceAccess(ctx context.Context, k8sClient kubernetes.Interface) error {
diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go
index 23c20a0ec..0d94a1bcc 100644
--- a/pkg/k8s/k8s.go
+++ b/pkg/k8s/k8s.go
@@ -19,6 +19,7 @@ const (
 	Deployment            = "deployment"
 	Endpoints             = "endpoints"
 	EndpointSlices        = "endpointslices"
+	ExtWorkload           = "externalworkload"
 	Job                   = "job"
 	MeshTLSAuthentication = "meshtlsauthentication"
 	MutatingWebhookConfig = "mutatingwebhookconfig"
@@ -50,9 +51,13 @@ const (
 
 	K8sCoreAPIGroup = "core"
 
-	NamespaceKind = "Namespace"
-	ServerKind    = "Server"
-	HTTPRouteKind = "HTTPRoute"
+	NamespaceKind   = "Namespace"
+	ServerKind      = "Server"
+	HTTPRouteKind   = "HTTPRoute"
+	ExtWorkloadKind = "ExternalWorkload"
+
+	WorkloadAPIGroup   = "workload.linkerd.io"
+	WorkloadAPIVersion = "v1alpha1"
 
 	// special case k8s job label, to not conflict with Prometheus' job label
 	l5dJob = "k8s_job"
diff --git a/policy-controller/k8s/api/src/external_workload.rs b/policy-controller/k8s/api/src/external_workload.rs
new file mode 100644
index 000000000..92cdf79c0
--- /dev/null
+++ b/policy-controller/k8s/api/src/external_workload.rs
@@ -0,0 +1,95 @@
+use kube::CustomResource;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+/// ExternalWorkload describes a single workload (i.e. a deployable unit,
+/// conceptually similar to a Kubernetes Pod) that is running outside of a
+/// Kubernetes cluster. An ExternalWorkload should be enrolled in the mesh and
+/// typically represents a virtual machine.
+#[derive(Clone, Debug, PartialEq, Eq, CustomResource, Deserialize, Serialize, JsonSchema)]
+#[kube(
+    group = "workload.linkerd.io",
+    version = "v1alpha1",
+    kind = "ExternalWorkload",
+    status = "ExternalWorkloadStatus",
+    namespaced
+)]
+pub struct ExternalWorkloadSpec {
+    /// MeshTls describes TLS settings associated with an external workload
+    #[serde(rename = "meshTls")]
+    pub mesh_tls: Option<MeshTls>,
+    /// Ports describes a set of ports exposed by the workload
+    pub ports: Vec<PortSpec>,
+    /// List of IP addresses that can be used to send traffic to an external
+    /// workload
+    #[serde(rename = "workloadIPs")]
+    pub workload_ips: Vec<WorkloadIP>,
+}
+
+/// MeshTls describes TLS settings associated with an external workload
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub struct MeshTls {
+    /// Identity associated with the workload. Used by peers to perform
+    /// verification in the mTLS handshake
+    pub identity: Option<String>,
+    /// ServerName is the DNS formatted name associated with the workload. Used
+    /// to terminate TLS using the SNI extension.
+    #[serde(rename = "serverName")]
+    pub server_name: Option<String>,
+}
+
+/// PortSpec represents a network port in a single workload.
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub struct PortSpec {
+    /// If specified, must be an IANA_SVC_NAME and unique within the exposed
+    /// ports set. Each named port must have a unique name. The name may be
+    /// referred to by services
+    pub name: Option<String>,
+    /// Number of port exposed on the workload's IP address.
+    /// Must be a valid port number, i.e. 0 < x < 65536.
+    pub port: std::num::NonZeroU16,
+    /// Protocol defines network protocols supported. One of UDP, TCP, or SCTP.
+    /// Should coincide with Service selecting the workload.
+    /// Defaults to "TCP" if unspecified.
+    pub protocol: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub struct ExternalWorkloadStatus {
+    pub conditions: Vec<Condition>,
+}
+
+/// WorkloadIPs contains a list of IP addresses exposed by an ExternalWorkload
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub struct WorkloadIP {
+    pub ip: String,
+}
+
+/// WorkloadCondition represents the service state of an ExternalWorkload
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Condition {
+    /// Type of the condition
+    // see: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#pod-conditions
+    #[serde(rename = "type")]
+    typ: String,
+    /// Status of the condition.
+    /// Can be True, False, Unknown
+    status: ConditionStatus,
+    /// Last time a condition transitioned from one status to another.
+    last_transition_time: crate::apimachinery::pkg::apis::meta::v1::Time,
+    /// Last time an ExternalWorkload was probed for a condition.
+    last_probe_time: Option<crate::apimachinery::pkg::apis::meta::v1::Time>,
+    /// Unique one word reason in CamelCase that describes the reason for a
+    /// transition.
+    reason: String,
+    /// Human readable message that describes details about last transition.
+    message: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub enum ConditionStatus {
+    True,
+    False,
+    Unknown,
+}
diff --git a/policy-controller/k8s/api/src/lib.rs b/policy-controller/k8s/api/src/lib.rs
index d21167a9f..db3f526a2 100644
--- a/policy-controller/k8s/api/src/lib.rs
+++ b/policy-controller/k8s/api/src/lib.rs
@@ -2,6 +2,7 @@
 #![forbid(unsafe_code)]
 
 pub mod duration;
+pub mod external_workload;
 pub mod labels;
 pub mod policy;
 


Test patch:
diff --git a/cli/cmd/testdata/install_cni_helm_default_output.golden b/cli/cmd/testdata/install_cni_helm_default_output.golden
index 6bd305f06..566534f1e 100644
--- a/cli/cmd/testdata/install_cni_helm_default_output.golden
+++ b/cli/cmd/testdata/install_cni_helm_default_output.golden
@@ -111,7 +111,7 @@ spec:
       # script copies the files into place and then sleeps so
       # that Kubernetes doesn't keep trying to restart it.
       - name: install-cni
-        image: cr.l5d.io/linkerd/cni-plugin:v1.2.2
+        image: cr.l5d.io/linkerd/cni-plugin:v1.3.0
         imagePullPolicy: IfNotPresent
         env:
         - name: DEST_CNI_NET_DIR