Repository: stern/stern
Pull request #243 (2023-03-05 02:11:09)

Add --node flag to filter on a specific node
ref #242

This PR adds `--node` flag to filter on a specific node. This flag will be helpful when we debug pods on the specific node.

```
# Print a DaemonSet pod on the specific node
stern --node <NODE_NAME> daemonsets/<DS_NAME>

# Print all pods on the specific node
stern --node <NODE_NAME> --all-namespaces --no-follow --max-log-requests 1 .
```

I will implement a completion for this flag after this PR is merged.

### Example

Let's say we have four nodes.

```
$ kubectl get nodes
NAME                 STATUS   ROLES           AGE   VERSION
kind-control-plane   Ready    control-plane   14m   v1.25.3
kind-worker          Ready    <none>          13m   v1.25.3
kind-worker2         Ready    <none>          13m   v1.25.3
kind-worker3         Ready    <none>          13m   v1.25.3
```

Without `--node`, stern shows all pods that the DaemonSet created.

```
$ stern -n kube-system --tail 1 --no-follow --only-log-lines daemonset/kindnet
kindnet-flqlg kindnet-cni I0305 01:34:00.843821       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
kindnet-5f4ms kindnet-cni I0305 01:33:59.442694       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
kindnet-bdgk4 kindnet-cni I0305 01:33:59.039534       1 main.go:227] handling current node
kindnet-hqn2k kindnet-cni I0305 01:33:59.268987       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
```

With `--node kind-worker2`, stern shows only the pod on the node `kind-worker2`.

```
$ stern --node kind-worker2 -n kube-system --tail 1 --no-follow --only-log-lines daemonset/kindnet
kindnet-hqn2k kindnet-cni I0305 01:36:19.251494       1 main.go:250] Node kind-worker3 has CIDR [10.244.1.0/24]
```



Patch:
diff --git a/README.md b/README.md
index 237ecae..7ad0495 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@ Supported Kubernetes resources are `pod`, `replicationcontroller`, `service`, `d
  `--max-log-requests`        | `-1`      | Maximum number of concurrent logs to request. Defaults to 50, but 5 when specifying --no-follow
  `--namespace`, `-n`         |           | Kubernetes namespace to use. Default to namespace configured in kubernetes context. To specify multiple namespaces, repeat this or set comma-separated value.
  `--no-follow`               | `false`   | Exit when all logs have been shown.
+ `--node`                    |           | Node name to filter on.
  `--only-log-lines`          | `false`   | Print only log lines
  `--output`, `-o`            | `default` | Specify predefined template. Currently support: [default, raw, json, extjson, ppextjson]
  `--prompt`, `-p`            | `false`   | Toggle interactive prompt for selecting 'app.kubernetes.io/instance' label values.
diff --git a/cmd/cmd.go b/cmd/cmd.go
index ab1ab00..72f7b48 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -73,6 +73,7 @@ type options struct {
 	verbosity           int
 	onlyLogLines        bool
 	maxLogRequests      int
+	node                string
 }
 
 func NewOptions(streams genericclioptions.IOStreams) *options {
@@ -190,12 +191,9 @@ func (o *options) sternConfig() (*stern.Config, error) {
 		}
 	}
 
-	fieldSelector := fields.Everything()
-	if o.fieldSelector != "" {
-		fieldSelector, err = fields.ParseSelector(o.fieldSelector)
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to parse selector as field selector")
-		}
+	fieldSelector, err := o.generateFieldSelector()
+	if err != nil {
+		return nil, err
 	}
 
 	var tailLines *int64
@@ -298,6 +296,7 @@ func (o *options) AddFlags(fs *pflag.FlagSet) {
 	fs.StringVar(&o.kubeConfig, "kube-config", o.kubeConfig, "Path to kubeconfig file to use.")
 	_ = fs.MarkDeprecated("kube-config", "Use --kubeconfig instead.")
 	fs.StringSliceVarP(&o.namespaces, "namespace", "n", o.namespaces, "Kubernetes namespace to use. Default to namespace configured in kubernetes context. To specify multiple namespaces, repeat this or set comma-separated value.")
+	fs.StringVar(&o.node, "node", o.node, "Node name to filter on.")
 	fs.IntVar(&o.maxLogRequests, "max-log-requests", o.maxLogRequests, "Maximum number of concurrent logs to request. Defaults to 50, but 5 when specifying --no-follow")
 	fs.StringVarP(&o.output, "output", "o", o.output, "Specify predefined template. Currently support: [default, raw, json, extjson, ppextjson]")
 	fs.BoolVarP(&o.prompt, "prompt", "p", o.prompt, "Toggle interactive prompt for selecting 'app.kubernetes.io/instance' label values.")
@@ -432,6 +431,25 @@ func (o *options) generateTemplate() (*template.Template, error) {
 	return template, err
 }
 
+func (o *options) generateFieldSelector() (fields.Selector, error) {
+	var queries []string
+	if o.fieldSelector != "" {
+		queries = append(queries, o.fieldSelector)
+	}
+	if o.node != "" {
+		queries = append(queries, fmt.Sprintf("spec.nodeName=%s", o.node))
+	}
+	if len(queries) == 0 {
+		return fields.Everything(), nil
+	}
+
+	fieldSelector, err := fields.ParseSelector(strings.Join(queries, ","))
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse selector as field selector")
+	}
+	return fieldSelector, nil
+}
+
 func NewSternCmd(stream genericclioptions.IOStreams) (*cobra.Command, error) {
 	o := NewOptions(stream)
 


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index 1718173..3ca3b33 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -376,7 +376,7 @@ func TestOptionsSternConfig(t *testing.T) {
 	local, _ := time.LoadLocation("Local")
 	utc, _ := time.LoadLocation("UTC")
 	labelSelector, _ := labels.Parse("l=sel")
-	fieldSelector, _ := fields.ParseSelector("f=field")
+	fieldSelector, _ := fields.ParseSelector("f=field,spec.nodeName=node1")
 
 	re := regexp.MustCompile
 
@@ -451,6 +451,7 @@ func TestOptionsSternConfig(t *testing.T) {
 				o.maxLogRequests = 30
 				o.resource = "res1"
 				o.onlyLogLines = true
+				o.node = "node1"
 
 				return o
 			}(),
@@ -484,6 +485,40 @@ func TestOptionsSternConfig(t *testing.T) {
 			}(),
 			false,
 		},
+		{
+			"fieldSelector without node",
+			func() *options {
+				o := NewOptions(streams)
+				o.fieldSelector = "f=field"
+
+				return o
+			}(),
+			func() *stern.Config {
+				c := defaultConfig()
+				sel, _ := fields.ParseSelector("f=field")
+				c.FieldSelector = sel
+
+				return c
+			}(),
+			false,
+		},
+		{
+			"node without fieldSelector",
+			func() *options {
+				o := NewOptions(streams)
+				o.node = "node1"
+
+				return o
+			}(),
+			func() *stern.Config {
+				c := defaultConfig()
+				sel, _ := fields.ParseSelector("spec.nodeName=node1")
+				c.FieldSelector = sel
+
+				return c
+			}(),
+			false,
+		},
 		{
 			"noFollow has the different default",
 			func() *options {