Skip to content

Commit 66c8041

Browse files
authored
feat: support xargs node names (#208)
1 parent 5bb2c37 commit 66c8041

5 files changed

Lines changed: 225 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,21 @@ kubectl krew install conditioner
6969
The general syntax for using the plugin is as follows:
7070

7171
```
72-
kubectl conditioner [NODE_NAME] [FLAGS]
72+
kubectl conditioner [NODE_NAME ...] [FLAGS]
7373
```
7474

7575
```shell
7676
kubectl conditioner -h
7777
The 'condition' command allows you to add, update, or remove status conditions on nodes.
78-
You need to provide the node name as an argument and use flags to specify the details of the condition.
78+
You need to provide one or more node names as arguments and use flags to specify the details of the condition.
7979
The '--type' flag is required and it specifies the type of condition you wish to interact with.
8080
The '--status' flag sets the status for the specific status condition and it can be 'true', 'false', or left blank for 'unknown'.
8181
The '--reason' flag sets the reason for the specific status condition.
8282
The '--message' flag sets the message for the specific status condition.
8383
If you wish to remove the condition from the node entirely, use the '--remove' flag.
8484

8585
Usage:
86-
conditioner [node name] [flags]
86+
conditioner [node name ...] [flags]
8787

8888
Examples:
8989

@@ -96,6 +96,9 @@ kubectl conditioner my-node --type DiskPressure --status false --reason KubeletH
9696
# Remove a condition from a node
9797
kubectl conditioner my-node --type NetworkUnavailable --remove
9898

99+
# Apply a condition to all nodes by piping kubectl output directly
100+
kubectl get nodes -o name | kubectl conditioner --type Ready --status true --reason KubeletReady --message "kubelet is posting ready status"
101+
99102

100103
Flags:
101104
--as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
@@ -177,6 +180,12 @@ allowed-condition-1 False Sun, 01 Sep 2024 07:21:47 -0400 Sun, 01 Sep 2024
177180
kubectl conditioner my-node --type NetworkUnavailable --remove
178181
```
179182

183+
- **Apply a condition to all nodes** by piping kubectl output directly:
184+
185+
```
186+
kubectl get nodes -o name | kubectl conditioner --type Ready --status true --reason KubeletReady --message "kubelet is posting ready status"
187+
```
188+
180189
### Flags
181190

182191
- `--type` (required): The type of condition (e.g., Ready, DiskPressure).

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/spf13/cobra v1.10.2
88
github.com/spf13/pflag v1.0.10
99
github.com/stretchr/testify v1.11.1
10+
golang.org/x/term v0.37.0
1011
k8s.io/api v0.35.1
1112
k8s.io/apimachinery v0.35.1
1213
k8s.io/cli-runtime v0.35.1
@@ -49,7 +50,6 @@ require (
4950
golang.org/x/oauth2 v0.30.0 // indirect
5051
golang.org/x/sync v0.18.0 // indirect
5152
golang.org/x/sys v0.38.0 // indirect
52-
golang.org/x/term v0.37.0 // indirect
5353
golang.org/x/text v0.31.0 // indirect
5454
golang.org/x/time v0.9.0 // indirect
5555
google.golang.org/protobuf v1.36.8 // indirect

‎go.sum‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,20 +146,12 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
146146
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
147147
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
148148
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
149-
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
150-
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
151149
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
152150
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
153-
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
154-
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
155151
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
156152
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
157-
k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE=
158-
k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY=
159153
k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE=
160154
k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw=
161-
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
162-
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
163155
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
164156
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
165157
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=

‎pkg/cmd/conditioner.go‎

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"context"
6+
"errors"
57
"fmt"
8+
"os"
69
"os/user"
10+
"strings"
11+
12+
"golang.org/x/term"
713

814
"github.com/devbytes-cloud/conditioner/pkg/config"
915
"github.com/devbytes-cloud/conditioner/pkg/jsonpatch"
@@ -28,10 +34,13 @@ kubectl conditioner my-node --type DiskPressure --status false --reason KubeletH
2834
2935
# Remove a condition from a node
3036
kubectl conditioner my-node --type NetworkUnavailable --remove
37+
38+
# Apply a condition to all nodes by piping kubectl output directly
39+
kubectl get nodes -o name | kubectl conditioner --type Ready --status true --reason KubeletReady --message "kubelet is posting ready status"
3140
`
3241

3342
long = `The 'conditioner' command allows you to add, update, or remove status conditions on nodes.
34-
You need to provide the node name as an argument and use flags to specify the details of the condition.
43+
You need to provide one or more node names as arguments and use flags to specify the details of the condition.
3544
The '--type' flag is required and it specifies the type of condition you wish to interact with.
3645
The '--status' flag sets the status for the specific status condition and it can be 'true', 'false', or left blank for 'unknown'.
3746
The '--reason' flag sets the reason for the specific status condition.
@@ -50,8 +59,8 @@ type ConditionOptions struct {
5059
// IOStreams provides the standard names for iostreams. This is useful for embedding and for unit testing.
5160
genericiooptions.IOStreams
5261

53-
// nodeName is the name of the node that the command is being run against.
54-
nodeName string
62+
// nodeNames are the names of the nodes that the command is being run against.
63+
nodeNames []string
5564

5665
// remove is a boolean that indicates whether the condition should be removed.
5766
remove bool
@@ -71,23 +80,31 @@ func NewConditionOptions(streams genericiooptions.IOStreams) *ConditionOptions {
7180
}
7281
}
7382

83+
// NewCmdCondition returns a cobra.Command that implements the conditioner subcommand.
84+
// It wires up flags, PreRunE (node name collection from args and stdin), and RunE
85+
// (config loading, completion, and execution).
7486
func NewCmdCondition(streams genericiooptions.IOStreams) *cobra.Command {
7587
o := NewConditionOptions(streams)
7688

7789
cmd := &cobra.Command{
78-
Use: "conditioner [node name] [flags]",
90+
Use: "conditioner [node name ...] [flags]",
7991
Short: "Manipulate status conditions on a specified node.",
8092
Long: long,
8193
Example: example,
8294
SilenceUsage: true,
8395
PreRunE: func(cmd *cobra.Command, args []string) error {
8496
o.args = args
85-
if len(o.args) != 1 {
86-
return fmt.Errorf("must provide a node to be conditioned")
97+
98+
stdinNames, err := o.readStdinNames()
99+
if err != nil {
100+
return err
87101
}
88102

89-
o.nodeName = o.args[0]
90-
return nil
103+
merged := make([]string, 0, len(stdinNames)+len(args))
104+
merged = append(merged, stdinNames...)
105+
merged = append(merged, args...)
106+
107+
return o.setNodeNames(merged)
91108
},
92109
RunE: func(c *cobra.Command, args []string) error {
93110
fs := config.FS{}
@@ -123,6 +140,27 @@ func NewCmdCondition(streams genericiooptions.IOStreams) *cobra.Command {
123140
return cmd
124141
}
125142

143+
// setNodeNames validates and normalizes the provided node name arguments, storing the
144+
// results in o.nodeNames. It returns an error if no names are supplied or if any
145+
// name is empty after normalization.
146+
func (o *ConditionOptions) setNodeNames(args []string) error {
147+
if len(args) == 0 {
148+
return fmt.Errorf("must provide at least one node to be conditioned")
149+
}
150+
151+
o.nodeNames = make([]string, 0, len(args))
152+
for _, rawName := range args {
153+
nodeName := normalizeNodeName(rawName)
154+
if nodeName == "" {
155+
return fmt.Errorf("node name cannot be empty")
156+
}
157+
158+
o.nodeNames = append(o.nodeNames, nodeName)
159+
}
160+
161+
return nil
162+
}
163+
126164
// Complete sets all information required for updating the current context
127165
// It retrieves the restConfig from the configFlags and creates a new Kubernetes client.
128166
// It also sets the condition status, reason, message, type, and remove flag from the command flags.
@@ -199,7 +237,26 @@ func (o *ConditionOptions) Complete(cmd *cobra.Command, _ []string, config *conf
199237

200238
// Run handles the condition applying or removal on nodes.
201239
func (o *ConditionOptions) Run() error {
202-
node, err := o.client.CoreV1().Nodes().Get(context.Background(), o.nodeName, metav1.GetOptions{})
240+
var errs []error
241+
242+
for _, nodeName := range o.nodeNames {
243+
if err := o.runForNode(nodeName); err != nil {
244+
errs = append(errs, fmt.Errorf("%s: %w", nodeName, err))
245+
}
246+
}
247+
248+
if len(errs) > 0 {
249+
return errors.Join(errs...)
250+
}
251+
252+
return nil
253+
}
254+
255+
// runForNode applies or removes the configured condition on a single node. It fetches
256+
// the node from the Kubernetes API, generates the appropriate JSON Patch operation,
257+
// applies it to the node's status, and prints a confirmation message.
258+
func (o *ConditionOptions) runForNode(nodeName string) error {
259+
node, err := o.client.CoreV1().Nodes().Get(context.Background(), nodeName, metav1.GetOptions{})
203260
if err != nil {
204261
return err
205262
}
@@ -227,6 +284,39 @@ func (o *ConditionOptions) Run() error {
227284
return nil
228285
}
229286

287+
// readStdinNames reads node names from o.In when it is not a TTY. Each non-empty line
288+
// is returned as a raw name; normalization happens later in setNodeNames. It returns
289+
// nil, nil when o.In is an interactive terminal, so interactive invocations are not
290+
// blocked waiting for input.
291+
func (o *ConditionOptions) readStdinNames() ([]string, error) {
292+
f, ok := o.In.(*os.File)
293+
if ok && term.IsTerminal(int(f.Fd())) {
294+
return nil, nil
295+
}
296+
297+
var names []string
298+
scanner := bufio.NewScanner(o.In)
299+
for scanner.Scan() {
300+
line := strings.TrimSpace(scanner.Text())
301+
if line != "" {
302+
names = append(names, line)
303+
}
304+
}
305+
if err := scanner.Err(); err != nil {
306+
return nil, fmt.Errorf("reading stdin: %w", err)
307+
}
308+
return names, nil
309+
}
310+
311+
// normalizeNodeName strips whitespace and the "node/" or "nodes/" prefixes that
312+
// kubectl outputs when using -o name (e.g. "node/worker-01" → "worker-01").
313+
func normalizeNodeName(node string) string {
314+
node = strings.TrimSpace(node)
315+
node = strings.TrimPrefix(node, "node/")
316+
node = strings.TrimPrefix(node, "nodes/")
317+
return node
318+
}
319+
230320
// findConditionType is a function that searches for a specific condition type in a slice of NodeCondition objects.
231321
// If a match is found, the function returns a pointer to the matching NodeCondition object and its index in the slice.
232322
// If no match is found, the function returns nil and -1.

‎pkg/cmd/conditioner_test.go‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package cmd
22

33
import (
4+
"fmt"
5+
"io"
6+
"strings"
47
"testing"
58

69
"github.com/devbytes-cloud/conditioner/pkg/config"
710
"github.com/spf13/cobra"
811
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
913

1014
corev1 "k8s.io/api/core/v1"
1115
"k8s.io/cli-runtime/pkg/genericiooptions"
@@ -32,3 +36,112 @@ func TestComplete(t *testing.T) {
3236
assert.Equal(t, "KubeletReady", o.condition.Reason)
3337
assert.Equal(t, "kubelet is posting ready status", o.condition.Message)
3438
}
39+
40+
func TestSetNodeNames(t *testing.T) {
41+
o := NewConditionOptions(genericiooptions.IOStreams{})
42+
43+
err := o.setNodeNames([]string{"node/worker-01", "worker-02", "nodes/worker-03"})
44+
require.NoError(t, err)
45+
assert.Equal(t, []string{"worker-01", "worker-02", "worker-03"}, o.nodeNames)
46+
}
47+
48+
func TestSetNodeNamesRequiresAtLeastOneNode(t *testing.T) {
49+
o := NewConditionOptions(genericiooptions.IOStreams{})
50+
51+
err := o.setNodeNames(nil)
52+
require.Error(t, err)
53+
assert.Contains(t, err.Error(), "must provide at least one node")
54+
}
55+
56+
func TestSetNodeNamesRejectsEmptyNodeName(t *testing.T) {
57+
o := NewConditionOptions(genericiooptions.IOStreams{})
58+
59+
err := o.setNodeNames([]string{"node/"})
60+
require.Error(t, err)
61+
assert.Contains(t, err.Error(), "node name cannot be empty")
62+
}
63+
64+
func TestReadStdinNames_NonTTY_ReturnsLines(t *testing.T) {
65+
streams, in, _, _ := genericiooptions.NewTestIOStreams()
66+
fmt.Fprintln(in, "worker-01")
67+
fmt.Fprintln(in, "worker-02")
68+
69+
o := NewConditionOptions(streams)
70+
names, err := o.readStdinNames()
71+
require.NoError(t, err)
72+
assert.Equal(t, []string{"worker-01", "worker-02"}, names)
73+
}
74+
75+
func TestReadStdinNames_SkipsBlankLines(t *testing.T) {
76+
streams, in, _, _ := genericiooptions.NewTestIOStreams()
77+
fmt.Fprintln(in, "worker-01")
78+
fmt.Fprintln(in, "")
79+
fmt.Fprintln(in, "worker-02")
80+
81+
o := NewConditionOptions(streams)
82+
names, err := o.readStdinNames()
83+
require.NoError(t, err)
84+
assert.Equal(t, []string{"worker-01", "worker-02"}, names)
85+
}
86+
87+
func TestReadStdinNames_EmptyInput_ErrorsFromSetNodeNames(t *testing.T) {
88+
streams, _, _, _ := genericiooptions.NewTestIOStreams()
89+
90+
o := NewConditionOptions(streams)
91+
names, err := o.readStdinNames()
92+
require.NoError(t, err)
93+
assert.Empty(t, names)
94+
95+
err = o.setNodeNames(names)
96+
require.Error(t, err)
97+
assert.Contains(t, err.Error(), "must provide at least one node")
98+
}
99+
100+
func TestReadStdinNames_PrefixNormalization(t *testing.T) {
101+
streams, in, _, _ := genericiooptions.NewTestIOStreams()
102+
fmt.Fprintln(in, "node/worker-01")
103+
fmt.Fprintln(in, "nodes/worker-02")
104+
105+
o := NewConditionOptions(streams)
106+
names, err := o.readStdinNames()
107+
require.NoError(t, err)
108+
// readStdinNames returns raw names; normalization happens inside setNodeNames
109+
assert.Equal(t, []string{"node/worker-01", "nodes/worker-02"}, names)
110+
111+
err = o.setNodeNames(names)
112+
require.NoError(t, err)
113+
assert.Equal(t, []string{"worker-01", "worker-02"}, o.nodeNames)
114+
}
115+
116+
func TestReadStdinNames_MergeOrder(t *testing.T) {
117+
streams, in, _, _ := genericiooptions.NewTestIOStreams()
118+
fmt.Fprintln(in, "stdin-node")
119+
120+
o := NewConditionOptions(streams)
121+
stdinNames, err := o.readStdinNames()
122+
require.NoError(t, err)
123+
124+
merged := append(stdinNames, "positional-node")
125+
err = o.setNodeNames(merged)
126+
require.NoError(t, err)
127+
assert.Equal(t, []string{"stdin-node", "positional-node"}, o.nodeNames)
128+
}
129+
130+
type errReader struct{}
131+
132+
func (e errReader) Read(_ []byte) (int, error) {
133+
return 0, fmt.Errorf("read error")
134+
}
135+
136+
func TestReadStdinNames_ScannerError(t *testing.T) {
137+
streams := genericiooptions.IOStreams{
138+
In: io.MultiReader(strings.NewReader("worker-01\n"), errReader{}),
139+
Out: io.Discard,
140+
ErrOut: io.Discard,
141+
}
142+
143+
o := NewConditionOptions(streams)
144+
_, err := o.readStdinNames()
145+
require.Error(t, err)
146+
assert.Contains(t, err.Error(), "reading stdin:")
147+
}

0 commit comments

Comments
 (0)