Repository: linkerd/linkerd2
Pull request #6060 (2021-04-26 16:09:48)

Support Traffic Splitting through `ServiceProfile.dstOverrides`
Initial part of #6054 

This PR 
- updates the destination logic to prevent the override of `ServiceProfiles.dstOverrides` when a `TS` is absent and no `dstOverrides` set. This means that any `serviceprofiles.dstOverrides` set by the user are correctly propagated to the proxy and thus making Traffic Splitting through service profiles work.
- This also updates the `profile_translator.toDstOverrides` to use the port from `GetProfiles` when there is no port in `dstOverrides.authority`


After these changes, The following `ServiceProfile` to perform
traffic splitting should work.

```yaml
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.:8080"
    weight: 500m
  - authority: "failing-svc.linkerd-trafficsplit-test-sp.svc.cluster.local.:8080"
    weight: 500m
```


This PR also adds an integration test i.e `TestTrafficSplitCliWithSP` which  checks for
Traffic Splitting through Service Profiles. This integration test follows the same pattern 
of other traffic split integration tests but Instead, we perform `linkerd viz stat` on the
client and check if there are expected server objects to be there as `linkerd viz stat ts`
does not _yet_ work with Service Profiles.

---
- update trafficsplit adapter to not do anything when ts are not present
- relax requirements of routes in sp-validator and Open API Validation
- nit: Update to the expectedLeaf to have the correct value
- add TestTrafficSplitCliWithSP integration test


Patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index c11dc8021..d6445afd8 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,14 +1,2 @@
 # By default, the maintainers group is responsible for everything.
-*			@linkerd/maintainers
-
-# These documents have special review requirements:
-/CHANGES.md		@adleong @olix0r
-/CODE_OF_CONDUCT.md	@olix0r
-/CONTRIBUTING.md	@olix0r
-/DCO    		@olix0r
-/GOVERNANCE.md		@olix0r
-/LICENSE		@olix0r
-/MAINTAINERS.md		@olix0r
-/README.md	    	@olix0r
-/SECURITY.md		@olix0r
-/SECURITY_AUDIT.pdf	@olix0r
+*   @linkerd/maintainers
diff --git a/CHANGES.md b/CHANGES.md
index b2aa81dcd..3a1e53f34 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,7 +2,7 @@
 
 ## edge-21.4.5
 
-This edge release adds a new `--short` flag to `linkerd check` to show a
+This edge release adds a new output format `short` for `linkerd check` to show a
 summary of the check output. This release also includes various proxy bug fixes
 and improvements.
 
@@ -15,8 +15,8 @@ and improvements.
     unlikely to occur in practice and would require very specific
     configuration overrides to be triggered.
 * CLI
-  * Added support for a `--short` flag to the check command to output a summary
-    of check results
+  * Added support for a new `short` format for the `--output` flag of the `check`
+    command to show a summary of check results
 
 ## edge-21.4.4
 
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/pkg/healthcheck/healthcheck_output.go b/pkg/healthcheck/healthcheck_output.go
index df195cfc3..3e7ffa38b 100644
--- a/pkg/healthcheck/healthcheck_output.go
+++ b/pkg/healthcheck/healthcheck_output.go
@@ -171,7 +171,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 +190,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 +211,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 +380,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()
 	}
 }
 
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)


Test patch:
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/opaqueports/opaque_ports_test.go b/test/integration/opaqueports/opaque_ports_test.go
index 722f2976d..b56b465a2 100644
--- a/test/integration/opaqueports/opaque_ports_test.go
+++ b/test/integration/opaqueports/opaque_ports_test.go
@@ -15,16 +15,24 @@ import (
 var TestHelper *testutil.TestHelper
 
 var (
-	opaquePodApp = "opaque-pod"
-	opaquePodSC  = "slow-cooker-opaque-pod"
-	opaqueSvcApp = "opaque-service"
-	opaqueSvcSC  = "slow-cooker-opaque-service"
-	tcpMetricRE  = regexp.MustCompile(
+	opaquePodApp         = "opaque-pod"
+	opaquePodSC          = "slow-cooker-opaque-pod"
+	opaqueSvcApp         = "opaque-service"
+	opaqueSvcSC          = "slow-cooker-opaque-service"
+	opaqueUnmeshedSvcPod = "opaque-unmeshed-svc"
+	opaqueUnmeshedSvcSC  = "slow-cooker-opaque-unmeshed-svc"
+	tcpMetricRE          = regexp.MustCompile(
 		`tcp_open_total\{direction="inbound",peer="src",target_addr="[0-9\.]+:[0-9]+",tls="true",client_id="default\.linkerd-opaque-ports-test\.serviceaccount\.identity\.linkerd\.cluster\.local"\} [0-9]+`,
 	)
+	tcpMetricOutUnmeshedRE = regexp.MustCompile(
+		`tcp_open_total\{direction="outbound",peer="dst",authority="[a-zA-Z\-]+\.[a-zA-Z\-]+\.svc\.cluster\.local:[0-9]+",target_addr="[0-9\.]+:[0-9]+",tls="no_identity",no_tls_reason="not_provided_by_service_discovery",.*\} [0-9]+`,
+	)
 	httpRequestTotalMetricRE = regexp.MustCompile(
 		`request_total\{direction="outbound",authority="[a-zA-Z\-]+\.[a-zA-Z\-]+\.svc\.cluster\.local:8080",target_addr="[0-9\.]+:8080",tls="true",.*`,
 	)
+	httpRequestTotalUnmeshedRE = regexp.MustCompile(
+		`request_total\{direction="outbound",authority="svc-opaque-unmeshed\.linkerd-opaque-ports-test\.svc\.cluster\.local:8080",target_addr="[0-9\.]+:8080",tls="no_identity",.*`,
+	)
 )
 
 func TestMain(m *testing.M) {
@@ -46,7 +54,7 @@ func TestOpaquePorts(t *testing.T) {
 			"failed to create %s namespace: %s", opaquePortsNs, err)
 	}
 
-	out, err := TestHelper.LinkerdRun("inject", "--manual", "testdata/opaque_ports_application.yaml")
+	out, err := TestHelper.LinkerdRun("inject", "testdata/opaque_ports_application.yaml")
 	if err != nil {
 		testutil.AnnotatedFatal(t, "'linkerd inject' command failed", err)
 	}
@@ -74,6 +82,15 @@ func TestOpaquePorts(t *testing.T) {
 		}
 	}
 
+	// Check that the unmeshed opaque service application started successfully.
+	if err := TestHelper.CheckPods(ctx, opaquePortsNs, opaqueUnmeshedSvcPod, 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)
+		}
+	}
+
 	// Wait for slow-cookers to start sending requests
 	time.Sleep(20 * time.Second)
 
