diff --git a/internal/cli/node/add.go b/internal/cli/node/add.go index 2a301af..b45d008 100644 --- a/internal/cli/node/add.go +++ b/internal/cli/node/add.go @@ -6,6 +6,7 @@ package node import ( "context" "fmt" + "strings" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -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 ", @@ -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) }, } @@ -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, "=") + 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 { @@ -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) } diff --git a/internal/cli/node/add_test.go b/internal/cli/node/add_test.go new file mode 100644 index 0000000..278ab13 --- /dev/null +++ b/internal/cli/node/add_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/cluster/join.go b/internal/cluster/join.go index 0efbc99..fc11b18 100644 --- a/internal/cluster/join.go +++ b/internal/cluster/join.go @@ -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 @@ -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 + } 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) } } } diff --git a/test/integration/multinode_test.go b/test/integration/multinode_test.go index 7394824..ab99e61 100644 --- a/test/integration/multinode_test.go +++ b/test/integration/multinode_test.go @@ -43,8 +43,8 @@ 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) @@ -52,27 +52,12 @@ var _ = Describe("Multi-Node Clusters", func() { 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") @@ -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} { @@ -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") + 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")