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
1 change: 1 addition & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ jobs:
timeout-minutes: 90
env:
BINK_NODE_IMAGE: ghcr.io/bootc-dev/bink/node:v${{ needs.supported-versions.outputs.default-version }}-fedora-${{ needs.supported-versions.outputs.fedora-version }}-disk-composefs
BINK_BOOT_FIRMWARE: uefi

- name: Collect logs
if: failure()
Expand Down
1 change: 1 addition & 0 deletions containerfiles/cluster-image/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ RUN dnf install -y --setopt=install_weak_deps=0 \
openssh-clients \
genisoimage \
podman \
edk2-ovmf \
&& dnf clean all

COPY qemu.conf /etc/libvirt/qemu.conf
Expand Down
92 changes: 50 additions & 42 deletions internal/cli/cluster/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,22 @@ import (
"github.com/spf13/viper"
)

type startOptions struct {
nodeName string
nodeImage string
apiPort int
memory int
maxMemory int
exposePath string
hostNetworkPopulator bool
targetImgRef string
registryUser string
registryPassword string
bootFirmware string
}

func newStartCmd() *cobra.Command {
var nodeName string
var nodeImage string
var apiPort int
var memory int
var maxMemory int
var exposePath string
var hostNetworkPopulator bool
var targetImgRef string
var registryUser string
var registryPassword string
opts := &startOptions{}

cmd := &cobra.Command{
Use: "start",
Expand All @@ -47,26 +52,27 @@ 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, targetImgRef, registryUser, registryPassword)
return runStart(cmd.Context(), logger, opts)
},
}

cmd.Flags().StringVar(&nodeName, "node-name", "node1", "Name for the control plane node")
cmd.Flags().StringVar(&nodeImage, "node-image", config.DefaultNodeImage, "Container image containing base VM images")
cmd.Flags().IntVar(&apiPort, "api-port", 0, "API server port to expose (0 = auto-assign random port)")
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().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)")
cmd.Flags().StringVar(&registryUser, "registry-user", "", "Username for the authenticated registry")
cmd.Flags().StringVar(&registryPassword, "registry-password", "", "Password for the authenticated registry")
cmd.Flags().StringVar(&opts.nodeName, "node-name", "node1", "Name for the control plane node")
cmd.Flags().StringVar(&opts.nodeImage, "node-image", config.DefaultNodeImage, "Container image containing base VM images")
cmd.Flags().IntVar(&opts.apiPort, "api-port", 0, "API server port to expose (0 = auto-assign random port)")
cmd.Flags().IntVar(&opts.memory, "memory", 0, "VM memory in MB (0 = use role default: 1900 for control-plane, 768 for worker)")
cmd.Flags().IntVar(&opts.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(&opts.exposePath, "expose", "", "Expose API and save kubeconfig to PATH after cluster is up")
cmd.Flags().BoolVar(&opts.hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)")
cmd.Flags().StringVar(&opts.targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)")
cmd.Flags().StringVar(&opts.registryUser, "registry-user", "", "Username for the authenticated registry")
cmd.Flags().StringVar(&opts.registryPassword, "registry-password", "", "Password for the authenticated registry")
cmd.Flags().StringVar(&opts.bootFirmware, "boot-firmware", "", "Boot firmware for the VM (uefi or bios, default: bios)")

return cmd
}

