Skip to content

Commit 8ac6092

Browse files
committed
feat: Add {add,delete} node commands
On add, a new node of the chosen role is created and joins the k8s cluster. On delete, the node is removed from etcd cluster, drained and gracefully removed from k8s cluster. Note: only docker is implemented. Other backends can be added later as needed. Signed-off-by: Ihar Hrachyshka <[email protected]> Assisted-By: Claude Code; claude-sonnet-4-20250514
1 parent 84d3e10 commit 8ac6092

File tree

19 files changed

+1756
-84
lines changed

19 files changed

+1756
-84
lines changed

hack/ci/e2e-node-ops.sh

Lines changed: 567 additions & 0 deletions
Large diffs are not rendered by default.

hack/ci/e2e.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ main() {
4242
# install kind
4343
install_kind
4444

45+
# run node operations e2e tests first
46+
"${REPO_ROOT}/hack/ci/e2e-node-ops.sh"
47+
4548
# build kubernetes / e2e test
4649
"${REPO_ROOT}/hack/ci/e2e-k8s.sh"
4750
}

pkg/cluster/internal/create/actions/kubeadmjoin/join.go

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,14 @@ limitations under the License.
1818
package kubeadmjoin
1919

2020
import (
21-
"strings"
22-
2321
"sigs.k8s.io/kind/pkg/cluster/constants"
2422
"sigs.k8s.io/kind/pkg/cluster/nodes"
2523
"sigs.k8s.io/kind/pkg/errors"
26-
"sigs.k8s.io/kind/pkg/exec"
27-
"sigs.k8s.io/kind/pkg/internal/version"
28-
"sigs.k8s.io/kind/pkg/log"
2924

3025
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
3126

3227
"sigs.k8s.io/kind/pkg/cluster/internal/create/actions"
28+
"sigs.k8s.io/kind/pkg/cluster/internal/kubeadm"
3329
)
3430

