Repository: linkerd/linkerd2
Pull request #11900 (2024-01-08 15:22:23)

Improve server-side validation for ExternalWorkload
We introduced an ExternalWorkload CRD along with bindings for mesh expansion. Currently, the CRD allows users to create ExternalWorkload resources without adding a meshTls strategy.

This change adds some more validation restrictions to the CRD definition (i.e. server side validation). When a meshTls strategy is used, we require both identity and serverName to be present. Furthermore, we require that when a list of ports is specified, each item in the list must contain a numeric port.

The change also touches on the bindings (Go & Rust types) to remove optionality from meshTls fields.

## Additional background
---

In https://github.com/linkerd/linkerd2/pull/11888#discussion_r1444312506 it was pointed out that we might have to think through optionality for meshTls. @zaharidichev and I caught up off-GitHub to discuss it. When a user provides meshTls settings we should enforce that both serverName and identity are present. We would never use one without the other (in the proxy-api we always set both fields).

On the topic of optionality, we also discussed an interesting scenario for mesh expansion. A VM may want to send traffic to a Kubernetes cluster (but not receive any traffic from a client in a Kubernetes cluster). A resource still needs to exist for policy discovery. Although both `workloadIPs` and `ports` are required fields, they can be set to empty in the event that a user may desire this behaviour.

For the purpose of this PR, I came up with a list of resources that I've created in-cluster. I annotated the manifest with where validation should fail and where it should succeed. To show that the above user story is fulfilled (i.e. an external workload that exists purely to satisfy policy discovery) we set ports and workloadIPs to empty arrays.

```yaml
# validation will fail
# missing workloadIPs and ports
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-2
spec:
  meshTls: {}
---
# validation will fail
# missing workloadIPs
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-3
spec:
  meshTls:
    identity: "foo"
    serverName: "foo"
  ports: []
---
# validation will work
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-4
spec:
  meshTls:
    identity: "foo"
    serverName: "foo"
  ports: []
  workloadIPs: []
---
# validation will fail
# missing serverName
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-5
spec:
  meshTls:
    identity: "foo"
  ports: []
  workloadIPs: []
---
# validation will work
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-6
spec:
  meshTls:
    identity: "foo"
    serverName: "foo"
  ports: []
  workloadIPs:
  - ip: "192.0.2.0"
---
# validation will work
apiVersion: workload.linkerd.io/v1alpha1
kind: ExternalWorkload
metadata:
  name: test-7
spec:
  meshTls:
    identity: "foo"
    serverName: "foo"
  ports:
  # only requires a port to be present in each list item.
  - port: 8080
  workloadIPs: []
```

```
# Only these 3 were accepted by the API Server
NAME     IDENTITY   AGE
test-4   foo        37m
test-7   foo        23m
test-6   foo        23m

```

<!--  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/.github/workflows/release.yml b/.github/workflows/release.yml
index 93da45124..780c8a3ba 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -313,7 +313,7 @@ jobs:
     - name: Checkout code
       uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
     - name: Log into GCP
-      uses: 'google-github-actions/auth@67e9c72af6e0492df856527b474995862b7b6591'
+      uses: 'google-github-actions/auth@f6de81663f7788d05bd15bcce18f0e57f23f0846'
       with:
         credentials_json: ${{ secrets.LINKERD_SITE_TOKEN }}
     - name: Edge Helm chart creation and upload
diff --git a/Cargo.lock b/Cargo.lock
index 1f47adcfc..64471137b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -668,11 +668,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
 [[package]]
 name = "home"
-version = "0.5.5"
+version = "0.5.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb"
+checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
diff --git a/charts/linkerd-crds/templates/policy/server.yaml b/charts/linkerd-crds/templates/policy/server.yaml
index a5d9f26da..ac3215c02 100644
--- a/charts/linkerd-crds/templates/policy/server.yaml
+++ b/charts/linkerd-crds/templates/policy/server.yaml
@@ -72,7 +72,9 @@ spec:
                   default: unknown
     - name: v1beta1
       served: true
-      storage: true
+      storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta2 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -130,3 +132,91 @@ spec:
         type: string
         description: The protocol of the server
         jsonPath: .spec.proxyProtocol
+    - name: v1beta2
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          required: [spec]
+          properties:
+            spec:
+              type: object
+              required:
+                - port
+              oneOf:
+                - required: [podSelector]
+                - required: [externalWorkloadSelector]
+              properties:
+                podSelector:
+                  type: object
+                  description: >-
+                    Selects pods in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                externalWorkloadSelector:
+                  type: object
+                  description: >-
+                    Selects ExternalWorkloads in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                port:
+                  description: >-
+                    A port name or number. Must exist in a pod spec.
+                  x-kubernetes-int-or-string: true
+                proxyProtocol:
+                  description: >-
+                    Configures protocol discovery for inbound connections.
+
+                    Supersedes the `config.linkerd.io/opaque-ports` annotation.
+                  type: string
+                  default: unknown
+      additionalPrinterColumns:
+      - name: Port
+        type: string
+        description: The port the server is listening on
+        jsonPath: .spec.port
+      - name: Protocol
+        type: string
+        description: The protocol of the server
+        jsonPath: .spec.proxyProtocol
diff --git a/charts/linkerd-crds/templates/workload/external-workload.yaml b/charts/linkerd-crds/templates/workload/external-workload.yaml
index 056d6a275..d55447260 100644
--- a/charts/linkerd-crds/templates/workload/external-workload.yaml
+++ b/charts/linkerd-crds/templates/workload/external-workload.yaml
@@ -65,12 +65,14 @@ spec:
                     minLength: 1
                     maxLength: 253
                 type: object
