Repository: linkerd/linkerd2
Pull request #4147 (2020-03-06 18:10:45)

Add bin/install-pr script

# Install PR

This script takes a Github pull request number as an argument, downloads the
docker images from the pull request's artifacts, pushes them, and installs
them on your Kubernetes cluster.  Requires a Github personal access token
in the $GITHUB_TOKEN environment variable.

This script contains the following shellcheck violations:

```
In bin/install-pr line 16:
  echo 'Generate a personal access token at https://github.com/settings/tokens and set it in the $GITHUB_TOKEN env var'
       ^-- SC2016: Expressions don't expand in single quotes, use double quotes for that.
```

This message is intended to display the literal dollar-sign and expansion is not desired.

```
In bin/install-pr line 47:
cd -
^-- SC2103: Use a ( subshell ) to avoid having to cd back.
```

In this section we set the `tag` variable and use it below.  Using a subshell would lose any changes to the `tag` variable when the subshell exits.

Signed-off-by: Alex Leong <alex@buoyant.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/master/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/master/CONTRIBUTING.md#committing
-->



Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 08de82bf8..010165911 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -289,7 +289,7 @@ jobs:
         until RES=$(curl -sL https://run.linkerd.io/$INSTALL | grep "LINKERD2_VERSION=\${LINKERD2_VERSION:-$TAG}") \
           || (( count++ >= 10 ))
         do
-          sleep 5
+          sleep 30
         done
         if [[ -z $RES ]]; then
           echo "::error::The version '$TAG' was NOT found published in the website"
diff --git a/bin/create-release-tag b/bin/create-release-tag
index 50f5ba9ae..ab95ff9c4 100755
--- a/bin/create-release-tag
+++ b/bin/create-release-tag
@@ -3,41 +3,98 @@
 set -o errexit
 set -o nounset
 
-if [[ $# -ne 1 ]]; then
-    echo "usage: ${0##*/} (edge|stable)-xx.xx.xx" >&2
+# Assert at least one and at most two arguments were passed.
+if [[ $# -lt 1 || $# -gt 2 ]]; then
+    echo "Error: ${0##*/} accepts 1 or 2 arguments" >&2
+    echo "Usage:" >&2
+    echo "    ${0##*/} edge" >&2
+    echo "    ${0##*/} stable x.x.x" >&2
     exit 1
 fi
 
-tag="$1"
-
-# Verify the tag format
-tag_format="^(edge|stable)-([0-9]+)\.([0-9]+)\.([0-9]+)$"
-if [[ $tag =~ $tag_format ]]; then
-    # todo: Use these values to verify the tag version increment.
-    # release_channel="${BASH_REMATCH[1]}"
-    # release_major="${BASH_REMATCH[2]}"
-    # release_minor="${BASH_REMATCH[3]}"
-    # release_patch="${BASH_REMATCH[4]}"
-    :
-else
-    echo "tag format incorrect; expected: $tag_format"
-    echo "example: edge-20.12.2, stable-2.10.1"
+release_channel="$1"
+
+# Verify the release channel.
+release_channel_regex="(edge|stable)"
+if [[ ! $release_channel =~ $release_channel_regex ]]; then
+    echo "Error: valid release channels: edge, stable"
+    echo "Usage:"
+    echo "    bin/create-release-tag edge"
+    echo "    bin/create-release-tag stable 2.4.8"
     exit 1
 fi
 
-# todo: Verify the tag version increment.
+if [ "$release_channel" == "stable" ]; then
+    if [ $# -ne 2 ]; then
+        echo "Error: accepts 2 arguments"
+        echo "Usage:"
+        echo "    bin/create-release-tag stable 2.4.8"
+        exit 1
+    fi
+
+    stable_tag="$2"
+
+    # Verify the stable tag format.
+    stable_tag_regex="^([0-9]+)\.([0-9]+)\.([0-9]+)$"
+    if [[ ! $stable_tag =~ $stable_tag_regex ]]; then
+        echo "Error: version reference incorrect"
+        echo "Usage:"
+        echo "    bin/create-release-tag stable 2.4.8"
+        exit 1
+    fi
+
+    # Set the script global `release_tag` variable.
+    release_tag="stable-$stable_tag"
+fi
+
+if [ "$release_channel" == "edge" ]; then
+if [ $# -ne 1 ]; then
+        echo "Error: accepts 1 argument"
+        echo "Usage:"
+        echo "    bin/create-release-tag edge"
+        exit 1
+    fi
+
+    # Some awks on Linux do no support quantifiers in regex, so this regex is
+    # explicit.
+    edge_tag_regex="(edge)-([0-9][0-9])\.([0-9]|[0-9][0-9])\.([0-9]+)"
+    url="https://run.linkerd.io/install-edge"
+
+    # Get the current edge version.
+    current_edge=$(curl -sL $url | awk -v tag_format="$edge_tag_regex" '$0 ~ tag_format')
+
+    # Get the third and fourth groups of the regex; they are the month and
+    # month minor values for the current edge version.
+    current_mm=$(echo "$current_edge" | sed -n -E "s/.*$edge_tag_regex}$/\3/p")
+    current_xx=$(echo "$current_edge" | sed -n -E "s/.*$edge_tag_regex}$/\4/p")
+
+    yy=$(date +"%y")
+    
+    # Strip leading zero
+    new_mm=$(date +"%-m")
+
+    # If this is a new month, `new_xx` should be 1; otherwise increment it.
+    if [ ! "$new_mm" == "$current_mm" ]; then
+        new_xx="1"
+    else
+        new_xx=$((current_xx+1))
+    fi
+
+    # Set the script global `release_tag` variable.
+    release_tag="edge-$yy.$new_mm.$new_xx"
+fi
 
 bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
 # shellcheck source=bin/_release.sh
 tmp=$(. "$bindir"/_release.sh; extract_release_notes)
 
-# Create an unsigned tag with the commit message.
-git tag -s -F "$tmp" "$tag"
+# Create a signed tag with the commit message.
+git tag -s -F "$tmp" "$release_tag"
 
 # Success message
-echo "tag created and signed."
+echo "$release_tag tag created and signed."
 echo ""
-echo "tag: $tag"
+echo "tag: $release_tag"
 echo ""
 echo "To push tag, run:"
-echo "    git push origin $tag"
+echo "    git push origin $release_tag"
diff --git a/bin/install-pr b/bin/install-pr
new file mode 100755
index 000000000..40445c27c
--- /dev/null
+++ b/bin/install-pr
@@ -0,0 +1,71 @@
+#!/bin/bash
+
+### Install PR ###
+#
+# This script takes a Github pull request number as an argument, downloads the
+# docker images from the pull request's artifacts, pushes them, and installs
+# them on your Kubernetes cluster.  Requires a Github personal access token
+# in the $GITHUB_TOKEN environment variable.
+
+set -eo pipefail
+
+pr=$1
+
+if [ -z "$pr" ]
+then
+  echo "usage: ${0##*/} (PR number)" >&2
+  exit 1
+fi
+
+if [ -z "$GITHUB_TOKEN" ]
+then
+  # shellcheck disable=SC2016
+  echo 'Generate a personal access token at https://github.com/settings/tokens and set it in the $GITHUB_TOKEN env var'
+  exit 1
+fi
+
+auth="Authorization: token $GITHUB_TOKEN"
+
+branch=$(curl -s -H "$auth" "https://api.github.com/repos/linkerd/linkerd2/pulls/$pr" | jq -r '.head.ref')
+
+artifacts=$(curl -s -H "$auth" "https://api.github.com/repos/linkerd/linkerd2/actions/workflows/kind_integration.yml/runs?branch=$branch" | jq -r '.workflow_runs[0].artifacts_url')
+
+archive_url=$(curl -s -H "$auth" "$artifacts" | jq -r '.artifacts[0].archive_download_url')
+
+dir=$(mktemp -d -t "linkerd-pr-$pr.XXXXXXXXXX")
+
+cd "$dir" || exit
+
+echo "### Downloading images ###"
+
+curl -L -o archive.zip -H "$auth" "$archive_url"
+
+unzip -o archive.zip
+
+echo "### Pushing images ###"
+
+for archive in cli-bin cni-plugin controller debug grafana proxy web
+do 
+  image=$(docker load -i "$archive.tar" | sed "s/Loaded image: //")
+  docker push "$image"
+  tag=$(echo "$image" | cut -f 2 -d ":")
+done
+
+cd -
+
+rm -rf "$dir"
+
+linkerd=$(bin/docker-pull-binaries "$tag" | head -n 1)
+
+echo "### Pre checks ###"
+
+$linkerd check --pre
+
+echo "### Installing $tag ###"
+
+$linkerd install | kubectl apply -f -
+
+$linkerd check
+
+echo "### Linkerd installed. CLI available: "
+echo "$linkerd"
diff --git a/cli/cmd/alpha_stat.go b/cli/cmd/alpha_stat.go
index e046ed103..41f20e679 100644
--- a/cli/cmd/alpha_stat.go
+++ b/cli/cmd/alpha_stat.go
@@ -14,6 +14,7 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 )
 
@@ -30,8 +31,9 @@ var allowedKinds = map[string]struct{}{
 }
 
 type alphaStatOptions struct {
-	namespace  string
-	toResource string
+	namespace     string
+	toResource    string
+	allNamespaces bool
 }
 
 func newCmdAlphaStat() *cobra.Command {
@@ -84,10 +86,19 @@ Examples:
 				return err
 			}
 
-			target, err := util.BuildResource(options.namespace, args[0])
+			namespace := options.namespace
+			if options.allNamespaces {
+				namespace = corev1.NamespaceAll
+			}
+			target, err := util.BuildResource(namespace, args[0])
 			if err != nil {
 				return err
 			}
+			if target.GetName() != "" && options.allNamespaces {
+				// Getting a named resource from all-namespaces is not supported.
+				return errors.New("cannot use --all-namespaces flag with a named target resource")
+			}
+
 			if _, ok := allowedKinds[target.GetType()]; !ok {
 				return fmt.Errorf("%s is not a supported resource type", target.GetType())
 			}
@@ -96,7 +107,7 @@ Examples:
 				return err
 			}
 			name := target.GetName()
-			toResource := buildToResource(options.namespace, options.toResource)
+			toResource := buildToResource("", options.toResource)
 			// TODO: Lift this requirement once the API supports it.
 			if toResource != nil && toResource.GetType() != target.GetType() {
 				return errors.New("the --to resource must have the same kind as the target resource")
@@ -114,7 +125,7 @@ Examples:
 					if err != nil {
 						return err
 					}
-					renderTrafficMetrics(metrics, stdout)
+					renderTrafficMetrics(metrics, options.allNamespaces, stdout)
 				}
 			} else {
 				if toResource != nil {
@@ -124,7 +135,7 @@ Examples:
 				if err != nil {
 					return err
 				}
-				renderTrafficMetricsList(metrics, stdout)
+				renderTrafficMetricsList(metrics, options.allNamespaces, stdout)
 			}
 
 			return nil
@@ -133,6 +144,7 @@ Examples:
 
 	statCmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the specified resource")
 	statCmd.PersistentFlags().StringVar(&options.toResource, "to", options.toResource, "If present, restricts outbound stats to the specified resource name")
+	statCmd.PersistentFlags().BoolVarP(&options.allNamespaces, "all-namespaces", "A", options.allNamespaces, "Ignore the --namespace flag and fetches data from all namespaces")
 
 	return statCmd
 }
@@ -148,14 +160,14 @@ func buildToResource(namespace, to string) *public.Resource {
 	return &toResource
 }
 
-func renderTrafficMetrics(metrics *smimetrics.TrafficMetrics, w io.Writer) {
-	t := buildTable(false)
+func renderTrafficMetrics(metrics *smimetrics.TrafficMetrics, allNamespaces bool, w io.Writer) {
+	t := buildTable(false, allNamespaces)
 	t.Data = []table.Row{metricsToRow(metrics, false)}
 	t.Render(w)
 }
 
-func renderTrafficMetricsList(metrics *smimetrics.TrafficMetricsList, w io.Writer) {
-	t := buildTable(false)
+func renderTrafficMetricsList(metrics *smimetrics.TrafficMetricsList, allNamespaces bool, w io.Writer) {
+	t := buildTable(false, allNamespaces)
 	t.Data = []table.Row{}
 	for _, row := range metrics.Items {
 		row := row // Copy to satisfy golint.
@@ -165,20 +177,24 @@ func renderTrafficMetricsList(metrics *smimetrics.TrafficMetricsList, w io.Write
 }
 
 func renderTrafficMetricsEdgesList(metrics *smimetrics.TrafficMetricsList, w io.Writer, toResource *public.Resource) {
-	outbound := toResource != nil
-	t := buildTable(outbound)
+	t := buildTable(true, false)
 	t.Data = []table.Row{}
 	for _, row := range metrics.Items {
 		row := row // Copy to satisfy golint.
 		if row.Edge.Direction != "to" {
 			continue
 		}
-		if toResource != nil && toResource.GetName() != "" &&
-			(row.Edge.Resource.Name != toResource.GetName() || row.Edge.Resource.Namespace != toResource.GetNamespace()) {
-			log.Debugf("Skipping edge %v", row.Edge.Resource)
-			continue
+		if toResource != nil {
+			if toResource.GetName() != "" && row.Edge.Resource.Name != toResource.GetName() {
+				log.Debugf("Skipping edge %v", row.Edge.Resource)
+				continue
+			}
+			if toResource.GetNamespace() != "" && row.Edge.Resource.Namespace != toResource.GetNamespace() {
+				log.Debugf("Skipping edge %v", row.Edge.Resource)
+				continue
+			}
 		}
-		t.Data = append(t.Data, metricsToRow(&row, outbound))
+		t.Data = append(t.Data, metricsToRow(&row, true))
 	}
 	t.Render(w)
 }
@@ -227,10 +243,11 @@ func metricsToRow(metrics *smimetrics.TrafficMetrics, outbound bool) []string {
 
 	var to string
 	if outbound {
-		to = metrics.Edge.Resource.Name
+		to = metrics.Edge.Resource.String()
 	}
 
 	return []string{
+		metrics.Resource.Namespace,
 		metrics.Resource.Name, // Name
 		metrics.Resource.Name, // From
 		to,                    // To
@@ -242,8 +259,15 @@ func metricsToRow(metrics *smimetrics.TrafficMetrics, outbound bool) []string {
 	}
 }
 
-func buildTable(outbound bool) table.Table {
+func buildTable(outbound, allNamespaces bool) table.Table {
 	columns := []table.Column{
+		table.Column{
+			Header:    "NAMESPACE",
+			Width:     9,
+			Hide:      !allNamespaces,
+			Flexible:  true,
+			LeftAlign: true,
+		},
 		table.Column{
 			Header:    "NAME",
 			Width:     4,
@@ -286,5 +310,7 @@ func buildTable(outbound bool) table.Table {
 			Width:  11,
 		},
 	}
-	return table.NewTable(columns, []table.Row{})
+	t := table.NewTable(columns, []table.Row{})
+	t.Sort = []int{0, 1} // Sort by namespace, then name.
+	return t
 }
diff --git a/controller/api/destination/endpoint_translator.go b/controller/api/destination/endpoint_translator.go
index d8e30d737..c4acdbc7c 100644
--- a/controller/api/destination/endpoint_translator.go
+++ b/controller/api/destination/endpoint_translator.go
@@ -39,9 +39,9 @@ func newEndpointTranslator(
 	return &endpointTranslator{controllerNS, identityTrustDomain, enableH2Upgrade, stream, log}
 }
 
-func (et *endpointTranslator) Add(set watcher.PodSet) {
+func (et *endpointTranslator) Add(set watcher.AddressSet) {
 	addrs := []*pb.WeightedAddr{}
-	for _, address := range set.Pods {
+	for _, address := range set.Addresses {
 		var (
 			wa  *pb.WeightedAddr
 			err error
@@ -49,14 +49,31 @@ func (et *endpointTranslator) Add(set watcher.PodSet) {
 		if address.Pod != nil {
 			wa, err = et.toWeightedAddr(address)
 		} else {
-			// handling address with no associated pod, such as those
-			// belonging to headless services
+			// handling address with no associated pod
 			var addr *net.TcpAddress
 			addr, err = et.toAddr(address)
 			wa = &pb.WeightedAddr{
 				Addr:   addr,
 				Weight: defaultWeight,
 			}
+
+			if address.Identity != "" {
+				wa.TlsIdentity = &pb.TlsIdentity{
+					Strategy: &pb.TlsIdentity_DnsLikeIdentity_{
+						DnsLikeIdentity: &pb.TlsIdentity_DnsLikeIdentity{
+							Name: address.Identity,
+						},
+					},
+				}
+				// in this case we most likely have a proxy on the side, so set protocol hint as well.
+				if et.enableH2Upgrade {
+					wa.ProtocolHint = &pb.ProtocolHint{
+						Protocol: &pb.ProtocolHint_H2_{
+							H2: &pb.ProtocolHint_H2{},
+						},
+					}
+				}
+			}
 		}
 		if err != nil {
 			et.log.Errorf("Failed to translate endpoints to weighted addr: %s", err)
@@ -78,9 +95,9 @@ func (et *endpointTranslator) Add(set watcher.PodSet) {
 	}
 }
 
-func (et *endpointTranslator) Remove(set watcher.PodSet) {
+func (et *endpointTranslator) Remove(set watcher.AddressSet) {
 	addrs := []*net.TcpAddress{}
-	for _, address := range set.Pods {
+	for _, address := range set.Addresses {
 		tcpAddr, err := et.toAddr(address)
 		if err != nil {
 			et.log.Errorf("Failed to translate endpoints to addr: %s", err)
diff --git a/controller/api/destination/watcher/endpoints_watcher.go b/controller/api/destination/watcher/endpoints_watcher.go
index 875c99370..a1ecdfe99 100644
--- a/controller/api/destination/watcher/endpoints_watcher.go
+++ b/controller/api/destination/watcher/endpoints_watcher.go
@@ -7,6 +7,7 @@ import (
 	"sync"
 
 	"github.com/linkerd/linkerd2/controller/k8s"
+	consts "github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/prometheus/client_golang/prometheus"
 	logging "github.com/sirupsen/logrus"
 	corev1 "k8s.io/api/core/v1"
@@ -24,19 +25,24 @@ const (
 // https://github.com/linkerd/linkerd2/issues/2204
 
 type (
-	// Address represents an individual port on an specific pod.
+	// Address represents an individual port on a specific endpoint.
+	// This endpoint might be the result of a the existence of a pod
+	// that is targeted by this service; alternatively it can be the
+	// case that this endpoint is not associated with a pod and maps
+	// to some other IP (i.e. a remote service gateway)
 	Address struct {
 		IP        string
 		Port      Port
 		Pod       *corev1.Pod
 		OwnerName string
 		OwnerKind string
+		Identity  string
 	}
 
-	// PodSet is a set of pods, indexed by IP.
-	PodSet struct {
-		Pods   map[PodID]Address
-		Labels map[string]string
+	// AddressSet is a set of Address, indexed by ID.
+	AddressSet struct {
+		Addresses map[ID]Address
+		Labels    map[string]string
 	}
 
 	portAndHostname struct {
@@ -89,15 +95,15 @@ type (
 		k8sAPI     *k8s.API
 
 		exists    bool
-		pods      PodSet
+		addresses AddressSet
 		listeners []EndpointUpdateListener
 		metrics   endpointsMetrics
 	}
 
 	// EndpointUpdateListener is the interface that subscribers must implement.
 	EndpointUpdateListener interface {
-		Add(set PodSet)
-		Remove(set PodSet)
+		Add(set AddressSet)
+		Remove(set AddressSet)
 		NoEndpoints(exists bool)
 	}
 )
@@ -417,32 +423,32 @@ func (sp *servicePublisher) metricsLabels(port Port, hostname string) prometheus
 // portPublisher.
 
 func (pp *portPublisher) updateEndpoints(endpoints *corev1.Endpoints) {
-	newPods := pp.endpointsToAddresses(endpoints)
-	if len(newPods.Pods) == 0 {
+	newAddressSet := pp.endpointsToAddresses(endpoints)
+	if len(newAddressSet.Addresses) == 0 {
 		for _, listener := range pp.listeners {
 			listener.NoEndpoints(true)
 		}
 	} else {
-		add, remove := diffPods(pp.pods, newPods)
+		add, remove := diffAddresses(pp.addresses, newAddressSet)
 		for _, listener := range pp.listeners {
-			if len(remove.Pods) > 0 {
+			if len(remove.Addresses) > 0 {
 				listener.Remove(remove)
 			}
-			if len(add.Pods) > 0 {
+			if len(add.Addresses) > 0 {
 				listener.Add(add)
 			}
 		}
 	}
 	pp.exists = true
-	pp.pods = newPods
+	pp.addresses = newAddressSet
 
 	pp.metrics.incUpdates()
-	pp.metrics.setPods(len(pp.pods.Pods))
+	pp.metrics.setPods(len(pp.addresses.Addresses))
 	pp.metrics.setExists(true)
 }
 
-func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSet {
-	pods := make(map[PodID]Address)
+func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) AddressSet {
+	addresses := make(map[ID]Address)
 	for _, subset := range endpoints.Subsets {
 		resolvedPort := pp.resolveTargetPort(subset)
 		for _, endpoint := range subset.Addresses {
@@ -458,9 +464,11 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 					}, "-"),
 					Namespace: endpoints.ObjectMeta.Namespace,
 				}
-				pods[id] = Address{
-					IP:   endpoint.IP,
-					Port: resolvedPort,
+
+				addresses[id] = Address{
+					IP:       endpoint.IP,
+					Port:     resolvedPort,
+					Identity: endpoints.Annotations[consts.RemoteGatewayIdentity],
 				}
 				continue
 			}
@@ -475,7 +483,7 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 					continue
 				}
 				ownerKind, ownerName := pp.k8sAPI.GetOwnerKindAndName(pod, false)
-				pods[id] = Address{
+				addresses[id] = Address{
 					IP:        endpoint.IP,
 					Port:      resolvedPort,
 					Pod:       pod,
@@ -485,9 +493,9 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 			}
 		}
 	}
-	return PodSet{
-		Pods:   pods,
-		Labels: map[string]string{"service": endpoints.Name, "namespace": endpoints.Namespace},
+	return AddressSet{
+		Addresses: addresses,
+		Labels:    map[string]string{"service": endpoints.Name, "namespace": endpoints.Namespace},
 	}
 }
 
@@ -517,7 +525,7 @@ func (pp *portPublisher) updatePort(targetPort namedPort) {
 
 func (pp *portPublisher) noEndpoints(exists bool) {
 	pp.exists = exists
-	pp.pods = PodSet{}
+	pp.addresses = AddressSet{}
 	for _, listener := range pp.listeners {
 		listener.NoEndpoints(exists)
 	}
@@ -529,8 +537,8 @@ func (pp *portPublisher) noEndpoints(exists bool) {
 
 func (pp *portPublisher) subscribe(listener EndpointUpdateListener) {
 	if pp.exists {
-		if len(pp.pods.Pods) > 0 {
-			listener.Add(pp.pods)
+		if len(pp.addresses.Addresses) > 0 {
+			listener.Add(pp.addresses)
 		} else {
 			listener.NoEndpoints(true)
 		}
@@ -584,28 +592,28 @@ func getTargetPort(service *corev1.Service, port Port) namedPort {
 	return targetPort
 }
 
-func diffPods(oldPods, newPods PodSet) (add, remove PodSet) {
+func diffAddresses(oldAddresses, newAddresses AddressSet) (add, remove AddressSet) {
 	// TODO: this detects pods which have been added or removed, but does not
-	// detect pods which have been modified.  A modified pod should trigger
+	// detect addresses which have been modified.  A modified address should trigger
 	// an add of the new version.
-	addPods := make(map[PodID]Address)
-	removePods := make(map[PodID]Address)
-	for id, pod := range newPods.Pods {
-		if _, ok := oldPods.Pods[id]; !ok {
-			addPods[id] = pod
+	addAddesses := make(map[ID]Address)
+	removeAddresses := make(map[ID]Address)
+	for id, address := range newAddresses.Addresses {
+		if _, ok := oldAddresses.Addresses[id]; !ok {
+			addAddesses[id] = address
 		}
 	}
-	for id, pod := range oldPods.Pods {
-		if _, ok := newPods.Pods[id]; !ok {
-			removePods[id] = pod
+	for id, address := range oldAddresses.Addresses {
+		if _, ok := newAddresses.Addresses[id]; !ok {
+			removeAddresses[id] = address
 		}
 	}
-	add = PodSet{
-		Pods:   addPods,
-		Labels: newPods.Labels,
+	add = AddressSet{
+		Addresses: addAddesses,
+		Labels:    newAddresses.Labels,
 	}
-	remove = PodSet{
-		Pods: removePods,
+	remove = AddressSet{
+		Addresses: removeAddresses,
 	}
 	return
 }
diff --git a/controller/api/destination/watcher/ip_watcher.go b/controller/api/destination/watcher/ip_watcher.go
index a38aa1330..b0a45d017 100644
--- a/controller/api/destination/watcher/ip_watcher.go
+++ b/controller/api/destination/watcher/ip_watcher.go
@@ -29,7 +29,7 @@ type (
 
 		// At most one of service or pod may be non-zero.
 		service ServiceID
-		pod     PodSet
+		pod     AddressSet
 
 		listeners map[EndpointUpdateListener]Port
 		endpoints *EndpointsWatcher
@@ -150,7 +150,7 @@ func (iw *IPWatcher) addPod(obj interface{}) {
 		return
 	}
 	ss := iw.getOrNewServiceSubscriptions(pod.Status.PodIP)
-	ss.updatePod(iw.podToPodSet(pod))
+	ss.updatePod(iw.podToAddressSet(pod))
 }
 
 func (iw *IPWatcher) deletePod(obj interface{}) {
@@ -228,7 +228,7 @@ func (iw *IPWatcher) getOrNewServiceSubscriptions(clusterIP string) *serviceSubs
 				iw.log.Errorf("Pod IP conflict: %v, %v", objs[0], objs[1])
 			}
 			if len(pods) == 1 {
-				ss.pod = iw.podToPodSet(pods[0])
+				ss.pod = iw.podToAddressSet(pods[0])
 			}
 		}
 
@@ -244,14 +244,14 @@ func (iw *IPWatcher) getServiceSubscriptions(clusterIP string) (ss *serviceSubsc
 	return
 }
 
-func (iw *IPWatcher) podToPodSet(pod *corev1.Pod) PodSet {
+func (iw *IPWatcher) podToAddressSet(pod *corev1.Pod) AddressSet {
 	if pod.Spec.HostNetwork {
-		return PodSet{}
+		return AddressSet{}
 	}
 
 	ownerKind, ownerName := iw.k8sAPI.GetOwnerKindAndName(pod, true)
-	return PodSet{
-		Pods: map[PodID]Address{
+	return AddressSet{
+		Addresses: map[PodID]Address{
 			PodID{
 				Name:      pod.Name,
 				Namespace: pod.Namespace,
@@ -292,7 +292,7 @@ func (ss *serviceSubscriptions) updateService(service *corev1.Service) {
 			}
 		}
 		ss.service = id
-		ss.pod = PodSet{}
+		ss.pod = AddressSet{}
 	}
 }
 
@@ -309,13 +309,13 @@ func (ss *serviceSubscriptions) deleteService() {
 	ss.service = ServiceID{}
 }
 
-func (ss *serviceSubscriptions) updatePod(podSet PodSet) {
+func (ss *serviceSubscriptions) updatePod(podSet AddressSet) {
 	ss.Lock()
 	defer ss.Unlock()
 
 	for listener, port := range ss.listeners {
 		listener.NoEndpoints(true) // Clear out previous endpoints.
-		if len(podSet.Pods) != 0 {
+		if len(podSet.Addresses) != 0 {
 			podSetWithPort := withPort(podSet, port)
 			listener.Add(podSetWithPort)
 		}
@@ -330,7 +330,7 @@ func (ss *serviceSubscriptions) deletePod() {
 		listener.NoEndpoints(true) // Clear out previous endpoints.
 		listener.Add(singletonAddress(ss.clusterIP, port))
 	}
-	ss.pod = PodSet{}
+	ss.pod = AddressSet{}
 }
 
 func (ss *serviceSubscriptions) subscribe(port Port, listener EndpointUpdateListener) error {
@@ -342,7 +342,7 @@ func (ss *serviceSubscriptions) subscribe(port Port, listener EndpointUpdateList
 		if err != nil {
 			return err
 		}
-	} else if len(ss.pod.Pods) != 0 {
+	} else if len(ss.pod.Addresses) != 0 {
 		podSetWithPort := withPort(ss.pod, port)
 		listener.Add(podSetWithPort)
 	} else {
@@ -362,21 +362,21 @@ func (ss *serviceSubscriptions) unsubscribe(port Port, listener EndpointUpdateLi
 	delete(ss.listeners, listener)
 }
 
-func withPort(pods PodSet, port Port) PodSet {
-	wp := PodSet{
-		Pods:   map[PodID]Address{},
-		Labels: pods.Labels,
+func withPort(pods AddressSet, port Port) AddressSet {
+	wp := AddressSet{
+		Addresses: map[PodID]Address{},
+		Labels:    pods.Labels,
 	}
-	for id, pod := range pods.Pods {
-		pod.Port = port
-		wp.Pods[id] = pod
+	for id, addr := range pods.Addresses {
+		addr.Port = port
+		wp.Addresses[id] = addr
 	}
 	return wp
 }
 
-func singletonAddress(ip string, port Port) PodSet {
-	return PodSet{
-		Pods: map[PodID]Address{
+func singletonAddress(ip string, port Port) AddressSet {
+	return AddressSet{
+		Addresses: map[PodID]Address{
 			PodID{}: Address{
 				IP:   ip,
 				Port: port,
diff --git a/grafana/dashboards/authority.json b/grafana/dashboards/authority.json
index 08b6caf7f..9e8adedc6 100644
--- a/grafana/dashboards/authority.json
+++ b/grafana/dashboards/authority.json
@@ -1180,7 +1180,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1202,7 +1202,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/cronjob.json b/grafana/dashboards/cronjob.json
index 8d9a97eb9..0f7268e1c 100644
--- a/grafana/dashboards/cronjob.json
+++ b/grafana/dashboards/cronjob.json
@@ -2174,7 +2174,7 @@
     "templating": {
       "list": [
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2196,7 +2196,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2218,7 +2218,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2240,7 +2240,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
diff --git a/grafana/dashboards/daemonset.json b/grafana/dashboards/daemonset.json
index c11e634d0..0a86b717c 100644
--- a/grafana/dashboards/daemonset.json
+++ b/grafana/dashboards/daemonset.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/deployment.json b/grafana/dashboards/deployment.json
index 32185b3f0..08c5d83d7 100644
--- a/grafana/dashboards/deployment.json
+++ b/grafana/dashboards/deployment.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/health.json b/grafana/dashboards/health.json
index 203faf5a6..c613141db 100644
--- a/grafana/dashboards/health.json
+++ b/grafana/dashboards/health.json
@@ -2233,7 +2233,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2258,7 +2258,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2283,7 +2283,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/job.json b/grafana/dashboards/job.json
index 69bdabd5d..e2e268c07 100644
--- a/grafana/dashboards/job.json
+++ b/grafana/dashboards/job.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/namespace.json b/grafana/dashboards/namespace.json
index 04b82c2dc..3d6a459ab 100644
--- a/grafana/dashboards/namespace.json
+++ b/grafana/dashboards/namespace.json
@@ -893,7 +893,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{deployment!=\"\"}, namespace)",
@@ -915,7 +915,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{namespace=\"$namespace\"}, deployment)",
diff --git a/grafana/dashboards/pod.json b/grafana/dashboards/pod.json
index 93ffa54b0..1f99773c3 100644
--- a/grafana/dashboards/pod.json
+++ b/grafana/dashboards/pod.json
@@ -2121,7 +2121,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2143,7 +2143,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2165,7 +2165,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{namespace=\"$namespace\"}, deployment)",
@@ -2187,7 +2187,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2212,7 +2212,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/grafana/dashboards/prometheus-benchmark.json b/grafana/dashboards/prometheus-benchmark.json
index 1a3d36423..c4911aeb5 100644
--- a/grafana/dashboards/prometheus-benchmark.json
+++ b/grafana/dashboards/prometheus-benchmark.json
@@ -3641,7 +3641,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -3663,7 +3663,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/replicaset.json b/grafana/dashboards/replicaset.json
index 59b56bb9b..9564224fc 100644
--- a/grafana/dashboards/replicaset.json
+++ b/grafana/dashboards/replicaset.json
@@ -2208,7 +2208,7 @@
     "templating": {
       "list": [
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "default",
             "value": "default"
@@ -2233,7 +2233,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "rs1",
             "value": "rs1"
@@ -2258,7 +2258,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "All",
             "value": "$__all"
@@ -2283,7 +2283,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "All",
             "value": "$__all"
diff --git a/grafana/dashboards/replicationcontroller.json b/grafana/dashboards/replicationcontroller.json
index 5b4389a69..36c271145 100644
--- a/grafana/dashboards/replicationcontroller.json
+++ b/grafana/dashboards/replicationcontroller.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/service.json b/grafana/dashboards/service.json
index dea9edc23..db517460f 100644
--- a/grafana/dashboards/service.json
+++ b/grafana/dashboards/service.json
@@ -1180,7 +1180,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1202,7 +1202,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1224,7 +1224,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -1249,7 +1249,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/grafana/dashboards/statefulset.json b/grafana/dashboards/statefulset.json
index b9491a996..b6df517ec 100644
--- a/grafana/dashboards/statefulset.json
+++ b/grafana/dashboards/statefulset.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/top-line.json b/grafana/dashboards/top-line.json
index 6483a4773..d9181a2b6 100644
--- a/grafana/dashboards/top-line.json
+++ b/grafana/dashboards/top-line.json
@@ -999,7 +999,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -1024,7 +1024,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/pkg/k8s/labels.go b/pkg/k8s/labels.go
index fbff494de..4a59499bb 100644
--- a/pkg/k8s/labels.go
+++ b/pkg/k8s/labels.go
@@ -374,6 +374,9 @@ const (
 	// in cases we detect a change in a remote gateway
 	RemoteGatewayResourceVersionAnnotation = SvcMirrorPrefix + "/remote-gateway-resource-version"
 
+	// RemoteGatewayIdentity follows the same kind of logic as RemoteGatewayNameLabel
+	RemoteGatewayIdentity = SvcMirrorPrefix + "/remote-gateway-identity"
+
 	// ConfigKeyName is the key in the secret that stores the kubeconfig needed to connect
 	// to a remote cluster
 	ConfigKeyName = "kubeconfig"
diff --git a/pkg/smimetrics/client.go b/pkg/smimetrics/client.go
index 019129fa0..b4c0ae367 100644
--- a/pkg/smimetrics/client.go
+++ b/pkg/smimetrics/client.go
@@ -2,6 +2,7 @@ package smimetrics
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net/http"
@@ -47,10 +48,22 @@ type (
 		Unit  string `json:"unit"`
 		Value string `json:"value"`
 	}
+
+	// Status is an API response that is returned when the operation is not
+	// successful.
+	Status struct {
+		Kind    string `json:"kind"`
+		Status  string `json:"status"`
+		Message string `json:"message"`
+	}
 )
 
 const apiBase = "/apis/metrics.smi-spec.io/v1alpha1"
 
+func (r Resource) String() string {
+	return fmt.Sprintf("%s.%s", r.Name, r.Namespace)
+}
+
 // GetTrafficMetrics returns the inbound traffic metrics for a specific named
 // resource.
 func GetTrafficMetrics(k8sAPI *k8s.KubernetesAPI, namespace, kind, name string, params map[string]string) (*TrafficMetrics, error) {
@@ -142,24 +155,47 @@ func getMetricsResponse(k8sAPI *k8s.KubernetesAPI, path string, params map[strin
 	return ioutil.ReadAll(httpRsp.Body)
 }
 
+func handleStatusResponse(bytes []byte) error {
+	var status Status
+	json.Unmarshal(bytes, &status)
+	if status.Kind == "Status" {
+		return errors.New(status.Message)
+	}
+	return nil
+}
+
 func parseTrafficMetrics(bytes []byte) (*TrafficMetrics, error) {
+	err := handleStatusResponse(bytes)
+	if err != nil {
+		return nil, err
+	}
+
 	var metrics TrafficMetrics
-	err := json.Unmarshal(bytes, &metrics)
+	err = json.Unmarshal(bytes, &metrics)
 	if err != nil {
 		log.Errorf("Failed to decode response as TrafficMetrics [%s]: %v", string(bytes), err)
-		return nil, err
+		return nil, errors.New(string(bytes))
 	}
 
+	log.Debugf("Parsed TrafficMetrics: %+v", metrics)
+
 	return &metrics, nil
 }
 
 func parseTrafficMetricsList(bytes []byte) (*TrafficMetricsList, error) {
+	err := handleStatusResponse(bytes)
+	if err != nil {
+		return nil, err
+	}
+
 	var metrics TrafficMetricsList
-	err := json.Unmarshal(bytes, &metrics)
+	err = json.Unmarshal(bytes, &metrics)
 	if err != nil {
 		log.Errorf("Failed to decode response as TrafficMetricsList [%s]: %v", string(bytes), err)
-		return nil, err
+		return nil, errors.New(string(bytes))
 	}
 
+	log.Debugf("Parsed TrafficMetricsList: %+v", metrics)
+
 	return &metrics, nil
 }


Test patch:
diff --git a/controller/api/destination/endpoint_translator_test.go b/controller/api/destination/endpoint_translator_test.go
index 7c57b1e45..3f5e22b38 100644
--- a/controller/api/destination/endpoint_translator_test.go
+++ b/controller/api/destination/endpoint_translator_test.go
@@ -1,8 +1,10 @@
 package destination
 
 import (
+	"fmt"
 	"reflect"
 	"sort"
+	"strings"
 	"testing"
 
 	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
@@ -92,6 +94,17 @@ var (
 			},
 		},
 	}
+
+	remoteGatewayWithNoTLS = watcher.Address{
+		IP:   "1.1.1.1",
+		Port: 1,
+	}
+
+	remoteGatewayWithTLS = watcher.Address{
+		IP:       "1.1.1.2",
+		Port:     2,
+		Identity: "some-identity",
+	}
 )
 
 func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointTranslator) {
@@ -99,7 +112,7 @@ func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointT
 	translator := newEndpointTranslator(
 		"linkerd",
 		"trust.domain",
-		false,
+		true,
 		"service-name.service-ns",
 		mockGetServer,
 		logging.WithField("test", t.Name()),
@@ -107,12 +120,77 @@ func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointT
 	return mockGetServer, translator
 }
 
-func TestEndpointTranslator(t *testing.T) {
+func TestEndpointTranslatorForRemoteGateways(t *testing.T) {
+	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS))
+		translator.Remove(mkAddressSetForServices(remoteGatewayWithTLS))
+
+		expectedNumUpdates := 2
+		actualNumUpdates := len(mockGetServer.updatesReceived)
+		if actualNumUpdates != expectedNumUpdates {
+			t.Fatalf("Expecting [%d] updates, got [%d]. Updates: %v", expectedNumUpdates, actualNumUpdates, mockGetServer.updatesReceived)
+		}
+	})
+
+	t.Run("Sends TlsIdentity when enabled", func(t *testing.T) {
+		expectedTLSIdentity := &pb.TlsIdentity_DnsLikeIdentity{
+			Name: "some-identity",
+		}
+
+		expectedProtocolHint := &pb.ProtocolHint{
+			Protocol: &pb.ProtocolHint_H2_{
+				H2: &pb.ProtocolHint_H2{},
+			},
+		}
+
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithTLS))
+
+		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+
+		actualTLSIdentity := addrs[0].GetTlsIdentity().GetDnsLikeIdentity()
+		if !reflect.DeepEqual(actualTLSIdentity, expectedTLSIdentity) {
+			t.Fatalf("Expected TlsIdentity to be [%v] but was [%v]", expectedTLSIdentity, actualTLSIdentity)
+		}
+
+		actualProtocolHint := addrs[0].GetProtocolHint()
+		if !reflect.DeepEqual(actualProtocolHint, expectedProtocolHint) {
+			t.Fatalf("Expected ProtoclHint to be [%v] but was [%v]", expectedProtocolHint, actualProtocolHint)
+		}
+	})
+
+	t.Run("Does not send TlsIdentity when not present", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS))
+
+		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+
+		if addrs[0].TlsIdentity != nil {
+			t.Fatalf("Expected no TlsIdentity to be sent, but got [%v]", addrs[0].TlsIdentity)
+		}
+		if addrs[0].ProtocolHint != nil {
+			t.Fatalf("Expected no ProtocolHint to be sent, but got [%v]", addrs[0].TlsIdentity)
+		}
+	})
+
+}
+
+func TestEndpointTranslatorForPods(t *testing.T) {
 	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
-		translator.Remove(mkPodSet(tlsOptionalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
+		translator.Remove(mkAddressSetForPods(tlsOptionalPod))
 
 		expectedNumUpdates := 2
 		actualNumUpdates := len(mockGetServer.updatesReceived)
@@ -124,8 +202,8 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Sends addresses as removed or added", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod, tlsOptionalPod))
-		translator.Remove(mkPodSet(tlsDisabledPod))
+		translator.Add(mkAddressSetForPods(normalPod, tlsOptionalPod))
+		translator.Remove(mkAddressSetForPods(tlsDisabledPod))
 
 		addressesAdded := mockGetServer.updatesReceived[0].GetAdd().Addrs
 		actualNumberOfAdded := len(addressesAdded)
@@ -152,7 +230,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Sends metric labels with added addresses", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
 
 		actualGlobalMetricLabels := mockGetServer.updatesReceived[0].GetAdd().MetricLabels
 		expectedGlobalMetricLabels := map[string]string{"namespace": "service-ns", "service": "service-name"}
@@ -179,7 +257,7 @@ func TestEndpointTranslator(t *testing.T) {
 
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -195,7 +273,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity for non-default identity-modes", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(tlsOptionalPod))
+		translator.Add(mkAddressSetForPods(tlsOptionalPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -210,7 +288,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity for other meshes", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(otherMeshPod))
+		translator.Add(mkAddressSetForPods(otherMeshPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -225,7 +303,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity when not enabled", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(tlsDisabledPod))
+		translator.Add(mkAddressSetForPods(tlsDisabledPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -238,14 +316,33 @@ func TestEndpointTranslator(t *testing.T) {
 	})
 }
 
-func mkPodSet(pods ...watcher.Address) watcher.PodSet {
-	set := watcher.PodSet{
-		Pods:   make(map[watcher.PodID]watcher.Address),
-		Labels: map[string]string{"service": "service-name", "namespace": "service-ns"},
+func mkAddressSetForServices(gatewayAddresses ...watcher.Address) watcher.AddressSet {
+	set := watcher.AddressSet{
+		Addresses: make(map[watcher.ServiceID]watcher.Address),
+		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
+	}
+	for _, a := range gatewayAddresses {
+		a := a // pin
+
+		id := watcher.ServiceID{
+			Name: strings.Join([]string{
+				a.IP,
+				fmt.Sprint(a.Port),
+			}, "-"),
+		}
+		set.Addresses[id] = a
+	}
+	return set
+}
+
+func mkAddressSetForPods(podAddresses ...watcher.Address) watcher.AddressSet {
+	set := watcher.AddressSet{
+		Addresses: make(map[watcher.PodID]watcher.Address),
+		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
 	}
-	for _, p := range pods {
+	for _, p := range podAddresses {
 		id := watcher.PodID{Name: p.Pod.Name, Namespace: p.Pod.Namespace}
-		set.Pods[id] = p
+		set.Addresses[id] = p
 	}
 	return set
 }
diff --git a/controller/api/destination/watcher/endpoints_watcher_test.go b/controller/api/destination/watcher/endpoints_watcher_test.go
index 6798ca2ca..07d566848 100644
--- a/controller/api/destination/watcher/endpoints_watcher_test.go
+++ b/controller/api/destination/watcher/endpoints_watcher_test.go
@@ -27,17 +27,21 @@ func newBufferingEndpointListener() *bufferingEndpointListener {
 }
 
 func addressString(address Address) string {
-	return fmt.Sprintf("%s:%d", address.IP, address.Port)
+	addressString := fmt.Sprintf("%s:%d", address.IP, address.Port)
+	if address.Identity != "" {
+		addressString = fmt.Sprintf("%s/%s", addressString, address.Identity)
+	}
+	return addressString
 }
 
-func (bel *bufferingEndpointListener) Add(set PodSet) {
-	for _, address := range set.Pods {
+func (bel *bufferingEndpointListener) Add(set AddressSet) {
+	for _, address := range set.Addresses {
 		bel.added = append(bel.added, addressString(address))
 	}
 }
 
-func (bel *bufferingEndpointListener) Remove(set PodSet) {
-	for _, address := range set.Pods {
+func (bel *bufferingEndpointListener) Remove(set AddressSet) {
+	for _, address := range set.Addresses {
 		bel.removed = append(bel.removed, addressString(address))
 	}
 }
@@ -271,7 +275,7 @@ status:
 			expectedError:                    false,
 		},
 		{
-			serviceType: "local services with missing pods",
+			serviceType: "local services with missing addresses",
 			k8sConfigs: []string{`
 apiVersion: v1
 kind: Service
@@ -615,5 +619,165 @@ status:
 		})
 
 	}
+}
+
+func TestEndpointsWatcherServiceMirrors(t *testing.T) {
+	for _, tt := range []struct {
+		serviceType                      string
+		k8sConfigs                       []string
+		id                               ServiceID
+		hostname                         string
+		port                             Port
+		expectedAddresses                []string
+		expectedNoEndpoints              bool
+		expectedNoEndpointsServiceExists bool
+	}{
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-gateway-identity: "gateway-identity-1"
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 8989`,
+			},
+			serviceType: "mirrored service with identity",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        8989,
+			expectedAddresses: []string{
+				"172.17.0.12:8989/gateway-identity-1",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 8989`,
+			},
+			serviceType: "mirrored service without identity",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        8989,
+			expectedAddresses: []string{
+				"172.17.0.12:8989",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-gateway-identity: "gateway-identity-1"
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 9999`,
+			},
+			serviceType: "mirrored service with remapped port in endpoints",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        9999,
+			expectedAddresses: []string{
+				"172.17.0.12:9999/gateway-identity-1",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+	} {
+		tt := tt // pin
+		t.Run("subscribes listener to "+tt.serviceType, func(t *testing.T) {
+			k8sAPI, err := k8s.NewFakeAPI(tt.k8sConfigs...)
+			if err != nil {
+				t.Fatalf("NewFakeAPI returned an error: %s", err)
+			}
+
+			watcher := NewEndpointsWatcher(k8sAPI, logging.WithField("test", t.Name()))
+
+			k8sAPI.Sync(nil)
+
+			listener := newBufferingEndpointListener()
+
+			err = watcher.Subscribe(tt.id, tt.port, tt.hostname, listener)
+
+			if err != nil {
+				t.Fatalf("NewFakeAPI returned an error: %s", err)
+			}
+
+			actualAddresses := make([]string, 0)
+			actualAddresses = append(actualAddresses, listener.added...)
+			sort.Strings(actualAddresses)
+
+			testCompare(t, tt.expectedAddresses, actualAddresses)
+
+			if listener.noEndpointsCalled != tt.expectedNoEndpoints {
+				t.Fatalf("Expected noEndpointsCalled to be [%t], got [%t]",
+					tt.expectedNoEndpoints, listener.noEndpointsCalled)
+			}
 
+			if listener.noEndpointsExists != tt.expectedNoEndpointsServiceExists {
+				t.Fatalf("Expected noEndpointsExists to be [%t], got [%t]",
+					tt.expectedNoEndpointsServiceExists, listener.noEndpointsExists)
+			}
+		})
+	}
 }
diff --git a/controller/api/destination/watcher/ip_watcher_test.go b/controller/api/destination/watcher/ip_watcher_test.go
index 2e4218088..17207a2b9 100644
--- a/controller/api/destination/watcher/ip_watcher_test.go
+++ b/controller/api/destination/watcher/ip_watcher_test.go
@@ -531,7 +531,7 @@ status:
 		deletingPod    bool
 	}{
 		{
-			description:    "can delete pods",
+			description:    "can delete addresses",
 			k8sConfigs:     podK8sConfig,
 			host:           "172.17.0.12",
 			port:           8989,
@@ -539,7 +539,7 @@ status:
 			deletingPod:    true,
 		},
 		{
-			description:    "can delete pods wrapped in a DeletedFinalStateUnknown",
+			description:    "can delete addresses wrapped in a DeletedFinalStateUnknown",
 			k8sConfigs:     podK8sConfig,
 			host:           "172.17.0.12",
 			port:           8989,