Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,21 @@ kubectl krew install conditioner
The general syntax for using the plugin is as follows:

```
kubectl conditioner [NODE_NAME] [FLAGS]
kubectl conditioner [NODE_NAME ...] [FLAGS]
```

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

Usage:
conditioner [node name] [flags]
conditioner [node name ...] [flags]

Examples:

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

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


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

- **Apply a condition to all nodes** by piping kubectl output directly:

```
kubectl get nodes -o name | kubectl conditioner --type Ready --status true --reason KubeletReady --message "kubelet is posting ready status"
```

### Flags

- `--type` (required): The type of condition (e.g., Ready, DiskPressure).
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.37.0
k8s.io/api v0.35.1
k8s.io/apimachinery v0.35.1
k8s.io/cli-runtime v0.35.1
Expand Down Expand Up @@ -49,7 +50,6 @@ require (
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
Expand Down
8 changes: 0 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -146,20 +146,12 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE=
k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY=
k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE=
k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
Expand Down
108 changes: 99 additions & 9 deletions pkg/cmd/conditioner.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/user"
"strings"

"golang.org/x/term"

"github.com/devbytes-cloud/conditioner/pkg/config"
"github.com/devbytes-cloud/conditioner/pkg/jsonpatch"
Expand All @@ -28,10 +34,13 @@ kubectl conditioner my-node --type DiskPressure --status false --reason KubeletH

# Remove a condition from a node
kubectl conditioner my-node --type NetworkUnavailable --remove

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

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

// nodeName is the name of the node that the command is being run against.
nodeName string
// nodeNames are the names of the nodes that the command is being run against.
nodeNames []string

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

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

cmd := &cobra.Command{
Use: "conditioner [node name] [flags]",
Use: "conditioner [node name ...] [flags]",
Short: "Manipulate status conditions on a specified node.",
Long: long,
Example: example,
SilenceUsage: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
o.args = args
if len(o.args) != 1 {
return fmt.Errorf("must provide a node to be conditioned")

stdinNames, err := o.readStdinNames()
if err != nil {
return err
}

o.nodeName = o.args[0]
return nil
merged := make([]string, 0, len(stdinNames)+len(args))
merged = append(merged, stdinNames...)
merged = append(merged, args...)

return o.setNodeNames(merged)
},
RunE: func(c *cobra.Command, args []string) error {
fs := config.FS{}
Expand Down Expand Up @@ -123,6 +140,27 @@ func NewCmdCondition(streams genericiooptions.IOStreams) *cobra.Command {
return cmd
}

// setNodeNames validates and normalizes the provided node name arguments, storing the
// results in o.nodeNames. It returns an error if no names are supplied or if any
// name is empty after normalization.
func (o *ConditionOptions) setNodeNames(args []string) error {
if len(args) == 0 {
return fmt.Errorf("must provide at least one node to be conditioned")
}

o.nodeNames = make([]string, 0, len(args))
for _, rawName := range args {
nodeName := normalizeNodeName(rawName)
if nodeName == "" {
return fmt.Errorf("node name cannot be empty")
}

o.nodeNames = append(o.nodeNames, nodeName)
}

return nil
}

// Complete sets all information required for updating the current context
// It retrieves the restConfig from the configFlags and creates a new Kubernetes client.
// It also sets the condition status, reason, message, type, and remove flag from the command flags.
Expand Down Expand Up @@ -199,7 +237,26 @@ func (o *ConditionOptions) Complete(cmd *cobra.Command, _ []string, config *conf

// Run handles the condition applying or removal on nodes.
func (o *ConditionOptions) Run() error {
node, err := o.client.CoreV1().Nodes().Get(context.Background(), o.nodeName, metav1.GetOptions{})
var errs []error

for _, nodeName := range o.nodeNames {
if err := o.runForNode(nodeName); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", nodeName, err))
}
}

if len(errs) > 0 {
return errors.Join(errs...)
}

return nil
}

// runForNode applies or removes the configured condition on a single node. It fetches
// the node from the Kubernetes API, generates the appropriate JSON Patch operation,
// applies it to the node's status, and prints a confirmation message.
func (o *ConditionOptions) runForNode(nodeName string) error {
node, err := o.client.CoreV1().Nodes().Get(context.Background(), nodeName, metav1.GetOptions{})
if err != nil {
return err
}
Expand Down Expand Up @@ -227,6 +284,39 @@ func (o *ConditionOptions) Run() error {
return nil
}

// readStdinNames reads node names from o.In when it is not a TTY. Each non-empty line
// is returned as a raw name; normalization happens later in setNodeNames. It returns
// nil, nil when o.In is an interactive terminal, so interactive invocations are not
// blocked waiting for input.
func (o *ConditionOptions) readStdinNames() ([]string, error) {
f, ok := o.In.(*os.File)
if ok && term.IsTerminal(int(f.Fd())) {
return nil, nil
}

var names []string
scanner := bufio.NewScanner(o.In)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
names = append(names, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading stdin: %w", err)
}
return names, nil
}

// normalizeNodeName strips whitespace and the "node/" or "nodes/" prefixes that
// kubectl outputs when using -o name (e.g. "node/worker-01" → "worker-01").
func normalizeNodeName(node string) string {
node = strings.TrimSpace(node)
node = strings.TrimPrefix(node, "node/")
node = strings.TrimPrefix(node, "nodes/")
return node
}

// findConditionType is a function that searches for a specific condition type in a slice of NodeCondition objects.
// If a match is found, the function returns a pointer to the matching NodeCondition object and its index in the slice.
// If no match is found, the function returns nil and -1.
Expand Down
113 changes: 113 additions & 0 deletions pkg/cmd/conditioner_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package cmd

import (
"fmt"
"io"
"strings"
"testing"

"github.com/devbytes-cloud/conditioner/pkg/config"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

corev1 "k8s.io/api/core/v1"
"k8s.io/cli-runtime/pkg/genericiooptions"
Expand All @@ -32,3 +36,112 @@ func TestComplete(t *testing.T) {
assert.Equal(t, "KubeletReady", o.condition.Reason)
assert.Equal(t, "kubelet is posting ready status", o.condition.Message)
}

func TestSetNodeNames(t *testing.T) {
o := NewConditionOptions(genericiooptions.IOStreams{})

err := o.setNodeNames([]string{"node/worker-01", "worker-02", "nodes/worker-03"})
require.NoError(t, err)
assert.Equal(t, []string{"worker-01", "worker-02", "worker-03"}, o.nodeNames)
}

func TestSetNodeNamesRequiresAtLeastOneNode(t *testing.T) {
o := NewConditionOptions(genericiooptions.IOStreams{})

err := o.setNodeNames(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "must provide at least one node")
}

func TestSetNodeNamesRejectsEmptyNodeName(t *testing.T) {
o := NewConditionOptions(genericiooptions.IOStreams{})

err := o.setNodeNames([]string{"node/"})
require.Error(t, err)
assert.Contains(t, err.Error(), "node name cannot be empty")
}

func TestReadStdinNames_NonTTY_ReturnsLines(t *testing.T) {
streams, in, _, _ := genericiooptions.NewTestIOStreams()
fmt.Fprintln(in, "worker-01")
fmt.Fprintln(in, "worker-02")

o := NewConditionOptions(streams)
names, err := o.readStdinNames()
require.NoError(t, err)
assert.Equal(t, []string{"worker-01", "worker-02"}, names)
}

func TestReadStdinNames_SkipsBlankLines(t *testing.T) {
streams, in, _, _ := genericiooptions.NewTestIOStreams()
fmt.Fprintln(in, "worker-01")
fmt.Fprintln(in, "")
fmt.Fprintln(in, "worker-02")

o := NewConditionOptions(streams)
names, err := o.readStdinNames()
require.NoError(t, err)
assert.Equal(t, []string{"worker-01", "worker-02"}, names)
}

func TestReadStdinNames_EmptyInput_ErrorsFromSetNodeNames(t *testing.T) {
streams, _, _, _ := genericiooptions.NewTestIOStreams()

o := NewConditionOptions(streams)
names, err := o.readStdinNames()
require.NoError(t, err)
assert.Empty(t, names)

err = o.setNodeNames(names)
require.Error(t, err)
assert.Contains(t, err.Error(), "must provide at least one node")
}

func TestReadStdinNames_PrefixNormalization(t *testing.T) {
streams, in, _, _ := genericiooptions.NewTestIOStreams()
fmt.Fprintln(in, "node/worker-01")
fmt.Fprintln(in, "nodes/worker-02")

o := NewConditionOptions(streams)
names, err := o.readStdinNames()
require.NoError(t, err)
// readStdinNames returns raw names; normalization happens inside setNodeNames
assert.Equal(t, []string{"node/worker-01", "nodes/worker-02"}, names)

err = o.setNodeNames(names)
require.NoError(t, err)
assert.Equal(t, []string{"worker-01", "worker-02"}, o.nodeNames)
}

func TestReadStdinNames_MergeOrder(t *testing.T) {
streams, in, _, _ := genericiooptions.NewTestIOStreams()
fmt.Fprintln(in, "stdin-node")

o := NewConditionOptions(streams)
stdinNames, err := o.readStdinNames()
require.NoError(t, err)

merged := append(stdinNames, "positional-node")
err = o.setNodeNames(merged)
require.NoError(t, err)
assert.Equal(t, []string{"stdin-node", "positional-node"}, o.nodeNames)
}

type errReader struct{}

func (e errReader) Read(_ []byte) (int, error) {
return 0, fmt.Errorf("read error")
}

func TestReadStdinNames_ScannerError(t *testing.T) {
streams := genericiooptions.IOStreams{
In: io.MultiReader(strings.NewReader("worker-01\n"), errReader{}),
Out: io.Discard,
ErrOut: io.Discard,
}

o := NewConditionOptions(streams)
_, err := o.readStdinNames()
require.Error(t, err)
assert.Contains(t, err.Error(), "reading stdin:")
}
Loading