Files
rollcall/cmd/rollcall/stateless.go
T
Ilker ManapandClaude Opus 5.5 996ecbd89d
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 37s
Lost power commands are visible; jobs not started in job_start_timeout fail
pxeBoot dropped the errors of its BMC calls, so a command lost while the
rack or region restarted produced an empty, success-looking event and the
job waited forever. Errors now go into the event (pxe boot failed), and a
watchdog fails jobs the machine has not started within job_start_timeout
(default 30m: real servers take 4-5 minutes to POST, longer in bulk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:23:38 +01:00

928 lines
26 KiB
Go

package main
// Stateless (Warewulf-style) provisioning: profiles, node naming and IP pools,
// per-node overlays rendered from templates, dnsmasq host export and the
// per-MAC PXE entry that boots a profile's root image from RAM.
import (
"archive/tar"
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"text/template"
"time"
"golang.org/x/crypto/ssh"
)
// ---------------------------------------------------------------- profiles
type Profile struct {
Name string `json:"name"`
Image string `json:"image"` // directory under images_dir: vmlinuz, initrd.img, filesystem.squashfs
Append string `json:"append"` // extra kernel parameters
Overlays []string `json:"overlays"` // directories under overlays_dir
NameFormat string `json:"name_format"` // e.g. node%03d
Vars map[string]any `json:"vars"`
Created time.Time `json:"created"`
}
func (s *Store) Profiles(ctx context.Context) ([]Profile, error) {
rows, err := s.pool.Query(ctx, `SELECT name, image, coalesce(append,''), overlays, coalesce(name_format,''), vars, created FROM profiles ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Profile{}
for rows.Next() {
var p Profile
var ov, vars []byte
if err := rows.Scan(&p.Name, &p.Image, &p.Append, &ov, &p.NameFormat, &vars, &p.Created); err != nil {
return nil, err
}
_ = json.Unmarshal(ov, &p.Overlays)
_ = json.Unmarshal(vars, &p.Vars)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) Profile(ctx context.Context, name string) (*Profile, error) {
ps, err := s.Profiles(ctx)
if err != nil {
return nil, err
}
for i := range ps {
if ps[i].Name == name {
return &ps[i], nil
}
}
return nil, errNotFound
}
func (s *Store) PutProfile(ctx context.Context, p Profile) error {
if p.Overlays == nil {
p.Overlays = []string{}
}
if p.Vars == nil {
p.Vars = map[string]any{}
}
if p.NameFormat == "" {
p.NameFormat = "node%03d"
}
ov, _ := json.Marshal(p.Overlays)
vars, _ := json.Marshal(p.Vars)
_, err := s.pool.Exec(ctx, `INSERT INTO profiles(name, image, append, overlays, name_format, vars) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (name) DO UPDATE SET image=$2, append=$3, overlays=$4, name_format=$5, vars=$6`,
p.Name, p.Image, p.Append, ov, p.NameFormat, vars)
return err
}
func (s *Store) DeleteProfile(ctx context.Context, name string) error {
var n int
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM machines WHERE profile=$1`, name).Scan(&n)
if n > 0 {
return fmt.Errorf("profile %s is assigned to %d machine(s)", name, n)
}
tag, err := s.pool.Exec(ctx, `DELETE FROM profiles WHERE name=$1`, name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errNotFound
}
return nil
}
// ---------------------------------------------------------------- allocation
type nodeAssignment struct {
Profile string
Name string
MgmtIP string
IBIP string
TokenHash string
Token []byte // sealed
}
func (s *Store) Assign(ctx context.Context, id string, n nodeAssignment) error {
tag, err := s.pool.Exec(ctx, `UPDATE machines SET profile=$2, node_name=$3, mgmt_ip=$4, ib_ip=$5,
node_token_hash=$6, node_token=$7, state='stateless', booted_at=NULL WHERE id=$1`,
id, n.Profile, n.Name, nilIfEmpty(n.MgmtIP), nilIfEmpty(n.IBIP), n.TokenHash, n.Token)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (s *Store) Unassign(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `UPDATE machines SET profile=NULL, node_name=NULL, mgmt_ip=NULL, ib_ip=NULL,
node_token_hash=NULL, node_token=NULL, state='ready', booted_at=NULL WHERE id=$1`, id)
return err
}
func (s *Store) ByNodeToken(ctx context.Context, hash string) (*Machine, error) {
return scanMachine(s.pool.QueryRow(ctx, `SELECT `+machineCols+` FROM machines WHERE node_token_hash=$1`, hash), true)
}
func (s *Store) MarkBooted(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `UPDATE machines SET state='booted', booted_at=coalesce(booted_at, now()), last_seen=now() WHERE id=$1`, id)
return err
}
func (s *Store) SetHostKey(ctx context.Context, id string, sealed []byte) error {
_, err := s.pool.Exec(ctx, `UPDATE machines SET host_key=$2 WHERE id=$1`, id, sealed)
return err
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
// parsePool turns "10.0.0.50-10.0.0.99" into the ordered list of addresses.
func parsePool(spec string) ([]string, error) {
lo, hi, ok := strings.Cut(strings.TrimSpace(spec), "-")
if !ok {
return nil, fmt.Errorf("pool %q: want A.B.C.D-A.B.C.D", spec)
}
a, b := net.ParseIP(strings.TrimSpace(lo)).To4(), net.ParseIP(strings.TrimSpace(hi)).To4()
if a == nil || b == nil {
return nil, fmt.Errorf("pool %q: bad address", spec)
}
ai, bi := binary.BigEndian.Uint32(a), binary.BigEndian.Uint32(b)
if bi < ai || bi-ai > 65536 {
return nil, fmt.Errorf("pool %q: bad range", spec)
}
out := make([]string, 0, bi-ai+1)
for i := ai; i <= bi; i++ {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, i)
out = append(out, ip.String())
}
return out, nil
}
// allocate picks a node name and addresses: from IPAM when the machine's boot
// NIC sits in a modeled subnet, else from the legacy node_pools.
func (a *App) allocate(ctx context.Context, m *Machine, nameFormat, wantName string) (name, mgmt, ib string, err error) {
if name, _, _, err = a.allocatePools(ctx, nameFormat, wantName, false); err != nil {
return
}
if b := bootNIC(machineNICs(m), m.MACs); b != nil {
for _, ip := range b.IPv4 {
if sn := subnetFor(a.subnetsSnapshot(), ip); sn != nil {
have, _ := a.store.IPs(ctx, sn.CIDR, m.ID)
for _, x := range have {
return name, x.IP, "", nil
}
mgmt, err = a.allocIP(ctx, *sn, m.ID, b.Name, name)
return
}
}
}
_, mgmt, ib, err = a.allocatePools(ctx, nameFormat, name, true)
return
}
func (a *App) allocatePools(ctx context.Context, nameFormat, wantName string, pools bool) (name, mgmt, ib string, err error) {
ms, err := a.store.List(ctx)
if err != nil {
return
}
usedName, usedIP := map[string]bool{}, map[string]bool{}
for _, m := range ms {
if m.NodeName != nil {
usedName[*m.NodeName] = true
}
if m.MgmtIP != nil {
usedIP[*m.MgmtIP] = true
}
if m.IBIP != nil {
usedIP[*m.IBIP] = true
}
}
name = wantName
if name == "" {
f := nameFormat
if f == "" {
f = "node%03d"
}
for i := 1; i < 100000; i++ {
if c := fmt.Sprintf(f, i); !usedName[c] {
name = c
break
}
}
} else if usedName[name] && !pools {
err = fmt.Errorf("node name %s already in use", name)
return
}
if !pools {
return
}
pick := func(pool string) (string, error) {
spec := a.cfg.NodePools[pool]
if spec == "" {
return "", nil
}
ips, err := parsePool(spec)
if err != nil {
return "", err
}
for _, ip := range ips {
if !usedIP[ip] {
usedIP[ip] = true
return ip, nil
}
}
return "", fmt.Errorf("pool %s exhausted", pool)
}
if mgmt, err = pick("mgmt"); err != nil {
return
}
ib, err = pick("ib")
return
}
// ---------------------------------------------------------------- template context
type NodeCtx struct {
ID, Name, Profile, State string
BootMAC string
MACs []string
MgmtIP, IBIP string
Sockets, CoresPerSocket int
ThreadsPerCore, CPUs int
MemMB int
GPUs int
IBGUIDs []string
HostKey, HostKeyPub string // only filled for the node being rendered
Tags []string
Manufacturer, Product string
Disks int
NVMe bool
PCIVendors []string // vendor ids seen on the PCI bus (e.g. 10de = NVIDIA, 15b3 = Mellanox)
OS string
Building, Room, Rack string
Unit int
AssetTag string
}
type overlayCtx struct {
Node NodeCtx
Nodes []NodeCtx // all machines with a profile, sorted by name
Profile Profile
Overlays []string // profile overlays plus those of the node's tags
CA string // the installation's CA certificate (PEM)
Vars map[string]any // cluster_vars overlaid with profile vars
Server string
Now string
}
func atoi(v any) int {
n, _ := strconv.Atoi(strings.TrimSpace(str(v)))
return n
}
func nodeCtx(m *Machine) NodeCtx {
var inv obj
_ = json.Unmarshal(m.Inventory, &inv)
n := NodeCtx{ID: m.ID, Name: deref(m.NodeName), Profile: deref(m.Profile), State: m.State, MACs: m.MACs,
MgmtIP: deref(m.MgmtIP), IBIP: deref(m.IBIP)}
if len(m.MACs) > 0 {
n.BootMAC = m.MACs[0]
}
n.Sockets = atoi(dig(inv, "cpu", "Socket(s)"))
n.CoresPerSocket = atoi(dig(inv, "cpu", "Core(s) per socket"))
n.ThreadsPerCore = atoi(dig(inv, "cpu", "Thread(s) per core"))
n.CPUs = atoi(dig(inv, "cpu", "CPU(s)"))
if n.Sockets == 0 {
n.Sockets = 1
}
if n.CoresPerSocket == 0 {
n.CoresPerSocket = max(1, n.CPUs/max(1, n.ThreadsPerCore))
}
if n.ThreadsPerCore == 0 {
n.ThreadsPerCore = 1
}
if n.CPUs == 0 {
n.CPUs = n.Sockets * n.CoresPerSocket * n.ThreadsPerCore
}
n.MemMB = int(num(dig(inv, "memory", "total_bytes")) / (1024 * 1024))
// count real accelerators only (NVIDIA, AMD, or PCI class 0302 "3D controller"); emulated VGA is not a GPU
for _, g := range toList(inv["gpus"]) {
vid, cls := str(dig(g, "vendor_id")), str(dig(g, "class_id"))
if vid == "10de" || vid == "1002" || cls == "0302" || cls == "1200" {
n.GPUs++
}
}
for _, h := range toList(dig(inv, "infiniband", "hcas")) {
if g := str(dig(h, "node_guid")); g != "" {
n.IBGUIDs = append(n.IBGUIDs, g)
}
}
n.Tags = effectiveTags(m)
n.Manufacturer, n.Product, n.OS = deref(m.Manufacturer), deref(m.Product), deref(m.OS)
n.Building, n.Room, n.Rack, n.Unit, n.AssetTag = m.Location.Building, m.Location.Room, m.Location.Rack, m.Location.Unit, m.Location.AssetTag
n.Disks = int(num(dig(inv, "storage", "disk_count")))
for _, d := range toList(dig(inv, "storage", "disks")) {
if strings.HasPrefix(str(dig(d, "name")), "nvme") || str(dig(d, "tran")) == "nvme" {
n.NVMe = true
}
}
seen := map[string]bool{}
for _, p := range toList(inv["pci"]) {
if v := str(dig(p, "vendor_id")); v != "" && !seen[v] {
seen[v] = true
n.PCIVendors = append(n.PCIVendors, v)
}
}
return n
}
var tmplFuncs = template.FuncMap{
"join": strings.Join,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"replace": strings.ReplaceAll,
"default": func(d, v any) any {
if v == nil || v == "" || v == 0 || v == false {
return d
}
return v
},
"mul": func(a, b int) int { return a * b },
"sub": func(a, b int) int { return a - b },
"pct": func(v, p int) int { return v * p / 100 },
"seq": func(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = i
}
return s
},
"json": func(v any) string { b, _ := json.Marshal(v); return string(b) },
"has": hasTag,
"contains": func(s, sub string) bool { return strings.Contains(s, sub) },
"hasPrefix": strings.HasPrefix,
"matches": func(pattern, s string) bool { ok, _ := regexp.MatchString(pattern, s); return ok },
"gte": func(a, b int) bool { return a >= b },
}
func (a *App) hostKeyFor(ctx context.Context, m *Machine) (priv, pub string, err error) {
if m.hostKey != nil {
if pemStr, err := a.sealer.Open(m.hostKey, m.ID); err == nil {
priv = pemStr
}
}
if priv == "" {
pubk, privk, err := ed25519.GenerateKey(nil)
if err != nil {
return "", "", err
}
block, err := ssh.MarshalPrivateKey(privk, "rollcall "+m.ID)
if err != nil {
return "", "", err
}
priv = string(pem.EncodeToMemory(block))
_ = pubk
sealed, err := a.sealer.Seal(priv, m.ID)
if err != nil {
return "", "", err
}
if err := a.store.SetHostKey(ctx, m.ID, sealed); err != nil {
return "", "", err
}
}
signer, err := ssh.ParsePrivateKey([]byte(priv))
if err != nil {
return "", "", err
}
pub = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
return priv, pub, nil
}
func (a *App) buildCtx(ctx context.Context, m *Machine) (*overlayCtx, error) {
p, err := a.store.Profile(ctx, deref(m.Profile))
if err != nil {
return nil, fmt.Errorf("profile %q: %w", deref(m.Profile), err)
}
ms, err := a.store.List(ctx)
if err != nil {
return nil, err
}
var nodes []NodeCtx
for _, x := range ms {
if x.Profile != nil && x.NodeName != nil {
nodes = append(nodes, nodeCtx(x))
}
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name })
me := nodeCtx(m)
if me.Name != "" {
if me.HostKey, me.HostKeyPub, err = a.hostKeyFor(ctx, m); err != nil {
return nil, err
}
}
vars := map[string]any{}
for k, v := range a.cfg.ClusterVars {
vars[k] = v
}
for k, v := range p.Vars {
vars[k] = v
}
overlays := append([]string{}, p.Overlays...)
if defs, err := a.store.Tags(ctx); err == nil {
for _, t := range defs {
if hasTag(me.Tags, t.Name) {
for _, o := range t.Overlays {
if !hasTag(overlays, o) {
overlays = append(overlays, o)
}
}
}
}
}
caPEM := ""
if a.ca != nil {
caPEM = string(a.ca.PEM)
}
return &overlayCtx{Node: me, Nodes: nodes, Profile: *p, Overlays: overlays, CA: caPEM, Vars: vars, Server: a.urlFor(m),
Now: time.Now().Format(time.RFC3339)}, nil
}
// renderOverlay produces a tar of every overlay dir of the node's profile for the stage.
func (a *App) renderOverlay(ctx context.Context, m *Machine, stage string) ([]byte, error) {
if stage != "system" && stage != "runtime" {
return nil, fmt.Errorf("stage must be system or runtime")
}
oc, err := a.buildCtx(ctx, m)
if err != nil {
return nil, err
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
seen := map[string]bool{}
for _, name := range oc.Overlays {
root := filepath.Join(a.cfg.OverlaysDir, name, stage)
if st, err := os.Stat(root); err != nil || !st.IsDir() {
continue
}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(root, p)
if rel == "." {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&fs.ModeSymlink != 0 { // follow symlinks (e.g. runtime -> system templates)
if info, err = os.Stat(p); err != nil {
return err
}
}
hdr := &tar.Header{Name: rel, Mode: int64(secretMode(rel, info.Mode().Perm())), ModTime: info.ModTime(), Uname: "root", Gname: "root"}
if d.IsDir() {
if seen[rel] {
return nil
}
seen[rel] = true
hdr.Typeflag, hdr.Name = tar.TypeDir, rel+"/"
return tw.WriteHeader(hdr)
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
if strings.HasSuffix(rel, ".tmpl") {
t, err := template.New(rel).Funcs(tmplFuncs).Option("missingkey=zero").Parse(string(data))
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
var out bytes.Buffer
if err := t.Execute(&out, oc); err != nil {
return fmt.Errorf("%s: %w", p, err)
}
data, hdr.Name = out.Bytes(), strings.TrimSuffix(rel, ".tmpl")
}
if seen[hdr.Name] { // later overlays override earlier ones; tar extraction keeps the last entry
log.Printf("overlay %s: %s overrides an earlier file", name, hdr.Name)
}
seen[hdr.Name] = true
hdr.Typeflag, hdr.Size = tar.TypeReg, int64(len(data))
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err = tw.Write(data)
return err
})
if err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// secretMode tightens permissions for files git cannot carry modes for.
func secretMode(rel string, mode fs.FileMode) fs.FileMode {
base := strings.TrimSuffix(path.Base(rel), ".tmpl")
switch {
case base == "munge.key":
return 0o400
case strings.HasPrefix(base, "ssh_host_") && !strings.HasSuffix(base, ".pub"),
base == "authorized_keys", strings.HasSuffix(base, ".key"), strings.HasSuffix(base, ".secret"):
return 0o600
}
return mode
}
// renderFile renders one template path of a profile with a cluster-wide context
// (used for files the controller needs too, e.g. slurm.conf).
func (a *App) renderFile(ctx context.Context, profile, stage, rel string) ([]byte, error) {
p, err := a.store.Profile(ctx, profile)
if err != nil {
return nil, err
}
ms, err := a.store.List(ctx)
if err != nil {
return nil, err
}
var m *Machine
for _, x := range ms {
if deref(x.Profile) == profile {
m = x
break
}
}
if m == nil { // no node yet: synthesize an empty one so cluster-level templates still render
m = &Machine{ID: "none", Profile: &p.Name}
}
oc, err := a.buildCtx(ctx, m)
if err != nil {
return nil, err
}
for _, name := range oc.Overlays {
for _, cand := range []string{rel + ".tmpl", rel} {
fp := filepath.Join(a.cfg.OverlaysDir, name, stage, cand)
data, err := os.ReadFile(fp)
if err != nil {
continue
}
if !strings.HasSuffix(cand, ".tmpl") {
return data, nil
}
t, err := template.New(rel).Funcs(tmplFuncs).Option("missingkey=zero").Parse(string(data))
if err != nil {
return nil, err
}
var out bytes.Buffer
if err := t.Execute(&out, oc); err != nil {
return nil, err
}
return out.Bytes(), nil
}
}
return nil, errNotFound
}
// ---------------------------------------------------------------- PXE + dnsmasq
const statelessTmpl = `# generated by rollcall %s
# node %s machine %s profile %s
DEFAULT menu.c32
PROMPT 0
TIMEOUT 30
MENU TITLE %s - stateless (%s)
LABEL node
MENU LABEL Boot %s (%s) from RAM
MENU DEFAULT
KERNEL images/%s/vmlinuz
INITRD images/%s/initrd.img
APPEND %s
LABEL recommission
MENU LABEL Re-run commissioning
KERNEL rollcall/vmlinuz
INITRD rollcall/initrd.img
APPEND %s
LABEL local
MENU LABEL Boot from local disk
LOCALBOOT 0
`
func (a *App) statelessAppend(m *Machine, p *Profile, token string) string {
base := a.urlFor(m)
parts := []string{"boot=live", "components",
"fetch=" + base + "/images/" + p.Image + "/filesystem.squashfs",
"hostname=" + deref(m.NodeName),
"live-config.nocomponents=user-setup,sudo",
"rollcall.server=" + base, "rollcall.node=" + token}
if p.Append != "" {
parts = append(parts, p.Append)
}
return strings.Join(parts, " ")
}
func (a *App) writePXEStateless(m *Machine, p *Profile, token string) error {
body := fmt.Sprintf(statelessTmpl, time.Now().Format(time.RFC3339), deref(m.NodeName), m.ID, p.Name,
deref(m.NodeName), p.Name, deref(m.NodeName), p.Name, p.Image, p.Image, a.statelessAppend(m, p, token),
a.appendFor(a.urlFor(m)))
return a.writePXEBody(m, body)
}
// writePXEAny picks the right per-MAC content for the machine's current state.
func (a *App) writePXEAny(ctx context.Context, m *Machine) error {
if m.Profile != nil && m.nodeToken != nil {
p, err := a.store.Profile(ctx, *m.Profile)
if err != nil {
return err
}
token, err := a.sealer.Open(m.nodeToken, m.ID)
if err != nil {
return err
}
return a.writePXEStateless(m, p, token)
}
if m.State == "deployed" {
return a.writePXEDeployed(m)
}
return a.writePXE(m)
}
// ---------------------------------------------------------------- handlers
func (a *App) handleProfiles(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ps, err := a.store.Profiles(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, ps)
case http.MethodPost:
var p Profile
if err := json.NewDecoder(r.Body).Decode(&p); err != nil || p.Name == "" || p.Image == "" {
fail(w, 400, fmt.Errorf("name and image are required"))
return
}
if strings.ContainsAny(p.Name, "/ \t") || strings.ContainsAny(p.Image, "/ \t") {
fail(w, 400, fmt.Errorf("name/image must be plain identifiers"))
return
}
if err := a.store.PutProfile(r.Context(), p); err != nil {
fail(w, 500, err)
return
}
// profile changes affect every assigned node's PXE entry and overlays
ms, _ := a.store.List(r.Context())
for _, m := range ms {
if deref(m.Profile) == p.Name {
if err := a.writePXEAny(r.Context(), m); err != nil {
log.Printf("pxe rewrite %s: %v", m.ID, err)
}
}
}
writeJSON(w, 200, p)
}
}
func (a *App) handleProfileDelete(w http.ResponseWriter, r *http.Request) {
if err := a.store.DeleteProfile(r.Context(), r.PathValue("name")); err != nil {
fail(w, 409, err)
return
}
writeJSON(w, 200, obj{"deleted": r.PathValue("name")})
}
func (a *App) handleAssign(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
var req struct {
Profile string `json:"profile"`
Name string `json:"name"`
Boot bool `json:"boot"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Profile == "" {
fail(w, 400, fmt.Errorf("profile is required"))
return
}
p, err := a.store.Profile(r.Context(), req.Profile)
if err != nil {
fail(w, 404, fmt.Errorf("profile %s: %w", req.Profile, err))
return
}
if _, err := os.Stat(filepath.Join(a.cfg.ImagesDir, p.Image, "filesystem.squashfs")); err != nil {
fail(w, 409, fmt.Errorf("image %s is not built (%s)", p.Image, err))
return
}
// keep the existing name/IPs when re-assigning an already named node
name, mgmt, ib := deref(m.NodeName), deref(m.MgmtIP), deref(m.IBIP)
if name == "" || (req.Name != "" && req.Name != name) {
if name, mgmt, ib, err = a.allocate(r.Context(), m, p.NameFormat, req.Name); err != nil {
fail(w, 409, err)
return
}
}
token := randomToken(16)
sum := sha256.Sum256([]byte(token))
sealed, err := a.sealer.Seal(token, m.ID)
if err != nil {
fail(w, 500, err)
return
}
if err := a.store.Assign(r.Context(), m.ID, nodeAssignment{Profile: p.Name, Name: name, MgmtIP: mgmt, IBIP: ib,
TokenHash: hex.EncodeToString(sum[:]), Token: sealed}); err != nil {
fail(w, 500, err)
return
}
m, _ = a.store.Get(r.Context(), m.ID)
if err := a.writePXEStateless(m, p, token); err != nil {
fail(w, 500, err)
return
}
if err := a.exportDnsmasq(r.Context()); err != nil {
log.Printf("dnsmasq export: %v", err)
}
_ = a.store.AddEvent(r.Context(), m.ID, "assigned", fmt.Sprintf("profile=%s name=%s mgmt=%s ib=%s", p.Name, name, mgmt, ib))
out := obj{"id": m.ID, "state": "stateless", "profile": p.Name, "name": name, "mgmt_ip": mgmt, "ib_ip": ib}
if req.Boot {
out["ipmi"], out["ipmi_ok"] = a.pxeBoot(r.Context(), m)
}
log.Printf("assigned %s -> %s as %s (%s)", m.ID, p.Name, name, mgmt)
writeJSON(w, 200, out)
}
// pxeBoot sets bootdev pxe and power-cycles (or powers on) through the BMC.
func (a *App) pxeBoot(ctx context.Context, m *Machine) (string, bool) {
o1, e1 := a.power(ctx, m, "bootdev", "pxe")
if errors.Is(e1, errNoBMC) {
return e1.Error(), false
}
st, _ := a.power(ctx, m, "status", "")
var o2 string
var e2 error
if strings.Contains(st, "off") {
o2, e2 = a.power(ctx, m, "on", "")
} else {
o2, e2 = a.power(ctx, m, "cycle", "")
}
detail := strings.TrimSpace(o1 + "\n" + o2)
ev := "pxe boot"
if e1 != nil || e2 != nil { // a lost or failed power command must not look like success
ev = "pxe boot failed"
for _, e := range []error{e1, e2} {
if e != nil {
detail = strings.TrimSpace(detail + "\n" + e.Error())
}
}
}
_ = a.store.AddEvent(ctx, m.ID, ev, detail)
return detail, e1 == nil && e2 == nil
}
func (a *App) handleUnassign(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
_ = a.store.FreeMachineIPs(r.Context(), m.ID, "auto")
a.kickIPAM()
if err := a.store.Unassign(r.Context(), m.ID); err != nil {
fail(w, 500, err)
return
}
m, _ = a.store.Get(r.Context(), m.ID)
_ = a.writePXE(m)
_ = a.exportDnsmasq(r.Context())
_ = a.store.AddEvent(r.Context(), m.ID, "unassigned", "")
writeJSON(w, 200, obj{"id": m.ID, "state": "ready"})
}
func (a *App) serveOverlay(w http.ResponseWriter, r *http.Request, m *Machine) {
stage := r.URL.Query().Get("stage")
if stage == "" {
stage = "system"
}
data, err := a.renderOverlay(r.Context(), m, stage)
if err != nil {
fail(w, 500, err)
return
}
sum := sha256.Sum256(data)
w.Header().Set("Content-Type", "application/x-tar")
w.Header().Set("ETag", `"`+hex.EncodeToString(sum[:8])+`"`)
w.Header().Set("X-Rollcall-Node-Name", deref(m.NodeName))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
// handleNodeOverlay is what a booted node calls; the token comes from its kernel cmdline.
func (a *App) handleNodeOverlay(w http.ResponseWriter, r *http.Request) {
tok := r.Header.Get("X-Rollcall-Node")
if tok == "" {
fail(w, 401, errors.New("X-Rollcall-Node required"))
return
}
sum := sha256.Sum256([]byte(tok))
m, err := a.store.ByNodeToken(r.Context(), hex.EncodeToString(sum[:]))
if err != nil {
fail(w, 401, errors.New("unknown node token"))
return
}
if m.State != "booted" {
_ = a.store.AddEvent(r.Context(), m.ID, "booted", r.RemoteAddr)
log.Printf("node %s (%s) booted from %s", deref(m.NodeName), m.ID, r.RemoteAddr)
}
_ = a.store.MarkBooted(r.Context(), m.ID)
a.serveOverlay(w, r, m)
}
func (a *App) handleAdminOverlay(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
if m.Profile == nil {
fail(w, 409, errors.New("machine has no profile"))
return
}
a.serveOverlay(w, r, m)
}
func (a *App) handleRender(w http.ResponseWriter, r *http.Request) {
stage := r.URL.Query().Get("stage")
if stage == "" {
stage = "system"
}
data, err := a.renderFile(r.Context(), r.PathValue("profile"), stage, path.Clean(r.PathValue("path")))
if err != nil {
fail(w, 500, err)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write(data)
}
func (a *App) handleImages(w http.ResponseWriter, r *http.Request) {
ents, err := os.ReadDir(a.cfg.ImagesDir)
if err != nil {
writeJSON(w, 200, []any{})
return
}
out := []obj{}
for _, e := range ents {
if !e.IsDir() {
continue
}
info := obj{"name": e.Name()}
for _, f := range []string{"vmlinuz", "initrd.img", "filesystem.squashfs"} {
if st, err := os.Stat(filepath.Join(a.cfg.ImagesDir, e.Name(), f)); err == nil {
info[f] = st.Size()
info["built"] = st.ModTime()
}
}
info["complete"] = info["filesystem.squashfs"] != nil && info["vmlinuz"] != nil && info["initrd.img"] != nil
out = append(out, info)
}
writeJSON(w, 200, out)
}