3531
// Action implements action for creating the kubeadm join
@@ -84,7 +80,7 @@ func joinSecondaryControlPlanes(
8480
// (this is not safe currently)
8581
for _, node := range secondaryControlPlanes {
8682
node := node // capture loop variable
87-
if err := runKubeadmJoin(ctx.Logger, node); err != nil {
83+
if err := kubeadm.RunKubeadmJoin(ctx.Logger, node); err != nil {
8884
return err
8985
}
9086
}
@@ -105,7 +101,7 @@ func joinWorkers(
105101
for _, node := range workers {
106102
node := node // capture loop variable
107103
fns = append(fns, func() error {
108-
return runKubeadmJoin(ctx.Logger, node)
104+
return kubeadm.RunKubeadmJoin(ctx.Logger, node)
109105
})
110106
}
111107
if err := errors.UntilErrorConcurrent(fns); err != nil {
@@ -115,39 +111,3 @@ func joinWorkers(
115111
ctx.Status.End(true)
116112
return nil
117113
}
118-
119-
// runKubeadmJoin executes kubeadm join command
120-
func runKubeadmJoin(logger log.Logger, node nodes.Node) error {
121-
kubeVersionStr, err := nodeutils.KubeVersion(node)
122-
if err != nil {
123-
return errors.Wrap(err, "failed to get kubernetes version from node")
124-
}
125-
kubeVersion, err := version.ParseGeneric(kubeVersionStr)
126-
if err != nil {
127-
return errors.Wrapf(err, "failed to parse kubernetes version %q", kubeVersionStr)
128-
}
129-
130-
args := []string{
131-
"join",
132-
// the join command uses the config file generated in a well known location
133-
"--config", "/kind/kubeadm.conf",
134-
// increase verbosity for debugging
135-
"--v=6",
136-
}
137-
// Newer versions set this in the config file.
138-
if kubeVersion.LessThan(version.MustParseSemantic("v1.23.0")) {
139-
// Skip preflight to avoid pulling images.
140-
// Kind pre-pulls images and preflight may conflict with that.
141-
args = append(args, "--skip-phases=preflight")
142-
}
143-
144-
// run kubeadm join
145-
cmd := node.Command("kubeadm", args...)
146-
lines, err := exec.CombinedOutputLines(cmd)
147-
logger.V(3).Info(strings.Join(lines, "\n"))
148-
if err != nil {
149-
return errors.Wrap(err, "failed to join node with kubeadm")
150-
}
151-
152-
return nil
153-
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package kubeadm
18+
19+
import (
20+
"strings"
21+
22+
"sigs.k8s.io/kind/pkg/cluster/nodes"
23+
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
24+
"sigs.k8s.io/kind/pkg/errors"
25+
"sigs.k8s.io/kind/pkg/exec"
26+
"sigs.k8s.io/kind/pkg/internal/version"
27+
"sigs.k8s.io/kind/pkg/log"
28+
)
29+
30+
// RunKubeadmJoin executes kubeadm join command using the config file at /kind/kubeadm.conf
31+
func RunKubeadmJoin(logger log.Logger, node nodes.Node) error {
32+
kubeVersionStr, err := nodeutils.KubeVersion(node)
33+
if err != nil {
34+
return errors.Wrap(err, "failed to get kubernetes version from node")
35+
}
36+
kubeVersion, err := version.ParseGeneric(kubeVersionStr)
37+
if err != nil {
38+
return errors.Wrapf(err, "failed to parse kubernetes version %q", kubeVersionStr)
39+
}
40+
41+
args := []string{
42+
"join",
43+
// the join command uses the config file at /kind/kubeadm.conf
44+
"--config", "/kind/kubeadm.conf",
45+
// increase verbosity for debugging
46+
"--v=6",
47+
}
48+
// Newer versions set this in the config file.
49+
if kubeVersion.LessThan(version.MustParseSemantic("v1.23.0")) {
50+
// Skip preflight to avoid pulling images.
51+
// Kind pre-pulls images and preflight may conflict with that.
52+
args = append(args, "--skip-phases=preflight")
53+
}
54+
55+
// run kubeadm join
56+
cmd := node.Command("kubeadm", args...)
57+
lines, err := exec.CombinedOutputLines(cmd)
58+
logger.V(3).Info(strings.Join(lines, "\n"))
59+
if err != nil {
60+
return errors.Wrap(err, "failed to join node with kubeadm")
61+
}
62+
63+
return nil
64+
}

pkg/cluster/internal/node/drain.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package node
18+
19+
import (
20+
"strings"
21+
22+
"sigs.k8s.io/kind/pkg/cluster/internal/providers"
23+
"sigs.k8s.io/kind/pkg/cluster/nodes"
24+
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
25+
"sigs.k8s.io/kind/pkg/errors"
26+
"sigs.k8s.io/kind/pkg/exec"
27+
"sigs.k8s.io/kind/pkg/log"
28+
)
29+
30+
// DrainAndRemoveNode safely drains and removes a node from the Kubernetes cluster
31+
func DrainAndRemoveNode(logger log.Logger, provider providers.Provider, cluster, nodeName string) error {
32+
// Get all nodes to find a control plane node for kubectl operations
33+
allNodes, err := provider.ListNodes(cluster)
34+
if err != nil {
35+
return errors.Wrap(err, "failed to list cluster nodes")
36+
}
37+
38+
// Find a control plane node to run kubectl from
39+
controlPlaneNode, err := nodeutils.BootstrapControlPlaneNode(allNodes)
40+
if err != nil {
41+
return errors.Wrap(err, "failed to find bootstrap control plane node")
42+
}
43+
44+
// Cordon the node first
45+
logger.V(0).Infof("Cordoning node %s...", nodeName)
46+
if err := cordonNode(controlPlaneNode, nodeName); err != nil {
47+
logger.Warnf("Failed to cordon node %s: %v", nodeName, err)
48+
// Continue with drain even if cordon fails
49+
}
50+
51+
// Drain the node
52+
logger.V(0).Infof("Draining node %s...", nodeName)
53+
if err := drainNode(logger, controlPlaneNode, nodeName); err != nil {
54+
logger.Warnf("Failed to drain node %s: %v", nodeName, err)
55+
// Continue with removal even if drain fails
56+
}
57+
58+
// Remove the node from the cluster
59+
logger.V(0).Infof("Removing node %s from cluster...", nodeName)
60+
if err := deleteNode(controlPlaneNode, nodeName); err != nil {
61+
logger.Warnf("Failed to delete node %s from cluster: %v", nodeName, err)
62+
// Continue with cleanup even if delete fails
63+
}
64+
65+
return nil
66+
}
67+
68+
// cordonNode marks the node as unschedulable
69+
func cordonNode(controlPlaneNode nodes.Node, nodeName string) error {
70+
cmd := controlPlaneNode.Command("kubectl", "cordon", nodeName)
71+
if err := cmd.Run(); err != nil {
72+
return errors.Wrap(err, "failed to cordon node")
73+
}
74+
return nil
75+
}
76+
77+
// drainNode safely evicts all pods from the node
78+
func drainNode(logger log.Logger, controlPlaneNode nodes.Node, nodeName string) error {
79+
args := []string{
80+
"drain",
81+
nodeName,
82+
"--ignore-daemonsets",
83+
"--delete-emptydir-data",
84+
"--force",
85+
"--timeout=60s",
86+
}
87+
88+
cmd := controlPlaneNode.Command("kubectl", args...)
89+
lines, err := exec.CombinedOutputLines(cmd)
90+
logger.V(3).Info(strings.Join(lines, "\n"))
91+
92+
if err != nil {
93+
return errors.Wrap(err, "failed to drain node")
94+
}
95+
96+
return nil
97+
}
98+
99+
// deleteNode removes the node from the cluster
100+
func deleteNode(controlPlaneNode nodes.Node, nodeName string) error {
101+
cmd := controlPlaneNode.Command("kubectl", "delete", "node", nodeName)
102+
if err := cmd.Run(); err != nil {
103+
return errors.Wrap(err, "failed to delete node from cluster")
104+
}
105+
return nil
106+
}
107+
108+
// ResetNode runs kubeadm reset on a node to clean up Kubernetes components
109+
func ResetNode(logger log.Logger, node nodes.Node) error {
110+
logger.V(0).Infof("Resetting Kubernetes components on node %s...", node.String())
111+
112+
args := []string{
113+
"reset",
114+
"--force",
115+
}
116+
117+
cmd := node.Command("kubeadm", args...)
118+
lines, err := exec.CombinedOutputLines(cmd)
119+
logger.V(3).Info(strings.Join(lines, "\n"))
120+
121+
if err != nil {
122+
logger.Warnf("Failed to reset node %s: %v", node.String(), err)
123+
// Don't fail the entire operation if reset fails
124+
}
125+
126+
return nil
127+
}

0 commit comments

Comments
 (0)