Day-0 install without a network plan; rollcall net discover
CI / agent (push) Successful in 2s
CI / site (push) Successful in 2s
CI / go (push) Successful in 36s

bootstrap.sh no longer needs the provisioning network: it installs and
starts the region on the management address; --iface later models the
subnet, serves DHCP and moves server_url. rollcall net discover shows what
each interface sees (LLDP, DHCP servers via a parallel probe, neighbours)
and suggests the next step. commission_append follows server_url. Doctor
treats a host that serves no DHCP yet as day 0. Fixes found on a clean VM:
os-release clobbered the rack name, install.sh not executable, slow DHCP
offers missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-28 18:28:37 +01:00
1 parent 01cc53120c
commit 0ff96d7e74
8 files changed
+488 -118

No files matched your search

+5 -1
View File
@@ -18,7 +18,7 @@ type Config struct {
MasterKeyFile string `json:"master_key_file"` // 32-byte hex key for AES-GCM at-rest encryption
AdminToken string `json:"admin_token"` // bearer token for the management API / web UI
AgentToken string `json:"agent_token"` // X-Rollcall-Token for the agent endpoints ("" = open)
CommissionAppend string `json:"commission_append"` // kernel cmdline used in generated pxelinux files
CommissionAppend string `json:"commission_append"` // kernel cmdline used in generated pxelinux files ("" = derived from server_url)
IPMITool string `json:"ipmitool"` // path, default "ipmitool"
BMCHints []BMCHint `json:"bmc_hints"` // out-of-band BMC mapping for machines without in-band IPMI
@@ -83,6 +83,10 @@ func loadConfig(path string) (*Config, error) {
c.ServerURL = "http://" + c.Listen // host:port, IPv6 already bracketed ([::1]:80)
}
c.ServerURL = strings.TrimRight(c.ServerURL, "/")
if c.CommissionAppend == "" { // follows server_url (it often changes once the provisioning network is known)
c.CommissionAppend = fmt.Sprintf("boot=live components fetch=%s/rollcall/filesystem.squashfs rollcall.server=%s noautologin rollcall.token=%s console=tty0 console=ttyS0,115200n8",
c.ServerURL, c.ServerURL, c.AgentToken)
}
if c.ImagesDir == "" {
c.ImagesDir = filepath.Join(filepath.Dir(c.ImageDir), "images")
}
+324
View File
@@ -0,0 +1,324 @@
package main
// rollcall net discover: what this host sees on each interface, for the day
// when the provisioning network is not decided yet. Addresses (and the subnet
// they imply), the switch port and VLAN from LLDP (lldpd), DHCP servers that
// answer, neighbours; then a suggestion of what to do with each interface.
import (
"encoding/json"
"flag"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
type discoIface struct {
Name string `json:"name"`
Kind string `json:"kind"` // physical | vlan | bond | bridge | virtual
MAC string `json:"mac"`
Up bool `json:"up"`
SpeedMbps int `json:"speed_mbps,omitempty"`
Addrs []string `json:"addrs"`
Subnets []string `json:"subnets"`
Default bool `json:"default_route"`
Switch string `json:"switch,omitempty"`
Port string `json:"port,omitempty"`
VLAN string `json:"vlan,omitempty"`
DHCP []string `json:"dhcp_servers"`
DHCPProbe string `json:"dhcp_probe,omitempty"` // why it was not probed
Neighbours int `json:"neighbours"`
Suggestion string `json:"suggestion"`
}
func discoverCmd(args []string) error {
fs := flag.NewFlagSet("net discover", flag.ExitOnError)
asJSON := fs.Bool("json", false, "JSON output")
noProbe := fs.Bool("no-dhcp-probe", false, "do not send DHCPDISCOVERs")
_ = fs.Parse(args)
ifs := discoverInterfaces(!*noProbe)
if *asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(ifs)
}
fmt.Printf("%-12s %-8s %-5s %6s %-22s %-28s %-14s %s\n", "INTERFACE", "KIND", "LINK", "SPEED", "ADDRESSES", "SWITCH / PORT / VLAN", "DHCP SERVERS", "NEIGH")
for _, i := range ifs {
link := "down"
if i.Up {
link = "up"
}
speed := "-"
if i.SpeedMbps > 0 {
speed = fmt.Sprintf("%dG", i.SpeedMbps/1000)
if i.SpeedMbps < 1000 {
speed = fmt.Sprintf("%dM", i.SpeedMbps)
}
}
sw := "-"
if i.Switch != "" || i.Port != "" {
sw = strings.Trim(i.Switch+" / "+i.Port+" / "+orDash(i.VLAN), " /")
}
dh := strings.Join(i.DHCP, ",")
if dh == "" {
dh = orDash(i.DHCPProbe)
}
name := i.Name
if i.Default {
name += "*"
}
fmt.Printf("%-12s %-8s %-5s %6s %-22s %-28s %-14s %d\n", name, i.Kind, link, speed, trunc(strings.Join(i.Addrs, " "), 22), trunc(sw, 28), trunc(dh, 14), i.Neighbours)
}
fmt.Println("(* carries the default route)")
fmt.Println()
for _, i := range ifs {
if i.Suggestion != "" {
fmt.Printf("%-12s %s\n", i.Name, i.Suggestion)
}
}
return nil
}
func discoverInterfaces(probe bool) []discoIface {
def := defaultRouteIface()
lldp := lldpNeighbours()
nets, _ := net.Interfaces()
var out []discoIface
type probeRes struct {
servers []string
err error
}
probes := map[string]chan probeRes{}
if probe { // in parallel: a DHCP server may take seconds to offer (it pings the address first)
for _, n := range nets {
if n.Flags&net.FlagLoopback != 0 || n.Flags&net.FlagUp == 0 || skipIface(n.Name) || ifaceKind(n.Name) == "bridge" || ifaceKind(n.Name) == "virtual" {
continue
}
ch := make(chan probeRes, 1)
probes[n.Name] = ch
go func(name string) { s, err := dhcpProbe(name, 6*time.Second); ch <- probeRes{s, err} }(n.Name)
}
}
for _, n := range nets {
if n.Flags&net.FlagLoopback != 0 || skipIface(n.Name) {
continue
}
d := discoIface{Name: n.Name, MAC: n.HardwareAddr.String(), Kind: ifaceKind(n.Name), Default: n.Name == def, Addrs: []string{}, Subnets: []string{}, DHCP: []string{}}
if b, err := os.ReadFile("/sys/class/net/" + n.Name + "/operstate"); err == nil {
d.Up = strings.TrimSpace(string(b)) == "up"
}
if b, err := os.ReadFile("/sys/class/net/" + n.Name + "/speed"); err == nil {
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && v > 0 {
d.SpeedMbps = v
}
}
addrs, _ := n.Addrs()
for _, a := range addrs {
ipn, ok := a.(*net.IPNet)
if !ok || ipn.IP.IsLinkLocalUnicast() {
continue
}
ones, _ := ipn.Mask.Size()
d.Addrs = append(d.Addrs, fmt.Sprintf("%s/%d", ipn.IP, ones))
d.Subnets = append(d.Subnets, (&net.IPNet{IP: ipn.IP.Mask(ipn.Mask), Mask: ipn.Mask}).String())
}
if l, ok := lldp[n.Name]; ok {
d.Switch, d.Port, d.VLAN = l[0], l[1], l[2]
}
d.Neighbours = neighbourCount(n.Name)
switch {
case !probe:
d.DHCPProbe = "not probed"
case !d.Up:
d.DHCPProbe = "link down"
case d.Kind == "bridge" || d.Kind == "virtual":
d.DHCPProbe = "skipped"
case probes[n.Name] == nil:
d.DHCPProbe = "skipped"
default:
r := <-probes[n.Name]
if s, err := r.servers, r.err; err != nil {
d.DHCPProbe = "needs root"
} else {
for _, x := range s {
if ifaceWithAddr(x) == "" {
d.DHCP = append(d.DHCP, x)
} else {
d.DHCP = append(d.DHCP, x+"(this host)")
}
}
if len(s) == 0 {
d.DHCPProbe = "none"
}
}
}
d.Suggestion = discoSuggest(d)
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func discoSuggest(d discoIface) string {
foreign := 0
for _, s := range d.DHCP {
if !strings.HasSuffix(s, "(this host)") {
foreign++
}
}
v4 := ""
for _, s := range d.Subnets {
if !strings.Contains(s, ":") {
v4 = s
break
}
}
vlan := ""
if d.VLAN != "" {
vlan = " (switch says VLAN " + d.VLAN + ")"
}
switch {
case d.Kind == "bridge" || d.Kind == "virtual":
return ""
case !d.Up:
return "no link: cable it to the provisioning network" + vlan
case d.Default && foreign > 0:
return "uplink: management network with its own DHCP (" + strings.Join(d.DHCP, ",") + "); keep it for the region's access" + vlan
case d.Default:
return "uplink: the region is reached here" + vlan
case v4 == "":
return "link up, no IPv4 address" + vlan + ": give it its address on the provisioning network, then bootstrap.sh --iface " + d.Name
case foreign > 0:
return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): either the network team turns it off for this VLAN, or keep it and point it at this host (next-server " + strings.Split(d.Addrs[0], "/")[0] + ", or a DHCP relay); then bootstrap.sh --iface " + d.Name + " --no-dhcp"
default:
return "candidate provisioning network " + v4 + vlan + ": bootstrap.sh --iface " + d.Name + " (or Network -> New subnet in the web UI)"
}
}
func skipIface(name string) bool {
for _, p := range []string{"docker", "veth", "virbr", "br-", "cni", "flannel", "tap", "vnet", "wg", "tun"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
func ifaceKind(name string) string {
base := "/sys/class/net/" + name
switch {
case exists(base + "/bridge"):
return "bridge"
case exists(base + "/bonding"):
return "bond"
case exists("/proc/net/vlan/" + name):
return "vlan"
case exists(base + "/device"):
return "physical"
}
return "virtual"
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
func defaultRouteIface() string {
b, err := os.ReadFile("/proc/net/route")
if err != nil {
return ""
}
for _, l := range strings.Split(string(b), "\n")[1:] {
f := strings.Fields(l)
if len(f) > 2 && f[1] == "00000000" && f[7] == "00000000" {
return f[0]
}
}
return ""
}
func neighbourCount(iface string) int {
out, err := exec.Command("ip", "-j", "neigh", "show", "dev", iface).Output()
if err != nil {
return 0
}
var ns []map[string]any
_ = json.Unmarshal(out, &ns)
n := 0
for _, x := range ns {
if st, _ := x["state"].([]any); len(st) > 0 && st[0] != "FAILED" && st[0] != "INCOMPLETE" {
n++
}
}
return n
}
// lldpNeighbours: interface -> [switch name, port, vlan] from lldpd.
func lldpNeighbours() map[string][3]string {
out := map[string][3]string{}
bin, err := exec.LookPath("lldpcli")
if err != nil {
return out
}
b, err := exec.Command(bin, "-f", "json0", "show", "neighbors", "details").Output()
if err != nil {
return out
}
var doc struct {
LLDP []struct {
Interface []struct {
Name string `json:"name"`
Chassis []struct {
Name []struct {
Value string `json:"value"`
} `json:"name"`
} `json:"chassis"`
Port []struct {
ID []struct {
Value string `json:"value"`
} `json:"id"`
Descr []struct {
Value string `json:"value"`
} `json:"descr"`
} `json:"port"`
VLAN []struct {
VlanID string `json:"vlan-id"`
PVID bool `json:"pvid"`
} `json:"vlan"`
} `json:"interface"`
} `json:"lldp"`
}
if json.Unmarshal(b, &doc) != nil {
return out
}
for _, l := range doc.LLDP {
for _, i := range l.Interface {
var sw, port, vlan string
if len(i.Chassis) > 0 && len(i.Chassis[0].Name) > 0 {
sw = i.Chassis[0].Name[0].Value
}
if len(i.Port) > 0 {
if len(i.Port[0].Descr) > 0 {
port = i.Port[0].Descr[0].Value
} else if len(i.Port[0].ID) > 0 {
port = i.Port[0].ID[0].Value
}
}
var vs []string
for _, v := range i.VLAN {
s := v.VlanID
if v.PVID {
s += "(untagged)"
}
vs = append(vs, s)
}
vlan = strings.Join(vs, ",")
out[filepath.Base(i.Name)] = [3]string{sw, port, vlan}
}
}
return out
}
+8 -4
View File
@@ -98,8 +98,10 @@ func doctorCmd(args []string) error {
}
if ports[fmt.Sprintf("%s/%d", p.proto, p.port)] {
d.ok(fmt.Sprintf("%s/%d", p.proto, p.port), p.what)
} else if p.port == 69 && !ports["udp/67"] {
d.ok("udp/69", "TFTP starts with the first VLAN served")
} else if p.port == 67 {
d.warn("udp/67", "nothing listens for DHCP", "no VLAN with dhcp on this rack yet (rollcall vlan add FABRIC VID -dhcp -rack "+cfg.RackName+"), or dnsmasq is down")
d.warn("udp/67", "this host serves no DHCP yet", "no provisioning network decided: rollcall net discover, then deploy/bootstrap.sh --iface IFACE (or a VLAN with dhcp in the web UI)")
} else {
d.fail(fmt.Sprintf("%s/%d", p.proto, p.port), "nothing listens for "+p.what, "systemctl status rollcall dnsmasq")
}
@@ -189,7 +191,7 @@ func doctorCmd(args []string) error {
} else {
d.ok("dns (host)", "resolves outside names")
}
if srvHost != "" && ports["udp/53"] {
if srvHost != "" && ports["udp/53"] && ports["udp/67"] {
r := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "udp", net.JoinHostPort(srvHost, "53"))
}}
@@ -211,8 +213,10 @@ func doctorCmd(args []string) error {
}
// other DHCP servers on the provisioning network
if iface != "" && !*noProbe {
servers, err := dhcpProbe(iface, 4*time.Second)
if iface != "" && !*noProbe && !ports["udp/67"] {
d.ok("dhcp probe", "skipped: this host serves no DHCP yet")
} else if iface != "" && !*noProbe {
servers, err := dhcpProbe(iface, 6*time.Second)
switch {
case err != nil:
d.warn("dhcp probe", err.Error(), "run as root; or --no-dhcp-probe")
+9
View File
@@ -28,6 +28,7 @@ func usage() {
rollcall tui [-dump WxH -page P -machine ID -plain]
rollcall serve [-config PATH] region: API, database, web UI, embedded rack controller
rollcall doctor [-deep] [-no-dhcp-probe] check this installation and say what to fix
rollcall net discover [-json] what each interface of this host sees (LLDP, DHCP, neighbours)
rollcall rack [-config /etc/rollcall/rack.json] a rack controller next to the machines
rollcall rack join CODE -url http://ADDR [-name N] [-listen ADDR:80] enroll a rack controller (writes rack.json + certificate)
rollcall ca show | issue NAME -host h1,h2 [-client] [-out DIR] the installation's certificate authority (region host)
@@ -79,6 +80,9 @@ func newFlags(name string) *flag.FlagSet { return flag.NewFlagSet(name, flag.Exi
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
if len(os.Args) > 2 && os.Args[1] == "net" && os.Args[2] == "discover" {
os.Args = append([]string{os.Args[0], "net-discover"}, os.Args[3:]...)
}
if len(os.Args) < 2 {
// A bare `rollcall` in a terminal opens the console; in a pipe or a
// script it prints its help, as it always did.
@@ -108,6 +112,11 @@ func main() {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
case "net-discover":
if err := discoverCmd(args); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
case "doctor":
if err := doctorCmd(args); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)