Repository: stern/stern
Pull request #208 (2023-01-20 00:04:57)

Support <resource>/<name> form as a query
Fixes https://github.com/stern/stern/issues/159

This PR supports a Kubernetes resource as a query in the form `<resource>/<name>`. As discussed in https://github.com/stern/stern/pull/206, this PR supports it as a query rather than a flag.

This feature will be useful when we want to distinguish pods created by multiple deployments whose names are partly the same.

```
$ kubectl get deploy -n kube-system | grep cert-manager
cert-manager              1/1     1            1           63d
cert-manager-cainjector   1/1     1            1           63d
cert-manager-webhook      1/1     1            1           63d

# It is not easy to select only pods created by `deployment/cert-manager` with a regular expression
$ stern -n kube-system --tail 0 cert-manager
+ cert-manager-webhook-6c587d5b6f-x26dr › cert-manager
+ cert-manager-7ff5799cff-7gjv5 › cert-manager
+ cert-manager-cainjector-6b4cf6bdf9-rxpcn › cert-manager

# The <resource>/<name> form allows users to select only pods created by `deployment/cert-manager`.
$ stern -n kube-system --tail 0 deployment/cert-manager
+ cert-manager-7ff5799cff-7gjv5 › cert-manager
```

Supported Kubernetes resources are the same as `kubectl logs`, which are `pod`, `replicationcontroller`, `service`, `daemonset`, `deployment`, `replicaset`, `statefulset` and `job`.

I will implement a dynamic completion for this feature after this PR is merged.

superbrothers: Thanks for the change! I will review it soon.

Patch:
diff --git a/README.md b/README.md
index 612c01d..224609d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,8 @@
 Stern allows you to `tail` multiple pods on Kubernetes and multiple containers
 within the pod. Each result is color coded for quicker debugging.
 
-The query is a regular expression so the pod name can easily be filtered and
+The query is a regular expression or a Kubernetes resource in the form
+ `<resource>/<name>` so the pod name can easily be filtered and
 you don't need to specify the exact id (for instance omitting the deployment
 id). If a pod is deleted it gets removed from tail and if a new pod is added it
 automatically gets tailed.
@@ -55,8 +56,15 @@ kubectl krew install stern
 stern pod-query [flags]
 ```
 
-The `pod` query is a regular expression so you could provide `"web-\w"` to tail
-`web-backend` and `web-frontend` pods but not `web-123`.
+The `pod` query is a regular expression or a Kubernetes resource in the form `<resource>/<name>`.
+
+The query is a regular expression when it is not a Kubernetes resource,
+so you could provide `"web-\w"` to tail `web-backend` and `web-frontend` pods but not `web-123`.
+
+When the query is in the form `<resource>/<name>` (exact match), you can select all pods belonging
+to the specified Kubernetes resource, such as `deployment/nginx`.
+Supported Kubernetes resources are `pod`, `replicationcontroller`, `service`, `daemonset`, `deployment`,
+`replicaset`, `statefulset` and `job`.
 
 ### cli flags
 
@@ -182,6 +190,11 @@ Tail the pods on `kind-control-plane` node across all namespaces
 stern --all-namespaces --field-selector spec.nodeName=kind-control-plane
 ```
 