+                required:
+                - identity
+                - serverName
               ports:
                 type: array
                 description: ports describes a list of ports exposed by the
                   workload
                 items:
-                  type: object
                   properties:
                     name:
                       type: string
@@ -87,6 +89,9 @@ spec:
                         TCP. Defaults to TCP.
                       type: string
                       default: "TCP"
+                  type: object
+                  required:
+                  - port
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
@@ -98,8 +103,7 @@ spec:
                       type: string
             type: object
             required:
-            - ports
-            - workloadIPs
+            - meshTls
           status:
             type: object
             properties:
diff --git a/cli/cmd/endpoints.go b/cli/cmd/endpoints.go
index 1e835d918..a25238b00 100644
--- a/cli/cmd/endpoints.go
+++ b/cli/cmd/endpoints.go
@@ -37,6 +37,8 @@ type (
 		name    string
 		address string
 		ip      string
+		weight  uint32
+		labels  map[string]string
 	}
 )
 
@@ -200,6 +202,8 @@ func requestEndpointsFromAPI(client destinationPb.DestinationClient, authorities
 					name:    labels["pod"],
 					address: tcpAddr.String(),
 					ip:      getIP(tcpAddr),
+					weight:  addr.GetWeight(),
+					labels:  addr.GetMetricLabels(),
 				})
 			}
 		}
