Day-0 install without a network plan; rollcall net discover
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:
1 parent
01cc53120c
commit
0ff96d7e74
8 files changed
+488
-118
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user