Rack controllers ask the region for a server certificate for their own URL's host (POST /api/v1/rack/server-cert; CN rack-server:NAME, ServerAuth only, never one of the region's names or addresses) and serve the agents' endpoints and images over HTTPS too (tls_listen, default <host>:443), reporting the HTTPS URL when they register (accepted only on the rack's own host). The pxelinux command lines name it with the CA fingerprint (rollcall.tls=, rollcall.ca=); the embedded rack uses the region's listener. Agents fetch /ca.pem, accept it only by that fingerprint and then use HTTPS with it, including the image download; a job fetched over HTTPS gets an HTTPS image URL. No fallback to HTTP once TLS was asked for; old agents ignore the new words. The CLI refuses a rack controller's certificate as the region. PXE itself (TFTP, kernel, initrd) stays unauthenticated: this protects what agents send and receive from listeners and from an HTTP server posing as the rack, not from an attacker rewriting boot files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
556 lines
22 KiB
Python
556 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Commissioning agent: runs once inside the ephemeral live image.
|
|
|
|
Collects a hardware inventory, configures a BMC account via in-band ipmitool
|
|
(falls back to a server-provided out-of-band hint for virtual machines),
|
|
posts everything to rollcall and powers the machine off.
|
|
|
|
Kernel cmdline options:
|
|
rollcall.server=http://10.213.77.1 (required) rollcall base URL
|
|
rollcall.nopoweroff stay up (debugging)
|
|
rollcall.bmcuser=commissioner BMC account name to create
|
|
rollcall.token=... agent token (sent as X-Rollcall-Token)
|
|
rollcall.mode=deploy rollcall.job=.. run rollcall-deploy.py instead (OS deployment / release job)
|
|
"""
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import socket
|
|
import ssl
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
LOG = open("/dev/console", "w", buffering=1) if os.path.exists("/dev/console") else sys.stdout
|
|
|
|
|
|
def log(msg):
|
|
line = f"[commission] {msg}"
|
|
print(line, file=LOG)
|
|
if LOG is not sys.stdout:
|
|
print(line, flush=True)
|
|
|
|
|
|
def run(cmd, timeout=120, text=True):
|
|
"""Return (rc, stdout). Never raises."""
|
|
try:
|
|
p = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
|
|
text=text, timeout=timeout)
|
|
return p.returncode, p.stdout if text else p.stdout
|
|
except FileNotFoundError:
|
|
return 127, ""
|
|
except subprocess.TimeoutExpired:
|
|
return 124, ""
|
|
|
|
|
|
def out(cmd, timeout=120):
|
|
rc, o = run(cmd, timeout)
|
|
return o.strip() if rc == 0 else ""
|
|
|
|
|
|
def jout(cmd, timeout=120):
|
|
o = out(cmd, timeout)
|
|
try:
|
|
return json.loads(o) if o else None
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def readf(path, default=""):
|
|
try:
|
|
with open(path) as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
return default
|
|
|
|
|
|
def cmdline():
|
|
opts = {}
|
|
for tok in readf("/proc/cmdline").split():
|
|
if tok.startswith("rollcall."):
|
|
k, _, v = tok[len("rollcall."):].partition("=")
|
|
opts[k] = v if v else True
|
|
return opts
|
|
|
|
|
|
# --------------------------------------------------------------------------- DMI
|
|
def dmi_system():
|
|
keys = ["system-manufacturer", "system-product-name", "system-version",
|
|
"system-serial-number", "system-uuid", "system-sku-number", "system-family",
|
|
"baseboard-manufacturer", "baseboard-product-name", "baseboard-serial-number",
|
|
"chassis-manufacturer", "chassis-type", "chassis-serial-number", "chassis-asset-tag",
|
|
"bios-vendor", "bios-version", "bios-release-date"]
|
|
d = {k: out(["dmidecode", "-s", k]) for k in keys}
|
|
# fallbacks via sysfs
|
|
d["system-uuid"] = d["system-uuid"] or readf("/sys/class/dmi/id/product_uuid")
|
|
d["system-serial-number"] = d["system-serial-number"] or readf("/sys/class/dmi/id/product_serial")
|
|
return d
|
|
|
|
|
|
def dmi_blocks(dtype):
|
|
"""Parse `dmidecode -t N` into a list of dicts (one per handle)."""
|
|
txt = out(["dmidecode", "-t", str(dtype)])
|
|
blocks, cur = [], None
|
|
for line in txt.splitlines():
|
|
if line.startswith("Handle "):
|
|
cur = {"_handle": line.split(",")[0].split()[1]}
|
|
blocks.append(cur)
|
|
elif cur is not None and line.startswith("\t") and ":" in line and not line.startswith("\t\t"):
|
|
k, _, v = line.strip().partition(":")
|
|
cur[k.strip()] = v.strip()
|
|
return blocks
|
|
|
|
|
|
def memory():
|
|
dimms = []
|
|
for b in dmi_blocks(17):
|
|
size = b.get("Size", "")
|
|
if not size or size.startswith("No Module"):
|
|
dimms.append({"locator": b.get("Locator"), "bank": b.get("Bank Locator"), "populated": False})
|
|
continue
|
|
dimms.append({
|
|
"locator": b.get("Locator"), "bank": b.get("Bank Locator"), "populated": True,
|
|
"size": size, "type": b.get("Type"), "type_detail": b.get("Type Detail"),
|
|
"speed": b.get("Speed"), "configured_speed": b.get("Configured Memory Speed"),
|
|
"manufacturer": b.get("Manufacturer"), "part_number": b.get("Part Number"),
|
|
"serial": b.get("Serial Number"), "rank": b.get("Rank"), "form_factor": b.get("Form Factor"),
|
|
})
|
|
total_kb = 0
|
|
m = re.search(r"MemTotal:\s+(\d+)", readf("/proc/meminfo"))
|
|
if m:
|
|
total_kb = int(m.group(1))
|
|
return {"total_bytes": total_kb * 1024, "dimm_slots": len(dimms),
|
|
"dimms_populated": sum(1 for d in dimms if d["populated"]), "dimms": dimms}
|
|
|
|
|
|
def cpu():
|
|
j = jout(["lscpu", "-J"]) or {}
|
|
flat = {}
|
|
for e in j.get("lscpu", []):
|
|
flat[e["field"].rstrip(":")] = e["data"]
|
|
for c in e.get("children", []) or []:
|
|
flat[c["field"].rstrip(":")] = c["data"]
|
|
want = ["Architecture", "CPU(s)", "Thread(s) per core", "Core(s) per socket", "Socket(s)",
|
|
"NUMA node(s)", "Vendor ID", "Model name", "CPU family", "Model", "Stepping",
|
|
"CPU max MHz", "CPU min MHz", "L1d cache", "L2 cache", "L3 cache", "Virtualization", "Flags"]
|
|
res = {k: flat.get(k) for k in want if k in flat}
|
|
flags = res.pop("Flags", "") or ""
|
|
res["flags_count"] = len(flags.split())
|
|
res["features"] = sorted(f for f in flags.split() if f in
|
|
("vmx", "svm", "avx", "avx2", "avx512f", "sse4_2", "aes", "sha_ni", "amx_tile"))
|
|
return res
|
|
|
|
|
|
# --------------------------------------------------------------------------- storage
|
|
def storage():
|
|
lsblk = jout(["lsblk", "-J", "-O", "-b", "-d"]) or {}
|
|
disks = []
|
|
for d in lsblk.get("blockdevices", []):
|
|
if d.get("type") not in ("disk", "nvme") and not d.get("name", "").startswith("nvme"):
|
|
continue
|
|
if d.get("name", "").startswith(("loop", "ram", "sr", "zram")):
|
|
continue
|
|
disks.append({k: d.get(k) for k in ("name", "size", "model", "serial", "vendor", "rev", "tran",
|
|
"rota", "wwn", "hctl", "subsystems", "log-sec", "phy-sec",
|
|
"rm", "ro", "path", "pttype", "fstype")})
|
|
nvme = jout(["nvme", "list", "-o", "json"]) or {}
|
|
smart = {}
|
|
for d in disks:
|
|
s = jout(["smartctl", "-i", "-H", "-j", d["path"]], timeout=30) or {}
|
|
if s:
|
|
smart[d["name"]] = {
|
|
"model_family": s.get("model_family"), "firmware": s.get("firmware_version"),
|
|
"rotation_rate": s.get("rotation_rate"), "form_factor": (s.get("form_factor") or {}).get("name"),
|
|
"healthy": (s.get("smart_status") or {}).get("passed"),
|
|
"power_on_hours": (s.get("power_on_time") or {}).get("hours"),
|
|
"interface_speed": ((s.get("interface_speed") or {}).get("current") or {}).get("string"),
|
|
}
|
|
ctrls = [p for p in pci_devices() if p["class_id"][:2] == "01"] # mass storage controllers
|
|
return {"disks": disks, "disk_count": len(disks),
|
|
"total_bytes": sum(int(d["size"] or 0) for d in disks),
|
|
"nvme": nvme.get("Devices"), "smart": smart, "controllers": ctrls}
|
|
|
|
|
|
# --------------------------------------------------------------------------- PCI
|
|
_PCI_CACHE = None
|
|
|
|
|
|
def pci_devices():
|
|
global _PCI_CACHE
|
|
if _PCI_CACHE is not None:
|
|
return _PCI_CACHE
|
|
txt = out(["lspci", "-Dvmmnnk"])
|
|
devs, cur = [], {}
|
|
for line in txt.splitlines() + [""]:
|
|
if not line.strip():
|
|
if cur:
|
|
devs.append(cur)
|
|
cur = {}
|
|
continue
|
|
k, _, v = line.partition(":")
|
|
cur[k.strip()] = v.strip()
|
|
res = []
|
|
for d in devs:
|
|
m_cls = re.search(r"\[([0-9a-f]{4})\]$", d.get("Class", ""))
|
|
m_ven = re.search(r"\[([0-9a-f]{4})\]$", d.get("Vendor", ""))
|
|
m_dev = re.search(r"\[([0-9a-f]{4})\]$", d.get("Device", ""))
|
|
cls = m_cls.group(1) if m_cls else "0000"
|
|
kind = {"01": "storage", "02": "network", "03": "gpu", "0c": "serial-bus", "06": "bridge"}.get(cls[:2], "other")
|
|
if cls == "0302" or (cls == "1200"):
|
|
kind = "accelerator"
|
|
res.append({
|
|
"slot": d.get("Slot"), "class": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Class", "")),
|
|
"class_id": cls, "kind": kind,
|
|
"vendor": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Vendor", "")),
|
|
"device": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Device", "")),
|
|
"vendor_id": m_ven.group(1) if m_ven else None, "device_id": m_dev.group(1) if m_dev else None,
|
|
"subsystem": d.get("SDevice"), "driver": d.get("Driver"), "rev": d.get("Rev"),
|
|
})
|
|
_PCI_CACHE = res
|
|
return res
|
|
|
|
|
|
def gpus():
|
|
return [p for p in pci_devices() if p["kind"] in ("gpu", "accelerator")]
|
|
|
|
|
|
# --------------------------------------------------------------------------- network
|
|
def ethtool_info(ifn):
|
|
info = {}
|
|
for line in out(["ethtool", "-i", ifn]).splitlines():
|
|
k, _, v = line.partition(":")
|
|
if k in ("driver", "version", "firmware-version", "bus-info"):
|
|
info[k.replace("-", "_")] = v.strip()
|
|
et = out(["ethtool", ifn])
|
|
m = re.search(r"Speed:\s*(\S+)", et)
|
|
info["speed"] = m.group(1) if m else None
|
|
m = re.search(r"Duplex:\s*(\S+)", et)
|
|
info["duplex"] = m.group(1) if m else None
|
|
m = re.search(r"Link detected:\s*(\S+)", et)
|
|
info["link"] = (m.group(1) == "yes") if m else None
|
|
m = re.search(r"Supported ports:\s*\[([^\]]*)\]", et)
|
|
info["ports"] = m.group(1).split() if m else None
|
|
sup = re.search(r"Supported link modes:(.*?)Supported pause", et, re.S)
|
|
if sup:
|
|
info["supported_link_modes"] = sup.group(1).split()
|
|
pm = out(["ethtool", "-P", ifn])
|
|
m = re.search(r"([0-9a-f]{2}(?::[0-9a-f]{2}){5})", pm)
|
|
info["permanent_mac"] = m.group(1) if m else None
|
|
return info
|
|
|
|
|
|
def network():
|
|
links = jout(["ip", "-j", "-d", "link"]) or []
|
|
addrs = {a["ifname"]: a for a in (jout(["ip", "-j", "addr"]) or [])}
|
|
nics = []
|
|
for l in links:
|
|
ifn = l["ifname"]
|
|
if ifn == "lo" or l.get("link_type") not in ("ether", "infiniband"):
|
|
continue
|
|
sysd = f"/sys/class/net/{ifn}"
|
|
if not os.path.exists(f"{sysd}/device"):
|
|
continue # virtual (bridge, bond, vlan, veth ...)
|
|
pci = os.path.basename(os.path.realpath(f"{sysd}/device"))
|
|
nic = {
|
|
"name": ifn, "mac": l.get("address"), "mtu": l.get("mtu"), "operstate": l.get("operstate"),
|
|
"link_type": l.get("link_type"), "pci": pci,
|
|
"sriov_totalvfs": readf(f"{sysd}/device/sriov_totalvfs") or None,
|
|
"numa_node": readf(f"{sysd}/device/numa_node") or None,
|
|
"ipv4": [a["local"] + "/" + str(a["prefixlen"]) for a in addrs.get(ifn, {}).get("addr_info", [])
|
|
if a.get("family") == "inet"],
|
|
"ipv6": [a["local"] + "/" + str(a["prefixlen"]) for a in addrs.get(ifn, {}).get("addr_info", [])
|
|
if a.get("family") == "inet6" and a.get("scope") == "global"],
|
|
}
|
|
if l.get("link_type") == "ether":
|
|
nic.update(ethtool_info(ifn))
|
|
nics.append(nic)
|
|
return nics
|
|
|
|
|
|
def infiniband():
|
|
hcas = []
|
|
for d in sorted(glob.glob("/sys/class/infiniband/*")):
|
|
name = os.path.basename(d)
|
|
ports = []
|
|
for p in sorted(glob.glob(f"{d}/ports/*")):
|
|
ports.append({
|
|
"port": int(os.path.basename(p)), "state": readf(f"{p}/state"),
|
|
"phys_state": readf(f"{p}/phys_state"), "rate": readf(f"{p}/rate"),
|
|
"link_layer": readf(f"{p}/link_layer"), "lid": readf(f"{p}/lid"),
|
|
"sm_lid": readf(f"{p}/sm_lid"), "port_guid": readf(f"{p}/gids/0") or None,
|
|
})
|
|
hcas.append({
|
|
"name": name, "node_guid": readf(f"{d}/node_guid"), "sys_image_guid": readf(f"{d}/sys_image_guid"),
|
|
"fw_ver": readf(f"{d}/fw_ver"), "hca_type": readf(f"{d}/hca_type"), "board_id": readf(f"{d}/board_id"),
|
|
"node_desc": readf(f"{d}/node_desc"),
|
|
"pci": os.path.basename(os.path.realpath(f"{d}/device")) if os.path.exists(f"{d}/device") else None,
|
|
"ports": ports,
|
|
})
|
|
return {"hcas": hcas, "ibstat": out(["ibstat"], timeout=30) or None}
|
|
|
|
|
|
def lldp(wait=25):
|
|
if run(["which", "lldpcli"])[0] != 0:
|
|
return None
|
|
deadline = time.time() + wait
|
|
neigh = None
|
|
while time.time() < deadline:
|
|
j = jout(["lldpcli", "-f", "json", "show", "neighbors", "details"], timeout=20)
|
|
if j and j.get("lldp") and j["lldp"].get("interface"):
|
|
neigh = j["lldp"]["interface"]
|
|
break
|
|
time.sleep(3)
|
|
return neigh
|
|
|
|
|
|
# --------------------------------------------------------------------------- BMC
|
|
def gen_password(n=16):
|
|
alphabet = string.ascii_letters + string.digits
|
|
return "".join(secrets.choice(alphabet) for _ in range(n))
|
|
|
|
|
|
def ipmi(args, timeout=30):
|
|
return run(["ipmitool"] + args, timeout=timeout)
|
|
|
|
|
|
def parse_kv(txt):
|
|
d = {}
|
|
for line in txt.splitlines():
|
|
if ":" in line:
|
|
k, _, v = line.partition(":")
|
|
k = k.strip()
|
|
if k and k not in d:
|
|
d[k] = v.strip()
|
|
return d
|
|
|
|
|
|
def bmc_inband(user_name):
|
|
"""Discover the BMC in-band, create/refresh user_name with ADMINISTRATOR on the LAN channel."""
|
|
for mod in ("ipmi_msghandler", "ipmi_devintf", "ipmi_si", "ipmi_ssif"):
|
|
run(["modprobe", mod])
|
|
time.sleep(2)
|
|
if not os.path.exists("/dev/ipmi0"):
|
|
return None
|
|
res = {"source": "inband", "errors": []}
|
|
rc, mc = ipmi(["mc", "info"])
|
|
res["mc_info"] = parse_kv(mc) if rc == 0 else None
|
|
rc, fru = ipmi(["fru", "print", "0"], timeout=60)
|
|
res["fru"] = parse_kv(fru) if rc == 0 else None
|
|
# find LAN channel
|
|
chan, lan = None, None
|
|
for c in range(1, 15):
|
|
rc, o = ipmi(["lan", "print", str(c)])
|
|
if rc == 0 and "IP Address" in o:
|
|
kv = parse_kv(o)
|
|
if kv.get("IP Address") and kv["IP Address"] != "0.0.0.0":
|
|
chan, lan = c, kv
|
|
break
|
|
chan, lan = chan or c, lan or kv
|
|
if chan is None:
|
|
res["errors"].append("no LAN channel found")
|
|
return res
|
|
res["channel"] = chan
|
|
res["lan"] = {k: lan.get(k) for k in ("IP Address Source", "IP Address", "Subnet Mask", "MAC Address",
|
|
"Default Gateway IP", "802.1q VLAN ID", "Cipher Suite Priv Max")}
|
|
res["address"] = lan.get("IP Address")
|
|
res["mac"] = lan.get("MAC Address")
|
|
# user table
|
|
rc, ul = ipmi(["user", "list", str(chan)])
|
|
users = []
|
|
for line in ul.splitlines()[1:]:
|
|
m = re.match(r"^\s*(\d+)\s+(.*?)\s+(true|false)\s+(true|false)\s+(true|false)\s+(\S.*)$", line)
|
|
if m:
|
|
users.append({"id": int(m.group(1)), "name": m.group(2).strip(), "callin": m.group(3) == "true",
|
|
"link": m.group(4) == "true", "ipmi_msg": m.group(5) == "true", "priv": m.group(6).strip()})
|
|
res["users_before"] = users
|
|
uid = next((u["id"] for u in users if u["name"] == user_name), None)
|
|
if uid is None:
|
|
uid = next((u["id"] for u in users if u["id"] >= 3 and not u["name"]), None)
|
|
if uid is None:
|
|
res["errors"].append("no free user slot")
|
|
return res
|
|
pw = gen_password()
|
|
steps = [
|
|
["user", "set", "name", str(uid), user_name],
|
|
["user", "set", "password", str(uid), pw],
|
|
["user", "priv", str(uid), "4", str(chan)],
|
|
["channel", "setaccess", str(chan), str(uid), "callin=on", "ipmi=on", "link=on", "privilege=4"],
|
|
["user", "enable", str(uid)],
|
|
]
|
|
ok = True
|
|
for s in steps:
|
|
rc, o = ipmi(s)
|
|
if rc != 0:
|
|
ok = False
|
|
res["errors"].append(f"ipmitool {' '.join(s[:3])} rc={rc}")
|
|
# some BMCs (e.g. iDRAC) also want SOL payload access enabled
|
|
ipmi(["sol", "payload", "enable", str(chan), str(uid)])
|
|
res.update({"user": user_name, "user_id": uid, "password": pw if ok else None, "configured": ok})
|
|
if res["address"] and res["address"] != "0.0.0.0":
|
|
rc, _ = ipmi(["-I", "lanplus", "-H", res["address"], "-U", user_name, "-P", pw, "mc", "info"], timeout=20)
|
|
res["verified_oob"] = (rc == 0)
|
|
return res
|
|
|
|
|
|
AGENT_TOKEN = ""
|
|
|
|
|
|
def tls_setup(opts, server):
|
|
"""rollcall.tls=URL rollcall.ca=SHA256: the CA certificate is fetched and
|
|
accepted only by that fingerprint; from then on everything goes over HTTPS
|
|
with it. When TLS was asked for and does not work, nothing falls back to HTTP."""
|
|
url, want = opts.get("tls"), str(opts.get("ca") or "").replace(":", "").lower()
|
|
if not isinstance(url, str) or not url.startswith("https://") or len(want) != 64:
|
|
return server, None, None
|
|
pem = None
|
|
for i in range(10):
|
|
try:
|
|
with urllib.request.urlopen(f"{server}/ca.pem", timeout=10) as r:
|
|
pem = r.read().decode()
|
|
break
|
|
except (urllib.error.URLError, OSError) as e:
|
|
log(f"ca.pem: {e}, retry {i + 1}/10")
|
|
time.sleep(3)
|
|
if pem is None:
|
|
raise RuntimeError("cannot fetch the CA certificate")
|
|
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != want:
|
|
raise RuntimeError("the CA certificate does not match rollcall.ca: not talking to this server")
|
|
path = "/run/rollcall-ca.pem"
|
|
with open(path, "w") as f:
|
|
f.write(pem)
|
|
log(f"talking to {url} over HTTPS (CA {want[:16]}...)")
|
|
return url.rstrip("/"), ssl.create_default_context(cafile=path), path
|
|
|
|
|
|
TLS = None # ssl context once tls_setup ran
|
|
|
|
|
|
def agent_headers():
|
|
h = {"Content-Type": "application/json"}
|
|
if AGENT_TOKEN:
|
|
h["X-Rollcall-Token"] = AGENT_TOKEN
|
|
return h
|
|
|
|
|
|
def bmc_hint(server, macs):
|
|
try:
|
|
req = urllib.request.Request(f"{server}/api/v1/bmc-hint?macs={','.join(macs)}", headers=agent_headers())
|
|
with urllib.request.urlopen(req, timeout=10, context=TLS) as r:
|
|
h = json.load(r)
|
|
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
|
return None
|
|
if not h or not h.get("address"):
|
|
return None
|
|
res = {"source": "hint", "address": h["address"], "port": h.get("port", 623),
|
|
"user": h.get("user"), "errors": []}
|
|
if not h.get("password"):
|
|
# the region keeps the credentials and checks the BMC itself (rollcall >= 0.3)
|
|
res["configured"] = True
|
|
return res
|
|
res["password"] = h["password"]
|
|
base = ["-I", "lanplus", "-H", h["address"], "-p", str(h.get("port", 623)), "-U", h.get("user", ""),
|
|
"-P", h.get("password", "")]
|
|
rc, mc = ipmi(base + ["mc", "info"], timeout=20)
|
|
res["mc_info"] = parse_kv(mc) if rc == 0 else None
|
|
res["verified_oob"] = (rc == 0)
|
|
res["configured"] = (rc == 0)
|
|
if rc != 0:
|
|
res["errors"].append("hinted BMC did not answer")
|
|
return res
|
|
|
|
|
|
# --------------------------------------------------------------------------- main
|
|
def post(server, payload, tries=30):
|
|
data = json.dumps(payload).encode()
|
|
req = urllib.request.Request(f"{server}/api/v1/commission", data=data, headers=agent_headers(), method="POST")
|
|
for i in range(tries):
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30, context=TLS) as r:
|
|
return json.load(r)
|
|
except (urllib.error.URLError, OSError) as e:
|
|
log(f"post failed ({e}), retry {i + 1}/{tries}")
|
|
time.sleep(5)
|
|
return None
|
|
|
|
|
|
def main():
|
|
opts = cmdline()
|
|
if opts.get("mode") == "deploy":
|
|
# same image, different job: hand over to the deploy/release agent
|
|
os.execv("/usr/bin/python3", ["python3", "/usr/local/bin/rollcall-deploy.py"])
|
|
server = opts.get("server")
|
|
if not server:
|
|
log("rollcall.server= missing on kernel cmdline; nothing to do")
|
|
return 2
|
|
server = server.rstrip("/")
|
|
global AGENT_TOKEN, TLS
|
|
server, TLS, _ = tls_setup(opts, server)
|
|
AGENT_TOKEN = opts.get("token") if isinstance(opts.get("token"), str) else ""
|
|
user_name = opts.get("bmcuser") or "commissioner"
|
|
started = time.time()
|
|
log(f"starting, server={server}")
|
|
|
|
inv = {"agent_version": "1", "started_at": started, "hostname": socket.gethostname(),
|
|
"kernel": readf("/proc/version"), "errors": []}
|
|
for key, fn in [("system", dmi_system), ("cpu", cpu), ("memory", memory), ("storage", storage),
|
|
("network", network), ("infiniband", infiniband), ("pci", pci_devices), ("gpus", gpus)]:
|
|
try:
|
|
inv[key] = fn()
|
|
log(f"collected {key}")
|
|
except Exception as e: # noqa: BLE001
|
|
inv[key] = None
|
|
inv["errors"].append(f"{key}: {e!r}")
|
|
log(f"FAILED {key}: {e!r}")
|
|
inv["tpm"] = [os.path.basename(p) for p in glob.glob("/sys/class/tpm/tpm*")]
|
|
inv["lldp"] = lldp()
|
|
log("collected lldp")
|
|
|
|
macs = [n["mac"] for n in (inv.get("network") or []) if n.get("mac")]
|
|
try:
|
|
bmc = bmc_inband(user_name)
|
|
if bmc is None:
|
|
log("no /dev/ipmi0, asking server for an out-of-band hint")
|
|
bmc = bmc_hint(server, macs)
|
|
except Exception as e: # noqa: BLE001
|
|
bmc = {"source": "error", "errors": [repr(e)]}
|
|
inv["bmc"] = bmc
|
|
log(f"bmc: {json.dumps({k: v for k, v in (bmc or {}).items() if k in ('source', 'address', 'user', 'configured', 'verified_oob', 'errors')})}")
|
|
|
|
sysinfo = inv.get("system") or {}
|
|
inv["identity"] = {"uuid": sysinfo.get("system-uuid"), "serial": sysinfo.get("system-serial-number"),
|
|
"macs": macs, "boot_mac": None}
|
|
# the interface that got DHCP is the one live-boot used
|
|
for n in inv.get("network") or []:
|
|
if n.get("ipv4"):
|
|
inv["identity"]["boot_mac"] = n["mac"]
|
|
break
|
|
inv["duration_s"] = round(time.time() - started, 1)
|
|
|
|
resp = post(server, inv)
|
|
if resp:
|
|
log(f"server accepted: machine {resp.get('id')} state={resp.get('state')}")
|
|
else:
|
|
log("could not deliver inventory to server")
|
|
keep = json.loads(json.dumps(inv))
|
|
if (keep.get("bmc") or {}).get("password"):
|
|
keep["bmc"]["password"] = "<not kept>"
|
|
with open(os.open("/root/inventory.json", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
|
json.dump(keep, f, indent=1)
|
|
if opts.get("nopoweroff") or not resp:
|
|
log("staying up (nopoweroff or delivery failure)")
|
|
return 0 if resp else 1
|
|
log("powering off")
|
|
time.sleep(3)
|
|
run(["systemctl", "poweroff"])
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|