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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]`.
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 10 additions & 8 deletions cmd/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
Expand All @@ -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)")
Expand Down
4 changes: 3 additions & 1 deletion cmd/initbmcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var (
initFile string
initChassis string
initBMCSubnet string
initStartIP string
initNodesPerChas int
initNodesPerBMC int
initStartNID int
Expand All @@ -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
}
Expand All @@ -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)")
Expand Down
10 changes: 9 additions & 1 deletion internal/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion internal/initbmcs/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion internal/initbmcs/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
}
51 changes: 51 additions & 0 deletions internal/netalloc/ipam.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package netalloc

import (
"context"
"fmt"
"net"

ipam "github.com/metal-stack/go-ipam"
Expand Down Expand Up @@ -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
}
43 changes: 43 additions & 0 deletions internal/netalloc/ipam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}