Files
rollcall/deploy/bootstrap.sh
T
Ilker ManapandClaude Opus 5.5 7a007ef048
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 36s
Proxy DHCP for networks whose DHCP server stays in charge
A VLAN in proxy mode leaves addresses to the site's DHCP server; the rack
only answers PXE (dnsmasq proxy range + pxe-service), machines keep DHCP
unless a static range is set aside. CLI -proxy-dhcp, web UI mode select,
bootstrap --proxy-dhcp, doctor/discover aware.

dnsmasq 2.91 (and 2.93) loops forever in its proxy path when the proxy
range carries a set: tag: option_filter appends a request's tags to the
context's persistent list, so the next request's stack interface tag
points at itself (found with gdb on the lab rack). Proxy ranges are
untagged and PXE services limited by interface; dhcp-boot/dhcp-match only
for served subnets; no dhcp-host lines for proxied ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:04:10 +01:00

328 lines
17 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 - a
# region's http://HOST/rollcall, or a release's download URL, where the files are
# commissioning-* (deploy/publish-image.sh); IMAGE_TOKEN=... for a private repository
# --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 (no PXE from here)
# --proxy-dhcp the site's DHCP server keeps handing out addresses; this host only answers
# PXE clients with where to boot from (no ranges, no NAT)
# --ssh-key K SSH public key (or a file of them) for root on deployed machines; repeatable.
# Default: the keys of the user running sudo, then root's.
# --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 SSHKEYS=()
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 ;;
--ssh-key) SSHKEYS+=("$2"); shift ;;
--no-dhcp) DHCP=0 ;;
--proxy-dhcp) DHCP=2 ;;
--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)"
case $DHCP in 1) DHCPTXT="served here" ;; 2) DHCPTXT="proxy (the site's server hands out addresses)" ;; *) DHCPTXT=off ;; esac
if [ -z "$GATEWAY" ] && [ $DHCP != 2 ]; 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:-from the site DHCP server}${NAT:+ via NAT out of $NAT}, DHCP $DHCPTXT"
else
[ -n "$UPLINK" ] || die "no default route: give this host network access first"
ADDR=$(addr_of "$UPLINK" | cut -d/ -f1)
if [ -f "$CONF" ]; then
echo " existing installation: server address and network model are kept"
else
echo " no provisioning network yet: the region starts on $UPLINK ($ADDR) without DHCP or subnets"
fi
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
# (without --iface the server address stays what it is)
if [ -n "$IFACE" ] && 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 SERVER=$(jq -r .server_url "$CONF"); ok "$CONF kept (server $SERVER)"
fi
fi
# SSH keys for deployed machines: given ones, else the operator's
keyfiles=()
if [ ${#SSHKEYS[@]} -eq 0 ] && grep -q '"ssh_keys": \[\]' "$CONF" 2>/dev/null; then
for f in "/home/${SUDO_USER:-none}/.ssh/authorized_keys" /root/.ssh/authorized_keys; do [ -r "$f" ] && keyfiles+=("$f"); done
fi
if [ ${#SSHKEYS[@]} -gt 0 ] || [ ${#keyfiles[@]} -gt 0 ]; then
n=$(python3 - "$CONF" "${SSHKEYS[@]}" -- "${keyfiles[@]}" <<'P2'
import json, os, re, sys
conf = sys.argv[1]; rest = sys.argv[2:]; sep = rest.index("--")
given, files = rest[:sep], rest[sep + 1:]
keyre = re.compile(r'((?:ssh-(?:ed25519|rsa|dss)|ecdsa-sha2-\S+|sk-\S+)\s+[A-Za-z0-9+/=]+(?:\s+\S+)?)')
def keys_of(text):
return [m.group(1).strip() for line in text.splitlines() for m in [keyre.search(line)] if m]
found = []
for g in given:
found += keys_of(open(g).read()) if os.path.isfile(g) else keys_of(g)
for f in files:
found += keys_of(open(f).read())
c = json.load(open(conf)); cv = c.setdefault("cluster_vars", {})
have = cv.get("ssh_keys") or []
new = [k for k in dict.fromkeys(found) if k not in have]
if new:
cv["ssh_keys"] = have + new
json.dump(c, open(conf, "w"), indent=2)
print(len(new))
P2
)
[ "$n" -gt 0 ] && { ok "$n SSH key(s) for root on deployed machines"; CHANGED=1; } || true
fi
[ "$(jq '.cluster_vars.ssh_keys | length' "$CONF")" -gt 0 ] || echo " note: no SSH keys for deployed machines yet (--ssh-key KEY)" >&2
# ------------------------------------------------------------------ 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)
auth=(); [ -n "${IMAGE_TOKEN:-}" ] && auth=(-H "Authorization: token $IMAGE_TOKEN")
# a release carries the image as commissioning-* (its plain SHA256SUMS is the binaries');
# a region's /rollcall directory has the plain names
pre=commissioning-
if ! curl -fsSL "${auth[@]}" -o "$tmp/SHA256SUMS" "$IMAGE_URL/${pre}SHA256SUMS"; then
pre=""
curl -fsSL "${auth[@]}" -o "$tmp/SHA256SUMS" "$IMAGE_URL/SHA256SUMS" || die "no commissioning-SHA256SUMS or SHA256SUMS at $IMAGE_URL${IMAGE_TOKEN:+}$( [ -z "${IMAGE_TOKEN:-}" ] && echo ' (private repository? set IMAGE_TOKEN)')"
fi
for f in vmlinuz initrd.img filesystem.squashfs; do curl -fSL --progress-bar "${auth[@]}" -o "$tmp/$f" "$IMAGE_URL/$pre$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
if [ $DHCP = 2 ]; then
# the site's DHCP server owns the addresses: no ranges (machines keep DHCP);
# set a static range aside later (rollcall range add) to give them fixed ones
rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning ${GATEWAY:+-gateway "$GATEWAY"} -domain "$DOMAIN" -name provisioning >/dev/null
ok "subnet $NET (addresses from the site's DHCP server)"
else
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)"
fi
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"
elif [ $DHCP = 2 ]; then
rollcall vlan add "$FAB" 0 -proxy-dhcp -rack "$RACK" >/dev/null
ok "proxy DHCP on $IFACE: PXE answers only; addresses from the site's DHCP server"
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