func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string, registryUser string, registryPassword string) error {
authRegistryRequested, err := registry.AuthRegistryRequested(registryUser, registryPassword)
func runStart(ctx context.Context, logger *logrus.Logger, opts *startOptions) error {
authRegistryRequested, err := registry.AuthRegistryRequested(opts.registryUser, opts.registryPassword)
if err != nil {
return fmt.Errorf("invalid auth registry credentials: %w", err)
}
Expand Down Expand Up @@ -95,7 +101,7 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI
return fmt.Errorf("ensuring registry: %w", err)
}
if authRegistryRequested {
if err := registryMgr.EnsureAuthRegistry(ctx, registryUser, registryPassword); err != nil {
if err := registryMgr.EnsureAuthRegistry(ctx, opts.registryUser, opts.registryPassword); err != nil {
return fmt.Errorf("ensuring auth registry: %w", err)
}
}
Expand All @@ -121,42 +127,44 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI
if err := podmanClient.EnsureImage(ctx, config.DefaultClusterImage); err != nil {
return fmt.Errorf("ensuring cluster image: %w", err)
}
if err := podmanClient.EnsureImage(ctx, nodeImage); err != nil {
if err := podmanClient.EnsureImage(ctx, opts.nodeImage); err != nil {
return fmt.Errorf("ensuring node image: %w", err)
}
logger.Info("")

logger.Info("Step 5: Preparing cluster images volume...")
clusterMgr := cluster.New(cluster.Config{
Name: clusterName,
ControlPlane: nodeName,
HostNetworkPopulator: hostNetworkPopulator,
ControlPlane: opts.nodeName,
HostNetworkPopulator: opts.hostNetworkPopulator,
Logger: logger,
})

clusterImagesVolume, err := clusterMgr.EnsureImagesVolume(ctx, nodeImage)
clusterImagesVolume, err := clusterMgr.EnsureImagesVolume(ctx, opts.nodeImage)
if err != nil {
return fmt.Errorf("ensuring images volume: %w", err)
}
logger.Info("")

logger.Infof("Step 6: Creating control plane node (%s)...", nodeName)
logger.Infof("Node image: %s", nodeImage)
logger.Infof("Step 6: Creating control plane node (%s)...", opts.nodeName)
logger.Infof("Node image: %s", opts.nodeImage)

// Convert 0 to -1 for auto-assign (to distinguish from unset)
apiPort := opts.apiPort
if apiPort == 0 {
apiPort = -1
}

controlPlane, err := node.New(nodeName, true,
node.WithNodeImage(nodeImage),
controlPlane, err := node.New(opts.nodeName, true,
node.WithNodeImage(opts.nodeImage),
node.WithClusterName(clusterName),
node.WithAPIPort(apiPort),
node.WithMemory(memory),
node.WithMaxMemory(maxMemory),
node.WithMemory(opts.memory),
node.WithMaxMemory(opts.maxMemory),
node.WithDNSIP(dnsIP),
node.WithClusterImagesVolume(clusterImagesVolume),
node.WithTargetImgRef(targetImgRef),
node.WithTargetImgRef(opts.targetImgRef),
node.WithBootFirmware(opts.bootFirmware),
)
if err != nil {
return fmt.Errorf("creating node: %w", err)
Expand All @@ -168,23 +176,23 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI
}

if exists {
return fmt.Errorf("%s already exists. Run 'bink cluster stop' first", nodeName)
return fmt.Errorf("%s already exists. Run 'bink cluster stop' first", opts.nodeName)
}

if err := controlPlane.Create(ctx); err != nil {
return fmt.Errorf("creating control plane node: %w", err)
}

logger.Infof("Adding %s DNS entry...", nodeName)
if err := dnsMgr.AddEntry(ctx, nodeName); err != nil {
return fmt.Errorf("adding %s DNS entry: %w", nodeName, err)
logger.Infof("Adding %s DNS entry...", opts.nodeName)
if err := dnsMgr.AddEntry(ctx, opts.nodeName); err != nil {
return fmt.Errorf("adding %s DNS entry: %w", opts.nodeName, err)
}
logger.Info("")

logger.Info("Step 7: Initializing Kubernetes cluster...")

if err := clusterMgr.Init(ctx, cluster.InitOptions{
NodeName: nodeName,
NodeName: opts.nodeName,
}); err != nil {
return fmt.Errorf("initializing cluster: %w", err)
}
Expand Down Expand Up @@ -217,9 +225,9 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI
logger.Info("")
}

if exposePath != "" {
if opts.exposePath != "" {
logger.Info("Step 9: Exposing API server...")
if err := api.RunExpose(ctx, logger, "", exposePath); err != nil {
if err := api.RunExpose(ctx, logger, "", opts.exposePath); err != nil {
return fmt.Errorf("exposing API: %w", err)
}
} else {
Expand Down
64 changes: 35 additions & 29 deletions internal/cli/node/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import (
"github.com/bootc-dev/bink/internal/podman"
)

type addOptions struct {
nodeImage string
role string
memory int
maxMemory int
hostNetworkPopulator bool
labelFlags []string
targetImgRef string
bootFirmware string
}

func newAddCmd() *cobra.Command {
var nodeImage string
var role string
var memory int
var maxMemory int
var hostNetworkPopulator bool
var labelFlags []string
var targetImgRef string
opts := &addOptions{}

cmd := &cobra.Command{
Use: "add <node-name>",
Expand All @@ -43,22 +48,23 @@ 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)
labels, err := parseLabels(opts.labelFlags)
if err != nil {
return err
}
logger := logrus.New()
return runAdd(cmd.Context(), args[0], nodeImage, role, memory, maxMemory, hostNetworkPopulator, labels, targetImgRef, logger)
return runAdd(cmd.Context(), args[0], opts, labels, logger)
},
}

cmd.Flags().StringVar(&nodeImage, "node-image", config.DefaultNodeImage, "Container image containing base VM images")
cmd.Flags().StringVarP(&role, "role", "r", "worker", "Node role: worker or control-plane")
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)")
cmd.Flags().StringVar(&targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)")
cmd.Flags().StringVar(&opts.nodeImage, "node-image", config.DefaultNodeImage, "Container image containing base VM images")
cmd.Flags().StringVarP(&opts.role, "role", "r", "worker", "Node role: worker or control-plane")
cmd.Flags().IntVar(&opts.memory, "memory", 0, "VM memory in MB (0 = use role default: 1900 for control-plane, 768 for worker)")
cmd.Flags().IntVar(&opts.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(&opts.hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)")
cmd.Flags().StringArrayVarP(&opts.labelFlags, "label", "l", nil, "Node label in key=value format (can be specified multiple times)")
cmd.Flags().StringVar(&opts.targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)")
cmd.Flags().StringVar(&opts.bootFirmware, "boot-firmware", "", "Boot firmware for the VM (uefi or bios, default: bios)")

cmd.RegisterFlagCompletionFunc("role", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"worker", "control-plane"}, cobra.ShellCompDirectiveNoFileComp
Expand Down Expand Up @@ -86,19 +92,18 @@ 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, targetImgRef string, logger *logrus.Logger) error {
// Validate and convert role to boolean
func runAdd(ctx context.Context, nodeName string, opts *addOptions, labels map[string]string, logger *logrus.Logger) error {
var isControlPlane bool
switch role {
switch opts.role {
case "worker":
isControlPlane = false
case "control-plane":
isControlPlane = true
default:
return fmt.Errorf("invalid role %q: must be either 'worker' or 'control-plane'", role)
return fmt.Errorf("invalid role %q: must be either 'worker' or 'control-plane'", opts.role)
}

logger.Infof("=== Creating %s node %s ===", role, nodeName)
logger.Infof("=== Creating %s node %s ===", opts.role, nodeName)
logger.Info("")

clusterName := viper.GetString("cluster.name")
Expand All @@ -112,8 +117,8 @@ func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, m
}

// Step 1: Create the new node
logger.Infof("Step 1: Creating %s node...", role)
logger.Infof("Node image: %s", nodeImage)
logger.Infof("Step 1: Creating %s node...", opts.role)
logger.Infof("Node image: %s", opts.nodeImage)

// Collect IPs of existing nodes to avoid collisions
existingContainers, err := podmanClient.ContainerList(ctx, config.LabelFilter(config.LabelClusterName, clusterName))
Expand All @@ -139,11 +144,11 @@ func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, m
clusterMgr := cluster.New(cluster.Config{
Name: clusterName,
ControlPlane: controlPlane,
HostNetworkPopulator: hostNetworkPopulator,
HostNetworkPopulator: opts.hostNetworkPopulator,
Logger: logger,
})

clusterImagesVolume, err := clusterMgr.EnsureImagesVolume(ctx, nodeImage)
clusterImagesVolume, err := clusterMgr.EnsureImagesVolume(ctx, opts.nodeImage)
if err != nil {
return fmt.Errorf("ensuring images volume: %w", err)
}
Expand All @@ -160,14 +165,15 @@ func runAdd(ctx context.Context, nodeName, nodeImage, role string, memory int, m
}

nodeOpts := []node.NodeOption{
node.WithNodeImage(nodeImage),
node.WithNodeImage(opts.nodeImage),
node.WithClusterName(clusterName),
node.WithMemory(memory),
node.WithMaxMemory(maxMemory),
node.WithMemory(opts.memory),
node.WithMaxMemory(opts.maxMemory),
node.WithUsedIPs(usedIPs),
node.WithDNSIP(dnsIP),
node.WithClusterImagesVolume(clusterImagesVolume),
node.WithTargetImgRef(targetImgRef),
node.WithTargetImgRef(opts.targetImgRef),
node.WithBootFirmware(opts.bootFirmware),
}
if isControlPlane {
nodeOpts = append(nodeOpts, node.WithAPIPort(-1))
Expand Down
11 changes: 6 additions & 5 deletions internal/node/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,12 @@ func (n *Node) createVM(ctx context.Context) error {
}

opts := &virsh.VirtInstallOptions{
Name: n.Name,
Memory: n.Memory,
MaxMemory: maxMemory,
VCPUs: n.VCPUs,
Disks: []string{overlayDisk, isoPath},
Name: n.Name,
Memory: n.Memory,
MaxMemory: maxMemory,
VCPUs: n.VCPUs,
BootFirmware: n.BootFirmware,
Disks: []string{overlayDisk, isoPath},
Networks: []virsh.NetworkConfig{
{
Type: "passt",
Expand Down
8 changes: 8 additions & 0 deletions internal/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Node struct {
APIPort int // Configured API port (0 = auto-assign)
AssignedAPIPort int // Actual assigned port after container creation
TargetImgRef string
BootFirmware string

usedIPs []string
podman *podman.Client
Expand Down Expand Up @@ -108,6 +109,13 @@ func WithClusterImagesVolume(volumeName string) NodeOption {
}
}

func WithBootFirmware(firmware string) NodeOption {
return func(n *Node) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, but why return an error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it is how the NodeOption is defined, see here. Functional Options Pattern is a known pattern in go. It is true that we don't return an error yet, but we could use it in the future to validate certain input

n.BootFirmware = firmware
return nil
}
}

func New(name string, isControlPlane bool, opts ...NodeOption) (*Node, error) {
var memory, maxMemory int
switch {
Expand Down
4 changes: 4 additions & 0 deletions internal/virsh/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func (c *Client) VirtInstall(ctx context.Context, opts *VirtInstallOptions) erro
"--noautoconsole",
}

if opts.BootFirmware == "uefi" {
args = append(args, "--boot", "uefi")
}

// Add shared memory support if filesystems are present (required for virtiofs)
if len(opts.Filesystems) > 0 {
args = append(args, "--memorybacking", "source.type=memfd,access.mode=shared")
Expand Down
1 change: 1 addition & 0 deletions internal/virsh/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type VirtInstallOptions struct {
Memory int
MaxMemory int
VCPUs int
BootFirmware string
Disks []string
Networks []NetworkConfig
Filesystems []FilesystemConfig
Expand Down
Loading
Loading