Repository: linkerd/linkerd2
Pull request #6059 (2021-04-26 11:09:36)

Add nodePorts option to multicluster helm chart
For LoadBalancer and NodePort type services it should be possible to set
a specific node port, this wasn't possible yet.

This commit adds nodePorts to the multicluster helm chart

Signed-off-by: Peter Smit <peter.smit@inscripta.io>

<!--  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
-->


kleimkuhler: Thanks for opening this up @psmit! You'll need to run `bin/helm-docs` to update the chart READMEs so that CI can pass.

I'm wondering if it makes sense to make a `gateway` record that has sub-records for each of the `gateway...` prefixed values right now—so `gatewayName` -> `gateway.name`. This would allow adding `gateway.nodePort.port` and `gateway.nodePort.probePort`. That way, it would be be more obvious that these values are specific to the gateway.

This would lead to a bigger change, but it does add some clarity that is currently not there; it may not be obvious that `nodePorts` value is specific to the gateway.
psmit: Thanks for the feedback @kleimkuhler . I have now grouped the variables under `gateway:`, but this would be a breaking change. Is that ok?
psmit: As far as I can see, none of the integration test failures have anything to do with the multicluster chart. Can someone confirm this?
alpeb: Given the structure of the `values.yaml` file was changed, its go counterpart `multicluster/values.values.go` and the other places in go code were this struct is referred to need to be updated. Glad to provide some additional guidance on that if needed :wink: 
psmit: @alpeb Thanks for the hint. This is indeed new for me, but I think I'll be able to adjust this accordingly. Could you give me a hint on testing this locally before I push a new commit?
alpeb: @psmit in order to test this you have to install multicluster in your target cluster with something like this:
```
linkerd mc install --set gateway.serviceType=NodePort --set gateway.nodePort=30500 --set gateway.probe.nodePort=30501|k apply -f -
```
And then generate the Link resources with
```
linkerd mc link --cluster-name target --set gateway.probe.port=30501
```
I realized this still requires additional changes. `multicluster/charts/linkerd-multicluster-link/values.yaml` also uses the `Gateway` struct in `multicluster/values/values.go`. So you need to modify `gateway` in that file to also have a tree structure.

On the other hand, `extractGatewayPort()` in `multicluster/cmd/gateways.go` (used when calling `linkerd mc link`) extracts the target cluster gateway port checking at the `Port` field in the gateway service's `spec.Ports` entries. You have to modify this to also check for `NodePort` if you're using that service type.

Separate question: The CI runs on this PR are requesting manual approval, which only happens when files under `.github/workflows` are modified. Did you happen to have done that, perhaps by mistake, in one of your earlier commits?
psmit: > I realized this still requires additional changes. `multicluster/charts/linkerd-multicluster-link/values.yaml` also uses the `Gateway` struct in `multicluster/values/values.go`. So you need to modify `gateway` in that file to also have a tree structure.
 
This I already had done. One question there is; do we keep the cli-flags the same or do we look to have dot-syntax in there as well?

> On the other hand, `extractGatewayPort()` in `multicluster/cmd/gateways.go` (used when calling `linkerd mc link`) extracts the target cluster gateway port checking at the `Port` field in the gateway service's `spec.Ports` entries. You have to modify this to also check for `NodePort` if you're using that service type.

In my own case, the NodePort would not correspond to the actual public port / IP, because there is a load balancer (not managed in k8s) before the service. I think this would be the primary use case of using the NodePort type service.

> Separate question: The CI runs on this PR are requesting manual approval, which only happens when files under `.github/workflows` are modified. Did you happen to have done that, perhaps by mistake, in one of your earlier commits?

No, I have not touched that folder. For me it says: `First-time contributors need a maintainer to approve running workflows`


psmit: Thanks @alpeb for your extensive review. I have tried to address all your comments, but I'm a small bit "in over my head". Originally I thought that this was simple as only changing the helm chart; not realizing that there was a lot of matching Go code that needed updating as well.



