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>
266 lines
14 KiB
Bash
Executable File
266 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
|
# Rollcall region on a fresh Debian 13 host. Run as root from a checkout
|
|
# (deploy/) or an unpacked release (rollcall + deploy/ + agent/).
|
|
#
|
|
# Day 0 - nothing about the network is decided yet:
|
|
# ./bootstrap.sh [--image-url URL | --build-image] [--name NAME]
|
|
# installs everything and starts the region on this host's management
|
|
# address: web UI and API work, no DHCP, no subnets. Then look around:
|
|
# rollcall net discover interfaces, switch ports (LLDP), DHCP servers, neighbours
|
|
#
|
|
# Later - once the provisioning network is known (run again, nothing is lost):
|
|
# ./bootstrap.sh --iface IFACE [--gateway IP | --nat UPLINK] [--domain D] [--no-dhcp]
|
|
# IFACE must have its address on that network; this host's address there
|
|
# becomes the server URL machines use, the subnet is modelled with ranges
|
|
# and (unless --no-dhcp) this host serves DHCP/PXE on it.
|
|
# Or do the same from the web UI / CLI (rollcall subnet add, vlan add -dhcp),
|
|
# or import the prefixes from NetBox (rollcall ipam import).
|
|
#
|
|
# --name NAME rack controller name of this host (default: short hostname)
|
|
# --image-url directory with vmlinuz, initrd.img, filesystem.squashfs, SHA256SUMS
|
|
# --build-image build the commissioning image here with live-build (~20 min)
|
|
# --gateway IP default router for machines on the provisioning network
|
|
# --nat UPLINK this host routes for them: forward + masquerade out of UPLINK
|
|
# (default with --iface and no --gateway: the default route's interface)
|
|
# --domain D DNS domain handed to machines (default rollcall.internal)
|
|
# --no-dhcp model the network but leave DHCP to the existing server
|
|
# --os NAME import one OS image now (e.g. debian-13); a nightly timer syncs the catalog
|
|
# --yes do not ask
|
|
set -euo pipefail
|
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
ROOT=$(cd "$HERE/.." && pwd)
|
|
IFACE="" RACK=$(hostname -s) DOMAIN=rollcall.internal GATEWAY="" NAT="" IMAGE_URL="" BUILD=0 OS="" DHCP=1 YES=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--iface) IFACE=$2; shift ;;
|
|
--name) RACK=$2; shift ;;
|
|
--domain) DOMAIN=$2; shift ;;
|
|
--gateway) GATEWAY=$2; shift ;;
|
|
--nat) NAT=$2; shift ;;
|
|
--image-url) IMAGE_URL=${2%/}; shift ;;
|
|
--build-image) BUILD=1 ;;
|
|
--os) OS=$2; shift ;;
|
|
--no-dhcp) DHCP=0 ;;
|
|
--yes|-y) YES=1 ;;
|
|
-h|--help) sed -n '2,31p' "$0"; exit 0 ;;
|
|
*) echo "unknown option $1 (--help)" >&2; exit 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
|
ok() { printf ' \033[32mOK\033[0m %s\n' "$*"; }
|
|
die() { printf ' \033[31mERROR\033[0m %s\n' "$*" >&2; exit 1; }
|
|
[ "$(id -u)" = 0 ] || die "run as root"
|
|
OSNAME=$(. /etc/os-release; echo "$ID $VERSION_ID")
|
|
case "$OSNAME" in debian*) ;; *) echo "note: tested on Debian 13; this is $OSNAME" >&2 ;; esac
|
|
CONF=/etc/rollcall/config.json
|
|
UPLINK=$(ip -4 route show default | awk '{print $5; exit}')
|
|
addr_of() { ip -4 -o addr show dev "$1" scope global | awk '{print $4; exit}'; }
|
|
|
|
# ------------------------------------------------------------------ 0. where
|
|
say "0/9 this host"
|
|
if [ -n "$IFACE" ]; then
|
|
CIDR_ADDR=$(addr_of "$IFACE")
|
|
[ -n "$CIDR_ADDR" ] || die "$IFACE has no IPv4 address; give it its address on the provisioning network first"
|
|
read -r ADDR NET PREFIX FIRST <<<"$(python3 -c 'import ipaddress,sys;i=ipaddress.ip_interface(sys.argv[1]);print(i.ip,i.network,i.network.prefixlen,i.network.network_address)' "$CIDR_ADDR")"
|
|
[ "$PREFIX" -le 24 ] || die "$NET is smaller than a /24; model it by hand (rollcall subnet add / range add)"
|
|
if [ -z "$GATEWAY" ]; then GATEWAY=$ADDR; NAT=${NAT:-$UPLINK}; fi
|
|
[ -n "$NAT" ] && [ "$NAT" = "$IFACE" ] && die "--nat must be the uplink, not $IFACE"
|
|
echo " provisioning $IFACE $CIDR_ADDR (subnet $NET), machines' gateway $GATEWAY${NAT:+ via NAT out of $NAT}, DHCP $([ $DHCP = 1 ] && echo on || echo off)"
|
|
else
|
|
[ -n "$UPLINK" ] || die "no default route: give this host network access first"
|
|
ADDR=$(addr_of "$UPLINK" | cut -d/ -f1)
|
|
echo " no provisioning network yet: the region starts on $UPLINK ($ADDR) without DHCP or subnets"
|
|
fi
|
|
SERVER=http://$ADDR
|
|
echo " region $SERVER, rack controller \"$RACK\""
|
|
if [ $YES = 0 ] && [ -t 0 ]; then
|
|
read -r -p " continue? [y/N] " a; case "$a" in y|Y|yes) ;; *) exit 1 ;; esac
|
|
fi
|
|
|
|
# ------------------------------------------------------------------ 1. packages
|
|
say "1/9 packages"
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
PKGS="postgresql dnsmasq pxelinux syslinux-common syslinux-efi qemu-utils ipmitool jq zstd curl ca-certificates python3 nftables lldpd"
|
|
[ $BUILD = 1 ] && PKGS="$PKGS live-build debootstrap squashfs-tools xorriso"
|
|
apt-get update -q >/dev/null
|
|
apt-get install -yq $PKGS >/dev/null
|
|
install -d /etc/dnsmasq.d
|
|
# until rollcall serves a VLAN, dnsmasq must not answer DNS/DHCP everywhere
|
|
# (rollcall's generated configuration adds the interfaces of the VLANs it serves)
|
|
printf '# rollcall bootstrap: answer only on loopback until rollcall serves a VLAN\n# (systemd-resolved keeps 127.0.0.53)\nbind-dynamic\ninterface=lo\n' > /etc/dnsmasq.d/00-rollcall-base.conf
|
|
systemctl restart dnsmasq || true
|
|
ok "installed (lldpd shows which switch port and VLAN each interface is on)"
|
|
|
|
# ------------------------------------------------------------------ 2. database
|
|
say "2/9 PostgreSQL"
|
|
systemctl enable --now postgresql >/dev/null
|
|
PGPASS=""
|
|
if [ -f "$CONF" ]; then
|
|
ok "configured before, kept"
|
|
else
|
|
PGPASS=$(head -c 32 /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 28)
|
|
if [ "$(runuser -u postgres -- psql -tAc "select 1 from pg_roles where rolname='rollcall'")" = 1 ]; then
|
|
runuser -u postgres -- psql -q -c "ALTER ROLE rollcall PASSWORD '$PGPASS'"
|
|
else
|
|
runuser -u postgres -- psql -q -c "CREATE ROLE rollcall LOGIN PASSWORD '$PGPASS'"
|
|
fi
|
|
[ "$(runuser -u postgres -- psql -tAc "select 1 from pg_database where datname='rollcall'")" = 1 ] || runuser -u postgres -- psql -q -c "CREATE DATABASE rollcall OWNER rollcall"
|
|
ok "role and database rollcall"
|
|
fi
|
|
|
|
# ------------------------------------------------------------------ 3. TFTP
|
|
say "3/9 TFTP boot files"
|
|
T=/srv/tftp
|
|
install -d $T $T/efi64 $T/pxelinux.cfg
|
|
cp -u /usr/lib/PXELINUX/pxelinux.0 $T/
|
|
for m in ldlinux.c32 libcom32.c32 libutil.c32 menu.c32; do cp -u /usr/lib/syslinux/modules/bios/$m $T/; done
|
|
cp -u /usr/lib/SYSLINUX.EFI/efi64/syslinux.efi $T/efi64/
|
|
for m in ldlinux.e64 libcom32.c32 libutil.c32 menu.c32; do cp -u /usr/lib/syslinux/modules/efi64/$m $T/efi64/; done
|
|
ln -sfn ../pxelinux.cfg $T/efi64/pxelinux.cfg
|
|
ok "BIOS (pxelinux.0) and UEFI (efi64/syslinux.efi)"
|
|
|
|
# ------------------------------------------------------------------ 4. binary
|
|
say "4/9 rollcall binary"
|
|
BIN=""
|
|
for b in "$ROOT/rollcall" "$HERE/rollcall"; do [ -x "$b" ] && BIN=$b && break; done
|
|
if [ -z "$BIN" ] && [ -d "$ROOT/cmd/rollcall" ] && command -v go >/dev/null; then
|
|
(cd "$ROOT/cmd/rollcall" && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" -o "$HERE/rollcall" .)
|
|
BIN=$HERE/rollcall
|
|
fi
|
|
[ -n "$BIN" ] || die "no rollcall binary next to this script (unpack a release, or build: cd cmd/rollcall && go build)"
|
|
[ "$BIN" = "$HERE/rollcall" ] || cp "$BIN" "$HERE/rollcall"
|
|
ok "$("$HERE/rollcall" version)"
|
|
|
|
# ------------------------------------------------------------------ 5. config
|
|
say "5/9 configuration"
|
|
getent passwd rollcall >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group rollcall
|
|
install -d -m 0750 -o root -g rollcall /etc/rollcall
|
|
FQDN=$(hostname -f 2>/dev/null || hostname)
|
|
CHANGED=0
|
|
if [ ! -f "$CONF" ]; then
|
|
AGENT=$("$HERE/rollcall" keygen) ADMIN=$("$HERE/rollcall" keygen)
|
|
python3 - "$HERE/config.example.json" "$CONF" <<P
|
|
import json, sys
|
|
c = json.load(open(sys.argv[1]))
|
|
c.update({
|
|
"listen": ":80", "server_url": "$SERVER",
|
|
"database_url": "postgres://rollcall:$PGPASS@127.0.0.1:5432/rollcall?sslmode=disable",
|
|
"admin_token": "$ADMIN", "agent_token": "$AGENT",
|
|
"commission_append": "", # derived from server_url, follows it when it changes
|
|
"rack_name": "$RACK", "tls_listen": ":443", "tls_names": sorted({"$FQDN", "$ADDR"}), "admin_http": "loopback",
|
|
"node_pools": {}, "bmc_hints": [],
|
|
})
|
|
c["cluster_vars"].update({"domain": "$DOMAIN", "cluster_name": "$RACK", "slurm_controller": "$RACK", "slurm_controller_ip": "$ADDR", "ssh_keys": []})
|
|
c["ipam"]["provider"] = ""
|
|
json.dump(c, open(sys.argv[2], "w"), indent=2)
|
|
P
|
|
chown root:rollcall "$CONF"; chmod 0640 "$CONF"
|
|
ok "$CONF written (tokens generated; the web UI is HTTPS-only: admin_http loopback)"
|
|
else
|
|
# an existing installation: the provisioning address may have arrived since
|
|
if python3 - "$CONF" "$SERVER" "$ADDR" "$FQDN" <<'P'
|
|
import json, sys
|
|
p, srv, addr, fqdn = sys.argv[1:]
|
|
c = json.load(open(p))
|
|
old = c.get("server_url", "")
|
|
names = set(c.get("tls_names") or []) | {addr, fqdn}
|
|
changed = old != srv or set(c.get("tls_names") or []) != names
|
|
gen = c.get("commission_append", "")
|
|
if old and old in gen and "rollcall.token=" in gen: # the old generated form: let it follow server_url
|
|
c["commission_append"] = ""
|
|
changed = True
|
|
c["server_url"], c["tls_names"] = srv, sorted(names)
|
|
if changed:
|
|
json.dump(c, open(p, "w"), indent=2)
|
|
sys.exit(0 if changed else 1)
|
|
P
|
|
then CHANGED=1; ok "server_url now $SERVER (was different), tls_names updated"
|
|
else ok "$CONF kept"
|
|
fi
|
|
fi
|
|
|
|
# ------------------------------------------------------------------ 6. services
|
|
say "6/9 services"
|
|
install -d /srv/rollcall/image
|
|
(cd "$HERE" && sh ./install.sh >/dev/null)
|
|
for _ in $(seq 1 30); do curl -sf -o /dev/null http://127.0.0.1/ca.pem && break; sleep 1; done
|
|
curl -sf -o /dev/null http://127.0.0.1/ca.pem || die "rollcall did not come up: journalctl -u rollcall"
|
|
ok "rollcall running, CA $(rollcall ca show | awk '/fingerprint/{print $2}')"
|
|
|
|
# ------------------------------------------------------------------ 7. commissioning image
|
|
say "7/9 commissioning image"
|
|
IMG=/srv/rollcall/image
|
|
if [ -s $IMG/vmlinuz ] && [ -s $IMG/initrd.img ] && [ -s $IMG/filesystem.squashfs ]; then
|
|
ok "present, kept"
|
|
elif [ -n "$IMAGE_URL" ]; then
|
|
tmp=$(mktemp -d -p /srv/rollcall)
|
|
curl -fsSL -o "$tmp/SHA256SUMS" "$IMAGE_URL/SHA256SUMS" || die "$IMAGE_URL/SHA256SUMS missing"
|
|
for f in vmlinuz initrd.img filesystem.squashfs; do curl -fsSL -o "$tmp/$f" "$IMAGE_URL/$f"; done
|
|
(cd "$tmp" && sha256sum -c --quiet SHA256SUMS) || die "checksum mismatch"
|
|
mv "$tmp"/vmlinuz "$tmp"/initrd.img "$tmp"/filesystem.squashfs "$tmp"/SHA256SUMS $IMG/ && rm -rf "$tmp"
|
|
chmod 0644 $IMG/*
|
|
ok "downloaded and verified from $IMAGE_URL"
|
|
elif [ $BUILD = 1 ]; then
|
|
[ -x "$ROOT/agent/build-image.sh" ] || die "agent/build-image.sh not found next to deploy/"
|
|
OUT=$IMG "$ROOT/agent/build-image.sh"
|
|
(cd $IMG && sha256sum vmlinuz initrd.img filesystem.squashfs > SHA256SUMS)
|
|
ok "built"
|
|
else
|
|
echo " not yet: rerun with --image-url URL or --build-image before machines commission" >&2
|
|
fi
|
|
|
|
# ------------------------------------------------------------------ 8. provisioning network
|
|
say "8/9 provisioning network"
|
|
if [ -z "$IFACE" ]; then
|
|
echo " not decided yet - nothing modelled, no DHCP served. When it is:"
|
|
echo " rollcall net discover what each interface sees (LLDP, DHCP servers, neighbours)"
|
|
echo " $0 --iface IFACE ... or the web UI: Network -> New subnet"
|
|
else
|
|
[ $CHANGED = 1 ] && systemctl restart rollcall && sleep 2
|
|
off() { python3 -c "import ipaddress,sys;print(ipaddress.ip_address('$FIRST')+int(sys.argv[1]))" "$1"; }
|
|
FAB=provisioning
|
|
rollcall fabric list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$FAB" || rollcall fabric add "$FAB" >/dev/null
|
|
if ! rollcall subnet list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$NET"; then
|
|
rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning -gateway "$GATEWAY" -dns "$ADDR" -domain "$DOMAIN" -name provisioning >/dev/null
|
|
rollcall range add "$NET" reserved "$(off 1)" "$(off 20)" >/dev/null
|
|
rollcall range add "$NET" static "$(off 50)" "$(off 99)" >/dev/null
|
|
rollcall range add "$NET" dynamic "$(off 100)" "$(off 200)" >/dev/null
|
|
ok "subnet $NET: reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)"
|
|
else
|
|
ok "subnet $NET exists, kept"
|
|
fi
|
|
if [ $DHCP = 1 ]; then
|
|
rollcall vlan add "$FAB" 0 -dhcp -rack "$RACK" >/dev/null
|
|
ok "DHCP/PXE on $IFACE"
|
|
fi
|
|
if [ -n "$NAT" ]; then
|
|
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/60-rollcall-forward.conf
|
|
sysctl -q -p /etc/sysctl.d/60-rollcall-forward.conf
|
|
install -d /etc/nftables.d
|
|
printf 'table ip rollcall_nat {\n chain postrouting {\n type nat hook postrouting priority 100;\n oifname "%s" ip saddr %s masquerade\n }\n}\n' "$NAT" "$NET" > /etc/nftables.d/rollcall-nat.nft
|
|
grep -q 'include "/etc/nftables.d/\*.nft"' /etc/nftables.conf 2>/dev/null || echo 'include "/etc/nftables.d/*.nft"' >> /etc/nftables.conf
|
|
nft -c -f /etc/nftables.conf && systemctl enable nftables >/dev/null 2>&1 && systemctl restart nftables
|
|
ok "forwarding + masquerade $NET out of $NAT"
|
|
fi
|
|
fi
|
|
|
|
# ------------------------------------------------------------------ 9. OS images
|
|
say "9/9 OS images"
|
|
if [ -n "$OS" ]; then
|
|
runuser -u rollcall -- rollcall os sync "$OS" || echo " os sync $OS failed; the nightly timer retries" >&2
|
|
else
|
|
echo " none imported now; rollcall-os-sync.timer syncs the catalog nightly (now: runuser -u rollcall -- rollcall os sync debian-13)"
|
|
fi
|
|
|
|
cat <<EOF
|
|
|
|
Done: rollcall region "$RACK" on $SERVER
|
|
web UI https://$ADDR (trust the CA from /ca.pem: $(rollcall ca show | awk '/fingerprint/{print $2}'))
|
|
admin from your laptop: rollcall admin-cert request NAME (token once: jq -r .admin_token $CONF)
|
|
check rollcall doctor
|
|
EOF
|
|
[ -z "$IFACE" ] && echo " next rollcall net discover (then: $0 --iface IFACE)"
|
|
exit 0
|