diff --git a/internal/cli/cluster/start.go b/internal/cli/cluster/start.go index e1bb98c..353b696 100644 --- a/internal/cli/cluster/start.go +++ b/internal/cli/cluster/start.go @@ -29,6 +29,7 @@ func newStartCmd() *cobra.Command { var maxMemory int var exposePath string var hostNetworkPopulator bool + var targetImgRef string cmd := &cobra.Command{ Use: "start", @@ -44,7 +45,7 @@ func newStartCmd() *cobra.Command { bink cluster start --memory 4096 --expose ./kubeconfig`, RunE: func(cmd *cobra.Command, args []string) error { logger := logrus.New() - return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator) + return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator, targetImgRef) }, } @@ -55,11 +56,12 @@ func newStartCmd() *cobra.Command { 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().StringVar(&exposePath, "expose", "", "Expose API and save kubeconfig to PATH after cluster is up") cmd.Flags().BoolVar(&hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)") + cmd.Flags().StringVar(&targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)") return cmd } -func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool) error { +func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string) error { logger.Info("=== Creating Kubernetes cluster ===") logger.Info("") @@ -140,6 +142,7 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI node.WithMaxMemory(maxMemory), node.WithDNSIP(dnsIP), node.WithClusterImagesVolume(clusterImagesVolume), + node.WithTargetImgRef(targetImgRef), ) if err != nil { return fmt.Errorf("creating node: %w", err) diff --git a/internal/cli/node/add.go b/internal/cli/node/add.go index 9cfa8fb..12bf9c1 100644 --- a/internal/cli/node/add.go +++ b/internal/cli/node/add.go @@ -27,6 +27,7 @@ func newAddCmd() *cobra.Command { var maxMemory int var hostNetworkPopulator bool var labelFlags []string + var targetImgRef string cmd := &cobra.Command{ Use: "add ", @@ -47,7 +48,7 @@ func newAddCmd() *cobra.Command { return err } logger := logrus.New() - return runAdd(cmd.Context(), args[0], nodeImage, role, memory, maxMemory, hostNetworkPopulator, labels, logger) + return runAdd(cmd.Context(), args[0], nodeImage, role, memory, maxMemory, hostNetworkPopulator, labels, targetImgRef, logger) }, } @@ -57,6 +58,7 @@ func newAddCmd() *cobra.Command { 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)") + cmd.Flags().StringVar(&targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)") cmd.RegisterFlagCompletionFunc("role", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"worker", "control-plane"}, cobra.ShellCompDirectiveNoFileComp @@ -84,7 +86,7 @@ func parseLabels(labelFlags []string) (map[string]string, error) { return labels, nil } -func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, maxMemory int, hostNetworkPopulator bool, labels map[string]string, logger *logrus.Logger) error { +func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, maxMemory int, hostNetworkPopulator bool, labels map[string]string, targetImgRef string, logger *logrus.Logger) error { // Validate and convert role to boolean var isControlPlane bool switch role { @@ -165,6 +167,7 @@ func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, m node.WithUsedIPs(usedIPs), node.WithDNSIP(dnsIP), node.WithClusterImagesVolume(clusterImagesVolume), + node.WithTargetImgRef(targetImgRef), } if isControlPlane { nodeOpts = append(nodeOpts, node.WithAPIPort(-1)) diff --git a/internal/node/cloudinit.go b/internal/node/cloudinit.go index c28005c..55dac2a 100644 --- a/internal/node/cloudinit.go +++ b/internal/node/cloudinit.go @@ -38,6 +38,7 @@ type CloudInitData struct { RegistryPort int RegistryHostname string ServiceCIDR string + TargetImgRef string } func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { @@ -54,6 +55,7 @@ func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { RegistryPort: config.RegistryPort, RegistryHostname: config.RegistryHostname, ServiceCIDR: config.ServiceCIDR, + TargetImgRef: n.TargetImgRef, } } diff --git a/internal/node/node.go b/internal/node/node.go index 6db93ec..2b5f027 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -29,6 +29,7 @@ type Node struct { ClusterImagesVolume string APIPort int // Configured API port (0 = auto-assign) AssignedAPIPort int // Actual assigned port after container creation + TargetImgRef string usedIPs []string podman *podman.Client @@ -93,6 +94,13 @@ func WithDNSIP(ip string) NodeOption { } } +func WithTargetImgRef(ref string) NodeOption { + return func(n *Node) error { + n.TargetImgRef = ref + return nil + } +} + func WithClusterImagesVolume(volumeName string) NodeOption { return func(n *Node) error { n.ClusterImagesVolume = volumeName diff --git a/internal/node/templates/user-data.yaml.tmpl b/internal/node/templates/user-data.yaml.tmpl index 4af2e68..989e4d2 100644 --- a/internal/node/templates/user-data.yaml.tmpl +++ b/internal/node/templates/user-data.yaml.tmpl @@ -85,3 +85,11 @@ runcmd: - nmcli connection up "cloud-init enp2s0" - systemctl enable --now crio - systemctl enable kubelet +{{- if .TargetImgRef}} + - | + mount -o remount,rw /sysroot + DEPLOY_PATH=$(bootc status --json | jq -r '.status.booted.ostree.deploySerial // empty') + CHECKSUM=$(bootc status --json | jq -r '.status.booted.ostree.checksum') + ORIGIN="/ostree/deploy/default/deploy/${CHECKSUM}.${DEPLOY_PATH}.origin" + sed -i 's|^container-image-reference=.*|container-image-reference=ostree-unverified-registry:{{.TargetImgRef}}|' "$ORIGIN" +{{- end}} diff --git a/test/integration/cluster_test.go b/test/integration/cluster_test.go index d6d011d..0fd0055 100644 --- a/test/integration/cluster_test.go +++ b/test/integration/cluster_test.go @@ -38,8 +38,10 @@ var _ = Describe("Cluster Lifecycle", func() { kubeconfigPath := fmt.Sprintf("../../kubeconfig-%s", clusterName) defer helpers.CleanupKubeconfig(kubeconfigPath) - By("Creating cluster with --expose, custom node name, and memory ballooning") - cmd := helpers.BinkCmd("cluster", "start", "--cluster-name", clusterName, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-name", customNodeName, "--expose", kubeconfigPath) + targetImgRef := "registry.cluster.local:5000/node:latest" + + By("Creating cluster with --expose, custom node name, memory ballooning, and target-imgref") + cmd := helpers.BinkCmd("cluster", "start", "--cluster-name", clusterName, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-name", customNodeName, "--expose", kubeconfigPath, "--target-imgref", targetImgRef) session := helpers.RunCommand(cmd) By("Verifying cluster creation command succeeded") @@ -93,6 +95,10 @@ var _ = Describe("Cluster Lifecycle", func() { sshOutput := string(sshSession.Out.Contents()) Expect(sshOutput).To(ContainSubstring(customNodeName), "SSH command output should contain the node hostname") + By("Verifying bootc status shows the overridden image reference") + bootcOutput := helpers.SSHExec(clusterName, customNodeName, "sudo bootc status") + Expect(bootcOutput).To(ContainSubstring(targetImgRef), "bootc status should show the target image reference") + By("Verifying bink node ssh propagates non-zero exit codes") failCmd := helpers.BinkCmd("node", "ssh", customNodeName, "--cluster-name", clusterName, "--", "exit", "42") failSession := helpers.RunCommand(failCmd)