Patch:
diff --git a/ADOPTERS.md b/ADOPTERS.md
index c347b2f0a..d5fcf111d 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -7,6 +7,7 @@
 - [Apester](https://apester.com)
 - [AppddictionStudio](https://appddicitonstudio.com)
 - [Attest](https://www.askattest.com)
+- [Bink](https://bink.com)
 - [Buoyant](https://buoyant.io)
 - [Candide](https://candidegardening.com)
 - [Celonis](https://celonis.com)
diff --git a/EXTENSIONS.md b/EXTENSIONS.md
index 333b04221..f1ebd07bc 100644
--- a/EXTENSIONS.md
+++ b/EXTENSIONS.md
@@ -106,7 +106,7 @@ instead of the output format described above.  E.g.
         },
         {
           "description": "linkerd-viz Namespace exists",
-          "hint": "https://linkerd.io/checks/#l5d-viz-ns-exists",
+          "hint": "https://linkerd.io/2/checks/#l5d-viz-ns-exists",
           "error": "could not find the linkerd-viz extension",
           "result": "error"
         }
diff --git a/charts/linkerd2/templates/serviceprofile-crd.yaml b/charts/linkerd2/templates/serviceprofile-crd.yaml
index 4705a8816..1b0e29a9a 100644
--- a/charts/linkerd2/templates/serviceprofile-crd.yaml
+++ b/charts/linkerd2/templates/serviceprofile-crd.yaml
@@ -151,8 +151,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/controller/api/destination/profile_translator.go b/controller/api/destination/profile_translator.go
index 3fea68825..42422d6a9 100644
--- a/controller/api/destination/profile_translator.go
+++ b/controller/api/destination/profile_translator.go
@@ -2,6 +2,8 @@ package destination
 
 import (
 	"errors"
+	"fmt"
+	"strings"
 	"time"
 
 	"github.com/golang/protobuf/ptypes/duration"
@@ -104,18 +106,26 @@ func (pt *profileTranslator) toServiceProfile(profile *sp.ServiceProfile) (*pb.D
 	return &pb.DestinationProfile{
 		Routes:             routes,
 		RetryBudget:        budget,
-		DstOverrides:       toDstOverrides(profile.Spec.DstOverrides),
+		DstOverrides:       toDstOverrides(profile.Spec.DstOverrides, pt.port),
 		FullyQualifiedName: pt.fullyQualifiedName,
 		Endpoint:           pt.endpoint,
 		OpaqueProtocol:     opaqueProtocol,
 	}, nil
 }
 
-func toDstOverrides(dsts []*sp.WeightedDst) []*pb.WeightedDst {
+func toDstOverrides(dsts []*sp.WeightedDst, port uint32) []*pb.WeightedDst {
 	pbDsts := []*pb.WeightedDst{}
 	for _, dst := range dsts {
+		authority := dst.Authority
+		// Authority should be a FQDN with port
+		// Use the port from GetProfile is absent in authority
+		hostPort := strings.Split(authority, ":")
+		if len(hostPort) == 1 {
+			authority = fmt.Sprintf("%s:%d", authority, port)
+		}
+
 		pbDst := &pb.WeightedDst{
-			Authority: dst.Authority,
+			Authority: authority,
 			// Weights are expressed in decimillis: 10_000 represents 100%
 			Weight: uint32(dst.Weight.MilliValue() * millisPerDecimilli),
 		}
diff --git a/controller/api/destination/traffic_split_adaptor.go b/controller/api/destination/traffic_split_adaptor.go
index 88456d2eb..111facbee 100644
--- a/controller/api/destination/traffic_split_adaptor.go
+++ b/controller/api/destination/traffic_split_adaptor.go
@@ -65,9 +65,11 @@ func (tsa *trafficSplitAdaptor) publish() {
 			overrides = append(overrides, dst)
 		}
 		merged.Spec.DstOverrides = overrides
-	} else {
-		// If there is no traffic split, always return a destination override
-		// so that it's known the host is a service.
+	}
+
+	// If there are no destination overrides set, always return a destination override
+	// so that it's known the host is a service.
+	if len(merged.Spec.DstOverrides) == 0 {
 		dst := &sp.WeightedDst{
 			Authority: fmt.Sprintf("%s.%s.svc.%s.:%d", tsa.id.Name, tsa.id.Namespace, tsa.clusterDomain, tsa.port),
 			Weight:    resource.MustParse("1"),
diff --git a/multicluster/charts/linkerd-multicluster-link/README.md b/multicluster/charts/linkerd-multicluster-link/README.md
index 1b46aca68..b74c864b8 100644
--- a/multicluster/charts/linkerd-multicluster-link/README.md
+++ b/multicluster/charts/linkerd-multicluster-link/README.md
@@ -23,11 +23,11 @@ Kubernetes: `>=1.16.0-0`
 |-----|------|---------|-------------|
 | controllerImage | string | `"cr.l5d.io/linkerd/controller"` | Docker image for the Service mirror component (uses the Linkerd controller image) |
 | controllerImageVersion | string | `"linkerdVersionValue"` | Tag for the Service Mirror container Docker image |
-| gatewayProbePort | int | `4191` | The port used for liveliness probing  |
-| logLevel | string | `"info"` | Log level for the Multicluster components  |
-| namespace | string | `"linkerd-multicluster"` | Service Mirror component namespace  |
+| gateway.probe.port | int | `4191` | The port used for liveliness probing |
+| logLevel | string | `"info"` | Log level for the Multicluster components |
+| namespace | string | `"linkerd-multicluster"` | Service Mirror component namespace |
 | serviceMirrorRetryLimit | int | `3` | Number of times update from the remote cluster is allowed to be requeued (retried) |
-| serviceMirrorUID | int | `2103` | User id under which the Service Mirror shall be ran  |
+| serviceMirrorUID | int | `2103` | User id under which the Service Mirror shall be ran |
 
 ----------------------------------------------
 Autogenerated from chart metadata using [helm-docs v1.4.0](https://github.com/norwoodj/helm-docs/releases/v1.4.0)
diff --git a/multicluster/charts/linkerd-multicluster-link/templates/gateway-mirror.yaml b/multicluster/charts/linkerd-multicluster-link/templates/gateway-mirror.yaml
index 7806d3e47..3aa6b6f1e 100644
--- a/multicluster/charts/linkerd-multicluster-link/templates/gateway-mirror.yaml
+++ b/multicluster/charts/linkerd-multicluster-link/templates/gateway-mirror.yaml
@@ -10,5 +10,5 @@ metadata:
 spec:
   ports:
   - name: mc-probe
-    port: {{.Values.gatewayProbePort}}
+    port: {{.Values.gateway.probe.port}}
     protocol: TCP
diff --git a/multicluster/charts/linkerd-multicluster-link/values.yaml b/multicluster/charts/linkerd-multicluster-link/values.yaml
index 654bd884c..bb6b5e105 100644
--- a/multicluster/charts/linkerd-multicluster-link/values.yaml
+++ b/multicluster/charts/linkerd-multicluster-link/values.yaml
@@ -3,14 +3,16 @@
 controllerImage: cr.l5d.io/linkerd/controller
 # -- Tag for the Service Mirror container Docker image
 controllerImageVersion: linkerdVersionValue
-# -- The port used for liveliness probing 
-gatewayProbePort: 4191
-# -- Service Mirror component namespace 
+gateway:
+  probe:
+    # -- The port used for liveliness probing
+    port: 4191
+# -- Service Mirror component namespace
 namespace: linkerd-multicluster
-# -- Log level for the Multicluster components 
+# -- Log level for the Multicluster components
 logLevel: info
 # -- Number of times update from the remote cluster is allowed to be requeued
 # (retried)
 serviceMirrorRetryLimit: 3
-# -- User id under which the Service Mirror shall be ran 
+# -- User id under which the Service Mirror shall be ran
 serviceMirrorUID: 2103
diff --git a/multicluster/charts/linkerd-multicluster/README.md b/multicluster/charts/linkerd-multicluster/README.md
index 5904aadd3..6c78298bf 100644
--- a/multicluster/charts/linkerd-multicluster/README.md
+++ b/multicluster/charts/linkerd-multicluster/README.md
@@ -71,13 +71,15 @@ Kubernetes: `>=1.16.0-0`
 
 | Key | Type | Default | Description |
 |-----|------|---------|-------------|
-| gateway | bool | `true` | If the gateway component should be installed |
-| gatewayName | string | `"linkerd-gateway"` | The name of the gateway that will be installed |
-| gatewayPort | int | `4143` | The port on which all the gateway will accept incoming traffic |
-| gatewayProbePath | string | `"/ready"` | The path that will be used by remote clusters for determining whether the gateway is alive |
-| gatewayProbePort | int | `4191` | The port used for liveliness probing |
-| gatewayProbeSeconds | int | `3` | The interval (in seconds) between liveness probes |
-| gatewayServiceType | string | `"LoadBalancer"` | Service Type of gateway Service |
+| gateway.enabled | bool | `true` | If the gateway component should be installed |
+| gateway.loadBalancerIP | string | `""` | Set loadBalancerIP on gateway service |
+| gateway.name | string | `"linkerd-gateway"` | The name of the gateway that will be installed |
+| gateway.port | int | `4143` | The port on which all the gateway will accept incoming traffic |
+| gateway.probe.path | string | `"/ready"` | The path that will be used by remote clusters for determining whether the gateway is alive |
+| gateway.probe.port | int | `4191` | The port used for liveliness probing |
+| gateway.probe.seconds | int | `3` |  |
+| gateway.serviceAnnotations | object | `{}` | Annotations to add to the gateway service |
+| gateway.serviceType | string | `"LoadBalancer"` | Service Type of gateway Service |
 | identityTrustDomain | string | `"cluster.local"` | Identity Trust Domain of the certificate authority |
 | installNamespace | bool | `true` | If the namespace should be installed |
 | linkerdNamespace | string | `"linkerd"` | Namespace of linkerd installation |
diff --git a/multicluster/charts/linkerd-multicluster/templates/gateway.yaml b/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
index da9fb62fe..17395d87f 100644
--- a/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
+++ b/multicluster/charts/linkerd-multicluster/templates/gateway.yaml
@@ -1,4 +1,4 @@
-{{if .Values.gateway -}}
+{{if .Values.gateway.enabled -}}
 ---
 apiVersion: apps/v1
 kind: Deployment
@@ -10,64 +10,71 @@ metadata:
     app.kubernetes.io/part-of: Linkerd
     app.kubernetes.io/version: {{.Values.linkerdVersion}}
     linkerd.io/control-plane-component: gateway
-    app: {{.Values.gatewayName}}
+    app: {{.Values.gateway.name}}
     linkerd.io/extension: multicluster
-  name: {{.Values.gatewayName}}
+  name: {{.Values.gateway.name}}
   namespace: {{.Values.namespace}}
 spec:
   replicas: 1
   selector:
     matchLabels:
-      app: {{.Values.gatewayName}}
+      app: {{.Values.gateway.name}}
   template:
     metadata:
       annotations:
         {{ include "partials.annotations.created-by" . }}
         linkerd.io/inject: enabled
-        config.linkerd.io/proxy-require-identity-inbound-ports: "{{.Values.gatewayProbePort}},{{.Values.gatewayPort}}"
+        config.linkerd.io/proxy-require-identity-inbound-ports: "{{.Values.gateway.probe.port}},{{.Values.gateway.port}}"
         config.linkerd.io/enable-gateway: "true"
       labels:
-        app: {{.Values.gatewayName}}
+        app: {{.Values.gateway.name}}
     spec:
       containers:
         - name: pause
           image: gcr.io/google_containers/pause
-      serviceAccountName: {{.Values.gatewayName}}
+      serviceAccountName: {{.Values.gateway.name}}
 ---
 apiVersion: v1
 kind: Service
 metadata:
-  name: {{.Values.gatewayName}}
+  name: {{.Values.gateway.name}}
   namespace: {{.Values.namespace}}
   labels:
     linkerd.io/extension: multicluster
   annotations:
-    mirror.linkerd.io/gateway-identity: {{.Values.gatewayName}}.{{.Values.namespace}}.serviceaccount.identity.{{.Values.linkerdNamespace}}.{{.Values.identityTrustDomain}}
-    mirror.linkerd.io/probe-period: "{{.Values.gatewayProbeSeconds}}"
-    mirror.linkerd.io/probe-path: {{.Values.gatewayProbePath}}
+    mirror.linkerd.io/gateway-identity: {{.Values.gateway.name}}.{{.Values.namespace}}.serviceaccount.identity.{{.Values.linkerdNamespace}}.{{.Values.identityTrustDomain}}
+    mirror.linkerd.io/probe-period: "{{.Values.gateway.probe.seconds}}"
+    mirror.linkerd.io/probe-path: {{.Values.gateway.probe.path}}
     mirror.linkerd.io/multicluster-gateway: "true"
     linkerd.io/control-plane-component: gateway
     {{ include "partials.annotations.created-by" . }}
-    {{- with .Values.gatewayServiceAnnotations }}{{ toYaml . | trim | nindent 4 }}{{- end }}
+    {{- with .Values.gateway.serviceAnnotations }}{{ toYaml . | trim | nindent 4 }}{{- end }}
 spec:
   ports:
+  {{- $setNodePorts := (or (eq .Values.gateway.serviceType "NodePort") (eq .Values.gateway.serviceType "LoadBalancer")) }}
   - name: mc-gateway
-    port: {{.Values.gatewayPort}}
+    port: {{.Values.gateway.port}}
     protocol: TCP
+  {{- if (and $setNodePorts .Values.gateway.nodePort) }}
+    nodePort: {{ .Values.gateway.nodePort }}
+  {{- end }}
   - name: mc-probe
-    port: {{.Values.gatewayProbePort}}
+    port: {{.Values.gateway.probe.port}}
     protocol: TCP
+  {{- if (and $setNodePorts .Values.gateway.probe.nodePort) }}
+    nodePort: {{ .Values.gateway.probe.nodePort }}
+  {{- end }}
   selector:
-    app: {{.Values.gatewayName}}
-  type: {{ .Values.gatewayServiceType }}
-{{- if .Values.loadBalancerIP }}
-  loadBalancerIP: {{ .Values.loadBalancerIP }}
+    app: {{.Values.gateway.name}}
+  type: {{ .Values.gateway.serviceType }}
+{{- if .Values.gateway.loadBalancerIP }}
+  loadBalancerIP: {{ .Values.gateway.loadBalancerIP }}
 {{- end }}
 ---
 kind: ServiceAccount
 apiVersion: v1
 metadata:
-  name: {{.Values.gatewayName}}
+  name: {{.Values.gateway.name}}
   namespace: {{.Values.namespace}}
   labels:
     linkerd.io/extension: multicluster
diff --git a/multicluster/charts/linkerd-multicluster/values.yaml b/multicluster/charts/linkerd-multicluster/values.yaml
index 5b5a37faa..2a8835853 100644
--- a/multicluster/charts/linkerd-multicluster/values.yaml
+++ b/multicluster/charts/linkerd-multicluster/values.yaml
@@ -1,16 +1,29 @@
-# -- If the gateway component should be installed
-gateway: true
-# -- The name of the gateway that will be installed
-gatewayName: linkerd-gateway
-# -- The port on which all the gateway will accept incoming traffic
-gatewayPort: 4143
-# -- The path that will be used by remote clusters for determining whether the
-# gateway is alive
-gatewayProbePath: /ready
-# -- The port used for liveliness probing
-gatewayProbePort: 4191
-# -- The interval (in seconds) between liveness probes
-gatewayProbeSeconds: 3
+gateway:
+  # -- If the gateway component should be installed
+  enabled: true
+  # -- The name of the gateway that will be installed
+  name: linkerd-gateway
+  # -- The port on which all the gateway will accept incoming traffic
+  port: 4143
+  # -- Service Type of gateway Service
+  serviceType: LoadBalancer
+  # nodePort -- Set the gateway nodePort (for LoadBalancer or NodePort) to a specific value
+  # nodePort:
+  probe:
+    # -- The path that will be used by remote clusters for determining whether the
+    # gateway is alive
+    path: /ready
+    # -- The port used for liveliness probing
+    port: 4191
+    # nodePort -- Set the probe nodePort (for LoadBalancer or NodePort) to a specific value
+    # nodePort:
+    # -- The interval (in seconds) between liveness probes
+    seconds: 3
+  # -- Annotations to add to the gateway service
+  serviceAnnotations: {}
+  # -- Set loadBalancerIP on gateway service
+  loadBalancerIP: ""
+
 # -- If the namespace should be installed
 installNamespace: true
 # -- Control plane version
@@ -24,8 +37,6 @@ remoteMirrorServiceAccount: true
 # -- The name of the service account used to allow remote clusters to mirror
 # local services
 remoteMirrorServiceAccountName: linkerd-service-mirror-remote-access-default
-# -- Service Type of gateway Service
-gatewayServiceType: LoadBalancer
 # -- Namespace of linkerd installation
 linkerdNamespace: linkerd
 # -- Identity Trust Domain of the certificate authority
diff --git a/multicluster/cmd/allow.go b/multicluster/cmd/allow.go
index 41506d611..a021431dc 100644
--- a/multicluster/cmd/allow.go
+++ b/multicluster/cmd/allow.go
@@ -108,7 +108,7 @@ func buildMulticlusterAllowValues(ctx context.Context, opts *allowOptions) (*mcc
 
 	defaults.Namespace = opts.namespace
 	defaults.LinkerdVersion = version.Version
-	defaults.Gateway = false
+	defaults.Gateway.Enabled = false
 	defaults.ServiceMirror = false
 	defaults.RemoteMirrorServiceAccount = true
 	defaults.RemoteMirrorServiceAccountName = opts.serviceAccountName
diff --git a/multicluster/cmd/gateways.go b/multicluster/cmd/gateways.go
index 4002bb687..e7f373c08 100644
--- a/multicluster/cmd/gateways.go
+++ b/multicluster/cmd/gateways.go
@@ -166,6 +166,9 @@ func gatewaysRowToTableRow(row *pb.GatewaysTable_Row) []string {
 func extractGatewayPort(gateway *corev1.Service) (uint32, error) {
 	for _, port := range gateway.Spec.Ports {
 		if port.Name == k8s.GatewayPortName {
+			if gateway.Spec.Type == "NodePort" {
+				return uint32(port.NodePort), nil
+			}
 			return uint32(port.Port), nil
 		}
 	}
diff --git a/multicluster/cmd/install.go b/multicluster/cmd/install.go
index 0893a161d..371472ae6 100644
--- a/multicluster/cmd/install.go
+++ b/multicluster/cmd/install.go
@@ -30,13 +30,9 @@ import (
 
 type (
 	multiclusterInstallOptions struct {
-		gateway                 bool
-		gatewayPort             uint32
-		gatewayProbeSeconds     uint32
-		gatewayProbePort        uint32
+		gateway                 multicluster.Gateway
 		namespace               string
 		remoteMirrorCredentials bool
-		gatewayServiceType      string
 	}
 )
 
@@ -56,7 +52,7 @@ func newMulticlusterInstallCommand() *cobra.Command {
 		Args:  cobra.NoArgs,
 		Example: `  # Default install.
   linkerd multicluster install | kubectl apply -f -
-  
+
 The installation can be configured by using the --set, --values, --set-string and --set-file flags.
 A full list of configurable values can be found at https://github.com/linkerd/linkerd2/blob/main/multicluster/charts/linkerd-multicluster/README.md
   `,
@@ -156,12 +152,12 @@ A full list of configurable values can be found at https://github.com/linkerd/li
 
 	flags.AddValueOptionsFlags(cmd.Flags(), &valuesOptions)
 	cmd.Flags().StringVar(&options.namespace, "namespace", options.namespace, "The namespace in which the multicluster add-on is to be installed. Must not be the control plane namespace. ")
-	cmd.Flags().BoolVar(&options.gateway, "gateway", options.gateway, "If the gateway component should be installed")
-	cmd.Flags().Uint32Var(&options.gatewayPort, "gateway-port", options.gatewayPort, "The port on the gateway used for all incoming traffic")
-	cmd.Flags().Uint32Var(&options.gatewayProbeSeconds, "gateway-probe-seconds", options.gatewayProbeSeconds, "The interval at which the gateway will be checked for being alive in seconds")
-	cmd.Flags().Uint32Var(&options.gatewayProbePort, "gateway-probe-port", options.gatewayProbePort, "The liveness check port of the gateway")
+	cmd.Flags().BoolVar(&options.gateway.Enabled, "gateway", options.gateway.Enabled, "If the gateway component should be installed")
+	cmd.Flags().Uint32Var(&options.gateway.Port, "gateway-port", options.gateway.Port, "The port on the gateway used for all incoming traffic")
+	cmd.Flags().Uint32Var(&options.gateway.Probe.Seconds, "gateway-probe-seconds", options.gateway.Probe.Seconds, "The interval at which the gateway will be checked for being alive in seconds")
+	cmd.Flags().Uint32Var(&options.gateway.Probe.Port, "gateway-probe-port", options.gateway.Probe.Port, "The liveness check port of the gateway")
 	cmd.Flags().BoolVar(&options.remoteMirrorCredentials, "service-mirror-credentials", options.remoteMirrorCredentials, "Whether to install the service account which can be used by service mirror components in source clusters to discover exported services")
-	cmd.Flags().StringVar(&options.gatewayServiceType, "gateway-service-type", options.gatewayServiceType, "Overwrite Service type for gateway service")
+	cmd.Flags().StringVar(&options.gateway.ServiceType, "gateway-service-type", options.gateway.ServiceType, "Overwrite Service type for gateway service")
 	cmd.Flags().DurationVar(&wait, "wait", 300*time.Second, "Wait for core control-plane components to be available")
 
 	// Hide developer focused flags in release builds.
@@ -185,13 +181,9 @@ func newMulticlusterInstallOptionsWithDefault() (*multiclusterInstallOptions, er
 	}
 
 	return &multiclusterInstallOptions{
-		gateway:                 defaults.Gateway,
-		gatewayPort:             defaults.GatewayPort,
-		gatewayProbeSeconds:     defaults.GatewayProbeSeconds,
-		gatewayProbePort:        defaults.GatewayProbePort,
+		gateway:                 *defaults.Gateway,
 		namespace:               defaults.Namespace,
 		remoteMirrorCredentials: true,
-		gatewayServiceType:      defaults.GatewayServiceType,
 	}, nil
 }
 
@@ -219,16 +211,16 @@ func buildMulticlusterInstallValues(ctx context.Context, opts *multiclusterInsta
 	}
 
 	defaults.Namespace = opts.namespace
-	defaults.Gateway = opts.gateway
-	defaults.GatewayPort = opts.gatewayPort
-	defaults.GatewayProbeSeconds = opts.gatewayProbeSeconds
-	defaults.GatewayProbePort = opts.gatewayProbePort
+	defaults.Gateway.Enabled = opts.gateway.Enabled
+	defaults.Gateway.Port = opts.gateway.Port
+	defaults.Gateway.Probe.Seconds = opts.gateway.Probe.Seconds
+	defaults.Gateway.Probe.Port = opts.gateway.Probe.Port
 	defaults.IdentityTrustDomain = values.IdentityTrustDomain
 	defaults.LinkerdNamespace = controlPlaneNamespace
 	defaults.ProxyOutboundPort = uint32(values.Proxy.Ports.Outbound)
 	defaults.LinkerdVersion = version.Version
 	defaults.RemoteMirrorServiceAccount = opts.remoteMirrorCredentials
-	defaults.GatewayServiceType = opts.gatewayServiceType
+	defaults.Gateway.ServiceType = opts.gateway.ServiceType
 
 	return defaults, nil
 }
diff --git a/multicluster/values/values.go b/multicluster/values/values.go
index 22d40c1d3..79dbac31c 100644
--- a/multicluster/values/values.go
+++ b/multicluster/values/values.go
@@ -18,29 +18,43 @@ const (
 
 // Values contains the top-level elements in the Helm charts
 type Values struct {
-	CliVersion                     string `json:"cliVersion"`
-	ControllerImage                string `json:"controllerImage"`
-	ControllerImageVersion         string `json:"controllerImageVersion"`
-	Gateway                        bool   `json:"gateway"`
-	GatewayName                    string `json:"gatewayName"`
-	GatewayPort                    uint32 `json:"gatewayPort"`
-	GatewayProbePath               string `json:"gatewayProbePath"`
-	GatewayProbePort               uint32 `json:"gatewayProbePort"`
-	GatewayProbeSeconds            uint32 `json:"gatewayProbeSeconds"`
-	IdentityTrustDomain            string `json:"identityTrustDomain"`
-	InstallNamespace               bool   `json:"installNamespace"`
-	LinkerdNamespace               string `json:"linkerdNamespace"`
-	LinkerdVersion                 string `json:"linkerdVersion"`
-	Namespace                      string `json:"namespace"`
-	ProxyOutboundPort              uint32 `json:"proxyOutboundPort"`
-	ServiceMirror                  bool   `json:"serviceMirror"`
-	LogLevel                       string `json:"logLevel"`
-	ServiceMirrorRetryLimit        uint32 `json:"serviceMirrorRetryLimit"`
-	ServiceMirrorUID               int64  `json:"serviceMirrorUID"`
-	RemoteMirrorServiceAccount     bool   `json:"remoteMirrorServiceAccount"`
-	RemoteMirrorServiceAccountName string `json:"remoteMirrorServiceAccountName"`
-	TargetClusterName              string `json:"targetClusterName"`
-	GatewayServiceType             string `json:"gatewayServiceType"`
+	CliVersion                     string   `json:"cliVersion"`
+	ControllerImage                string   `json:"controllerImage"`
+	ControllerImageVersion         string   `json:"controllerImageVersion"`
+	Gateway                        *Gateway `json:"gateway"`
+	IdentityTrustDomain            string   `json:"identityTrustDomain"`
+	InstallNamespace               bool     `json:"installNamespace"`
+	LinkerdNamespace               string   `json:"linkerdNamespace"`
+	LinkerdVersion                 string   `json:"linkerdVersion"`
+	Namespace                      string   `json:"namespace"`
+	ProxyOutboundPort              uint32   `json:"proxyOutboundPort"`
+	ServiceMirror                  bool     `json:"serviceMirror"`
+	LogLevel                       string   `json:"logLevel"`
+	ServiceMirrorRetryLimit        uint32   `json:"serviceMirrorRetryLimit"`
+	ServiceMirrorUID               int64    `json:"serviceMirrorUID"`
+	RemoteMirrorServiceAccount     bool     `json:"remoteMirrorServiceAccount"`
+	RemoteMirrorServiceAccountName string   `json:"remoteMirrorServiceAccountName"`
+	TargetClusterName              string   `json:"targetClusterName"`
+}
+
+// Gateway contains all opttions related to the Gateway Service
+type Gateway struct {
+	Enabled            bool              `json:"enabled"`
+	Name               string            `json:"name"`
+	Port               uint32            `json:"port"`
+	NodePort           uint32            `json:"nodePort"`
+	ServiceType        string            `json:"serviceType"`
+	Probe              *Probe            `json:"probe"`
+	ServiceAnnotations map[string]string `json:"serviceAnnotations"`
+	LoadBalancerIP     string            `json:"loadBalancerIP"`
+}
+
+// Probe contains all options for the Probe Service
+type Probe struct {
+	Path     string `json:"path"`
+	Port     uint32 `json:"port"`
+	NodePort uint32 `json:"nodePort"`
+	Seconds  uint32 `json:"seconds"`
 }
 
 // NewInstallValues returns a new instance of the Values type.
diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go
index d9a71b975..3b53db57b 100644
--- a/pkg/healthcheck/healthcheck.go
+++ b/pkg/healthcheck/healthcheck.go
@@ -156,11 +156,6 @@ const (
 	keyKeyName                    = "tls.key"
 )
 
-// DefaultHintBaseURL is the base URL on the linkerd.io website that all check hints
-// point to. Each check adds its own `hintAnchor` to specify a location on the
-// page.
-const DefaultHintBaseURL = "https://linkerd.io/checks/#"
-
 // AllowedClockSkew sets the allowed skew in clock synchronization
 // between the system running inject command and the node(s), being
 // based on assumed node's heartbeat interval (5 minutes) plus default TLS
@@ -367,13 +362,12 @@ type Category struct {
 }
 
 // NewCategory returns an instance of Category with the specified data
-// and the DefaultHintBaseURL
 func NewCategory(id CategoryID, checkers []Checker, enabled bool) *Category {
 	return &Category{
 		ID:          id,
 		checkers:    checkers,
 		enabled:     enabled,
-		hintBaseURL: DefaultHintBaseURL,
+		hintBaseURL: HintBaseURL(version.Version),
 	}
 }
 
@@ -496,7 +490,7 @@ func (hc *HealthChecker) GetCategories() []*Category {
 //
 // Note that all checks should include a `hintAnchor` with a corresponding section
 // in the linkerd check faq:
-// https://linkerd.io/checks/#
+// https://linkerd.io/{major-version}/checks/#
 func (hc *HealthChecker) allCategories() []*Category {
 	return []*Category{
 		NewCategory(
diff --git a/pkg/healthcheck/healthcheck_output.go b/pkg/healthcheck/healthcheck_output.go
index df195cfc3..4b22ef191 100644
--- a/pkg/healthcheck/healthcheck_output.go
+++ b/pkg/healthcheck/healthcheck_output.go
@@ -8,11 +8,13 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"regexp"
 	"strings"
 	"time"
 
 	"github.com/briandowns/spinner"
 	"github.com/fatih/color"
+	"github.com/linkerd/linkerd2/pkg/version"
 	"github.com/mattn/go-isatty"
 )
 
@@ -25,12 +27,19 @@ const (
 	WideOutput = "wide"
 	// ShortOutput is used to specify the short output format
 	ShortOutput = "short"
+
+	// DefaultHintBaseURL is the default base URL on the linkerd.io website
+	// that all check hints for the latest linkerd version point to. Each
+	// check adds its own `hintAnchor` to specify a location on the page.
+	DefaultHintBaseURL = "https://linkerd.io/2/checks/#"
 )
 
 var (
 	okStatus   = color.New(color.FgGreen, color.Bold).SprintFunc()("\u221A")  // √
 	warnStatus = color.New(color.FgYellow, color.Bold).SprintFunc()("\u203C") // ‼
 	failStatus = color.New(color.FgRed, color.Bold).SprintFunc()("\u00D7")    // ×
+
+	reStableVersion = regexp.MustCompile(`stable-(\d\.\d+)\.`)
 )
 
 // CheckResults contains a slice of CheckResult structs.
@@ -101,7 +110,7 @@ func RunExtensionsChecks(wout io.Writer, werr io.Writer, extensions []string, fl
 					Category:    CategoryID(extensionCmd),
 					Description: fmt.Sprintf("Linkerd extension command %s exists", extensionCmd),
 					Err:         err,
-					HintURL:     DefaultHintBaseURL + "extensions",
+					HintURL:     HintBaseURL(version.Version) + "extensions",
 					Warning:     true,
 				},
 			}
@@ -130,7 +139,7 @@ func RunExtensionsChecks(wout io.Writer, werr io.Writer, extensions []string, fl
 					Category:    CategoryID(extensionCmd),
 					Description: fmt.Sprintf("Running: %s", command),
 					Err:         err,
-					HintURL:     DefaultHintBaseURL + "extensions",
+					HintURL:     HintBaseURL(version.Version) + "extensions",
 				})
 				success = false
 			} else {
@@ -171,7 +180,11 @@ func runChecksTable(wout io.Writer, hc Runner, output string) bool {
 	prettyPrintResults := func(result *CheckResult) {
 		lastCategory = printCategory(wout, lastCategory, result)
 
-		restartSpinnerOnRetry(spin, result)
+		spin.Stop()
+		if result.Retry {
+			restartSpinner(spin, result)
+			return
+		}
 
 		status := getResultStatus(result)
 
@@ -186,7 +199,11 @@ func runChecksTable(wout io.Writer, hc Runner, output string) bool {
 
 		lastCategory = printCategory(wout, lastCategory, result)
 
-		restartSpinnerOnRetry(spin, result)
+		spin.Stop()
+		if result.Retry {
+			restartSpinner(spin, result)
+			return
+		}
 
 		status := getResultStatus(result)
 
@@ -203,7 +220,11 @@ func runChecksTable(wout io.Writer, hc Runner, output string) bool {
 			return
 		}
 
-		restartSpinnerOnRetry(spin, result)
+		spin.Stop()
+		if result.Retry {
+			restartSpinner(spin, result)
+			return
+		}
 
 		status := getResultStatus(result)
 
@@ -368,14 +389,10 @@ func getResultStatus(result *CheckResult) string {
 	return status
 }
 
-func restartSpinnerOnRetry(spin *spinner.Spinner, result *CheckResult) {
-	spin.Stop()
-	if result.Retry {
-		if isatty.IsTerminal(os.Stdout.Fd()) {
-			spin.Suffix = fmt.Sprintf(" %s", result.Err)
-			spin.Color("bold") // this calls spin.Restart()
-		}
-		return
+func restartSpinner(spin *spinner.Spinner, result *CheckResult) {
+	if isatty.IsTerminal(os.Stdout.Fd()) {
+		spin.Suffix = fmt.Sprintf(" %s", result.Err)
+		spin.Color("bold") // this calls spin.Restart()
 	}
 }
 
@@ -393,3 +410,13 @@ func printCategory(wout io.Writer, lastCategory CategoryID, result *CheckResult)
 
 	return result.Category
 }
+
+// HintBaseURL returns the base URL on the linkerd.io website that check hints
+// point to, depending on the version
+func HintBaseURL(ver string) string {
+	stableVersion := reStableVersion.FindStringSubmatch(ver)
+	if stableVersion == nil {
+		return DefaultHintBaseURL
+	}
+	return fmt.Sprintf("https://linkerd.io/%s/checks/#", stableVersion[1])
+}
diff --git a/pkg/multicluster/link.go b/pkg/multicluster/link.go
index 5676115e1..824f31c3e 100644
--- a/pkg/multicluster/link.go
+++ b/pkg/multicluster/link.go
@@ -200,7 +200,7 @@ func ExtractProbeSpec(gateway *corev1.Service) (ProbeSpec, error) {
 		return ProbeSpec{}, errors.New("probe path is empty")
 	}
 
-	port, err := extractPort(gateway.Spec.Ports, consts.ProbePortName)
+	port, err := extractPort(gateway.Spec, consts.ProbePortName)
 	if err != nil {
 		return ProbeSpec{}, err
 	}
@@ -248,9 +248,12 @@ func GetLink(ctx context.Context, client dynamic.Interface, namespace, name stri
 	return NewLink(*unstructured)
 }
 
-func extractPort(port []corev1.ServicePort, portName string) (uint32, error) {
-	for _, p := range port {
+func extractPort(spec corev1.ServiceSpec, portName string) (uint32, error) {
+	for _, p := range spec.Ports {
 		if p.Name == portName {
+			if spec.Type == "NodePort" {
+				return uint32(p.NodePort), nil
+			}
 			return uint32(p.Port), nil
 		}
 	}
diff --git a/pkg/profiles/profiles.go b/pkg/profiles/profiles.go
index b71c47d7d..6e4476f1d 100644
--- a/pkg/profiles/profiles.go
+++ b/pkg/profiles/profiles.go
@@ -62,10 +62,6 @@ func Validate(data []byte) error {
 		return fmt.Errorf("ServiceProfile \"%s\" has invalid name: %s", serviceProfile.Name, errs[0])
 	}
 
-	if len(serviceProfile.Spec.Routes) == 0 {
-		return fmt.Errorf("ServiceProfile \"%s\" has no routes", serviceProfile.Name)
-	}
-
 	for _, route := range serviceProfile.Spec.Routes {
 		if route.Name == "" {
 			return fmt.Errorf("ServiceProfile \"%s\" has a route with no name", serviceProfile.Name)
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 64da7cb14..845a92ec0 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -5542,9 +5542,9 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.2:
     react-is "^16.7.0"
 
 hosted-git-info@^2.1.4:
-  version "2.8.8"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
-  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hpack.js@^2.1.6:
   version "2.1.6"


Test patch:
diff --git a/cli/cmd/testdata/check_output.golden b/cli/cmd/testdata/check_output.golden
index 39c9efb0b..db4ea9a1a 100644
--- a/cli/cmd/testdata/check_output.golden
+++ b/cli/cmd/testdata/check_output.golden
@@ -3,6 +3,6 @@ category
 √ check1
 × check2
     This should contain instructions for fail
-    see https://linkerd.io/checks/#hint-anchor for hints
+    see https://linkerd.io/2/checks/#hint-anchor for hints
 
 Status check results are ×
diff --git a/cli/cmd/testdata/check_output_json.golden b/cli/cmd/testdata/check_output_json.golden
index b87ac212e..38cc72a72 100644
--- a/cli/cmd/testdata/check_output_json.golden
+++ b/cli/cmd/testdata/check_output_json.golden
@@ -10,7 +10,7 @@
         },
         {
           "description": "check2",
-          "hint": "https://linkerd.io/checks/#hint-anchor",
+          "hint": "https://linkerd.io/2/checks/#hint-anchor",
           "error": "This should contain instructions for fail",
           "result": "error"
         }
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index dc086c125..35a15dbef 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 7da86ec77..7238a1930 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 427737f5a..261927267 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 25765ab7e..114965312 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index 346416635..11d923efa 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index e39f2f7d4..ddcf7f364 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index bb4704cd6..2cdc83002 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 5fe0b6668..0a5b50069 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -309,8 +309,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_helm_output.golden b/cli/cmd/testdata/install_helm_output.golden
index 8eb1297e5..9c39512de 100644
--- a/cli/cmd/testdata/install_helm_output.golden
+++ b/cli/cmd/testdata/install_helm_output.golden
@@ -388,8 +388,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_helm_output_ha.golden b/cli/cmd/testdata/install_helm_output_ha.golden
index 946c81653..b5af94d41 100644
--- a/cli/cmd/testdata/install_helm_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_output_ha.golden
@@ -388,8 +388,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 82c78d6ca..a75c28753 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -388,8 +388,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index 0c7b23725..9474c314c 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -388,8 +388,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 6ec7a8bc1..8d1dbd75f 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 1bdc8073e..8cc5a7aad 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index c1bf22358..9436150e5 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -378,8 +378,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 527abd231..c48d1979a 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -364,8 +364,6 @@ spec:
           spec:
             type: object
             description: Spec is the custom resource spec
-            required:
-            - routes
             properties:
               dstOverrides:
                 type: array
diff --git a/pkg/profiles/profiles_test.go b/pkg/profiles/profiles_test.go
index f2aae539a..a9ca7951b 100644
--- a/pkg/profiles/profiles_test.go
+++ b/pkg/profiles/profiles_test.go
@@ -123,15 +123,6 @@ spec:
       foo: bar
       method: GET
       pathRegex: /route-1`,
-		},
-		{
-			err: errors.New("ServiceProfile \"name.ns.svc.cluster.local\" has no routes"),
-			sp: `apiVersion: linkerd.io/v1alpha2
-kind: ServiceProfile
-metadata:
-  name: name.ns.svc.cluster.local
-  namespace: linkerd-ns
-spec:`,
 		},
 		{
 			err: errors.New("ServiceProfile \"name.ns.svc.cluster.local\" has a route with no condition"),
diff --git a/test/integration/install_test.go b/test/integration/install_test.go
index ef0588ec8..29904f84d 100644
--- a/test/integration/install_test.go
+++ b/test/integration/install_test.go
@@ -946,8 +946,10 @@ func getCheckOutput(t *testing.T, goldenFile string, namespace string) string {
 	tpl := template.Must(template.ParseFiles("testdata" + "/" + goldenFile))
 	vars := struct {
 		ProxyVersionErr string
+		HintURL         string
 	}{
 		healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{}).Error(),
+		healthcheck.HintBaseURL(TestHelper.GetVersion()),
 	}
 
 	var expected bytes.Buffer
@@ -982,8 +984,10 @@ func TestCheckViz(t *testing.T) {
 	tpl := template.Must(template.ParseFiles("testdata" + "/" + golden))
 	vars := struct {
 		ProxyVersionErr string
+		HintURL         string
 	}{
 		healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{}).Error(),
+		healthcheck.HintBaseURL(TestHelper.GetVersion()),
 	}
 
 	var expected bytes.Buffer
diff --git a/test/integration/testdata/check.viz.external-prometheus.golden b/test/integration/testdata/check.viz.external-prometheus.golden
index f8989f74b..727dd2b66 100644
--- a/test/integration/testdata/check.viz.external-prometheus.golden
+++ b/test/integration/testdata/check.viz.external-prometheus.golden
@@ -11,7 +11,7 @@ linkerd-viz
 √ viz extension proxies are healthy
 ‼ viz extension proxies are up-to-date
     {{.ProxyVersionErr}}
-    see https://linkerd.io/checks/#l5d-viz-proxy-cp-version for hints
+    see https://linkerd.io/2/checks/#l5d-viz-proxy-cp-version for hints
 √ viz extension proxies and cli versions match
 √ can initialize the client
 √ viz extension self-check
diff --git a/test/integration/testdata/check.viz.golden b/test/integration/testdata/check.viz.golden
index af33cd21b..99f74c4e4 100644
--- a/test/integration/testdata/check.viz.golden
+++ b/test/integration/testdata/check.viz.golden
@@ -11,7 +11,7 @@ linkerd-viz
 √ viz extension proxies are healthy
 ‼ viz extension proxies are up-to-date
     {{.ProxyVersionErr}}
-    see https://linkerd.io/checks/#l5d-viz-proxy-cp-version for hints
+    see {{.HintURL}}l5d-viz-proxy-cp-version for hints
 √ viz extension proxies and cli versions match
 √ prometheus is installed and configured correctly
 √ can initialize the client
diff --git a/test/integration/testdata/check.viz.proxy.golden b/test/integration/testdata/check.viz.proxy.golden
index d62947f61..3be3d6aac 100644
--- a/test/integration/testdata/check.viz.proxy.golden
+++ b/test/integration/testdata/check.viz.proxy.golden
@@ -11,7 +11,7 @@ linkerd-viz
 √ viz extension proxies are healthy
 ‼ viz extension proxies are up-to-date
     {{.ProxyVersionErr}}
-    see https://linkerd.io/checks/#l5d-viz-proxy-cp-version for hints
+    see {{.HintURL}}l5d-viz-proxy-cp-version for hints
 √ viz extension proxies and cli versions match
 √ prometheus is installed and configured correctly
 √ can initialize the client
diff --git a/test/integration/tracing/testdata/check.jaeger.golden b/test/integration/tracing/testdata/check.jaeger.golden
index 41c125b5e..5d4503107 100644
--- a/test/integration/tracing/testdata/check.jaeger.golden
+++ b/test/integration/tracing/testdata/check.jaeger.golden
@@ -8,7 +8,7 @@ linkerd-jaeger
 √ jaeger extension proxies are healthy
 ‼ jaeger extension proxies are up-to-date
     {{.ProxyVersionErr}}
-    see https://linkerd.io/checks/#l5d-jaeger-proxy-cp-version for hints
+    see {{.HintURL}}l5d-jaeger-proxy-cp-version for hints
 √ jaeger extension proxies and cli versions match
 
 Status check results are √
diff --git a/test/integration/tracing/tracing_test.go b/test/integration/tracing/tracing_test.go
index 1885f7b8b..076e38f6b 100644
--- a/test/integration/tracing/tracing_test.go
+++ b/test/integration/tracing/tracing_test.go
@@ -92,8 +92,10 @@ func TestTracing(t *testing.T) {
 		tpl := template.Must(template.ParseFiles("testdata" + "/" + golden))
 		vars := struct {
 			ProxyVersionErr string
+			HintURL         string
 		}{
 			healthcheck.CheckProxyVersionsUpToDate(pods, version.Channels{}).Error(),
+			healthcheck.HintBaseURL(TestHelper.GetVersion()),
 		}
 
 		var expected bytes.Buffer
diff --git a/test/integration/trafficsplit/testdata/applications-at-diff-ports.yaml b/test/integration/trafficsplit/testdata/applications-at-diff-ports.yaml
new file mode 100644
index 000000000..29e5b533b
--- /dev/null
+++ b/test/integration/trafficsplit/testdata/applications-at-diff-ports.yaml
@@ -0,0 +1,107 @@
+# Two backend pods, one always failing
+# and another one returning OK response
+# Slowcooker is used to generate traffic
+# that will be routed via traffic split
+
+# Two backends serve at different ports i.e
+# 8080 and 8081.
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: backend
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: backend
+  template:
+    metadata:
+      labels:
+        app: backend
+    spec:
+      containers:
+      - name: backend
+        image: buoyantio/bb:v0.0.6
+        args:
+        - terminus
+        - "--h1-server-port=8080"
+        - "--response-text=backend1"
+        ports:
+        - containerPort: 8080
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: backend-svc
+spec:
+  selector:
+    app: backend
+  ports:
+  - name: http
+    port: 8080
+    targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: failing
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: failing
+  template:
+    metadata:
+      labels:
+        app: failing
+    spec:
+      containers:
+      - name: failing
+        image: buoyantio/bb:v0.0.6
+        args:
+        - terminus
+        - "--h1-server-port=8081"
+        - "--response-text=failing"
+        - "--percent-failure=100"
+        ports:
+        - containerPort: 8081
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: failing-svc
+spec:
+  selector:
+    app: failing
+  ports:
+  - name: http
+    port: 8081
+    targetPort: 8081
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: slow-cooker
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: slow-cooker
+  template:
+    metadata:
+      labels:
+        app: slow-cooker
+    spec:
+      containers:
+      - name: slow-cooker
+        image: buoyantio/slow_cooker:1.3.0
+        command:
+        - "/bin/sh"
+        args:
+        - "-c"
+        - |
+          sleep 5 # wait for pods to start
+          /slow_cooker/slow_cooker http://backend-svc:8080
+        ports:
+        - containerPort: 9999
diff --git a/test/integration/trafficsplit/testdata/sp/traffic-split-leaf-weights.yaml b/test/integration/trafficsplit/testdata/sp/traffic-split-leaf-weights.yaml
new file mode 100644
index 000000000..be6f321f0
--- /dev/null
+++ b/test/integration/trafficsplit/testdata/sp/traffic-split-leaf-weights.yaml
@@ -0,0 +1,11 @@
+apiVersion: linkerd.io/v1alpha2
+kind: ServiceProfile
+metadata:
+  name: backend-svc.linkerd-trafficsplit-test-sp.svc.cluster.local
+spec:
+  dstOverrides:
+  - authority: "backend-svc.linkerd-trafficsplit-test-sp.svc.cluster.local"
+    weight: 500m
+  - authority: "failing-svc.linkerd-trafficsplit-test-sp.svc.cluster.local:8081"
+    weight: 0m
+---
diff --git a/test/integration/trafficsplit/testdata/sp/updated-traffic-split-leaf-weights.yaml b/test/integration/trafficsplit/testdata/sp/updated-traffic-split-leaf-weights.yaml
new file mode 100644
index 000000000..52f446e10
--- /dev/null
+++ b/test/integration/trafficsplit/testdata/sp/updated-traffic-split-leaf-weights.yaml
@@ -0,0 +1,11 @@
+apiVersion: linkerd.io/v1alpha2
+kind: ServiceProfile
+metadata:
+  name: backend-svc.linkerd-trafficsplit-test-sp.svc.cluster.local
+spec:
+  dstOverrides:
+  - authority: "backend-svc.linkerd-trafficsplit-test-sp.svc.cluster.local"
+    weight: 500m
+  - authority: "failing-svc.linkerd-trafficsplit-test-sp.svc.cluster.local:8081"
+    weight: 500m
+---
diff --git a/test/integration/trafficsplit/trafficsplit_test.go b/test/integration/trafficsplit/trafficsplit_test.go
index 37106d2cd..83921e3ee 100644
--- a/test/integration/trafficsplit/trafficsplit_test.go
+++ b/test/integration/trafficsplit/trafficsplit_test.go
@@ -67,6 +67,39 @@ func parseStatTsRow(out string, expectedRowCount, expectedColumnCount int) (map[
 	return statRows, nil
 }
 
+func parseStatRows(out string, expectedRowCount, expectedColumnCount int) ([]*testutil.RowStat, error) {
+	rows, err := testutil.CheckRowCount(out, expectedRowCount)
+	if err != nil {
+		return nil, err
+	}
+
+	var statRows []*testutil.RowStat
+
+	for _, row := range rows {
+		fields := strings.Fields(row)
+
+		if len(fields) != expectedColumnCount {
+			return nil, fmt.Errorf(
+				"Expected [%d] columns in stat output, got [%d]; full output:\n%s",
+				expectedColumnCount, len(fields), row)
+		}
+
+		row := &testutil.RowStat{
+			Name:               fields[0],
+			Meshed:             fields[1],
+			Success:            fields[2],
+			Rps:                fields[3],
+			P50Latency:         fields[4],
+			P95Latency:         fields[5],
+			P99Latency:         fields[6],
+			TCPOpenConnections: fields[7],
+		}
+
+		statRows = append(statRows, row)
+
+	}
+	return statRows, nil
+}
 func statTrafficSplit(from string, ns string) (map[string]*statTsRow, error) {
 	cmd := []string{"viz", "stat", "ts", "--from", from, "--namespace", ns, "-t", "30s", "--unmeshed"}
 	out, err := TestHelper.LinkerdRun(cmd...)
@@ -217,7 +250,7 @@ func TestTrafficSplitCli(t *testing.T) {
 					expectedFailingSvcOutput := &statTsRow{
 						name:       "backend-traffic-split",
 						apex:       "backend-svc",
-						leaf:       "backend-svc",
+						leaf:       "failing-svc",
 						weight:     "0",
 						success:    "-",
 						rps:        "-",
@@ -280,7 +313,7 @@ func TestTrafficSplitCli(t *testing.T) {
 					expectedFailingSvcOutput := &statTsRow{
 						name:    "backend-traffic-split",
 						apex:    "backend-svc",
-						leaf:    "backend-svc",
+						leaf:    "failing-svc",
 						weight:  weight,
 						success: "0.00%",
 						rps:     "0.5rps",
@@ -299,3 +332,196 @@ func TestTrafficSplitCli(t *testing.T) {
 		})
 	}
 }
+
+func TestTrafficSplitCliWithSP(t *testing.T) {
+
+	version := "sp"
+	ctx := context.Background()
+	TestHelper.WithDataPlaneNamespace(ctx, fmt.Sprintf("trafficsplit-test-%s", version), map[string]string{}, t, func(t *testing.T, prefixedNs string) {
+		out, err := TestHelper.LinkerdRun("inject", "--manual", "testdata/applications-at-diff-ports.yaml")
+		if err != nil {
+			testutil.AnnotatedFatal(t, "'linkerd inject' command failed", err)
+		}
+
+		out, err = TestHelper.KubectlApply(out, prefixedNs)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
+				"'kubectl apply' command failed\n%s", out)
+		}
+
+		TsResourceFile := fmt.Sprintf("testdata/%s/traffic-split-leaf-weights.yaml", version)
+		TsResource, err := testutil.ReadFile(TsResourceFile)
+
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "cannot read updated traffic split resource",
+				"cannot read updated traffic split resource: %s, %s", TsResource, err)
+		}
+
+		out, err = TestHelper.KubectlApply(TsResource, prefixedNs)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "failed to update traffic split resource",
+				"failed to update traffic split resource: %s\n %s", err, out)
+		}
+
+		// wait for deployments to start
+		for _, deploy := range []string{"backend", "failing", "slow-cooker"} {
+			if err := TestHelper.CheckPods(ctx, prefixedNs, deploy, 1); err != nil {
+				if rce, ok := err.(*testutil.RestartCountError); ok {
+					testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
+				} else {
+					testutil.AnnotatedError(t, "CheckPods timed-out", err)
+				}
+			}
+		}
+
+		t.Run(fmt.Sprintf("ensure traffic is sent to one backend only for %s", version), func(t *testing.T) {
+			timeout := 40 * time.Second
+			err := TestHelper.RetryFor(timeout, func() error {
+				out, err := TestHelper.LinkerdRun("viz", "stat", "deploy", "--namespace", prefixedNs, "--from", "deploy/slow-cooker", "-t", "30s")
+				if err != nil {
+					return err
+				}
+
+				rows, err := parseStatRows(out, 1, 8)
+				if err != nil {
+					return err
+				}
+
+				expectedRows := []*testutil.RowStat{
+					{
+						Name:               "backend",
+						Meshed:             "1/1",
+						Success:            "100.00%",
+						TCPOpenConnections: "1",
+					},
+				}
+
+				if err := validateRowStats(expectedRows, rows); err != nil {
+					return err
+				}
+				return nil
+			})
+
+			if err != nil {
+				testutil.AnnotatedFatal(t, fmt.Sprintf("timed-out ensuring traffic is sent to one backend only (%s)", timeout), err)
+			}
+		})
+
+		t.Run(fmt.Sprintf("update traffic split resource with equal weights for %s", version), func(t *testing.T) {
+
+			updatedTsResourceFile := fmt.Sprintf("testdata/%s/updated-traffic-split-leaf-weights.yaml", version)
+			updatedTsResource, err := testutil.ReadFile(updatedTsResourceFile)
+
+			if err != nil {
+				testutil.AnnotatedFatalf(t, "cannot read updated traffic split resource",
+					"cannot read updated traffic split resource: %s, %s", updatedTsResource, err)
+			}
+
+			out, err := TestHelper.KubectlApply(updatedTsResource, prefixedNs)
+			if err != nil {
+				testutil.AnnotatedFatalf(t, "failed to update traffic split resource",
+					"failed to update traffic split resource: %s\n %s", err, out)
+			}
+		})
+
+		t.Run(fmt.Sprintf("ensure traffic is sent to both backends for %s", version), func(t *testing.T) {
+			timeout := 40 * time.Second
+			err := TestHelper.RetryFor(timeout, func() error {
+
+				out, err := TestHelper.LinkerdRun("viz", "stat", "deploy", "-n", prefixedNs, "--from", "deploy/slow-cooker", "-t", "30s")
+				if err != nil {
+					return err
+				}
+
+				rows, err := parseStatRows(out, 2, 8)
+				if err != nil {
+					return err
+				}
+
+				expectedRows := []*testutil.RowStat{
+					{
+						Name:               "backend",
+						Meshed:             "1/1",
+						Success:            "100.00%",
+						TCPOpenConnections: "1",
+					},
+					{
+						Name:               "failing",
+						Meshed:             "1/1",
+						Success:            "0.00%",
+						TCPOpenConnections: "1",
+					},
+				}
+
+				if err := validateRowStats(expectedRows, rows); err != nil {
+					return err
+				}
+				return nil
+			})
+
+			if err != nil {
+				testutil.AnnotatedFatal(t, fmt.Sprintf("timed-out ensuring traffic is sent to both backends (%s)", timeout), err)
+			}
+		})
+	})
+}
+
+func validateRowStats(expectedRowStats, actualRowStats []*testutil.RowStat) error {
+
+	if len(expectedRowStats) != len(actualRowStats) {
+		return fmt.Errorf("Expected number of rows to be %d, but found %d", len(expectedRowStats), len(actualRowStats))
+	}
+
+	for i := 0; i < len(expectedRowStats); i++ {
+		err := compareRowStat(expectedRowStats[i], actualRowStats[i])
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func compareRowStat(expectedRow, actualRow *testutil.RowStat) error {
+
+	if actualRow.Name != expectedRow.Name {
+		return fmt.Errorf("Expected name to be '%s', got '%s'",
+			expectedRow.Name, actualRow.Name)
+	}
+
+	if actualRow.Meshed != expectedRow.Meshed {
+		return fmt.Errorf("Expected meshed to be '%s', got '%s'",
+			expectedRow.Meshed, actualRow.Meshed)
+	}
+
+	if !strings.HasSuffix(actualRow.Rps, "rps") {
+		return fmt.Errorf("Unexpected rps for [%s], got [%s]",
+			actualRow.Name, actualRow.Rps)
+	}
+
+	if !strings.HasSuffix(actualRow.P50Latency, "ms") {
+		return fmt.Errorf("Unexpected p50 latency for [%s], got [%s]",
+			actualRow.Name, actualRow.P50Latency)
+	}
+
+	if !strings.HasSuffix(actualRow.P95Latency, "ms") {
+		return fmt.Errorf("Unexpected p95 latency for [%s], got [%s]",
+			actualRow.Name, actualRow.P95Latency)
+	}
+
+	if !strings.HasSuffix(actualRow.P99Latency, "ms") {
+		return fmt.Errorf("Unexpected p99 latency for [%s], got [%s]",
+			actualRow.Name, actualRow.P99Latency)
+	}
+
+	if actualRow.Success != expectedRow.Success {
+		return fmt.Errorf("Expected success to be '%s', got '%s'",
+			expectedRow.Success, actualRow.Success)
+	}
+
+	if actualRow.TCPOpenConnections != expectedRow.TCPOpenConnections {
+		return fmt.Errorf("Expected tcp to be '%s', got '%s'",
+			expectedRow.TCPOpenConnections, actualRow.TCPOpenConnections)
+	}
+
+	return nil
+}
diff --git a/web/srv/testdata/api_check_output.json b/web/srv/testdata/api_check_output.json
index b7539ed8c..70f331e50 100644
--- a/web/srv/testdata/api_check_output.json
+++ b/web/srv/testdata/api_check_output.json
@@ -13,7 +13,7 @@
             "Warning": true,
             "Err": {},
             "ErrMsg": "check2-error",
-            "HintURL": "https://linkerd.io/checks/#check2-hint-anchor"
+            "HintURL": "https://linkerd.io/2/checks/#check2-hint-anchor"
         }],
         "linkerd-config": [{
             "Category": "linkerd-config",
@@ -24,4 +24,4 @@
         }]
     },
     "success": true
-}
\ No newline at end of file
+}