@@ -130,6 +147,27 @@ func TestOpaquePorts(t *testing.T) {
 			testutil.AnnotatedFatalf(t, "failed to find expected TCP metric when pod is opaque", "failed to find expected TCP metric when pod is opaque\n%s", metrics)
 		}
 	})
+
+	t.Run("expect inbound TCP connection metric with no TLS identity for traffic between meshed and unmeshed opaque service", func(t *testing.T) {
+		// Slow cooker is meshed, should have valid outbound TCP metric, valid
+		// inbound TCP metric and no HTTP metric.
+		pods, err := TestHelper.GetPods(ctx, opaquePortsNs,
+			map[string]string{"app": opaqueUnmeshedSvcSC})
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error getting pods", "error getting pods\n%s", err)
+		}
+		metrics, err := getPodMetrics(pods[0], opaquePortsNs)
+		if err != nil {
+			testutil.AnnotatedFatalf(t, "error getting metrics for pod", "error getting metrics for pod\n%s", err)
+		}
+
+		if httpRequestTotalUnmeshedRE.MatchString(metrics) {
+			testutil.AnnotatedFatalf(t, "expected not to find HTTP outbound requests when service is opaque", "expected not to find HTTP outbound requests when service is opaque\n%s", metrics)
+		}
+		if !tcpMetricOutUnmeshedRE.MatchString(metrics) {
+			testutil.AnnotatedFatalf(t, "failed to find expected TCP outbound metric when pod is opaque", "failed to find expected TCP outbound metric when pod is opaque\n%s", metrics)
+		}
+	})
 }
 
 func getPodMetrics(pod v1.Pod, ns string) (string, error) {
diff --git a/test/integration/opaqueports/testdata/opaque_ports_application.yaml b/test/integration/opaqueports/testdata/opaque_ports_application.yaml
index ddd98b681..37165f08b 100644
--- a/test/integration/opaqueports/testdata/opaque_ports_application.yaml
+++ b/test/integration/opaqueports/testdata/opaque_ports_application.yaml
@@ -127,3 +127,69 @@ spec:
         ports:
         - containerPort: 9999
       restartPolicy: OnFailure
+---
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: slow-cooker-opaque-unmeshed-svc
+spec:
+  template:
+    metadata:
+      labels:
+        app: slow-cooker-opaque-unmeshed-svc
+    spec:
+      containers:
+      - name: slow-cooker-opaque-unmeshed-svc
+        image: buoyantio/slow_cooker:1.3.0
+        command:
+        - "/bin/sh"
+        args:
+        - "-c"
+        - |
+          sleep 15 # wait for pods to start
+          /slow_cooker/slow_cooker -qps 1 -metric-addr 0.0.0.0:9999 http://svc-opaque-unmeshed:8080
+        ports:
+        - containerPort: 9999
+      restartPolicy: OnFailure
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: opaque-unmeshed-svc
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: opaque-unmeshed-svc
+  template:
+    metadata:
+      annotations:
+        linkerd.io/inject: disabled
+      labels:
+        app: opaque-unmeshed-svc
+    spec:
+      containers:
+      - name: app
+        image: buoyantio/bb:v0.0.6
+        args:
+        - terminus
+        - "--h1-server-port=8080"
+        - "--response-text=opaque-unmeshed-svc"
+        ports:
+        - containerPort: 8080
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: svc-opaque-unmeshed
+  labels:
+    app: svc-opaque-unmeshed
+  annotations:
+    config.linkerd.io/opaque-ports: "8080"
+spec:
+  selector:
+    app: opaque-unmeshed-svc
+  ports:
+  - name: http
+    port: 8080
+    targetPort: 8080
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
+}