+Tail the pods created by `deployment/nginx`
+```
+stern deployment/nginx
+```
+
 Pipe the log message to jq:
 ```
 stern backend -o json | jq .
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 4a77ecc..d61d643 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -62,6 +62,7 @@ type options struct {
 	prompt              bool
 	podQuery            string
 	noFollow            bool
+	resource            string
 }
 
 func NewOptions(streams genericclioptions.IOStreams) *options {
@@ -86,16 +87,23 @@ func NewOptions(streams genericclioptions.IOStreams) *options {
 
 func (o *options) Complete(args []string) error {
 	if len(args) > 0 {
-		o.podQuery = args[0]
+		if s := args[0]; strings.Contains(s, "/") {
+			o.resource = s
+		} else {
+			o.podQuery = s
+		}
 	}
 
 	return nil
 }
 
 func (o *options) Validate() error {
-	if !o.prompt && o.podQuery == "" && o.selector == "" && o.fieldSelector == "" {
+	if !o.prompt && o.podQuery == "" && o.resource == "" && o.selector == "" && o.fieldSelector == "" {
 		return errors.New("One of pod-query, --selector, --field-selector or --prompt is required")
 	}
+	if o.selector != "" && o.resource != "" {
+		return errors.New("--selector and the <resource>/<name> query can not be set at the same time")
+	}
 
 	return nil
 }
@@ -313,6 +321,7 @@ func (o *options) sternConfig() (*stern.Config, error) {
 		TailLines:             tailLines,
 		Template:              template,
 		Follow:                !o.noFollow,
+		Resource:              o.resource,
 
 		Out:    o.Out,
 		ErrOut: o.ErrOut,
diff --git a/stern/config.go b/stern/config.go
index 47d8068..3e75a8e 100644
--- a/stern/config.go
+++ b/stern/config.go
@@ -47,6 +47,7 @@ type Config struct {
 	TailLines             *int64
 	Template              *template.Template
 	Follow                bool
+	Resource              string
 
 	Out    io.Writer
 	ErrOut io.Writer
diff --git a/stern/resource_matcher.go b/stern/resource_matcher.go
new file mode 100644
index 0000000..ec5ab60
--- /dev/null
+++ b/stern/resource_matcher.go
@@ -0,0 +1,52 @@
+//   Copyright 2017 Wercker Holding BV
+//
+//   Licensed under the Apache License, Version 2.0 (the "License");
+//   you may not use this file except in compliance with the License.
+//   You may obtain a copy of the License at
+//
+//       http://www.apache.org/licenses/LICENSE-2.0
+//
+//   Unless required by applicable law or agreed to in writing, software
+//   distributed under the License is distributed on an "AS IS" BASIS,
+//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//   See the License for the specific language governing permissions and
+//   limitations under the License.
+
+package stern
+
+// ResourceMatcher is a matcher for Kubernetes resources
+type ResourceMatcher struct {
+	name    string   // the resource name in singular e.g. "deployment"
+	aliases []string // the aliases of the resource e.g. "deploy" and "deployments"
+}
+
+// Name returns the resource name in singular
+func (r *ResourceMatcher) Name() string {
+	return r.name
+}
+
+// AllNames returns the resource names including the aliases
+func (r *ResourceMatcher) AllNames() []string {
+	return append(r.aliases, r.name)
+}
+
+// Matches returns if name matches one of the resource names
+func (r *ResourceMatcher) Matches(name string) bool {
+	for _, n := range r.AllNames() {
+		if n == name {
+			return true
+		}
+	}
+	return false
+}
+
+var (
+	PodMatcher                   = ResourceMatcher{name: "pod", aliases: []string{"po", "pods"}}
+	ReplicationControllerMatcher = ResourceMatcher{name: "replicationcontroller", aliases: []string{"rc", "replicationcontrollers"}}
+	ServiceMatcher               = ResourceMatcher{name: "service", aliases: []string{"svc", "services"}}
+	DaemonSetMatcher             = ResourceMatcher{name: "daemonset", aliases: []string{"ds", "daemonsets"}}
+	DeploymentMatcher            = ResourceMatcher{name: "deployment", aliases: []string{"deploy", "deployments"}}
+	ReplicaSetMatcher            = ResourceMatcher{name: "replicaset", aliases: []string{"rs", "replicasets"}}
+	StatefulSetMatcher           = ResourceMatcher{name: "statefulset", aliases: []string{"sts", "statefulsets"}}
+	JobMatcher                   = ResourceMatcher{name: "job", aliases: []string{"jobs"}} // job does not have a short name
+)
diff --git a/stern/stern.go b/stern/stern.go
index c6355f8..14fa1b8 100644
--- a/stern/stern.go
+++ b/stern/stern.go
@@ -17,12 +17,19 @@ package stern
 import (
 	"context"
 	"fmt"
+	"regexp"
+	"strings"
 	"sync"
 
 	"github.com/pkg/errors"
+
 	"github.com/stern/stern/kubernetes"
 	"golang.org/x/sync/errgroup"
-	corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
+
+	"k8s.io/apimachinery/pkg/labels"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clientset "k8s.io/client-go/kubernetes"
 )
 
 var tails = make(map[string]*Tail)
@@ -55,7 +62,7 @@ func Run(ctx context.Context, config *Config) error {
 		return err
 	}
 
-	clientset, err := corev1client.NewForConfig(cc)
+	client, err := clientset.NewForConfig(cc)
 	if err != nil {
 		return err
 	}
@@ -75,6 +82,26 @@ func Run(ctx context.Context, config *Config) error {
 		}
 	}
 
+	var resource struct {
+		kind string
+		name string
+	}
+	if config.Resource != "" {
+		parts := strings.Split(config.Resource, "/")
+		if len(parts) != 2 {
+			return errors.New("resource must be specified in the form \"<resource>/<name>\"")
+		}
+		resource.kind, resource.name = parts[0], parts[1]
+		if PodMatcher.Matches(resource.kind) {
+			// Pods can share the same labels, so we also set the pod query.
+			podName, err := regexp.Compile("^" + resource.name + "$")
+			if err != nil {
+				return errors.Wrap(err, "failed to compile regular expression for pod")
+			}
+			config.PodQuery = podName
+		}
+	}
+
 	filter := &targetFilter{
 		podFilter:              config.PodQuery,
 		excludePodFilter:       config.ExcludePodQuery,
@@ -85,7 +112,7 @@ func Run(ctx context.Context, config *Config) error {
 		containerStates:        config.ContainerStates,
 	}
 	newTail := func(t *Target) *Tail {
-		return NewTail(clientset, t.Node, t.Namespace, t.Pod, t.Container, config.Template, config.Out, config.ErrOut, &TailOptions{
+		return NewTail(client.CoreV1(), t.Node, t.Namespace, t.Pod, t.Container, config.Template, config.Out, config.ErrOut, &TailOptions{
 			Timestamps:   config.Timestamps,
 			Location:     config.Location,
 			SinceSeconds: int64(config.Since.Seconds()),
@@ -100,9 +127,13 @@ func Run(ctx context.Context, config *Config) error {
 	if !config.Follow {
 		var eg errgroup.Group
 		for _, n := range namespaces {
+			selector, err := chooseSelector(ctx, client, n, resource.kind, resource.name, config.LabelSelector)
+			if err != nil {
+				return err
+			}
 			targets, err := ListTargets(ctx,
-				clientset.Pods(n),
-				config.LabelSelector,
+				client.CoreV1().Pods(n),
+				selector,
 				config.FieldSelector,
 				filter,
 			)
@@ -130,9 +161,13 @@ func Run(ctx context.Context, config *Config) error {
 	defer close(errCh)
 
 	for _, n := range namespaces {
+		selector, err := chooseSelector(ctx, client, n, resource.kind, resource.name, config.LabelSelector)
+		if err != nil {
+			return err
+		}
 		a, r, err := WatchTargets(ctx,
-			clientset.Pods(n),
-			config.LabelSelector,
+			client.CoreV1().Pods(n),
+			selector,
 			config.FieldSelector,
 			filter,
 		)
@@ -203,3 +238,79 @@ func Run(ctx context.Context, config *Config) error {
 		return nil
 	}
 }
+
+func chooseSelector(ctx context.Context, client clientset.Interface, namespace, kind, name string, selector labels.Selector) (labels.Selector, error) {
+	if kind == "" {
+		return selector, nil
+	}
+	labelMap, err := retrieveLabelsFromResource(ctx, client, namespace, kind, name)
+	if err != nil {
+		return nil, err
+	}
+	if len(labelMap) == 0 {
+		return nil, fmt.Errorf("resource %s/%s has no labels to select", kind, name)
+	}
+	return labels.SelectorFromSet(labelMap), nil
+}
+
+func retrieveLabelsFromResource(ctx context.Context, client clientset.Interface, namespace, kind, name string) (map[string]string, error) {
+	opt := metav1.GetOptions{}
+	switch {
+	// core
+	case PodMatcher.Matches(kind):
+		o, err := client.CoreV1().Pods(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Labels, nil
+	case ReplicationControllerMatcher.Matches(kind):
+		o, err := client.CoreV1().ReplicationControllers(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		if o.Spec.Template == nil { // RC's spec.template is a pointer field
+			return nil, fmt.Errorf("%s does not have spec.template", name)
+		}
+		return o.Spec.Template.Labels, nil
+	case ServiceMatcher.Matches(kind):
+		o, err := client.CoreV1().Services(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Selector, nil
+	// apps
+	case DaemonSetMatcher.Matches(kind):
+		o, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Template.Labels, nil
+	case DeploymentMatcher.Matches(kind):
+		o, err := client.AppsV1().Deployments(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Template.Labels, nil
+	case ReplicaSetMatcher.Matches(kind):
+		o, err := client.AppsV1().ReplicaSets(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Template.Labels, nil
+	case StatefulSetMatcher.Matches(kind):
+		o, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Template.Labels, nil
+	// batch
+	// We do not support cronjobs because they might not have labels to select.
+	case JobMatcher.Matches(kind):
+		o, err := client.BatchV1().Jobs(namespace).Get(ctx, name, opt)
+		if err != nil {
+			return nil, err
+		}
+		return o.Spec.Template.Labels, nil
+	}
+	return nil, fmt.Errorf("resource type %s is not supported", kind)
+}


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index e6d6ce5..2019f5d 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -58,6 +58,17 @@ func TestOptionsValidate(t *testing.T) {
 			NewOptions(streams),
 			"One of pod-query, --selector, --field-selector or --prompt is required",
 		},
+		{
+			"Specify both selector and resource",
+			func() *options {
+				o := NewOptions(streams)
+				o.selector = "app=nginx"
+				o.resource = "deployment/nginx"
+
+				return o
+			}(),
+			"--selector and the <resource>/<name> query can not be set at the same time",
+		},
 		{
 			"Use prompt",
 			func() *options {
diff --git a/stern/stern_test.go b/stern/stern_test.go
new file mode 100644
index 0000000..b4f7114
--- /dev/null
+++ b/stern/stern_test.go
@@ -0,0 +1,202 @@
+package stern
+
+import (
+	"context"
+	"reflect"
+	"testing"
+
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes/fake"
+)
+
+func TestRetrieveLabelsFromResource(t *testing.T) {
+	genMeta := func(name string) metav1.ObjectMeta {
+		return metav1.ObjectMeta{
+			Name:      name,
+			Namespace: "ns1",
+		}
+	}
+	genPodTemplateSpec := func(name string) corev1.PodTemplateSpec {
+		return corev1.PodTemplateSpec{
+			ObjectMeta: metav1.ObjectMeta{
+				Labels: map[string]string{
+					"app": name,
+				},
+			},
+		}
+	}
+	objs := []runtime.Object{
+		// core
+		&corev1.Pod{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "pod1",
+				Namespace: "ns1",
+				Labels: map[string]string{
+					"app": "pod-label",
+				},
+			},
+		},
+		&corev1.ReplicationController{
+			ObjectMeta: genMeta("rc1"),
+			Spec: corev1.ReplicationControllerSpec{
+				Template: &corev1.PodTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{
+							"app": "rc-label",
+						},
+					},
+				},
+			},
+		},
+		&corev1.Service{
+			ObjectMeta: genMeta("svc1"),
+			Spec: corev1.ServiceSpec{
+				Selector: map[string]string{
+					"app": "svc-label",
+				},
+			},
+		},
+		// apps
+		&appsv1.DaemonSet{
+			ObjectMeta: genMeta("ds1"),
+			Spec: appsv1.DaemonSetSpec{
+				Template: genPodTemplateSpec("ds-label"),
+			},
+		},
+		&appsv1.Deployment{
+			ObjectMeta: genMeta("deploy1"),
+			Spec: appsv1.DeploymentSpec{
+				Template: genPodTemplateSpec("deploy-label"),
+			},
+		},
+		&appsv1.ReplicaSet{
+			ObjectMeta: genMeta("rs1"),
+			Spec: appsv1.ReplicaSetSpec{
+				Template: genPodTemplateSpec("rs-label"),
+			},
+		},
+		&appsv1.StatefulSet{
+			ObjectMeta: genMeta("sts1"),
+			Spec: appsv1.StatefulSetSpec{
+				Template: genPodTemplateSpec("sts-label"),
+			},
+		},
+		// batch
+		&batchv1.Job{
+			ObjectMeta: genMeta("job1"),
+			Spec: batchv1.JobSpec{
+				Template: genPodTemplateSpec("job-label"),
+			},
+		},
+		&batchv1.CronJob{
+			ObjectMeta: genMeta("cj1"),
+			Spec: batchv1.CronJobSpec{
+				JobTemplate: batchv1.JobTemplateSpec{
+					Spec: batchv1.JobSpec{
+						Template: genPodTemplateSpec("cj-label"),
+					},
+				},
+			},
+		},
+	}
+	client := fake.NewSimpleClientset(objs...)
+	tests := []struct {
+		desc      string
+		kinds     []string
+		name      string
+		label     string
+		wantError bool
+	}{
+		// core
+		{
+			desc:  "pods",
+			kinds: []string{"po", "pods", "pod"},
+			name:  "pod1",
+			label: "pod-label",
+		},
+		{
+			desc:  "replicationcontrollers",
+			kinds: []string{"rc", "replicationcontrollers", "replicationcontroller"},
+			name:  "rc1",
+			label: "rc-label",
+		},
+		{
+			desc:  "services",
+			kinds: []string{"svc", "services", "service"},
+			name:  "svc1",
+			label: "svc-label",
+		},
+		// apps
+		{
+			desc:  "daemonsets",
+			kinds: []string{"ds", "daemonsets", "daemonset"},
+			name:  "ds1",
+			label: "ds-label",
+		},
+		{
+			desc:  "deployments",
+			kinds: []string{"deploy", "deployments", "deployment"},
+			name:  "deploy1",
+			label: "deploy-label",
+		},
+		{
+			desc:  "replicasets",
+			kinds: []string{"rs", "replicasets", "replicaset"},
+			name:  "rs1",
+			label: "rs-label",
+		},
+		{
+			desc:  "statefulsets",
+			kinds: []string{"sts", "statefulsets", "statefulset"},
+			name:  "sts1",
+			label: "sts-label",
+		},
+		// batch
+		{
+			desc:  "jobs",
+			kinds: []string{"job", "jobs"},
+			name:  "job1",
+			label: "job-label",
+		},
+		// invalid
+		{
+			desc:      "invalid",
+			kinds:     []string{"", "unknown"},
+			name:      "dummy",
+			wantError: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			for _, kind := range tt.kinds {
+				labels, err := retrieveLabelsFromResource(context.Background(), client, "ns1", kind, tt.name)
+				if tt.wantError {
+					if err == nil {
+						t.Errorf("expected error, but got no error")
+					}
+					return
+				}
+				if err != nil {
+					t.Errorf("unexpected error: %v", err)
+					return
+				}
+				expectedLabels := map[string]string{"app": tt.label}
+				if !reflect.DeepEqual(expectedLabels, labels) {
+					t.Errorf("expected %v, but actual %v", expectedLabels, labels)
+				}
+
+				// test not found
+				_, err = retrieveLabelsFromResource(context.Background(), client, "ns1", kind, "not-found")
+				if !kerrors.IsNotFound(err) {
+					t.Errorf("expected not found, but actual %v", err)
+				}
+			}
+		})
+	}
+}