Files
rollcall/deploy/bootstrap.sh
T
Ilker ManapandClaude Opus 5.5 01cc53120c
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 36s
From-scratch install: deploy/bootstrap.sh and rollcall doctor
bootstrap.sh sets up a region on a fresh Debian 13 host (packages,
PostgreSQL, TFTP tree, config, services, verified commissioning image,
provisioning subnet with ranges/DHCP, optional NAT, first OS image).
rollcall doctor checks an installation and says what to fix, including a
DHCPDISCOVER probe for foreign DHCP servers. pxelinux-default no longer
hard-codes the lab address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:08:30 +01:00

241 lines
12 KiB
Bash
Executable File

#!/bin/bash
# Rollcall region on a fresh Debian 13 host, in one go. Run as root from a
# checkout (deploy/) or an unpacked release (rollcall + deploy/ + agent/).
#
# ./bootstrap.sh --iface eth1 [--name NAME] [--domain D] [--gateway IP | --nat UPLINK]
# [--image-url URL | --build-image] [--os debian-13] [--no-dhcp] [--yes]
#
# --iface interface on the provisioning network; it must already have
# its IPv4 address (this host's address there becomes the server URL)
# --name rack controller name of this host (default: short hostname)
# --domain DNS domain handed to machines (default: rollcall.internal)
# --gateway default router for machines on the provisioning network
# --nat UPLINK make this host the router: forward + masquerade out of UPLINK
# (default when --gateway is not given: the interface of the default route)
# --image-url directory with vmlinuz, initrd.img, filesystem.squashfs and SHA256SUMS
# --build-image build the commissioning image here with live-build (~20 min)
# --os NAME import one OS image now (e.g. debian-13); the nightly sync does the rest
# --no-dhcp model the network but do not serve DHCP (another DHCP server is in charge)
#
# Safe to run again: existing configuration, database and image are kept.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
IFACE="" NAME=$(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) NAME=$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,24p' "$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"
. /etc/os-release
[ "$ID" = debian ] || echo "note: tested on Debian 13; this is $PRETTY_NAME" >&2
# ------------------------------------------------------------------ 0. what and where
say "0/9 provisioning network"
if [ -z "$IFACE" ]; then
def=$(ip -4 route show default | awk '{print $5; exit}')
cand=$(ip -4 -o addr show scope global | awk -v d="$def" '$2 != d {print $2}' | sort -u)
[ "$(echo "$cand" | grep -c .)" = 1 ] || die "say which interface faces the machines: --iface IFACE (candidates: $(echo $cand))"
IFACE=$cand
fi
CIDR_ADDR=$(ip -4 -o addr show dev "$IFACE" scope global | awk '{print $4; exit}')
[ -n "$CIDR_ADDR" ] || die "$IFACE has no IPv4 address; configure it first (netplan / ifupdown)"
read -r ADDR NET PREFIX FIRST <<<"$(python3 - "$CIDR_ADDR" <<'P'
import ipaddress, sys
i = ipaddress.ip_interface(sys.argv[1])
print(i.ip, i.network, i.network.prefixlen, i.network.network_address)
P
)"
[ "$PREFIX" -le 24 ] || die "$NET is smaller than a /24; create the subnet and ranges by hand"
off() { python3 -c "import ipaddress,sys;print(ipaddress.ip_address('$FIRST')+int(sys.argv[1]))" "$1"; }
UPLINK=$(ip -4 route show default | awk '{print $5; exit}')
if [ -z "$GATEWAY" ]; then
GATEWAY=$ADDR
NAT=${NAT:-$UPLINK}
fi
[ -n "$NAT" ] && [ "$NAT" = "$IFACE" ] && die "--nat must be the uplink, not the provisioning interface"
SERVER=http://$ADDR
cat <<EOF
interface $IFACE ($CIDR_ADDR), subnet $NET
server $SERVER (rack controller "$NAME")
machines gateway $GATEWAY${NAT:+ (NAT out of $NAT)}, DNS $ADDR, domain $DOMAIN, DHCP $([ $DHCP = 1 ] && echo on || echo off)
ranges reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)
EOF
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"
[ $BUILD = 1 ] && PKGS="$PKGS live-build debootstrap squashfs-tools xorriso"
apt-get update -q >/dev/null
apt-get install -yq $PKGS >/dev/null
# until rollcall writes its configuration, dnsmasq must not grab port 53 on every address
install -d /etc/dnsmasq.d
[ -e /etc/dnsmasq.d/00-rollcall-base.conf ] || printf '# rollcall bootstrap: bind only where configured (systemd-resolved keeps 127.0.0.53)\nbind-dynamic\n' > /etc/dnsmasq.d/00-rollcall-base.conf
systemctl restart dnsmasq || true
ok "$(echo $PKGS | wc -w) packages"
# ------------------------------------------------------------------ 2. database
say "2/9 PostgreSQL"
systemctl enable --now postgresql >/dev/null
CONF=/etc/rollcall/config.json
if [ -f "$CONF" ]; then
ok "config exists, database settings kept"
else
PGPASS=$(head -c 24 /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 28)
if [ "$(su postgres -c "psql -tAc \"select 1 from pg_roles where rolname='rollcall'\"")" = 1 ]; then
su postgres -c "psql -q -c \"ALTER ROLE rollcall PASSWORD '$PGPASS'\""
else
su postgres -c "psql -q -c \"CREATE ROLE rollcall LOGIN PASSWORD '$PGPASS'\""
fi
[ "$(su postgres -c "psql -tAc \"select 1 from pg_database where datname='rollcall'\"")" = 1 ] || su postgres -c "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
if [ ! -f "$CONF" ]; then
AGENT=$("$HERE/rollcall" keygen) ADMIN=$("$HERE/rollcall" keygen)
FQDN=$(hostname -f 2>/dev/null || hostname)
python3 - "$CONF" <<P
import json, sys
c = json.load(open("$HERE/config.example.json"))
srv = "$SERVER"
c.update({
"listen": ":80", "server_url": srv,
"database_url": "postgres://rollcall:$PGPASS@127.0.0.1:5432/rollcall?sslmode=disable",
"admin_token": "$ADMIN", "agent_token": "$AGENT",
"commission_append": f"boot=live components fetch={srv}/rollcall/filesystem.squashfs rollcall.server={srv} noautologin rollcall.token=$AGENT console=tty0 console=ttyS0,115200n8",
"rack_name": "$NAME", "tls_listen": ":443", "tls_names": sorted({"$FQDN", "$ADDR"}), "admin_http": "loopback",
"node_pools": {}, "bmc_hints": [],
})
c["cluster_vars"].update({"domain": "$DOMAIN", "cluster_name": "$NAME", "slurm_controller": "$NAME", "slurm_controller_ip": "$ADDR", "ssh_keys": []})
c["ipam"]["provider"] = ""
json.dump(c, open(sys.argv[1], "w"), indent=2)
P
chown root:rollcall "$CONF"; chmod 0640 "$CONF"
ok "$CONF (tokens generated; admin_http loopback: the web UI is HTTPS-only)"
else
ok "$CONF kept"
fi
# ------------------------------------------------------------------ 6. install
say "6/9 services"
install -d /srv/rollcall/image
(cd "$HERE" && ./install.sh >/dev/null)
for i 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 -fL --progress-bar -o "$tmp/$f" "$IMAGE_URL/$f"; done
(cd "$tmp" && grep -E ' (\*)?(vmlinuz|initrd.img|filesystem.squashfs)$' SHA256SUMS | sha256sum -c --quiet -) || die "checksum mismatch"
mv "$tmp"/vmlinuz "$tmp"/initrd.img "$tmp"/filesystem.squashfs "$tmp"/SHA256SUMS $IMG/ && rmdir "$tmp" 2>/dev/null || 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 " no image yet: rerun with --image-url URL or --build-image (machines cannot commission without it)" >&2
fi
# ------------------------------------------------------------------ 8. network model
say "8/9 network model"
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 with ranges"
else
ok "subnet $NET exists, kept"
fi
if [ $DHCP = 1 ]; then
rollcall vlan add "$FAB" 0 -dhcp -rack "$NAME" >/dev/null
ok "DHCP/PXE on $IFACE by rack controller $NAME"
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 --now nftables >/dev/null && systemctl reload nftables
ok "forwarding + masquerade $NET out of $NAT"
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 fetches the catalog nightly (or: runuser -u rollcall -- rollcall os sync debian-13)"
fi
cat <<EOF
Done. Rollcall region "$NAME" on $SERVER
web UI https://$ADDR (CA: rollcall ca show; trust /ca.pem in your browser)
admin a certificate for your laptop: rollcall admin-cert request NAME (needs the token once:
jq -r .admin_token $CONF)
machines set them to PXE boot on the network of $IFACE; unknown ones commission themselves
check rollcall doctor
EOF