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
32 changes: 30 additions & 2 deletions internal/cli/node/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package node
import (
"context"
"fmt"
"strings"

"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
Expand All @@ -26,6 +27,7 @@ func newAddCmd() *cobra.Command {
var memory int
var maxMemory int
var hostNetworkPopulator bool
var labelFlags []string

cmd := &cobra.Command{
Use: "add <node-name>",
Expand All @@ -41,8 +43,12 @@ func newAddCmd() *cobra.Command {
bink node add node2 --role control-plane`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labels, err := parseLabels(labelFlags)
if err != nil {
return err
}
logger := logrus.New()
return runAdd(cmd.Context(), args[0], controlPlane, nodeImage, role, memory, maxMemory, hostNetworkPopulator, logger)
return runAdd(cmd.Context(), args[0], controlPlane, nodeImage, role, memory, maxMemory, hostNetworkPopulator, labels, logger)
},
}

Expand All @@ -52,11 +58,32 @@ func newAddCmd() *cobra.Command {
cmd.Flags().IntVar(&memory, "memory", 0, "VM memory in MB (0 = use role default: 1900 for control-plane, 768 for worker)")
cmd.Flags().IntVar(&maxMemory, "max-memory", 0, "VM max memory in MB for balloon (0 = use role default: 4096 for control-plane, 2048 for worker)")
cmd.Flags().BoolVar(&hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)")
cmd.Flags().StringArrayVarP(&labelFlags, "label", "l", nil, "Node label in key=value format (can be specified multiple times)")

return cmd
}

func runAdd(ctx context.Context, nodeName, controlPlane, nodeImage, role string, memory int, maxMemory int, hostNetworkPopulator bool, logger *logrus.Logger) error {
func parseLabels(labelFlags []string) (map[string]string, error) {
labels := make(map[string]string, len(labelFlags))
for _, l := range labelFlags {
k, v, ok := strings.Cut(l, "=")
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
if !ok || k == "" {
return nil, fmt.Errorf("invalid label %q: must be in key=value format", l)
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k == "" {
return nil, fmt.Errorf("invalid label %q: key must not be empty", l)
}
if _, exists := labels[k]; exists {
return nil, fmt.Errorf("duplicate label key %q", k)
}
labels[k] = v
}
return labels, nil
}

func runAdd(ctx context.Context, nodeName, controlPlane, nodeImage, role string, memory int, maxMemory int, hostNetworkPopulator bool, labels map[string]string, logger *logrus.Logger) error {
// Validate and convert role to boolean
var isControlPlane bool
switch role {
Expand Down Expand Up @@ -170,6 +197,7 @@ func runAdd(ctx context.Context, nodeName, controlPlane, nodeImage, role string,
ControlPlane: controlPlane,
IsControlPlane: isControlPlane,
NodeClusterIP: newNode.ClusterIP,
Labels: labels,
}); err != nil {
return fmt.Errorf("joining node to cluster: %w", err)
}
Expand Down
93 changes: 93 additions & 0 deletions internal/cli/node/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2026 The bink Authors
// SPDX-License-Identifier: Apache-2.0

package node

import (
"testing"
)

func TestParseLabels(t *testing.T) {
tests := []struct {
name string
input []string
want map[string]string
wantErr bool
}{
{
name: "valid single label",
input: []string{"env=test"},
want: map[string]string{"env": "test"},
},
{
name: "valid multiple labels",
input: []string{"env=test", "tier=frontend"},
want: map[string]string{"env": "test", "tier": "frontend"},
},
{
name: "empty value is valid",
input: []string{"env="},
want: map[string]string{"env": ""},
},
{
name: "value with equals sign",
input: []string{"config=a=b"},
want: map[string]string{"config": "a=b"},
},
{
name: "whitespace trimmed",
input: []string{" env = test "},
want: map[string]string{"env": "test"},
},
{
name: "missing equals sign",
input: []string{"foo"},
wantErr: true,
},
{
name: "empty key",
input: []string{"=bar"},
wantErr: true,
},
{
name: "whitespace-only key",
input: []string{" =bar"},
wantErr: true,
},
{
name: "duplicate key",
input: []string{"env=test", "env=prod"},
wantErr: true,
},
{
name: "empty input",
input: []string{},
want: map[string]string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseLabels(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("parseLabels(%v) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Errorf("parseLabels(%v) unexpected error: %v", tt.input, err)
return
}
if len(got) != len(tt.want) {
t.Errorf("parseLabels(%v) = %v, want %v", tt.input, got, tt.want)
return
}
for k, v := range tt.want {
if got[k] != v {
t.Errorf("parseLabels(%v)[%q] = %q, want %q", tt.input, k, got[k], v)
}
}
})
}
}
18 changes: 13 additions & 5 deletions internal/cluster/join.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type JoinOptions struct {
IsControlPlane bool
NodeClusterIP string
Timeout time.Duration
Labels map[string]string
}

// Join joins a node to the cluster
Expand Down Expand Up @@ -87,21 +88,28 @@ func (c *Cluster) Join(ctx context.Context, opts JoinOptions) error {
}
}

// Label worker nodes with the worker role
// Label nodes (role label applied last to prevent user override)
labels := make(map[string]string)
for k, v := range opts.Labels {
labels[k] = v
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
}
if !opts.IsControlPlane {
labels["node-role.kubernetes.io/worker"] = "worker"
}

if len(labels) > 0 {
c.logger.Info("")
c.logger.Infof("=== Labeling %s as worker ===", nodeName)
c.logger.Infof("=== Labeling %s ===", nodeName)

containerName := fmt.Sprintf("k8s-%s-%s", c.name, controlPlane)
kubeClient, err := c.newKubeClient(ctx, cpSSHClient, containerName)
if err != nil {
c.logger.Warnf("Failed to create kubernetes client (non-fatal): %v", err)
} else {
labels := map[string]string{"node-role.kubernetes.io/worker": "worker"}
if err := kubeClient.LabelNode(ctx, nodeName, labels); err != nil {
c.logger.Warnf("Failed to label node as worker (non-fatal): %v", err)
c.logger.Warnf("Failed to label node (non-fatal): %v", err)
} else {
c.logger.Infof("✅ Node %s labeled as worker", nodeName)
c.logger.Infof("✅ Node %s labeled", nodeName)
}
}
}
Expand Down
34 changes: 12 additions & 22 deletions test/integration/multinode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,36 +43,21 @@ var _ = Describe("Multi-Node Clusters", func() {
kubeClient, kubeconfigPath := helpers.SetupKubeClient(clusterName)
defer helpers.CleanupKubeconfig(kubeconfigPath)

By("Adding first worker node")
helpers.AddNode(clusterName, node2, "--role", "worker")
By("Adding first worker node with labels")
helpers.AddNode(clusterName, node2, "--role", "worker", "--label", "env=test1")

By("Verifying node2 container is running")
containerName2 := helpers.NodeContainerName(clusterName, node2)
container2 := helpers.GetContainer(containerName2)
Expect(container2).ToNot(BeNil(), "Container %s should exist", containerName2)
Expect(container2.State).To(Equal("running"), "Container should be running")

By("Verifying node2 joined Kubernetes with worker role")
By("Verifying node2 joined Kubernetes with worker role and custom labels")
helpers.WaitForNodeReady(kubeClient, node2, 5*time.Minute)
n2, err := kubeClient.CoreV1().Nodes().Get(context.Background(), node2, metav1.GetOptions{})
Expect(err).ToNot(HaveOccurred())
hasWorkerRole := false
for key := range n2.Labels {
if key == "node-role.kubernetes.io/worker" {
hasWorkerRole = true
break
}
}
hasControlPlaneRole := false
for key := range n2.Labels {
if key == "node-role.kubernetes.io/control-plane" {
hasControlPlaneRole = true
break
}
}
Expect(hasControlPlaneRole).To(BeFalse(), "node2 should not have control-plane role")
// Worker role label may not be set by kubeadm by default, so just verify it's not control-plane
_ = hasWorkerRole
Expect(n2.Labels).ToNot(HaveKey("node-role.kubernetes.io/control-plane"), "node2 should not have control-plane role")
Expect(n2.Labels).To(HaveKeyWithValue("env", "test1"), "node2 should have label env=test1")

By("Verifying DNS entry for node2")
hostsFile := helpers.PodmanExec(helpers.DNSContainerName(clusterName), "cat /var/lib/dnsmasq/cluster-hosts")
Expand All @@ -85,8 +70,8 @@ var _ = Describe("Multi-Node Clusters", func() {
Expect(listOutput).To(ContainSubstring(node1), "node list should contain node1")
Expect(listOutput).To(ContainSubstring(node2), "node list should contain node2")

By("Adding second worker node")
helpers.AddNode(clusterName, node3, "--role", "worker")
By("Adding second worker node with different labels")
helpers.AddNode(clusterName, node3, "--role", "worker", "--label", "env=test2")

By("Verifying all three containers are running")
for _, nodeName := range []string{node1, node2, node3} {
Expand All @@ -112,6 +97,11 @@ var _ = Describe("Multi-Node Clusters", func() {
Expect(ready).To(BeTrue(), "Node %s should be Ready", node.Name)
}

By("Verifying custom labels on node3")
n3, err := kubeClient.CoreV1().Nodes().Get(context.Background(), node3, metav1.GetOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(n3.Labels).To(HaveKeyWithValue("env", "test2"), "node3 should have label env=test2")
Comment thread
alicefr marked this conversation as resolved.

By("Verifying DNS entries for all nodes")
hostsFile = helpers.PodmanExec(helpers.DNSContainerName(clusterName), "cat /var/lib/dnsmasq/cluster-hosts")
Expect(hostsFile).To(ContainSubstring(node1), "cluster-hosts should contain node1")
Expand Down
Loading