diff --git a/README.md b/README.md index 3a1e523..7e27e17 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,22 @@ Show help: Writes `examples/inventory.yaml` with a `bmcs:` list and `nodes: []`. +**Advanced: Start IP allocation at a specific address** + +To reserve the beginning of the subnet (e.g., for gateway, DNS), use `--start-ip`: + +```bash +./ochami_bootstrap init-bmcs --file examples/inventory.yaml \ + --chassis "x9000c1=02:23:28:01" \ + --bmc-subnet 192.168.100.0/24 \ + --start-ip 192.168.100.10 \ + --nodes-per-chassis 32 \ + --nodes-per-bmc 2 \ + --start-nid 1 +``` + +This skips IPs .1-.9 and begins allocating BMC IPs from .10. + ### 2) Discover bootable NICs and allocate IPs The discovery flow reads the YAML `--file` (must contain non-empty `bmcs[]`) and writes back the same file with updated `nodes[]`. @@ -108,6 +124,23 @@ export REDFISH_PASSWORD=secret --ssh-pubkey ~/.ssh/id_rsa.pub # optional: set AuthorizedKeys on each BMC ``` +**Advanced: Start node IP allocation at a specific address** + +Use `--node-start-ip` to skip the beginning of the node subnet: + +```bash +export REDFISH_USER=admin +export REDFISH_PASSWORD=secret +./ochami_bootstrap discover \ + --file examples/inventory.yaml \ + --node-subnet 10.42.0.0/24 \ + --node-start-ip 10.42.0.100 \ + --timeout 12s \ + --insecure +``` + +This reserves IPs .1-.99 and allocates node IPs starting from .100. + Notes: - The program makes simple heuristic decisions about which NIC is bootable (UEFI path hints, DHCP addresses, or a MAC on an enabled interface). - IP allocation is done with `github.com/metal-stack/go-ipam`. The code reserves `.1` (first host) as a gateway and avoids network/broadcast implicitly. diff --git a/cmd/discover.go b/cmd/discover.go index 26acdf6..701bb19 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -20,13 +20,14 @@ import ( ) var ( - discFile string - discBMCSubnet string - discNodeSubnet string - discInsecure bool - discTimeout time.Duration - discSSHPubKey string - discDryRun bool + discFile string + discBMCSubnet string + discNodeSubnet string + discNodeStartIP string + discInsecure bool + discTimeout time.Duration + discSSHPubKey string + discDryRun bool ) var discoverCmd = &cobra.Command{ @@ -111,7 +112,7 @@ var discoverCmd = &cobra.Command{ } } - nodes, err := discover.UpdateNodes(&doc, discBMCSubnet, discNodeSubnet, user, pass, discInsecure, discTimeout) + nodes, err := discover.UpdateNodes(&doc, discBMCSubnet, discNodeSubnet, discNodeStartIP, user, pass, discInsecure, discTimeout) if err != nil { return err } @@ -133,6 +134,7 @@ func init() { discoverCmd.Flags().StringVarP(&discFile, "file", "f", "", "YAML file containing bmcs[] and nodes[] (nodes will be overwritten)") discoverCmd.Flags().StringVar(&discBMCSubnet, "bmc-subnet", "", "CIDR for BMC IPs, e.g. 192.168.100.0/24 (if not specified, uses --node-subnet)") discoverCmd.Flags().StringVar(&discNodeSubnet, "node-subnet", "", "CIDR for node IPs, e.g. 10.42.0.0/24 (if not specified, uses --bmc-subnet)") + discoverCmd.Flags().StringVar(&discNodeStartIP, "node-start-ip", "", "Start node IP allocation at this address (skips all IPs before it)") discoverCmd.Flags().BoolVar(&discInsecure, "insecure", true, "allow insecure TLS to BMCs") discoverCmd.Flags().DurationVar(&discTimeout, "timeout", 12*time.Second, "per-BMC discovery timeout") discoverCmd.Flags().StringVar(&discSSHPubKey, "ssh-pubkey", "", "Path to an SSH public key to set as AuthorizedKeys on each BMC (optional)") diff --git a/cmd/initbmcs.go b/cmd/initbmcs.go index e47c7aa..59050b9 100644 --- a/cmd/initbmcs.go +++ b/cmd/initbmcs.go @@ -19,6 +19,7 @@ var ( initFile string initChassis string initBMCSubnet string + initStartIP string initNodesPerChas int initNodesPerBMC int initStartNID int @@ -38,7 +39,7 @@ var initBmcsCmd = &cobra.Command{ if len(chassis) == 0 { return fmt.Errorf("--chassis must specify at least one entry, e.g. x9000c1=02:23:28:01") } - bmcs, err := initbmcs.Generate(chassis, initNodesPerChas, initNodesPerBMC, initStartNID, initBMCSubnet) + bmcs, err := initbmcs.Generate(chassis, initNodesPerChas, initNodesPerBMC, initStartNID, initBMCSubnet, initStartIP) if err != nil { return err } @@ -60,6 +61,7 @@ func init() { initBmcsCmd.Flags().StringVarP(&initFile, "file", "f", "", "Output YAML file containing bmcs[] and nodes[]") initBmcsCmd.Flags().StringVar(&initChassis, "chassis", "x9000c1=02:23:28:01,x9000c3=02:23:28:03", "comma-separated chassis=macprefix list") initBmcsCmd.Flags().StringVar(&initBMCSubnet, "bmc-subnet", "192.168.100.0/24", "BMC subnet in CIDR notation, e.g. 192.168.100.0/24") + initBmcsCmd.Flags().StringVar(&initStartIP, "start-ip", "1", "Start IP allocation at this address (skips all IPs before it)") initBmcsCmd.Flags().IntVar(&initNodesPerChas, "nodes-per-chassis", 32, "number of nodes per chassis") initBmcsCmd.Flags().IntVar(&initNodesPerBMC, "nodes-per-bmc", 2, "number of nodes managed by each BMC") initBmcsCmd.Flags().IntVar(&initStartNID, "start-nid", 1, "starting node id (1-based)") diff --git a/internal/discover/discover.go b/internal/discover/discover.go index d508015..092eb37 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -20,7 +20,8 @@ import ( // UpdateNodes reads existing nodes for reservations, discovers bootable NICs per BMC, // allocates IPs, and returns the new nodes list. -func UpdateNodes(doc *inventory.FileFormat, bmcSubnet, nodeSubnet string, user, pass string, insecure bool, timeout time.Duration) ([]inventory.Entry, error) { +// nodeStartIP is an optional IP address to start node allocation from (skips all IPs before it) +func UpdateNodes(doc *inventory.FileFormat, bmcSubnet, nodeSubnet, nodeStartIP string, user, pass string, insecure bool, timeout time.Duration) ([]inventory.Entry, error) { // Create allocator for node IPs nodeAlloc, err := netalloc.NewAllocator(nodeSubnet) if err != nil { @@ -34,6 +35,13 @@ func UpdateNodes(doc *inventory.FileFormat, bmcSubnet, nodeSubnet string, user, } } + // Reserve all IPs before the start IP if specified + if nodeStartIP != "" { + if err := nodeAlloc.ReserveUpTo(nodeStartIP); err != nil { + return nil, fmt.Errorf("reserve up to node start IP: %w", err) + } + } + // Create BMC allocator if subnet is different, otherwise reuse node allocator var bmcAlloc *netalloc.Allocator if bmcSubnet == nodeSubnet { diff --git a/internal/initbmcs/generate.go b/internal/initbmcs/generate.go index 0087a0a..9b1b7d7 100644 --- a/internal/initbmcs/generate.go +++ b/internal/initbmcs/generate.go @@ -49,12 +49,20 @@ func ParseChassisSpec(spec string) map[string]string { // Generate creates the BMC entries for an initial inventory. // bmcSubnet should be in CIDR notation, e.g. "192.168.100.0/24" -func Generate(chassis map[string]string, nodesPerChassis, nodesPerBMC, startNID int, bmcSubnet string) ([]inventory.Entry, error) { +// startIP is an optional IP address to start allocation from (skips all IPs before it) +func Generate(chassis map[string]string, nodesPerChassis, nodesPerBMC, startNID int, bmcSubnet, startIP string) ([]inventory.Entry, error) { alloc, err := netalloc.NewAllocator(bmcSubnet) if err != nil { return nil, fmt.Errorf("bmc subnet init: %w", err) } + // Reserve all IPs before the start IP if specified + if startIP != "" { + if err := alloc.ReserveUpTo(startIP); err != nil { + return nil, fmt.Errorf("reserve up to start IP: %w", err) + } + } + var bmcs []inventory.Entry nid := startNID for c, macPref := range chassis { diff --git a/internal/initbmcs/generate_test.go b/internal/initbmcs/generate_test.go index 5193dfa..7c0c97d 100644 --- a/internal/initbmcs/generate_test.go +++ b/internal/initbmcs/generate_test.go @@ -24,7 +24,7 @@ func TestParseChassisSpec(t *testing.T) { func TestGenerateSingleChassisDeterministic(t *testing.T) { chassis := map[string]string{"x9000c1": "02:23:28:01"} - bmcs, err := Generate(chassis, 4, 2, 1, "192.168.100.0/24") + bmcs, err := Generate(chassis, 4, 2, 1, "192.168.100.0/24", "") if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -37,3 +37,19 @@ func TestGenerateSingleChassisDeterministic(t *testing.T) { t.Fatalf("Generate result mismatch:\n got: %#v\nwant: %#v", bmcs, want) } } + +func TestGenerateWithStartIP(t *testing.T) { + chassis := map[string]string{"x9000c1": "02:23:28:01"} + bmcs, err := Generate(chassis, 4, 2, 1, "192.168.100.0/24", "192.168.100.10") + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + want := []inventory.Entry{ + {Xname: "x9000c1s0b0", MAC: "02:23:28:01:30:00", IP: "192.168.100.10"}, + {Xname: "x9000c1s0b1", MAC: "02:23:28:01:30:10", IP: "192.168.100.11"}, + } + if !reflect.DeepEqual(bmcs, want) { + t.Fatalf("Generate result mismatch:\n got: %#v\nwant: %#v", bmcs, want) + } +} diff --git a/internal/netalloc/ipam.go b/internal/netalloc/ipam.go index 04a87d8..b2ee08d 100644 --- a/internal/netalloc/ipam.go +++ b/internal/netalloc/ipam.go @@ -7,6 +7,7 @@ package netalloc import ( "context" + "fmt" "net" ipam "github.com/metal-stack/go-ipam" @@ -57,3 +58,53 @@ func (a *Allocator) Contains(ip string) bool { } return n.Contains(parsedIP) } + +// ReserveUpTo reserves all IP addresses from the start of the subnet up to (but not including) the specified IP. +// This is useful for skipping a range of IPs before allocation begins. +func (a *Allocator) ReserveUpTo(startIP string) error { + if startIP == "" { + return nil + } + if !a.Contains(startIP) { + return fmt.Errorf("start IP %s is not in subnet %s", startIP, a.prefix.Cidr) + } + // Parse the start IP + startParsed := net.ParseIP(startIP) + if startParsed == nil { + return fmt.Errorf("invalid start IP: %s", startIP) + } + + // Reserve IPs until we reach the start IP + for { + addr, err := a.ipm.AcquireIP(context.Background(), a.prefix.Cidr) + if err != nil { + // No more IPs available or error + return nil + } + allocatedIP := net.ParseIP(addr.IP.String()) + // Stop when we've reserved everything before startIP + if allocatedIP.Equal(startParsed) || isIPGreaterThan(allocatedIP, startParsed) { + // Release this IP since we don't want to reserve it + _, _ = a.ipm.ReleaseIP(context.Background(), addr) + return nil + } + } +} + +// isIPGreaterThan returns true if ip1 > ip2 +func isIPGreaterThan(ip1, ip2 net.IP) bool { + ip1v4 := ip1.To4() + ip2v4 := ip2.To4() + if ip1v4 == nil || ip2v4 == nil { + return false + } + for i := 0; i < 4; i++ { + if ip1v4[i] > ip2v4[i] { + return true + } + if ip1v4[i] < ip2v4[i] { + return false + } + } + return false +} diff --git a/internal/netalloc/ipam_test.go b/internal/netalloc/ipam_test.go index c7bd032..291be2b 100644 --- a/internal/netalloc/ipam_test.go +++ b/internal/netalloc/ipam_test.go @@ -50,3 +50,46 @@ func TestAllocatorReserveAndNext(t *testing.T) { t.Fatalf("got %s want 10.0.1.5", ip) } } + +func TestAllocatorReserveUpTo(t *testing.T) { + a, err := NewAllocator("10.0.0.0/24") + if err != nil { + t.Fatalf("NewAllocator: %v", err) + } + + // Reserve all IPs up to .10 + if err := a.ReserveUpTo("10.0.0.10"); err != nil { + t.Fatalf("ReserveUpTo: %v", err) + } + + // First Next should return .10 + ip1, err := a.Next() + if err != nil { + t.Fatalf("Next: %v", err) + } + if ip1 != "10.0.0.10" { + t.Fatalf("expected first allocation after ReserveUpTo to be 10.0.0.10, got %s", ip1) + } + + // Second allocation should be .11 + ip2, err := a.Next() + if err != nil { + t.Fatalf("Next: %v", err) + } + if ip2 != "10.0.0.11" { + t.Fatalf("expected second allocation to be 10.0.0.11, got %s", ip2) + } +} + +func TestAllocatorReserveUpToInvalidIP(t *testing.T) { + a, err := NewAllocator("10.0.0.0/24") + if err != nil { + t.Fatalf("NewAllocator: %v", err) + } + + // Try to reserve up to an IP outside the subnet + err = a.ReserveUpTo("192.168.1.1") + if err == nil { + t.Fatalf("expected error when reserving IP outside subnet") + } +}