Profiles bind a RAM-booted root image to overlay templates; assigning a machine allocates a node name and management IP, writes the per-MAC PXE entry with a per-node token and exports static leases for dnsmasq. Nodes pull rendered overlays (hosts, slurm.conf from inventory, gres.conf, SSH host key, munge key) through rollcall-overlay.service; the runtime refresh timer is the heartbeat. Adds the compute-debian13 image definition and a generic live-build root image script, CLI and web UI for profiles/assign. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
884 lines
25 KiB
Go
884 lines
25 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"
|
|
"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
|
|
}
|
|
|
|
func (a *App) allocate(ctx context.Context, p *Profile, wantName string) (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 := p.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] {
|
|
err = fmt.Errorf("node name %s already in use", name)
|
|
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
|
|
}
|
|
|
|
type overlayCtx struct {
|
|
Node NodeCtx
|
|
Nodes []NodeCtx // all machines with a profile, sorted by name
|
|
Profile Profile
|
|
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)
|
|
}
|
|
}
|
|
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) },
|
|
}
|
|
|
|
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
|
|
}
|
|
return &overlayCtx{Node: me, Nodes: nodes, Profile: *p, Vars: vars, Server: a.cfg.ServerURL,
|
|
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.Profile.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 p.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 {
|
|
parts := []string{"boot=live", "components",
|
|
"fetch=" + a.cfg.ServerURL + "/images/" + p.Image + "/filesystem.squashfs",
|
|
"hostname=" + deref(m.NodeName),
|
|
"live-config.nocomponents=user-setup,sudo",
|
|
"rollcall.server=" + a.cfg.ServerURL, "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.cfg.CommissionAppend)
|
|
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)
|
|
}
|
|
return a.writePXE(m)
|
|
}
|
|
|
|
// exportDnsmasq writes dhcp-hostsfile and addn-hosts files; a systemd path unit HUPs dnsmasq.
|
|
func (a *App) exportDnsmasq(ctx context.Context) error {
|
|
if a.cfg.DnsmasqDir == "" {
|
|
return nil
|
|
}
|
|
ms, err := a.store.List(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
domain := str(a.cfg.ClusterVars["domain"])
|
|
var dhcp, hosts bytes.Buffer
|
|
fmt.Fprintf(&dhcp, "# generated by rollcall %s\n", time.Now().Format(time.RFC3339))
|
|
fmt.Fprintf(&hosts, "# generated by rollcall %s\n", time.Now().Format(time.RFC3339))
|
|
for _, m := range ms {
|
|
if m.NodeName == nil || len(m.MACs) == 0 {
|
|
continue
|
|
}
|
|
name := *m.NodeName
|
|
if m.MgmtIP != nil {
|
|
fmt.Fprintf(&dhcp, "%s,%s,%s\n", m.MACs[0], *m.MgmtIP, name)
|
|
if domain != "" {
|
|
fmt.Fprintf(&hosts, "%s %s.%s %s\n", *m.MgmtIP, name, domain, name)
|
|
} else {
|
|
fmt.Fprintf(&hosts, "%s %s\n", *m.MgmtIP, name)
|
|
}
|
|
}
|
|
if m.IBIP != nil {
|
|
fmt.Fprintf(&hosts, "%s %s-ib\n", *m.IBIP, name)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(a.cfg.DnsmasqDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
for fn, b := range map[string]*bytes.Buffer{"hosts.dhcp": &dhcp, "hosts": &hosts} {
|
|
p := filepath.Join(a.cfg.DnsmasqDir, fn)
|
|
if err := os.WriteFile(p+".tmp", b.Bytes(), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(p+".tmp", p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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(), p, 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) {
|
|
c, err := a.credsFor(m)
|
|
if err != nil {
|
|
return err.Error(), false
|
|
}
|
|
o1, e1 := a.ipmi(ctx, c, "chassis", "bootdev", "pxe")
|
|
st, _ := a.ipmi(ctx, c, "chassis", "power", "status")
|
|
var o2 string
|
|
var e2 error
|
|
if strings.Contains(st, "off") {
|
|
o2, e2 = a.ipmi(ctx, c, "chassis", "power", "on")
|
|
} else {
|
|
o2, e2 = a.ipmi(ctx, c, "chassis", "power", "cycle")
|
|
}
|
|
_ = a.store.AddEvent(ctx, m.ID, "pxe boot", strings.TrimSpace(o1+"\n"+o2))
|
|
return strings.TrimSpace(o1 + "\n" + o2), 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
|
|
}
|
|
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)
|
|
}
|