package api import ( "context" "fmt" "net" "strconv" "testing" "github.com/go-test/deep" proxy "github.com/linkerd/linkerd2-proxy-api/go/tap" "github.com/linkerd/linkerd2/controller/api/util" "github.com/linkerd/linkerd2/controller/k8s" "github.com/linkerd/linkerd2/pkg/addr" pkgK8s "github.com/linkerd/linkerd2/pkg/k8s" metricsPb "github.com/linkerd/linkerd2/viz/metrics-api/gen/viz" tapPb "github.com/linkerd/linkerd2/viz/tap/gen/tap" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) type tapExpected struct { err error k8sRes []string req *tapPb.TapByResourceRequest requireID string } // mockTapByResourceServer satisfies controller.tap.Tap_TapByResourceServer type mockTapByResourceServer struct { util.MockServerStream } func (m *mockTapByResourceServer) Send(event *tapPb.TapEvent) error { return nil } // mockProxyTapServer satisfies proxy.tap.TapServer type mockProxyTapServer struct { proxy.UnimplementedTapServer mockControllerServer mockTapByResourceServer // for cancellation ctx context.Context } func (m *mockProxyTapServer) Observe(req *proxy.ObserveRequest, obsSrv proxy.Tap_ObserveServer) error { m.ctx = obsSrv.Context() m.mockControllerServer.Cancel() return nil } func TestTapByResource(t *testing.T) { expectations := []tapExpected{ { err: status.Error(codes.InvalidArgument, "TapByResource received nil target ResourceSelection"), k8sRes: []string{}, req: &tapPb.TapByResourceRequest{}, }, { err: status.Errorf(codes.Unimplemented, "unexpected match specified: any:{}"), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_Any{ Any: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, }, { err: status.Errorf(codes.NotFound, "no pods to tap for type=\"pod\" name=\"emojivoto-not-meshed\"\n"), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-not-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-not-meshed", }, }, }, }, { err: status.Errorf(codes.Unimplemented, "unimplemented resource type: bad-type"), k8sRes: []string{}, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: "bad-type", Name: "emojivoto-meshed-not-found", }, }, }, }, { err: status.Errorf(codes.NotFound, "pod \"emojivoto-meshed-not-found\" not found"), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed-not-found", }, }, }, }, { err: status.Errorf(codes.NotFound, "no pods to tap for type=\"pod\" name=\"emojivoto-meshed\"\n"), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion status: phase: Finished podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed", }, }, }, }, { err: status.Errorf(codes.NotFound, `no pods to tap for type="pod" name="emojivoto-meshed-tap-disabled" 1 pods found with tap disabled via the viz.linkerd.io/disable-tap annotation: * emojivoto-meshed-tap-disabled remove this annotation to make these pods valid tap targets `), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed-tap-disabled namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: viz.linkerd.io/disable-tap: "true" linkerd.io/proxy-version: testinjectversion status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed-tap-disabled", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_All{ All: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, }, { err: status.Errorf(codes.NotFound, `no pods to tap for type="pod" name="emojivoto-meshed-tap-not-enabled" 1 pods found with tap not enabled: * emojivoto-meshed-tap-not-enabled restart these pods to enable tap and make them valid tap targets `), k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed-tap-not-enabled namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: linkerd.io/proxy-version: testinjectversion status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed-tap-not-enabled", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_All{ All: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, }, { // success, underlying tap events tested in http_server_test.go err: nil, k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_All{ All: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, requireID: ".emojivoto.serviceaccount.identity.controller-ns.cluster.local", }, { err: nil, k8sRes: []string{` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion spec: serviceAccountName: emojivoto-meshed-sa status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "emojivoto", Type: pkgK8s.Pod, Name: "emojivoto-meshed", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_All{ All: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, requireID: "emojivoto-meshed-sa.emojivoto.serviceaccount.identity.controller-ns.cluster.local", }, { err: nil, k8sRes: []string{` apiVersion: v1 kind: Namespace metadata: name: emojivoto `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc linkerd.io/control-plane-ns: controller-ns annotations: viz.linkerd.io/tap-enabled: "true" linkerd.io/proxy-version: testinjectversion spec: serviceAccountName: emojivoto-meshed-sa status: phase: Running podIP: 127.0.0.1 `, }, req: &tapPb.TapByResourceRequest{ Target: &metricsPb.ResourceSelection{ Resource: &metricsPb.Resource{ Namespace: "", Type: pkgK8s.Namespace, Name: "emojivoto", }, }, Match: &tapPb.TapByResourceRequest_Match{ Match: &tapPb.TapByResourceRequest_Match_All{ All: &tapPb.TapByResourceRequest_Match_Seq{}, }, }, }, requireID: "emojivoto-meshed-sa.emojivoto.serviceaccount.identity.controller-ns.cluster.local", }, } for i, exp := range expectations { exp := exp // pin t.Run(fmt.Sprintf("%d: Returns expected response", i), func(t *testing.T) { k8sAPI, err := k8s.NewFakeAPI(exp.k8sRes...) if err != nil { t.Fatalf("NewFakeAPI returned an error: %s", err) } stream := mockTapByResourceServer{ MockServerStream: util.NewMockServerStream(), } s := grpc.NewServer() mockProxyTapServer := mockProxyTapServer{ mockControllerServer: stream, } proxy.RegisterTapServer(s, &mockProxyTapServer) lis, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatalf("Failed to listen") } // TODO: mock out the underlying grpc tap events errChan := make(chan error, 1) go func() { errChan <- s.Serve(lis) }() defer func() { if err := <-errChan; err != nil { t.Fatalf("Failed to serve on %+v: %s", lis, err) } }() defer s.GracefulStop() _, port, err := net.SplitHostPort(lis.Addr().String()) if err != nil { t.Fatal(err.Error()) } tapPort, err := strconv.ParseUint(port, 10, 32) if err != nil { t.Fatalf("Invalid port: %s", port) } fakeGrpcServer := newGRPCTapServer(uint(tapPort), "controller-ns", "cluster.local", k8sAPI, nil) k8sAPI.Sync(nil) err = fakeGrpcServer.TapByResource(exp.req, &stream) if err != nil || exp.err != nil { code := status.Code(err) expCode := status.Code(exp.err) if code != expCode { t.Fatalf("TapByResource returned unexpected error code: [%s], expected: [%s]", code, expCode) } if err.Error() != exp.err.Error() { t.Fatalf("TapByResource returned unexpected error message: [%s], expected: [%s]", err.Error(), exp.err.Error()) } } if exp.requireID != "" { md, ok := metadata.FromIncomingContext(mockProxyTapServer.ctx) if !ok { t.Fatalf("FromIncomingContext failed given: %+v", mockProxyTapServer.ctx) } if diff := deep.Equal(md.Get(pkgK8s.RequireIDHeader), []string{exp.requireID}); diff != nil { t.Fatalf("Unexpected l5d-require-id header: %+v", diff) } } }) } } func TestHydrateIPLabels(t *testing.T) { expectations := []struct { k8sRes []string requestedIP string labels map[string]string }{ { // Requested IP that doesn't match node or any pod k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, }, requestedIP: "10.20.30.40", labels: map[string]string{}, }, { // Requested IP that matches node only k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, }, requestedIP: "1.2.3.4", labels: map[string]string{"node": "node1"}, }, { // Requested IP that matches node and pod k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 1.2.3.4 `, }, requestedIP: "1.2.3.4", labels: map[string]string{"node": "node1"}, }, { // Requested IP that doesn't match node and matches exactly one pod k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, }, requestedIP: "5.6.7.8", labels: map[string]string{ "namespace": "emojivoto", "pod": "emojivoto-meshed", "serviceaccount": "default", }, }, { // Requested IP that doesn't match node and matches exactly one running pod and one finished pod k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed-2 namespace: emojivoto labels: app: emoji-svc status: phase: Finished podIP: 5.6.7.8 `, }, requestedIP: "5.6.7.8", labels: map[string]string{ "namespace": "emojivoto", "pod": "emojivoto-meshed", "serviceaccount": "default", }, }, { // Requested IP that doesn't match node and matches two running pods k8sRes: []string{` apiVersion: v1 kind: Node metadata: name: node1 status: addresses: - address: 1.2.3.4 type: InternalIP `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, ` apiVersion: v1 kind: Pod metadata: name: emojivoto-meshed-2 namespace: emojivoto labels: app: emoji-svc status: phase: Running podIP: 5.6.7.8 `, }, requestedIP: "5.6.7.8", labels: map[string]string{}, }, } ctx := context.Background() for i, exp := range expectations { exp := exp // pin t.Run(fmt.Sprintf("%d: Returns expected response", i), func(t *testing.T) { k8sAPI, err := k8s.NewFakeAPI(exp.k8sRes...) if err != nil { t.Fatalf("NewFakeAPI returned an error: %s", err) } s, _ := NewGrpcTapServer(4190, "controller-ns", "cluster.local", k8sAPI, nil) k8sAPI.Sync(nil) labels := make(map[string]string) ip, err := addr.ParsePublicIP(exp.requestedIP) if err != nil { t.Fatalf("Error parsing IP %s: %s", exp.requestedIP, err) } s.hydrateIPLabels(ctx, ip, labels) if diff := deep.Equal(labels, exp.labels); diff != nil { t.Fatalf("Unexpected labels: %+v", diff) } }) } }