Repository: linkerd/linkerd2
Pull request #11883 (2024-01-05 03:44:43)

build(deps): bump ahash from 0.8.6 to 0.8.7
Bumps [ahash](https://github.com/tkaitchuck/ahash) from 0.8.6 to 0.8.7.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a href="https://github.com/tkaitchuck/ahash/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ahash&package-manager=cargo&previous-version=0.8.6&new-version=0.8.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 91080e27c..3252c5b75 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -25,9 +25,9 @@ checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
 
 [[package]]
 name = "ahash"
-version = "0.8.6"
+version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
+checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01"
 dependencies = [
  "cfg-if",
  "getrandom",
@@ -826,9 +826,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "1.9.2"
+version = "1.9.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
 dependencies = [
  "autocfg",
  "hashbrown 0.12.3",
@@ -1181,6 +1181,7 @@ dependencies = [
  "linkerd-policy-controller-k8s-status",
  "openssl",
  "parking_lot",
+ "regex",
  "serde",
  "serde_json",
  "thiserror",
@@ -1564,9 +1565,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
 
 [[package]]
 name = "pest"
-version = "2.7.5"
+version = "2.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae9cee2a55a544be8b89dc6848072af97a20f2422603c10865be2a42b580fff5"
+checksum = "1f200d8d83c44a45b21764d1916299752ca035d15ecd46faca3e9a2a2bf6ad06"
 dependencies = [
  "memchr",
  "thiserror",
@@ -2391,7 +2392,7 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
 dependencies = [
  "futures-core",
  "futures-util",
- "indexmap 1.9.2",
+ "indexmap 1.9.3",
  "pin-project",
  "pin-project-lite",
  "rand",
diff --git a/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml b/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
index 6840d5ee6..58ee815f5 100644
--- a/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
+++ b/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
@@ -49,7 +49,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
diff --git a/charts/linkerd-crds/templates/workload/external-workload.yaml b/charts/linkerd-crds/templates/workload/external-workload.yaml
new file mode 100644
index 000000000..056d6a275
--- /dev/null
+++ b/charts/linkerd-crds/templates/workload/external-workload.yaml
@@ -0,0 +1,157 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    {{ include "partials.annotations.created-by" . }}
+  labels:
+    helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+    linkerd.io/control-plane-ns: {{.Release.Namespace}}
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/install.go b/cli/cmd/install.go
index 0c0e4a881..90e46b636 100644
--- a/cli/cmd/install.go
+++ b/cli/cmd/install.go
@@ -55,6 +55,7 @@ var (
 		"templates/policy/server.yaml",
 		"templates/serviceprofile.yaml",
 		"templates/gateway.networking.k8s.io_httproutes.yaml",
+		"templates/workload/external-workload.yaml",
 	}
 
 	templatesControlPlane = []string{
diff --git a/policy-controller/Cargo.toml b/policy-controller/Cargo.toml
index 7579db45a..188f3a8bf 100644
--- a/policy-controller/Cargo.toml
+++ b/policy-controller/Cargo.toml
@@ -33,6 +33,7 @@ serde_json = "1"
 thiserror = "1"
 tokio-stream = { version = "0.1", features = ["sync"] }
 tracing = "0.1"
+regex = "1"
 
 [dependencies.clap]
 version = "4"
diff --git a/policy-controller/src/admission.rs b/policy-controller/src/admission.rs
index 09515126b..63eb41cf2 100644
--- a/policy-controller/src/admission.rs
+++ b/policy-controller/src/admission.rs
@@ -1,3 +1,4 @@
+use super::validation;
 use crate::k8s::{
     labels,
     policy::{
@@ -296,7 +297,12 @@ fn validate_identity_ref(id: &NamespacedTargetRef) -> Result<()> {
 #[async_trait::async_trait]
 impl Validate<MeshTLSAuthenticationSpec> for Admission {
     async fn validate(self, _ns: &str, _name: &str, spec: MeshTLSAuthenticationSpec) -> Result<()> {
-        // The CRD validates identity strings, but does not validate identity references.
+        for id in spec.identities.iter().flatten() {
+            if let Err(err) = validation::validate_identity(id) {
+                bail!("id {} is invalid: {}", id, err);
+            }
+        }
+
         for id in spec.identity_refs.iter().flatten() {
             validate_identity_ref(id)?;
         }
diff --git a/policy-controller/src/lib.rs b/policy-controller/src/lib.rs
index 4fbff7360..8efbb1cb9 100644
--- a/policy-controller/src/lib.rs
+++ b/policy-controller/src/lib.rs
@@ -2,6 +2,7 @@
 #![forbid(unsafe_code)]
 mod admission;
 pub mod index_list;
+mod validation;
 pub use self::admission::Admission;
 use anyhow::Result;
 use linkerd_policy_controller_core::inbound::{
diff --git a/policy-controller/src/validation.rs b/policy-controller/src/validation.rs
new file mode 100644
index 000000000..c84840268
--- /dev/null
+++ b/policy-controller/src/validation.rs
@@ -0,0 +1,245 @@
+use anyhow::Result;
+use thiserror::Error;
+
+use regex::Regex;
+
+const SCHEME_PREFIX: &str = "spiffe://";
+const VALID_TRUST_DOMAIN_CHARS: &str = "abcdefghijklmnopqrstuvwxyz0123456789-._";
+const VALID_PATH_SEGMENT_CHARS: &str =
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._";
+
+const DNS_LIKE_IDENTITY_REGEX: &str =
+    r"^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$";
+
+#[derive(Debug, Error, PartialEq, Clone)]
+pub enum IdError {
+    /// The trust domain name of SPIFFE ID cannot be empty.
+    #[error("SPIFFE trust domain is missing")]
+    MissingTrustDomain,
+
+    /// A trust domain name can only contain chars in a limited char set.
+    #[error(
+        "SPIFFE trust domain characters are limited to lowercase letters, numbers, dots, dashes, and \
+         underscores"
+    )]
+    BadTrustDomainChar,
+
+    /// A path segment can only contain chars in a limited char set.
+    #[error(
+        "SPIFFE path segment characters are limited to letters, numbers, dots, dashes, and underscores"
+    )]
+    BadPathSegmentChar,
+
+    /// Path cannot contain empty segments, e.g '//'
+    #[error("SPIFFE path cannot contain empty segments")]
+    EmptySegment,
+
+    /// Path cannot contain dot segments, e.g '/.', '/..'
+    #[error("SPIFFE path cannot contain dot segments")]
+    DotSegment,
+
+    /// Path cannot have a trailing slash.
+    #[error("SPIFFE path cannot have a trailing slash")]
+    TrailingSlash,
+
+    #[error(
+        "identity must be a valid SPIFFE id or a DNS SAN, matching the regex: {}",
+        DNS_LIKE_IDENTITY_REGEX
+    )]
+    Invalid,
+}
+
+// Validates that an ID is either in DNS or SPIFFE form. SPIFFE
+// validation is based on https://github.com/spiffe/spiffe/blob/27b59b81ba8c56885ac5d4be73b35b9b3305fd7a/standards/SPIFFE-ID.md.
+// Implementation is based on: https://github.com/maxlambrecht/rust-spiffe/blob/3d3614f70d0d7a4b9190ab9650e224f2ac362368/spiffe/src/spiffe_id/mod.rs
+pub(crate) fn validate_identity(id: &str) -> Result<(), IdError> {
+    if let Some(rest) = id.strip_prefix(SCHEME_PREFIX) {
+        let i = rest.find('/').unwrap_or(rest.len());
+
+        if i == 0 {
+            return Err(IdError::MissingTrustDomain);
+        }
+
+        let td = &rest[..i];
+        if td.chars().any(|c| !VALID_TRUST_DOMAIN_CHARS.contains(c)) {
+            return Err(IdError::BadTrustDomainChar);
+        }
+
+        let path = &rest[i..];
+
+        if !path.is_empty() {
+            validate_path(path)?;
+        }
+
+        Ok(())
+    } else {
+        let regex = Regex::new(DNS_LIKE_IDENTITY_REGEX).expect("should_compile");
+        if !regex.is_match(id) {
+            return Err(IdError::Invalid);
+        }
+        Ok(())
+    }
+}
+
+/// Validates that a path string is a conformant path for a SPIFFE ID.
+/// See https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#22-path
+fn validate_path(path: &str) -> Result<(), IdError> {
+    let chars = path.char_indices().peekable();
+    let mut segment_start = 0;
+
+    for (idx, c) in chars {
+        if c == '/' {
+            match &path[segment_start..idx] {
+                "/" => return Err(IdError::EmptySegment),
+                "/." | "/.." => return Err(IdError::DotSegment),
+                _ => {}
+            }
+            segment_start = idx;
+            continue;
+        }
+
+        if !VALID_PATH_SEGMENT_CHARS.contains(c) {
+            return Err(IdError::BadPathSegmentChar);
+        }
+    }
+
+    match &path[segment_start..] {
+        "/" => return Err(IdError::TrailingSlash),
+        "/." | "/.." => return Err(IdError::DotSegment),
+        _ => {}
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn valid_dns() {
+        assert!(validate_identity("system.local").is_ok())
+    }
+
+    #[test]
+    fn valid_dns_all() {
+        assert!(validate_identity("*").is_ok())
+    }
+
+    #[test]
+    fn valid_dns_prefix() {
+        assert!(validate_identity("*.system.local").is_ok())
+    }
+
+    #[test]
+    fn invalid_dns_suffix() {
+        let err = validate_identity("system.local.*").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_trailing_dot() {
+        let err = validate_identity("system.local.").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_leading_dot() {
+        let err = validate_identity(".system.local").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_double_dots() {
+        let err = validate_identity("system..local").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn valid_spiffe_no_path() {
+        assert!(validate_identity("spiffe://trustdomain").is_ok())
+    }
+
+    #[test]
+    fn valid_spiffe_with_path() {
+        assert!(validate_identity("spiffe://trustdomain/path/element").is_ok())
+    }
+
+    #[test]
+    fn invalid_spiffe_scheme() {
+        let err = validate_identity("http://domain.test/path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_wrong_scheme() {
+        let err = validate_identity("spiffe:/path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_empty_trust_domain() {
+        let err = validate_identity("spiffe:///path/element").unwrap_err();
+        assert_eq!(err, IdError::MissingTrustDomain);
+    }
+
+    #[test]
+    fn invalid_spiffe_no_slashes_in_scheme() {
+        let err = validate_identity("spiffe:path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_query() {
+        let err = validate_identity("spiffe://domain.test/path/element?query=").unwrap_err();
+        assert_eq!(err, IdError::BadPathSegmentChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_fragment() {
+        let err = validate_identity("spiffe://domain.test/path/element#fragment-1").unwrap_err();
+        assert_eq!(err, IdError::BadPathSegmentChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_str_port() {
+        let err = validate_identity("spiffe://domain.test:8080/path/element").unwrap_err();
+        assert_eq!(err, IdError::BadTrustDomainChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_user_info() {
+        let err = validate_identity("spiffe://user:password@test.org/path/element").unwrap_err();
+        assert_eq!(err, IdError::BadTrustDomainChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_trailing_slash() {
+        let err = validate_identity("spiffe://test.org/").unwrap_err();
+        assert_eq!(err, IdError::TrailingSlash);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_empty_segment() {
+        let err = validate_identity("spiffe://test.org//").unwrap_err();
+        assert_eq!(err, IdError::EmptySegment);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_path_with_trailing_slash() {
+        let err = validate_identity("spiffe://test.org/path/other/").unwrap_err();
+        assert_eq!(err, IdError::TrailingSlash);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_dot_segment() {
+        let err = validate_identity("spiffe://test.org/./other").unwrap_err();
+        assert_eq!(err, IdError::DotSegment);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_double_dot_segment() {
+        let err = validate_identity("spiffe://test.org/../other").unwrap_err();
+        assert_eq!(err, IdError::DotSegment);
+    }
+}


Test patch:
diff --git a/cli/cmd/testdata/install_crds.golden b/cli/cmd/testdata/install_crds.golden
index 62baf3bbd..9632fe2c7 100644
--- a/cli/cmd/testdata/install_crds.golden
+++ b/cli/cmd/testdata/install_crds.golden
@@ -5364,7 +5364,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10137,3 +10136,160 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/cli dev-undefined
+  labels:
+    helm.sh/chart: linkerd-crds-1.9.1-edge
+    linkerd.io/control-plane-ns: linkerd
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/testdata/install_helm_crds_output.golden b/cli/cmd/testdata/install_helm_crds_output.golden
index a106d8844..ee66ee8f1 100644
--- a/cli/cmd/testdata/install_helm_crds_output.golden
+++ b/cli/cmd/testdata/install_helm_crds_output.golden
@@ -5370,7 +5370,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10153,3 +10152,162 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+# Source: linkerd-crds/templates/workload/external-workload.yaml
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/helm linkerd-version
+  labels:
+    helm.sh/chart: linkerd-crds-
+    linkerd.io/control-plane-ns: linkerd-dev
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/testdata/install_helm_crds_output_ha.golden b/cli/cmd/testdata/install_helm_crds_output_ha.golden
index a106d8844..ee66ee8f1 100644
--- a/cli/cmd/testdata/install_helm_crds_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_crds_output_ha.golden
@@ -5370,7 +5370,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10153,3 +10152,162 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+# Source: linkerd-crds/templates/workload/external-workload.yaml
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/helm linkerd-version
+  labels:
+    helm.sh/chart: linkerd-crds-
+    linkerd.io/control-plane-ns: linkerd-dev
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message