@@ -230,6 +234,9 @@ type rowEndpoint struct {
 	Port      uint32 `json:"port"`
 	Pod       string `json:"pod"`
 	Service   string `json:"service"`
+	Weight    uint32 `json:"weight"`
+
+	Labels map[string]string `json:"labels"`
 }
 
 func writeEndpointsToBuffer(endpoints endpointsInfo, w *tabwriter.Writer, options *endpointsOptions) {
@@ -255,6 +262,8 @@ func writeEndpointsToBuffer(endpoints endpointsInfo, w *tabwriter.Writer, option
 					Port:      port,
 					Pod:       name,
 					Service:   serviceID,
+					Weight:    pod.weight,
+					Labels:    pod.labels,
 				}
 
 				endpointsTables[namespace] = append(endpointsTables[namespace], row)
diff --git a/controller/api/destination/watcher/endpoints_watcher.go b/controller/api/destination/watcher/endpoints_watcher.go
index 63ad57b42..7ac2996a8 100644
--- a/controller/api/destination/watcher/endpoints_watcher.go
+++ b/controller/api/destination/watcher/endpoints_watcher.go
@@ -1182,6 +1182,7 @@ func (pp *portPublisher) unsubscribe(listener EndpointUpdateListener) {
 }
 
 func (pp *portPublisher) updateServer(server *v1beta1.Server, selector labels.Selector, isAdd bool) {
+	updated := false
 	for id, address := range pp.addresses.Addresses {
 		if address.Pod != nil && selector.Matches(labels.Set(address.Pod.Labels)) {
 			var portMatch bool
@@ -1207,12 +1208,18 @@ func (pp *portPublisher) updateServer(server *v1beta1.Server, selector labels.Se
 				} else {
 					address.OpaqueProtocol = false
 				}
-				pp.addresses.Addresses[id] = address
+				if pp.addresses.Addresses[id].OpaqueProtocol != address.OpaqueProtocol {
+					pp.addresses.Addresses[id] = address
+					updated = true
+				}
 			}
 		}
 	}
-	for _, listener := range pp.listeners {
-		listener.Add(pp.addresses)
+	if updated {
+		for _, listener := range pp.listeners {
+			listener.Add(pp.addresses)
+		}
+		pp.metrics.incUpdates()
 	}
 }
 
diff --git a/controller/gen/apis/externalworkload/v1alpha1/types.go b/controller/gen/apis/externalworkload/v1alpha1/types.go
index fae321dcc..719deee26 100644
--- a/controller/gen/apis/externalworkload/v1alpha1/types.go
+++ b/controller/gen/apis/externalworkload/v1alpha1/types.go
@@ -46,24 +46,26 @@ type ExternalWorkloadList struct {
 // ExternalWorkloadSpec represents the desired state of an external workload
 type ExternalWorkloadSpec struct {
 	// MeshTls describes TLS settings associated with an external workload
+	MeshTls MeshTls `json:"meshTls"`
+	// Ports describes a set of ports exposed by the workload
 	//
 	// +optional
-	MeshTls MeshTls `json:"meshTls,omitempty"`
-	// Ports describes a set of ports exposed by the workload
-	Ports []PortSpec `json:"ports"`
+	Ports []PortSpec `json:"ports,omitempty"`
 	// List of IP addresses that can be used to send traffic to an external
 	// workload
-	WorkloadIPs []WorkloadIP `json:"workloadIPs"`
+	//
+	// +optional
+	WorkloadIPs []WorkloadIP `json:"workloadIPs,omitempty"`
 }
 
 // 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"`
+	Identity string `json:"identity"`
 	// ServerName is the DNS formatted name associated with the workload. Used
 	// to terminate TLS using the SNI extension.
-	ServerName string `json:"serverName,omitempty"`
+	ServerName string `json:"serverName"`
 }
 
 // PortSpec represents a network port in a single workload.
diff --git a/go.mod b/go.mod
index 143b6aeb6..022eda022 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/evanphx/json-patch v5.7.0+incompatible
 	github.com/fatih/color v1.16.0
 	github.com/fsnotify/fsnotify v1.7.0
-	github.com/go-openapi/spec v0.20.13
+	github.com/go-openapi/spec v0.20.14
 	github.com/go-test/deep v1.1.0
 	github.com/golang/protobuf v1.5.3
 	github.com/gorilla/websocket v1.5.1
@@ -33,7 +33,7 @@ require (
 	github.com/spf13/cobra v1.8.0
 	github.com/spf13/pflag v1.0.5
 	go.opencensus.io v0.24.0
-	golang.org/x/net v0.19.0
+	golang.org/x/net v0.20.0
 	golang.org/x/tools v0.16.1
 	google.golang.org/grpc v1.60.1
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0
@@ -140,12 +140,12 @@ require (
 	go.opentelemetry.io/otel/metric v1.19.0 // indirect
 	go.opentelemetry.io/otel/trace v1.19.0 // indirect
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
-	golang.org/x/crypto v0.17.0 // indirect
+	golang.org/x/crypto v0.18.0 // indirect
 	golang.org/x/mod v0.14.0 // indirect
 	golang.org/x/oauth2 v0.13.0 // indirect
 	golang.org/x/sync v0.5.0 // indirect
-	golang.org/x/sys v0.15.0 // indirect
-	golang.org/x/term v0.15.0 // indirect
+	golang.org/x/sys v0.16.0 // indirect
+	golang.org/x/term v0.16.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
 	google.golang.org/api v0.143.0 // indirect
diff --git a/go.sum b/go.sum
index d9c9d6217..61fab1e87 100644
--- a/go.sum
+++ b/go.sum
@@ -154,8 +154,8 @@ github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbX
 github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
 github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
 github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
-github.com/go-openapi/spec v0.20.13 h1:XJDIN+dLH6vqXgafnl5SUIMnzaChQ6QTo0/UPMbkIaE=
-github.com/go-openapi/spec v0.20.13/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw=
+github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/Do=
+github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw=
 github.com/go-openapi/swag v0.22.6 h1:dnqg1XfHXL9aBxSbktBqFR5CxVyVI+7fYWhAf1JOeTw=
 github.com/go-openapi/swag v0.22.6/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -476,8 +476,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -537,8 +537,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
-golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -590,14 +590,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/policy-controller/k8s/api/src/external_workload.rs b/policy-controller/k8s/api/src/external_workload.rs
index 92cdf79c0..65ba1b17a 100644
--- a/policy-controller/k8s/api/src/external_workload.rs
+++ b/policy-controller/k8s/api/src/external_workload.rs
@@ -17,13 +17,13 @@ use serde::{Deserialize, Serialize};
 pub struct ExternalWorkloadSpec {
     /// MeshTls describes TLS settings associated with an external workload
     #[serde(rename = "meshTls")]
-    pub mesh_tls: Option<MeshTls>,
+    pub mesh_tls: MeshTls,
     /// Ports describes a set of ports exposed by the workload
-    pub ports: Vec<PortSpec>,
+    pub ports: Option<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>,
+    pub workload_ips: Option<Vec<WorkloadIP>>,
 }
 
 /// MeshTls describes TLS settings associated with an external workload
@@ -31,11 +31,11 @@ pub struct ExternalWorkloadSpec {
 pub struct MeshTls {
     /// Identity associated with the workload. Used by peers to perform
     /// verification in the mTLS handshake
-    pub identity: Option<String>,
+    pub identity: 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>,
+    pub server_name: String,
 }
 
 /// PortSpec represents a network port in a single workload.
@@ -54,17 +54,17 @@ pub struct PortSpec {
     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,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub struct ExternalWorkloadStatus {
+    pub conditions: Vec<Condition>,
+}
+
 /// WorkloadCondition represents the service state of an ExternalWorkload
 #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
diff --git a/policy-controller/k8s/api/src/policy/server.rs b/policy-controller/k8s/api/src/policy/server.rs
index 5c40f1459..82a68ec75 100644
--- a/policy-controller/k8s/api/src/policy/server.rs
+++ b/policy-controller/k8s/api/src/policy/server.rs
@@ -8,17 +8,26 @@ use std::{fmt, num::NonZeroU16};
 #[derive(Clone, Debug, PartialEq, Eq, CustomResource, Deserialize, Serialize, JsonSchema)]
 #[kube(
     group = "policy.linkerd.io",
-    version = "v1beta1",
+    version = "v1beta2",
     kind = "Server",
     namespaced
 )]
 #[serde(rename_all = "camelCase")]
 pub struct ServerSpec {
-    pub pod_selector: labels::Selector,
+    #[serde(flatten)]
+    pub selector: Selector,
     pub port: Port,
     pub proxy_protocol: Option<ProxyProtocol>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
+pub enum Selector {
+    #[serde(rename = "podSelector")]
+    Pod(labels::Selector),
+    #[serde(rename = "externalWorkloadSelector")]
+    ExternalWorkload(labels::Selector),
+}
+
 /// References a pod spec's port by name or number.
 #[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
 #[serde(untagged)]
diff --git a/policy-controller/k8s/index/src/inbound/index.rs b/policy-controller/k8s/index/src/inbound/index.rs
index 576d92d34..8b94c1c49 100644
--- a/policy-controller/k8s/index/src/inbound/index.rs
+++ b/policy-controller/k8s/index/src/inbound/index.rs
@@ -25,7 +25,9 @@ use linkerd_policy_controller_core::{
     },
     IdentityMatch, Ipv4Net, Ipv6Net, NetworkMatch,
 };
-use linkerd_policy_controller_k8s_api::{self as k8s, policy::server::Port, ResourceExt};
+use linkerd_policy_controller_k8s_api::{
+    self as k8s, policy::server::Port, policy::server::Selector, ResourceExt,
+};
 use parking_lot::RwLock;
 use std::{
     collections::{hash_map::Entry, BTreeSet},
@@ -924,34 +926,36 @@ impl Pod {
         );
 
         for (srvname, server) in policy.servers.iter() {
-            if server.pod_selector.matches(&self.meta.labels) {
-                for port in self.select_ports(&server.port_ref).into_iter() {
-                    // If the port is already matched to a server, then log a warning and skip
-                    // updating it so it doesn't flap between servers.
-                    if let Some(prior) = matched_ports.get(&port) {
-                        tracing::warn!(
-                            port = %port,
-                            server = %prior,
-                            conflict = %srvname,
-                            "Port already matched by another server; skipping"
-                        );
-                        continue;
-                    }
+            if let Selector::Pod(pod_selector) = &server.selector {
+                if pod_selector.matches(&self.meta.labels) {
+                    for port in self.select_ports(&server.port_ref).into_iter() {
+                        // If the port is already matched to a server, then log a warning and skip
+                        // updating it so it doesn't flap between servers.
+                        if let Some(prior) = matched_ports.get(&port) {
+                            tracing::warn!(
+                                port = %port,
+                                server = %prior,
+                                conflict = %srvname,
+                                "Port already matched by another server; skipping"
+                            );
+                            continue;
+                        }
 
-                    let s = policy.inbound_server(
-                        srvname.clone(),
-                        server,
-                        authentications,
-                        self.probes
-                            .get(&port)
-                            .into_iter()
-                            .flatten()
-                            .map(|p| p.as_str()),
-                    );
-                    self.update_server(port, srvname, s);
+                        let s = policy.inbound_server(
+                            srvname.clone(),
+                            server,
+                            authentications,
+                            self.probes
+                                .get(&port)
+                                .into_iter()
+                                .flatten()
+                                .map(|p| p.as_str()),
+                        );
+                        self.update_server(port, srvname, s);
 
-                    matched_ports.insert(port, srvname.clone());
-                    unmatched_ports.remove(&port);
+                        matched_ports.insert(port, srvname.clone());
+                        unmatched_ports.remove(&port);
+                    }
                 }
             }
         }
diff --git a/policy-controller/k8s/index/src/inbound/server.rs b/policy-controller/k8s/index/src/inbound/server.rs
index 67ca821b6..d017655e0 100644
--- a/policy-controller/k8s/index/src/inbound/server.rs
+++ b/policy-controller/k8s/index/src/inbound/server.rs
@@ -1,12 +1,14 @@
 use crate::ClusterInfo;
 use linkerd_policy_controller_core::inbound::ProxyProtocol;
-use linkerd_policy_controller_k8s_api::{self as k8s, policy::server::Port};
+use linkerd_policy_controller_k8s_api::{
+    self as k8s, policy::server::Port, policy::server::Selector,
+};
 
 /// The parts of a `Server` resource that can change.
 #[derive(Debug, PartialEq)]
 pub(crate) struct Server {
     pub labels: k8s::Labels,
-    pub pod_selector: k8s::labels::Selector,
+    pub selector: Selector,
     pub port_ref: Port,
     pub protocol: ProxyProtocol,
 }
@@ -15,7 +17,7 @@ impl Server {
     pub(crate) fn from_resource(srv: k8s::policy::Server, cluster: &ClusterInfo) -> Self {
         Self {
             labels: srv.metadata.labels.into(),
-            pod_selector: srv.spec.pod_selector,
+            selector: srv.spec.selector,
             port_ref: srv.spec.port,
             protocol: proxy_protocol(srv.spec.proxy_protocol, cluster),
         }
diff --git a/policy-controller/src/admission.rs b/policy-controller/src/admission.rs
index 63eb41cf2..06ceafbb3 100644
--- a/policy-controller/src/admission.rs
+++ b/policy-controller/src/admission.rs
@@ -1,12 +1,9 @@
 use super::validation;
-use crate::k8s::{
-    labels,
-    policy::{
-        httproute, AuthorizationPolicy, AuthorizationPolicySpec, HttpRoute, HttpRouteSpec,
-        LocalTargetRef, MeshTLSAuthentication, MeshTLSAuthenticationSpec, NamespacedTargetRef,
-        NetworkAuthentication, NetworkAuthenticationSpec, Server, ServerAuthorization,
-        ServerAuthorizationSpec, ServerSpec,
-    },
+use crate::k8s::policy::{
+    httproute, server::Selector, AuthorizationPolicy, AuthorizationPolicySpec, HttpRoute,
+    HttpRouteSpec, LocalTargetRef, MeshTLSAuthentication, MeshTLSAuthenticationSpec,
+    NamespacedTargetRef, NetworkAuthentication, NetworkAuthenticationSpec, Server,
+    ServerAuthorization, ServerAuthorizationSpec, ServerSpec,
 };
 use anyhow::{anyhow, bail, ensure, Result};
 use futures::future;
@@ -328,7 +325,7 @@ impl Validate<ServerSpec> for Admission {
             let server_name = server.name_unchecked();
             if server_name != name
                 && server.spec.port == spec.port
-                && Self::overlaps(&server.spec.pod_selector, &spec.pod_selector)
+                && Self::overlaps(&server.spec.selector, &spec.selector)
             {
                 let server_ns = server.namespace();
                 let server_ns = server_ns.as_deref().unwrap_or("default");
@@ -351,9 +348,19 @@ impl Admission {
     // example, if `left` selects pods with 'foo=bar' and `right` selects pods with
     // 'foo', we should indicate the selectors overlap. It's a bit tricky to work
     // through all of the cases though, so we'll just punt for now.
-    fn overlaps(left: &labels::Selector, right: &labels::Selector) -> bool {
-        if left.selects_all() || right.selects_all() {
-            return true;
+    fn overlaps(left: &Selector, right: &Selector) -> bool {
+        match (left, right) {
+            (Selector::Pod(ps_left), Selector::Pod(ps_right)) => {
+                if ps_left.selects_all() || ps_right.selects_all() {
+                    return true;
+                }
+            }
+            (Selector::ExternalWorkload(et_left), Selector::ExternalWorkload(et_right)) => {
+                if et_left.selects_all() || et_right.selects_all() {
+                    return true;
+                }
+            }
+            (_, _) => return false,
         }
 
         left == right
diff --git a/web/app/package.json b/web/app/package.json
index 3798e8dd6..b5b1d81d6 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -22,7 +22,7 @@
     "d3-force": "3.0.0",
     "d3-format": "3.1.0",
     "d3-selection": "3.0.0",
-    "date-fns": "3.0.6",
+    "date-fns": "3.1.0",
     "jest-progress-bar-reporter": "^1.0.25",
     "locales-detector": "3.0.2",
     "lodash": "4.17.21",
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 493933218..7f6c6f72d 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -3440,10 +3440,10 @@ data-urls@^3.0.2:
     whatwg-mimetype "^3.0.0"
     whatwg-url "^11.0.0"
 
-date-fns@3.0.6:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.0.6.tgz#fe3aeb7592d359f075ffc41cb16139828810ca83"
-  integrity sha512-W+G99rycpKMMF2/YD064b2lE7jJGUe+EjOES7Q8BIGY8sbNdbgcs9XFTZwvzc9Jx1f3k7LB7gZaZa7f8Agzljg==
+date-fns@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.1.0.tgz#576df8df93202942aeebd49ecc042218feb40426"
+  integrity sha512-ZO7yefXV/wCWzd3I9haCHmfzlfA3i1a2HHO7ZXjtJrRjXt8FULKJ2Vl8wji3XYF4dQ0ZJ/tokXDZeYlFvgms9Q==
 
 date-fns@^2.16.1:
   version "2.30.0"
@@ -4651,9 +4651,9 @@ flatted@^3.1.0:
   integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
 
 follow-redirects@^1.0.0:
-  version "1.14.8"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
-  integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
+  version "1.15.4"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
+  integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
 
 for-each@^0.3.3:
   version "0.3.3"


Test patch:
diff --git a/cli/cmd/endpoints_test.go b/cli/cmd/endpoints_test.go
index 28acfba77..e30e90aa7 100644
--- a/cli/cmd/endpoints_test.go
+++ b/cli/cmd/endpoints_test.go
@@ -116,6 +116,8 @@ func TestEndpoints(t *testing.T) {
 }
 
 func testEndpointsCall(exp endpointsExp, t *testing.T) {
+	t.Helper()
+
 	updates := make([]pb.Update, 0)
 	for _, endpoint := range exp.endpoints {
 		addrSet := util.BuildAddrSet(endpoint)
diff --git a/cli/cmd/testdata/endpoints_one_output_json.golden b/cli/cmd/testdata/endpoints_one_output_json.golden
index 669a03893..912a7da56 100644
--- a/cli/cmd/testdata/endpoints_one_output_json.golden
+++ b/cli/cmd/testdata/endpoints_one_output_json.golden
@@ -4,13 +4,21 @@
     "ip": "1.2.3.4",
     "port": 8080,
     "pod": "emoji-6bf9f47bd5-jjcrl",
-    "service": "emoji-svc.emojivoto"
+    "service": "emoji-svc.emojivoto",
+    "weight": 0,
+    "labels": {
+      "pod": "emoji-6bf9f47bd5-jjcrl"
+    }
   },
   {
     "namespace": "emojivoto",
     "ip": "5.6.7.8",
     "port": 8080,
     "pod": "voting-7bf9f47bd5-jjdrl",
-    "service": "voting-svc.emojivoto"
+    "service": "voting-svc.emojivoto",
+    "weight": 0,
+    "labels": {
+      "pod": "voting-7bf9f47bd5-jjdrl"
+    }
   }
 ]
diff --git a/cli/cmd/testdata/install_crds.golden b/cli/cmd/testdata/install_crds.golden
index 9632fe2c7..24aba5bb9 100644
--- a/cli/cmd/testdata/install_crds.golden
+++ b/cli/cmd/testdata/install_crds.golden
@@ -5793,7 +5793,9 @@ spec:
                   default: unknown
     - name: v1beta1
       served: true
-      storage: true
+      storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta2 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -5851,6 +5853,94 @@ spec:
         type: string
         description: The protocol of the server
         jsonPath: .spec.proxyProtocol
+    - name: v1beta2
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          required: [spec]
+          properties:
+            spec:
+              type: object
+              required:
+                - port
+              oneOf:
+                - required: [podSelector]
+                - required: [externalWorkloadSelector]
+              properties:
+                podSelector:
+                  type: object
+                  description: >-
+                    Selects pods in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                externalWorkloadSelector:
+                  type: object
+                  description: >-
+                    Selects ExternalWorkloads in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                port:
+                  description: >-
+                    A port name or number. Must exist in a pod spec.
+                  x-kubernetes-int-or-string: true
+                proxyProtocol:
+                  description: >-
+                    Configures protocol discovery for inbound connections.
+
+                    Supersedes the `config.linkerd.io/opaque-ports` annotation.
+                  type: string
+                  default: unknown
+      additionalPrinterColumns:
+      - name: Port
+        type: string
+        description: The port the server is listening on
+        jsonPath: .spec.port
+      - name: Protocol
+        type: string
+        description: The protocol of the server
+        jsonPath: .spec.proxyProtocol
 ---
 ###
 ### Service Profile CRD
@@ -10203,12 +10293,14 @@ spec:
                     minLength: 1
                     maxLength: 253
                 type: object
+                required:
+                - identity
+                - serverName
               ports:
                 type: array
                 description: ports describes a list of ports exposed by the
                   workload
                 items:
-                  type: object
                   properties:
                     name:
                       type: string
@@ -10225,6 +10317,9 @@ spec:
                         TCP. Defaults to TCP.
                       type: string
                       default: "TCP"
+                  type: object
+                  required:
+                  - port
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
@@ -10236,8 +10331,7 @@ spec:
                       type: string
             type: object
             required:
-            - ports
-            - workloadIPs
+            - meshTls
           status:
             type: object
             properties:
diff --git a/cli/cmd/testdata/install_helm_crds_output.golden b/cli/cmd/testdata/install_helm_crds_output.golden
index ee66ee8f1..050b51875 100644
--- a/cli/cmd/testdata/install_helm_crds_output.golden
+++ b/cli/cmd/testdata/install_helm_crds_output.golden
@@ -5805,7 +5805,9 @@ spec:
                   default: unknown
     - name: v1beta1
       served: true
-      storage: true
+      storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta2 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -5863,6 +5865,94 @@ spec:
         type: string
         description: The protocol of the server
         jsonPath: .spec.proxyProtocol
+    - name: v1beta2
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          required: [spec]
+          properties:
+            spec:
+              type: object
+              required:
+                - port
+              oneOf:
+                - required: [podSelector]
+                - required: [externalWorkloadSelector]
+              properties:
+                podSelector:
+                  type: object
+                  description: >-
+                    Selects pods in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                externalWorkloadSelector:
+                  type: object
+                  description: >-
+                    Selects ExternalWorkloads in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                port:
+                  description: >-
+                    A port name or number. Must exist in a pod spec.
+                  x-kubernetes-int-or-string: true
+                proxyProtocol:
+                  description: >-
+                    Configures protocol discovery for inbound connections.
+
+                    Supersedes the `config.linkerd.io/opaque-ports` annotation.
+                  type: string
+                  default: unknown
+      additionalPrinterColumns:
+      - name: Port
+        type: string
+        description: The port the server is listening on
+        jsonPath: .spec.port
+      - name: Protocol
+        type: string
+        description: The protocol of the server
+        jsonPath: .spec.proxyProtocol
 ---
 # Source: linkerd-crds/templates/serviceprofile.yaml
 ---
@@ -10221,12 +10311,14 @@ spec:
                     minLength: 1
                     maxLength: 253
                 type: object
+                required:
+                - identity
+                - serverName
               ports:
                 type: array
                 description: ports describes a list of ports exposed by the
                   workload
                 items:
-                  type: object
                   properties:
                     name:
                       type: string
@@ -10243,6 +10335,9 @@ spec:
                         TCP. Defaults to TCP.
                       type: string
                       default: "TCP"
+                  type: object
+                  required:
+                  - port
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
@@ -10254,8 +10349,7 @@ spec:
                       type: string
             type: object
             required:
-            - ports
-            - workloadIPs
+            - meshTls
           status:
             type: object
             properties:
diff --git a/cli/cmd/testdata/install_helm_crds_output_ha.golden b/cli/cmd/testdata/install_helm_crds_output_ha.golden
index ee66ee8f1..050b51875 100644
--- a/cli/cmd/testdata/install_helm_crds_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_crds_output_ha.golden
@@ -5805,7 +5805,9 @@ spec:
                   default: unknown
     - name: v1beta1
       served: true
-      storage: true
+      storage: false
+      deprecated: true
+      deprecationWarning: "policy.linkerd.io/v1alpha1 Server is deprecated; use policy.linkerd.io/v1beta2 Server"
       schema:
         openAPIV3Schema:
           type: object
@@ -5863,6 +5865,94 @@ spec:
         type: string
         description: The protocol of the server
         jsonPath: .spec.proxyProtocol
+    - name: v1beta2
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          required: [spec]
+          properties:
+            spec:
+              type: object
+              required:
+                - port
+              oneOf:
+                - required: [podSelector]
+                - required: [externalWorkloadSelector]
+              properties:
+                podSelector:
+                  type: object
+                  description: >-
+                    Selects pods in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                externalWorkloadSelector:
+                  type: object
+                  description: >-
+                    Selects ExternalWorkloads in the same namespace.
+
+                    The result of matchLabels and matchExpressions are ANDed.
+                    Selects all if empty.
+                  properties:
+                    matchLabels:
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    matchExpressions:
+                      type: array
+                      items:
+                        type: object
+                        required: [key, operator]
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                            enum: [In, NotIn, Exists, DoesNotExist]
+                          values:
+                            type: array
+                            items:
+                              type: string
+                port:
+                  description: >-
+                    A port name or number. Must exist in a pod spec.
+                  x-kubernetes-int-or-string: true
+                proxyProtocol:
+                  description: >-
+                    Configures protocol discovery for inbound connections.
+
+                    Supersedes the `config.linkerd.io/opaque-ports` annotation.
+                  type: string
+                  default: unknown
+      additionalPrinterColumns:
+      - name: Port
+        type: string
+        description: The port the server is listening on
+        jsonPath: .spec.port
+      - name: Protocol
+        type: string
+        description: The protocol of the server
+        jsonPath: .spec.proxyProtocol
 ---
 # Source: linkerd-crds/templates/serviceprofile.yaml
 ---
@@ -10221,12 +10311,14 @@ spec:
                     minLength: 1
                     maxLength: 253
                 type: object
+                required:
+                - identity
+                - serverName
               ports:
                 type: array
                 description: ports describes a list of ports exposed by the
                   workload
                 items:
-                  type: object
                   properties:
                     name:
                       type: string
@@ -10243,6 +10335,9 @@ spec:
                         TCP. Defaults to TCP.
                       type: string
                       default: "TCP"
+                  type: object
+                  required:
+                  - port
               workloadIPs:
                 type: array
                 description: workloadIPs contains a list of IP addresses that
@@ -10254,8 +10349,7 @@ spec:
                       type: string
             type: object
             required:
-            - ports
-            - workloadIPs
+            - meshTls
           status:
             type: object
             properties:
diff --git a/policy-controller/k8s/index/src/inbound/tests.rs b/policy-controller/k8s/index/src/inbound/tests.rs
index 21e7b176e..fec468d5d 100644
--- a/policy-controller/k8s/index/src/inbound/tests.rs
+++ b/policy-controller/k8s/index/src/inbound/tests.rs
@@ -118,7 +118,7 @@ fn mk_server(
         },
         spec: k8s::policy::ServerSpec {
             port,
-            pod_selector: pod_labels.into_iter().collect(),
+            selector: k8s::policy::server::Selector::Pod(pod_labels.into_iter().collect()),
             proxy_protocol,
         },
     }
diff --git a/policy-controller/k8s/status/src/tests/http_routes.rs b/policy-controller/k8s/status/src/tests/http_routes.rs
index 6f43697a2..468b967cf 100644
--- a/policy-controller/k8s/status/src/tests/http_routes.rs
+++ b/policy-controller/k8s/status/src/tests/http_routes.rs
@@ -172,7 +172,7 @@ fn make_server(
         },
         spec: k8s::policy::ServerSpec {
             port,
-            pod_selector: pod_labels.into_iter().collect(),
+            selector: k8s::policy::server::Selector::Pod(pod_labels.into_iter().collect()),
             proxy_protocol,
         },
     }
diff --git a/policy-test/src/web.rs b/policy-test/src/web.rs
index 0230de582..6b86bd247 100644
--- a/policy-test/src/web.rs
+++ b/policy-test/src/web.rs
@@ -41,7 +41,9 @@ pub fn server(ns: &str) -> k8s::policy::Server {
             ..Default::default()
         },
         spec: k8s::policy::ServerSpec {
-            pod_selector: k8s::labels::Selector::from_iter(Some(("app", "web"))),
+            selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::from_iter(Some((
+                "app", "web",
+            )))),
             port: k8s::policy::server::Port::Name("http".to_string()),
             proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
         },
diff --git a/policy-test/tests/admit_server.rs b/policy-test/tests/admit_server.rs
index 7f3270c1f..d8dcb34cd 100644
--- a/policy-test/tests/admit_server.rs
+++ b/policy-test/tests/admit_server.rs
@@ -1,6 +1,6 @@
 use linkerd_policy_controller_k8s_api::{
-    self as api, labels,
-    policy::server::{Port, ProxyProtocol, Server, ServerSpec},
+    self as api,
+    policy::server::{Port, ProxyProtocol, Selector, Server, ServerSpec},
 };
 use linkerd_policy_test::{admission, with_temp_ns};
 
@@ -13,7 +13,7 @@ async fn accepts_valid() {
             ..Default::default()
         },
         spec: ServerSpec {
-            pod_selector: api::labels::Selector::default(),
+            selector: Selector::Pod(api::labels::Selector::default()),
             port: Port::Number(80.try_into().unwrap()),
             proxy_protocol: None,
         },
@@ -31,7 +31,7 @@ async fn accepts_server_updates() {
                 ..Default::default()
             },
             spec: ServerSpec {
-                pod_selector: api::labels::Selector::from_iter(Some(("app", "test"))),
+                selector: Selector::Pod(api::labels::Selector::from_iter(Some(("app", "test")))),
                 port: Port::Number(80.try_into().unwrap()),
                 proxy_protocol: None,
             },
@@ -57,7 +57,7 @@ async fn accepts_server_updates() {
 async fn rejects_identitical_pod_selector() {
     with_temp_ns(|client, ns| async move {
         let spec = ServerSpec {
-            pod_selector: api::labels::Selector::from_iter(Some(("app", "test"))),
+            selector: Selector::Pod(api::labels::Selector::from_iter(Some(("app", "test")))),
             port: Port::Number(80.try_into().unwrap()),
             proxy_protocol: None,
         };
@@ -103,7 +103,7 @@ async fn rejects_all_pods_selected() {
                 ..Default::default()
             },
             spec: ServerSpec {
-                pod_selector: api::labels::Selector::from_iter(Some(("app", "test"))),
+                selector: Selector::Pod(api::labels::Selector::from_iter(Some(("app", "test")))),
                 port: Port::Number(80.try_into().unwrap()),
                 proxy_protocol: Some(ProxyProtocol::Http2),
             },
@@ -119,7 +119,7 @@ async fn rejects_all_pods_selected() {
                 ..Default::default()
             },
             spec: ServerSpec {
-                pod_selector: api::labels::Selector::default(),
+                selector: Selector::Pod(api::labels::Selector::default()),
                 port: Port::Number(80.try_into().unwrap()),
                 // proxy protocol doesn't factor into the selection
                 proxy_protocol: Some(ProxyProtocol::Http1),
@@ -151,7 +151,8 @@ async fn rejects_invalid_proxy_protocol() {
     )]
     #[serde(rename_all = "camelCase")]
     pub struct ServerSpec {
-        pub pod_selector: labels::Selector,
+        #[serde(flatten)]
+        pub selector: Selector,
         pub port: Port,
         pub proxy_protocol: String,
     }
@@ -171,7 +172,7 @@ async fn rejects_invalid_proxy_protocol() {
             ..Default::default()
         },
         spec: ServerSpec {
-            pod_selector: api::labels::Selector::default(),
+            selector: Selector::Pod(api::labels::Selector::default()),
             port: Port::Number(80.try_into().unwrap()),
             proxy_protocol: "garbanzo".to_string(),
         },
diff --git a/policy-test/tests/inbound_api.rs b/policy-test/tests/inbound_api.rs
index 12e5008e0..dd15b762d 100644
--- a/policy-test/tests/inbound_api.rs
+++ b/policy-test/tests/inbound_api.rs
@@ -630,7 +630,7 @@ fn mk_admin_server(ns: &str, name: &str) -> k8s::policy::Server {
             ..Default::default()
         },
         spec: k8s::policy::ServerSpec {
-            pod_selector: k8s::labels::Selector::default(),
+            selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::default()),
             port: k8s::policy::server::Port::Number(4191.try_into().unwrap()),
             proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
         },
diff --git a/policy-test/tests/inbound_http_route_status.rs b/policy-test/tests/inbound_http_route_status.rs
index e461e212d..102a73119 100644
--- a/policy-test/tests/inbound_http_route_status.rs
+++ b/policy-test/tests/inbound_http_route_status.rs
@@ -16,7 +16,9 @@ async fn inbound_accepted_parent() {
                 ..Default::default()
             },
             spec: k8s::policy::ServerSpec {
-                pod_selector: k8s::labels::Selector::from_iter(Some(("app", server_name))),
+                selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::from_iter(
+                    Some(("app", server_name)),
+                )),
                 port: k8s::policy::server::Port::Name("http".to_string()),
                 proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
             },
@@ -91,7 +93,9 @@ async fn inbound_multiple_parents() {
                 ..Default::default()
             },
             spec: k8s::policy::ServerSpec {
-                pod_selector: k8s::labels::Selector::from_iter(Some(("app", "test-valid-server"))),
+                selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::from_iter(
+                    Some(("app", "test-valid-server")),
+                )),
                 port: k8s::policy::server::Port::Name("http".to_string()),
                 proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
             },
@@ -182,7 +186,9 @@ async fn inbound_accepted_reconcile_no_parent() {
                 ..Default::default()
             },
             spec: k8s::policy::ServerSpec {
-                pod_selector: k8s::labels::Selector::from_iter(Some(("app", server_name))),
+                selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::from_iter(
+                    Some(("app", server_name)),
+                )),
                 port: k8s::policy::server::Port::Name("http".to_string()),
                 proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
             },
@@ -229,7 +235,9 @@ async fn inbound_accepted_reconcile_parent_delete() {
                 ..Default::default()
             },
             spec: k8s::policy::ServerSpec {
-                pod_selector: k8s::labels::Selector::from_iter(Some(("app", server_name))),
+                selector: k8s::policy::server::Selector::Pod(k8s::labels::Selector::from_iter(
+                    Some(("app", server_name)),
+                )),
                 port: k8s::policy::server::Port::Name("http".to_string()),
                 proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
             },
diff --git a/test/integration/deep/endpoints/endpoints_test.go b/test/integration/deep/endpoints/endpoints_test.go
index 88f6e6bb6..da34dd418 100644
--- a/test/integration/deep/endpoints/endpoints_test.go
+++ b/test/integration/deep/endpoints/endpoints_test.go
@@ -110,8 +110,10 @@ func createTestCaseTable(controlNs, endpointNs string) []testCase {
     "namespace": "(\S*)",
     "ip": "\d+\.\d+\.\d+\.\d+",
     "port": 8086,
-    "pod": "linkerd\-destination\-[a-f0-9]+\-[a-z0-9]+",
-    "service": "linkerd\-dst\.\S*"
+    "pod": "linkerd-destination\-[a-f0-9]+\-[a-z0-9]+",
+    "service": "linkerd-dst\.\S*",
+    "weight": \d+,
+    "labels": \{(?s).*\}
   \}
 \]`,
 			ns: controlNs,
@@ -124,8 +126,10 @@ func createTestCaseTable(controlNs, endpointNs string) []testCase {
     "namespace": "(\S*)",
     "ip": "\d+\.\d+\.\d+\.\d+",
     "port": 8080,
-    "pod": "linkerd\-identity\-[a-f0-9]+\-[a-z0-9]+",
-    "service": "linkerd\-identity\.\S*"
+    "pod": "linkerd-identity\-[a-f0-9]+\-[a-z0-9]+",
+    "service": "linkerd-identity\.\S*",
+    "weight": \d+,
+    "labels": \{(?s).*\}
   \}
 \]`,
 			ns: controlNs,
@@ -138,8 +142,10 @@ func createTestCaseTable(controlNs, endpointNs string) []testCase {
     "namespace": "(\S*)",
     "ip": "\d+\.\d+\.\d+\.\d+",
     "port": 8443,
-    "pod": "linkerd\-proxy\-injector-[a-f0-9]+\-[a-z0-9]+",
-    "service": "linkerd\-proxy\-injector\.\S*"
+    "pod": "linkerd-proxy-injector-[a-f0-9]+\-[a-z0-9]+",
+    "service": "linkerd-proxy-injector\.\S*",
+    "weight": \d+,
+    "labels": \{(?s).*\}
   \}
 \]`,
 			ns: controlNs,
@@ -152,8 +158,10 @@ func createTestCaseTable(controlNs, endpointNs string) []testCase {
     "namespace": "(\S*)",
     "ip": "\d+\.\d+\.\d+\.\d+",
     "port": 8080,
-    "pod": "nginx\-[a-f0-9]+\-[a-z0-9]+",
-    "service": "nginx\.\S*"
+    "pod": "nginx-[a-f0-9]+\-[a-z0-9]+",
+    "service": "nginx\.\S*",
+    "weight": \d+,
+    "labels": \{(?s).*\}
   \}
 \]`,
 			ns: endpointNs,