KERNEL BUILD TOOL 002 (UPDATED) AMDGPU/INTEL GPU/NVIDIA GPU KERNEL
THIS KERNEL BUILD TOOL WAS UPDATED ON THE DATE OF THIS POST, AND THE ONE POSTED ON THE PREVIOUS POST PROBABLY WON’T WORK, I THINK I’LL JUST DELETE THAT POST ALTOGETHER, AND LEAVE THIS ONE UP… THE TOOL WILL WORK ON AMDGPUs FOR SURE, NVIDIA IS UNTESTED
#!/usr/bin/python3 -I
# -*- coding: utf-8 -*-
"""
enterprise_linux_kernel_tool.py
Pick a kernel from kernel.org, configure it for the machine it is run on, build
it, prove that out-of-tree (DKMS) modules still compile against it, and install
it without taking over the boot menu.
./enterprise_linux_kernel_tool.py interactive wizard
./enterprise_linux_kernel_tool.py kernels what kernel.org offers
./enterprise_linux_kernel_tool.py detect what this machine is
./enterprise_linux_kernel_tool.py doctor can this machine build?
./enterprise_linux_kernel_tool.py plan --kernel stable
./enterprise_linux_kernel_tool.py build --kernel 6.12 --modules trimmed
./enterprise_linux_kernel_tool.py install --source ~/src/linux-7.2.6
./enterprise_linux_kernel_tool.py dkms-check --source ~/src/linux-7.2.6
./enterprise_linux_kernel_tool.py dkms-cap cap amdgpu-dkms (install does it too)
./enterprise_linux_kernel_tool.py status --json what is installed (doctor --json too)
Standard library only, and the SYNTAX is held to Python 3.6 so RHEL 8's
platform interpreter can run it: no walrus, no `capture_output=`, no
`text=`, no dataclasses. The shebang pins /usr/bin/python3 -- the interpreter
the package manager itself depends on -- because "python3" on PATH is not
reliably CPython (on the machine this was written on it is a PyPy whose ssl
module segfaults).
THE ONE THING THIS TOOL EXISTS TO GET RIGHT, inherited from the shell script it
replaces: GPU drivers are MODULES. A built-in (=y) driver probes before the
root filesystem is mounted, so its firmware must already be in the initramfs --
but dracut and initramfs-tools pick firmware by looking at installed MODULES,
and a built-in driver is not one. Result: no display, no /dev/kfd. As modules
they also stay replaceable, which is what amdgpu-dkms and NVIDIA's stack need.
The one exception is the boot console (efifb and vesafb, or simpledrm): BUILT IN.
It needs no firmware, and until a GPU driver takes over it is all the screen has.
Licence: public domain / CC0. No warranty. It installs a kernel; read it first.
"""
import sys
if sys.version_info < (3, 6):
sys.exit("enterprise_linux_kernel_tool.py needs Python 3.6 or newer")
import argparse
import base64
import configparser
import errno
import fcntl
import glob
import gzip
import hashlib
import json
import os
import re
import shlex
import shutil
import signal
import struct
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
TOOL = "enterprise_linux_kernel_tool"
VERSION = "0.1.0"
PLATFORM_PYTHON = "/usr/bin/python3"
KORG_RELEASES = "https://www.kernel.org/releases.json"
KORG_CDN = "https://cdn.kernel.org/pub/linux/kernel"
USER_AGENT = TOOL + "/" + VERSION
# --------------------------------------------------------------- local or remote
# --local, --remote[=USER@HOST], --attach, --sessions: satellite_remote.py, written here
# by sync_remote.py. main() hands its argv to where_to_run() before anything else.
# >>> satellite_remote >>> (generated by sync_remote.py from satellite_remote.py -- edit that file, not this block)
import errno
import fcntl
import hashlib
import io
import json
import os
import re
import select
import shlex
import signal
import socket
import struct
import subprocess
import sys
import tarfile
import tempfile
import termios
import time
import tty
_SR_VERSION = "1"
_SR_HOME = "~/.satellite_toolkit" # on the machine that runs the tool
_SR_CONFIG = "~/.config/satellite_toolkit/remote.json" # on the machine that sends it: the last target
_SR_SATELLITE_SSH = ("~/code/py/satellite_ssh",) # where satellite_ssh.py keeps its passwords
_SR_DETACH = b"\x1d" # Ctrl-] -- telnet's escape, nothing else uses it
# The keeper's last word to an attached client: an OSC sequence a terminal ignores, carrying the
# program's exit code, so the attach on the far side can exit with it (and ssh with that).
_SR_EXIT = b"\x1b]777;satellite-remote-exit;"
_SR_SUDO = re.compile(br"\[sudo\] password for ([^:\r\n]+): *$")
_SR_TAIL = 65536 # what a re-attach shows of the past
# A session name as _sr_session_name makes it (and the keeper's -2, -3 for a second one started
# in the same second): what `--attach NAME`, with a space, takes for a name.
_SR_NAME_SHAPE = re.compile(r"^[A-Za-z0-9_]{1,20}-[0-9]{8}-[0-9]{6}(-[0-9]{1,3})?$")
# Exit codes of a remote run that did not see the program end (the module's docstring).
_SR_DETACHED = 75 # EX_TEMPFAIL: Ctrl-], still running there
_SR_DROPPED = 255 # ssh's own code for a lost connection
class RemoteError(Exception):
"""Said in one paragraph and the tool exits 1 -- never a traceback."""
def _sr_say(text):
sys.stderr.write(text + "\n")
sys.stderr.flush()
def _sr_tty():
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except (AttributeError, ValueError):
return False
# ---------------------------------------------------------------- the options
def _sr_take(argv):
"""-> (rest, opts): this block's options taken out of argv, everything else kept in order.
Nothing after a bare `--` is looked at: it belongs to whatever comes after it."""
o = {"where": None, "target": None, "attach": None, "sessions": False,
"keeper": None, "keeper_attach": None, "keeper_list": False, "size": None}
rest, i = [], 0
while i < len(argv):
a = argv[i]
if a == "--":
rest.extend(argv[i:])
break
if a == "--local":
o["where"] = "local"
elif a == "--remote":
o["where"] = "remote"
# `--remote build` must not take `build` for a machine: only USER@HOST is a target.
if i + 1 < len(argv) and "@" in argv[i + 1] and not argv[i + 1].startswith("-"):
o["target"] = argv[i + 1]
i += 1
elif a.startswith("--remote="):
o["where"], o["target"] = "remote", a.split("=", 1)[1] or None
elif a == "--attach":
o["attach"] = ""
# `--attach NAME` as well as --attach=NAME -- but only a word shaped like a session
# name, so `--attach status` still leaves `status` to the tool.
if i + 1 < len(argv) and not argv[i + 1].startswith("-") and _SR_NAME_SHAPE.match(argv[i + 1]):
o["attach"] = argv[i + 1]
i += 1
elif a.startswith("--attach="):
o["attach"] = a.split("=", 1)[1]
elif a == "--sessions":
o["sessions"] = True
elif a.startswith("--satellite-keeper="): # internal: we ARE the keeper, there
o["keeper"] = a.split("=", 1)[1]
elif a.startswith("--satellite-keeper-attach="): # internal: the attach, there
o["keeper_attach"] = a.split("=", 1)[1]
elif a == "--satellite-keeper-list":
o["keeper_list"] = True
elif a.startswith("--satellite-size="):
o["size"] = a.split("=", 1)[1]
else:
rest.append(a)
i += 1
return rest, o
def _sr_wants_question(rest):
if os.environ.get("SATELLITE_TOOLKIT_WHERE") == "local" or not _sr_tty():
return False
# --version is a real option in several tools (an LLVM / CPython / GCC release),
# so it still asks; only help and the tool's own version are quiet
return not any(a in ("-h", "--help", "--tool-version") for a in rest)
def _sr_ask(prompt, default=""):
try:
sys.stdout.write(prompt)
sys.stdout.flush()
answer = sys.stdin.readline()
except (KeyboardInterrupt, EOFError):
sys.stdout.write("\n")
raise SystemExit(130)
if not answer: # the end of the input is the default, not a loop
return default
return answer.strip() or default
def _sr_ask_where():
sys.stdout.write("\nRun where? l = this machine r = another machine, over ssh\n")
while True:
a = _sr_ask("[l]: ", "l").lower()
if a in ("l", "local", "here", "this"):
return "local"
if a in ("r", "remote", "there", "ssh"):
return "remote"
sys.stdout.write(" l or r\n")
# ---------------------------------------------------------------- the last target
def _sr_config():
try:
with open(os.path.expanduser(_SR_CONFIG)) as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _sr_remember(target, tool_name):
path = os.path.expanduser(_SR_CONFIG)
data = _sr_config()
data["last"] = target
data.setdefault("by_tool", {})[tool_name] = target
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".writing"
with open(tmp, "w") as f:
json.dump(data, f, indent=1, sort_keys=True)
os.replace(tmp, path)
except OSError:
pass # remembering is a convenience, never a reason to stop
def _sr_split_target(target):
if not target or "@" not in target:
raise RemoteError("a remote machine is USER@HOST, not %r" % (target or ""))
user, host = target.split("@", 1)
host = host.strip("[]") # an IPv6 address may come bracketed; ssh wants it bare
if not user or not host or any(c.isspace() for c in target):
raise RemoteError("a remote machine is USER@HOST, not %r" % target)
return user, host
def _sr_target(o, tool_name):
if o["target"]:
return o["target"]
cfg = _sr_config()
last = cfg.get("by_tool", {}).get(tool_name) or cfg.get("last") or ""
if not _sr_tty():
if last and (o["attach"] is not None or o["sessions"]):
return last
raise RemoteError("--remote needs USER@HOST when nobody is at a terminal to ask"
+ (" (the last one was %s)" % last if last else ""))
while True:
t = _sr_ask("ssh to (USER@HOST)%s: " % (" [%s]" % last if last else ""), last)
if "@" in t:
return t
sys.stdout.write(" USER@HOST, e.g. claude_code@192.168.1.20\n")
# ---------------------------------------------------------------- the login
def _sr_satellite_ssh():
"""satellite_ssh, from where it keeps its passwords -- or None. Its folder is where the
kept password files live, so it is imported from there, never copied."""
dirs = [os.environ.get("SATELLITE_SSH_DIR")] + [os.path.expanduser(d) for d in _SR_SATELLITE_SSH]
for d in dirs:
if d and os.path.isfile(os.path.join(d, "satellite_ssh.py")):
if d not in sys.path:
sys.path.insert(0, d)
try:
import satellite_ssh
return satellite_ssh
except Exception: # a broken copy must not stop a plain-ssh login
return None
return None
def _sr_ssh_words(text):
"""What ssh's own error means, in a sentence -- the two that come up after a reinstall."""
low = text.lower()
if "remote host identification has changed" in low or "host key verification failed" in low:
return ("the machine answered with a NEW host key -- it was reinstalled, or it is not the "
"machine you think. If it was reinstalled: ssh-keygen -R <host>, then try again")
if "permission denied" in low:
return "the login was refused (the password or the key)"
if "connection refused" in low:
return "nothing listens for ssh there (is sshd running? the firewall?)"
if "timed out" in low or "no route to host" in low or "network is unreachable" in low:
return "the machine cannot be reached (is it on? the address?)"
return ""
class RemoteLink(object):
"""One ssh login kept open on a control socket; every command, upload and attach goes over
it, so a password is typed (by satellite_ssh, or by a person) once."""
def __init__(self, target):
self.target = target
self.user, self.host = _sr_split_target(target)
self.kept = None # satellite_ssh, when it keeps a password for the target
self.session = None # satellite_ssh's Session
self.dir = None # our own control socket's folder (plain ssh)
self.sock = None
self.python = None # the python3 there
# A port, a key, a known_hosts file: SATELLITE_SSH_OPTIONS="-p 2222 -i KEY", passed to
# every ssh -- the one way to reach a machine ~/.ssh/config does not describe.
self.options = shlex.split(os.environ.get("SATELLITE_SSH_OPTIONS", ""))
def open(self):
mod = _sr_satellite_ssh()
if mod is not None and mod.has_password(self.user, self.host):
try:
self.session = mod.connect(self.user, self.host, options=self.options)
except mod.Refused as e:
raise RemoteError("%s: %s%s" % (self.target, e, ("\n " + _sr_ssh_words(str(e)))
if _sr_ssh_words(str(e)) else ""))
self.kept = mod
return self
# Plain ssh: a key or an agent, or the password typed here. ControlPersist keeps the
# master after -f puts it in the background; close() ends it -- and 10 minutes with no
# ssh using it end it too, so a tool killed before close() leaves no master for good.
self.dir = tempfile.mkdtemp(prefix="satellite-remote-", dir="/tmp" if os.path.isdir("/tmp") else None)
self.sock = os.path.join(self.dir, "ssh") # short: a socket path stops at 107 bytes
argv = (["ssh", "-M", "-S", self.sock] + self.options
+ ["-o", "ControlPersist=10m", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=30", "-fN", self.target])
if not _sr_tty():
argv[1:1] = ["-o", "BatchMode=yes"] # nobody to type a password: a key, or nothing
# stderr to a file, not a pipe: the master that -f leaves behind must never be what we
# wait on for an end of file.
with tempfile.TemporaryFile() as errf:
rc = subprocess.call(argv, stdin=None if _sr_tty() else subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=errf)
errf.seek(0)
text = errf.read().decode("utf-8", "replace").strip()
if rc != 0:
hint = _sr_ssh_words(text)
self.close()
raise RemoteError("cannot log in to %s%s\n %s%s" % (
self.target, (": " + hint) if hint else "", text.replace("\n", "\n "),
"" if mod is None else "\n (satellite_ssh keeps no password for it: "
"python3 ~/code/py/satellite_ssh/satellite_ssh.py %s setpassword)" % self.target))
return self
def ssh(self, *flags):
"""argv for one ssh over the open login, flags before the target."""
if self.session is not None:
return self.session.ssh(*flags)
return ["ssh", "-S", self.sock] + self.options + list(flags) + [self.target]
def run(self, command, data=None, timeout=None):
"""One shell command there: (exit code, stdout, stderr) as text."""
p = subprocess.Popen(self.ssh("-o", "BatchMode=yes", "-T") + [command],
stdin=subprocess.PIPE if data is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
out, err = p.communicate(data, timeout=timeout)
except subprocess.TimeoutExpired:
p.kill()
out, err = p.communicate()
return 124, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
return p.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
def password(self):
"""The kept password, when satellite_ssh keeps one -- read when it is needed, because
the 6-hour rotation may have changed it since the login."""
if self.kept is None:
return None
return self.kept.read_kept(self.user, self.host)
def close(self):
if self.session is not None:
try:
self.session.close()
except Exception:
pass
self.session = None
if self.sock:
if os.path.exists(self.sock):
subprocess.call(["ssh", "-S", self.sock] + self.options + ["-O", "exit", self.target],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sock = None
if self.dir:
try:
os.rmdir(self.dir)
except OSError:
pass
self.dir = None
# The python3 on the far side, found once: the platform python first, as the tools want.
_SR_FIND_PYTHON = r'''for p in /usr/bin/python3 /usr/libexec/platform-python /usr/bin/python3.[0-9]* python3; do
if command -v "$p" >/dev/null 2>&1 && "$p" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 6) else 1)' 2>/dev/null; then
command -v "$p"; exit 0
fi
done
exit 1'''
def _sr_python(link):
if link.python:
return link.python
rc, out, err = link.run("sh -c %s" % shlex.quote(_SR_FIND_PYTHON), timeout=60)
if rc == 0 and out.strip():
link.python = out.strip().splitlines()[-1]
return link.python
if rc == 255:
raise RemoteError("ssh to %s failed: %s" % (link.target, err.strip()))
raise RemoteError("%s has no python3 (3.6 or newer), which every tool here runs under. On it:\n"
" sudo dnf install python3 (Enterprise Linux, Fedora)\n"
" sudo apt-get install python3 (Debian, Ubuntu)" % link.target)
# ---------------------------------------------------------------- the upload
def _sr_pack(files):
"""files = [(path here, path there)] -> (sha256 of names + contents, tar.gz bytes). The
digest names the folder there, so a tool that did not change is not sent again."""
h = hashlib.sha256()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for here, there in sorted(files, key=lambda f: f[1]):
with open(here, "rb") as f:
data = f.read()
h.update(there.encode() + b"\0" + data + b"\0")
info = tarfile.TarInfo(there)
info.size = len(data)
info.mode = os.stat(here).st_mode & 0o755
info.mtime = int(os.stat(here).st_mtime)
tar.addfile(info, io.BytesIO(data))
return h.hexdigest(), buf.getvalue()
def _sr_upload(link, files, name):
"""-> the folder there that holds files, sent only when it is not there already."""
digest, blob = _sr_pack(files)
folder = "%s/code/%s-%s" % (_SR_HOME, name, digest[:12])
q = '"$HOME"' + shlex.quote(folder[1:]) # ~/... -> "$HOME"'/...': quoted, and still home
rc, out, err = link.run('test -f %s/.complete && echo have || echo send' % q, timeout=60)
if rc != 0:
raise RemoteError("ssh to %s failed: %s" % (link.target, err.strip() or out.strip()))
if out.strip() != "have":
_sr_say(" sending %s (%d KB) to %s" % (name, max(1, len(blob) // 1024), link.target))
# Two runs sending the same tool at once each unpack into a .part of their own; the
# first rename wins (mv -T: never INTO a folder that is there), and the other one --
# the same files, by the digest -- is dropped. Only a folder left half-made before is
# replaced.
script = ('set -e; umask 077; d=%s; p="$d.part.$$"; rm -rf "$p"; mkdir -p "$p"; '
'tar -xzf - -C "$p"; touch "$p/.complete"; mv -T "$p" "$d" 2>/dev/null && exit 0; '
'if [ -f "$d/.complete" ]; then rm -rf "$p"; exit 0; fi; rm -rf "$d"; mv -T "$p" "$d"') % q
rc, out, err = link.run("sh -c %s" % shlex.quote(script), data=blob, timeout=600)
if rc != 0:
raise RemoteError("could not unpack %s on %s: %s" % (name, link.target, (err or out).strip()))
return folder
# ---------------------------------------------------------------- the keeper (runs THERE)
def _sr_sessions():
d = os.path.join(os.path.expanduser(_SR_HOME), "sessions")
os.makedirs(d, mode=0o700, exist_ok=True)
return d
def _sr_socket_path(sdir):
"""A UNIX socket path stops at 107 bytes; a long home or name gets a short one in /tmp,
named in the session folder."""
path = os.path.join(sdir, "socket")
if len(path.encode()) < 100:
return path
short = os.path.join(tempfile.gettempdir(), "satellite-remote-%d" % os.getuid())
os.makedirs(short, mode=0o700, exist_ok=True)
return os.path.join(short, hashlib.sha256(sdir.encode()).hexdigest()[:16])
def _sr_set_size(fd, rows, cols):
try:
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
except (OSError, ValueError):
pass
def _sr_size(text, default=(40, 120)):
try:
rows, cols = (int(x) for x in text.lower().split("x"))
if 2 <= rows <= 1000 and 10 <= cols <= 1000:
return rows, cols
except (AttributeError, ValueError):
pass
return default
def _sr_exit_code(status):
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
if os.WIFSIGNALED(status):
return 128 + os.WTERMSIG(status)
return 1
def _sr_write_file(path, text):
tmp = path + ".writing"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
def _sr_keeper_main(name, argv, size=None):
"""`<tool> --satellite-keeper=NAME -- COMMAND...`: start COMMAND on a pseudo-terminal in a
session that outlives this ssh login, say it is listening, and return. The keeper itself
is a daemon: its own session (setsid), no terminal, stdio on /dev/null."""
cmd = argv[1:] if argv[:1] == ["--"] else list(argv)
if not cmd or not re.match(r"^[A-Za-z0-9._-]{1,48}$", name):
_sr_say("satellite_remote: a session needs a plain name and a command")
return 2
# Two runs started in the same second ask for the same name: the second is NAME-2, then
# NAME-3 ... mkdir is the claim, so two starters can never share a folder. The name that
# was taken is the one in the "ok NAME PID" answer.
root = _sr_sessions()
for n in range(1, 100):
sdir = os.path.join(root, name if n == 1 else "%s-%d" % (name, n))
try:
os.mkdir(sdir, 0o700)
break
except OSError as e:
if e.errno != errno.EEXIST or n == 99:
_sr_say("satellite_remote: session %s: %s" % (os.path.basename(sdir), e))
return 2
_sr_write_file(os.path.join(sdir, "cmd.json"), json.dumps(
{"argv": cmd, "started": time.strftime("%Y-%m-%d %H:%M:%S"), "cwd": os.getcwd(),
"version": _SR_VERSION}, indent=1))
ready_r, ready_w = os.pipe()
if os.fork() > 0: # the starter: wait for the keeper's word
os.close(ready_w)
word = b""
deadline = time.time() + 20
while time.time() < deadline:
r, _, _ = select.select([ready_r], [], [], 0.5)
if r:
chunk = os.read(ready_r, 256)
if not chunk:
break
word += chunk
if b"\n" in word:
break
os.close(ready_r)
word = word.decode("utf-8", "replace").strip()
if not word.startswith("ok"):
_sr_say("satellite_remote: the session did not start: %s" % (word or "no answer"))
return 1
sys.stdout.write("%s\n" % word)
sys.stdout.flush()
return 0
os.close(ready_r)
os.setsid()
if os.fork() > 0: # the second fork: never a session leader again
os._exit(0)
try:
_sr_keeper_loop(sdir, cmd, ready_w, size)
except BaseException as e: # a keeper that dies says so, in its folder
try:
_sr_write_file(os.path.join(sdir, "keeper-error"), "%s: %s\n" % (type(e).__name__, e))
os.write(ready_w, ("error %s\n" % e).encode())
except OSError:
pass
os._exit(0)
def _sr_keeper_loop(sdir, cmd, ready_w, size):
devnull = os.open(os.devnull, os.O_RDWR)
for fd in (0, 1, 2):
os.dup2(devnull, fd)
os.chdir(os.path.expanduser("~"))
log = os.open(os.path.join(sdir, "output.log"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
path = _sr_socket_path(sdir)
if path != os.path.join(sdir, "socket"):
_sr_write_file(os.path.join(sdir, "socket.path"), path)
try:
os.unlink(path)
except OSError:
pass
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
old = os.umask(0o077)
listener.bind(path)
os.umask(old)
listener.listen(4)
listener.setblocking(False)
import pty
env = dict(os.environ)
env["SATELLITE_TOOLKIT_WHERE"] = "local" # the program there never asks "where?" again
env.setdefault("TERM", "xterm-256color")
pid, master = pty.fork()
if pid == 0:
try:
os.execvpe(cmd[0], cmd, env)
except OSError as e:
sys.stderr.write("satellite_remote: cannot start %s: %s\n" % (cmd[0], e))
os._exit(127)
_sr_set_size(master, *_sr_size(size))
_sr_write_file(os.path.join(sdir, "pids"), "%d %d\n" % (os.getpid(), pid))
os.write(ready_w, ("ok %s %d\n" % (os.path.basename(sdir), pid)).encode())
os.close(ready_w)
clients = {} # socket -> [bytes waiting to go, hello seen, input, since]
ended = None
status = None # the program's wait status, once it has one
drain_until = 0.0
def finish(st):
code = _sr_exit_code(st)
_sr_write_file(os.path.join(sdir, "exit_code"), "%d\n" % code)
_sr_write_file(os.path.join(sdir, "ended"), time.strftime("%Y-%m-%d %H:%M:%S") + "\n")
for cst in clients.values():
if cst[1]:
cst[0] += _SR_EXIT + str(code).encode() + b"\x07"
os.close(master)
return code
while True:
rd = [listener, master] if ended is None else [listener]
wr = []
for c, st in clients.items():
rd.append(c)
if st[0]:
wr.append(c)
# Ended, and every client has had all of it. One that connected but has not said its
# hello yet is waited for (10 s at most): quitting under it would show it neither the
# output nor the exit code -- only a reset connection.
if ended is not None and not wr and all(st[1] or time.time() - st[3] > 10 for st in clients.values()):
break
r, w, _ = select.select(rd, wr, [], 1.0)
if master in r:
try:
data = os.read(master, 65536)
except OSError as e: # EIO: the program and everything it started are gone
if e.errno != errno.EIO:
raise
data = b""
if data:
os.write(log, data)
for st in clients.values():
if st[1]:
st[0] += data
else:
if status is None:
_, status = os.waitpid(pid, 0)
ended = finish(status)
if ended is None and status is None:
done, st_now = os.waitpid(pid, os.WNOHANG)
if done == pid:
# The program is gone but something it started still holds the terminal (a
# daemon it left behind): what is already written is read for a moment more,
# then the session ends with the program's own exit code.
status, drain_until = st_now, time.time() + 2.0
if ended is None and status is not None and time.time() > drain_until and master not in r:
ended = finish(status)
if listener in r:
try:
c, _ = listener.accept()
c.setblocking(False)
clients[c] = [b"", False, b"", time.time()]
except OSError:
pass
for c in list(clients):
st = clients[c]
if c in r:
try:
data = c.recv(65536)
except OSError:
data = b""
if not data:
c.close()
del clients[c]
continue
if not st[1]: # the hello: "ATTACH <tail bytes> <rows> <cols>\n"
st[2] += data
if b"\n" not in st[2]:
if len(st[2]) > 256:
c.close()
del clients[c]
continue
hello, _, extra = st[2].partition(b"\n")
words = hello.decode("ascii", "replace").split()
tail = int(words[1]) if len(words) > 1 and words[1].isdigit() else _SR_TAIL
if len(words) > 3 and words[2].isdigit() and words[3].isdigit() and ended is None:
_sr_set_size(master, int(words[2]), int(words[3]))
size_now = os.fstat(log).st_size
with open(os.path.join(sdir, "output.log"), "rb") as f:
if tail and size_now > tail:
f.seek(size_now - tail)
st[0] = f.read(size_now if not tail else min(tail, size_now))
st[1], st[2] = True, b""
if ended is not None:
st[0] += _SR_EXIT + str(ended).encode() + b"\x07"
data = extra
if data and ended is None:
try:
os.write(master, data)
except OSError:
pass
if c in w and c in clients and st[0]:
try:
sent = c.send(st[0][:262144])
st[0] = st[0][sent:]
except (BlockingIOError, InterruptedError):
pass
except OSError:
c.close()
del clients[c]
continue
# A client that stopped reading is dropped, never waited for: the program's output
# must keep flowing into the log, or the build itself would stop on a full pty.
if c in clients and len(st[0]) > (8 << 20):
c.close()
del clients[c]
if ended is not None and not clients:
break
listener.close()
try:
os.unlink(path)
except OSError:
pass
os.close(log)
def _sr_session_state(sdir):
"""-> dict: name, state (running | ended | stopped), exit code, times, command."""
info = {"name": os.path.basename(sdir), "state": "running", "exit": None}
try:
with open(os.path.join(sdir, "cmd.json")) as f:
c = json.load(f)
info["argv"], info["started"] = c.get("argv", []), c.get("started", "")
except (OSError, ValueError):
info["argv"], info["started"] = [], ""
try:
with open(os.path.join(sdir, "exit_code")) as f:
info["exit"] = int(f.read().strip())
info["state"] = "ended"
with open(os.path.join(sdir, "ended")) as f:
info["ended"] = f.read().strip()
except (OSError, ValueError):
pass
if info["state"] == "running":
try:
with open(os.path.join(sdir, "pids")) as f:
keeper = int(f.read().split()[0])
os.kill(keeper, 0)
except (OSError, ValueError, IndexError):
info["state"] = "stopped" # no keeper and no exit code: a reboot, a kill -9
return info
def _sr_list_here():
root = _sr_sessions()
rows = [_sr_session_state(os.path.join(root, n)) for n in sorted(os.listdir(root))]
sys.stdout.write(json.dumps(rows) + "\n")
return 0
def _sr_attach_here(name, size=None):
"""`<tool> --satellite-keeper-attach=NAME`, THERE, inside `ssh -tt`: this terminal joins the
session. Exits with the program's exit code once it ends."""
sdir = os.path.join(_sr_sessions(), name)
if not os.path.isdir(sdir):
_sr_say("satellite_remote: there is no session %s here" % name)
return 2
path = os.path.join(sdir, "socket")
if os.path.exists(os.path.join(sdir, "socket.path")):
with open(os.path.join(sdir, "socket.path")) as f:
path = f.read().strip()
st = _sr_session_state(sdir)
rows, cols = _sr_size(size, (0, 0))
if not rows:
try:
cols, rows = os.get_terminal_size(0)
except OSError:
rows, cols = 40, 120
saved = None
if os.isatty(0):
saved = termios.tcgetattr(0)
tty.setraw(0)
try:
return _sr_attach_socket(sdir, name, path, st, rows, cols)
finally:
if saved is not None:
termios.tcsetattr(0, termios.TCSADRAIN, saved)
def _sr_attach_socket(sdir, name, path, st, rows, cols):
c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
c.connect(path)
except OSError:
c.close()
tail = b""
try:
with open(os.path.join(sdir, "output.log"), "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - _SR_TAIL))
tail = f.read()
except OSError:
pass
os.write(1, tail)
if st["state"] == "ended":
_sr_say("\r\n[%s ended: exit %d]" % (name, st["exit"]))
return st["exit"]
_sr_say("\r\n[%s stopped without an exit code -- the machine restarted, or it was killed]" % name)
return 1
code, held, inputs = 1, b"", [0]
try:
c.sendall(("ATTACH %d %d %d\n" % (_SR_TAIL, rows, cols)).encode())
while True:
r, _, _ = select.select([c] + inputs, [], [])
if c in r:
data = c.recv(65536)
if not data:
if held:
os.write(1, held)
break
data = held + data
at = data.find(_SR_EXIT)
if at >= 0:
end = data.find(b"\x07", at)
if end < 0:
held = data
continue
os.write(1, data[:at])
try:
code = int(data[at + len(_SR_EXIT):end])
except ValueError:
code = 1
return code
# Hold back a tail that could be the start of the exit mark, split across reads.
keep = 0
for n in range(1, len(_SR_EXIT)):
if data.endswith(_SR_EXIT[:n]):
keep = n
held = data[len(data) - keep:] if keep else b""
os.write(1, data[:len(data) - keep] if keep else data)
if 0 in r:
try:
data = os.read(0, 4096)
except OSError: # ssh went away: the session stays, this attach goes
break
if not data:
# The end of the input is not the end of the session: a terminal that hangs
# up sends SIGHUP, which ends this attach anyway; a plain end of input (a
# pipe, /dev/null) only means nothing more will be typed.
if os.isatty(0):
break
inputs = []
continue
c.sendall(data)
except OSError: # the keeper went away under us (killed, the machine
pass # stopping): the session's own record says the rest
finally:
c.close()
st = _sr_session_state(sdir)
return st["exit"] if st["exit"] is not None else code
# ---------------------------------------------------------------- the drive (runs HERE)
def _sr_window_size():
try:
cols, rows = os.get_terminal_size(1)
return rows, cols
except OSError:
return 40, 120
def _sr_drive(argv, link, detach_hint=""):
"""Run argv (an `ssh -tt ... attach`) on a pseudo-terminal of our own: what it prints goes to
this terminal, what is typed goes to it, Ctrl-] leaves. -> exit code, or None when detached.
A ^C that reaches THIS process (no terminal: a piped run) leaves too, and is raised again as
KeyboardInterrupt once the ssh is gone -- the session there keeps running either way.
A sudo question there is answered with the kept password -- once per question."""
import pty
interactive = _sr_tty()
rows, cols = _sr_window_size()
pid, fd = pty.fork()
if pid == 0:
try:
os.execvp(argv[0], argv)
finally:
os._exit(127)
_sr_set_size(fd, rows, cols)
saved = None
if interactive:
saved = termios.tcgetattr(0)
tty.setraw(0)
def resized(signum, frame):
_sr_set_size(fd, *_sr_window_size())
old_winch = signal.signal(signal.SIGWINCH, resized) if interactive else None
window, last_out, answered, refused, detached = b"", time.time(), 0, False, False
interrupted = False
try:
while True:
try:
r, _, _ = select.select([fd] + ([0] if interactive else []), [], [], 0.25)
except InterruptedError:
continue
if fd in r:
try:
data = os.read(fd, 65536)
except OSError:
data = b""
if not data:
break
os.write(1, data)
window = (window + data)[-2048:]
last_out = time.time()
if b"Sorry, try again" in data or b"incorrect password" in data:
refused = answered > 0
if interactive and 0 in r:
data = os.read(0, 4096)
if _SR_DETACH in data:
detached = True
break
if data:
os.write(fd, data)
window = b""
# sudo asks, nothing has come for a moment (something is waiting), we have the password
m = _SR_SUDO.search(window) if window else None
if m and time.time() - last_out > 0.3 and not refused and answered < 20:
user = m.group(1).decode("utf-8", "replace").strip()
pw = link.password() if user == link.user else None
window = b""
if pw:
os.write(fd, pw.encode() + b"\n")
answered += 1
except KeyboardInterrupt: # not a terminal: ^C leaves, the session stays
interrupted = True
finally:
if saved is not None:
termios.tcsetattr(0, termios.TCSADRAIN, saved)
if old_winch is not None:
signal.signal(signal.SIGWINCH, old_winch)
if detached or interrupted:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
os.waitpid(pid, 0)
try:
os.close(fd)
except OSError:
pass
_sr_say("\r\n%s -- it keeps running there.%s" % ("interrupted here" if interrupted else "detached",
detach_hint))
if interrupted:
raise KeyboardInterrupt()
return None
_, status = os.waitpid(pid, 0)
try:
os.close(fd)
except OSError:
pass
return _sr_exit_code(status)
# ---------------------------------------------------------------- one machine, there
class RemoteMachine(object):
"""What the toolkit's main file and where_to_run() use: a login, the code there, quick
commands, and sessions."""
def __init__(self, target):
self.link = RemoteLink(target)
self.target = target
def open(self):
self.link.open()
_sr_python(self.link)
return self
def close(self):
self.link.close()
def code(self, files, name):
"""Send files ([(here, there)]) unless they are there already -> the folder there."""
return _sr_upload(self.link, files, name)
def command(self, folder, entry, args):
"""The shell words that run entry there, with --local so it never asks again."""
words = [self.link.python, "-I", "%s/%s" % (folder, entry), "--local"] + list(args)
return " ".join(_sr_home_quote(w) for w in words)
def capture(self, folder, entry, args, timeout=600):
"""Run it there, quickly, and take what it prints: (exit code, stdout, stderr)."""
return self.link.run("cd && SATELLITE_TOOLKIT_WHERE=local " + self.command(folder, entry, args),
timeout=timeout)
def sessions(self, folder, entry):
rc, out, err = self.link.run("cd && " + " ".join(_sr_home_quote(w) for w in
[self.link.python, "-I", "%s/%s" % (folder, entry), "--satellite-keeper-list"]),
timeout=60)
if rc != 0:
raise RemoteError("could not list the sessions on %s: %s" % (self.target, (err or out).strip()))
try:
return json.loads(out.strip().splitlines()[-1])
except (ValueError, IndexError):
return []
def start(self, folder, entry, args, name):
"""-> the session's name: name, or name-2, name-3 ... when one of that name is there."""
rows, cols = _sr_window_size()
words = ([self.link.python, "-I", "%s/%s" % (folder, entry), "--satellite-keeper=" + name,
"--satellite-size=%dx%d" % (rows, cols), "--", self.link.python, "-I", "%s/%s" % (folder, entry),
"--local"] + list(args))
rc, out, err = self.link.run("cd && " + " ".join(_sr_home_quote(w) for w in words), timeout=60)
word = (out.strip().splitlines() or [""])[-1].split() # "ok NAME PID"
if rc != 0 or len(word) < 2 or word[0] != "ok":
raise RemoteError("could not start the session on %s: %s" % (self.target, (err or out).strip()))
return word[1]
def attach(self, folder, entry, name, hint=""):
rows, cols = _sr_window_size()
words = [self.link.python, "-I", "%s/%s" % (folder, entry), "--satellite-keeper-attach=" + name,
"--satellite-size=%dx%d" % (rows, cols)]
argv = self.link.ssh("-tt", "-q", "-o", "ServerAliveInterval=30") + ["cd && " + " ".join(_sr_home_quote(w) for w in words)]
return _sr_drive(argv, self.link, hint)
def _sr_home_quote(word):
"""Quote for the remote shell -- except a leading ~/, which stays $HOME."""
if word.startswith("~/"):
return '"$HOME"' + shlex.quote(word[1:])
return shlex.quote(word)
def _sr_short(tool_name):
"""The first word of a tool's session names -- clang, python, linux_kernel ... --attach
alone looks for it."""
short = re.sub(r"[^A-Za-z0-9]+", "_", re.sub(r"_tool$", "", tool_name.replace("enterprise_", "")))[:20]
return short.strip("_") or "tool"
def _sr_session_name(tool_name):
# Second resolution only: a second run started in the same second is made NAME-2 by the
# keeper (_sr_keeper_main), which is where the name is claimed.
return "%s-%s" % (_sr_short(tool_name), time.strftime("%Y%m%d-%H%M%S"))
def _sr_after_attach(machine, folder, entry, name, code, hint):
"""The attach ended with code. That is the program's exit code only when the session ENDED
there: a dropped connection ends ssh too (255, or 128+N when ssh was killed) while the build
goes on. So the machine is asked which it was."""
try:
found = [s for s in machine.sessions(folder, entry) if s.get("name") == name]
except RemoteError:
_sr_say("\n the connection to %s dropped and it cannot be reached again -- session %s may "
"still be running there.%s" % (machine.target, name, hint))
return _SR_DROPPED
if not found: # no such session there: the attach said so
return code
s = found[0]
if s["state"] == "running":
_sr_say("\n the connection dropped -- it keeps running there.%s" % hint)
return _SR_DROPPED
if s["state"] == "ended" and s.get("exit") is not None:
_sr_say(" session %s on %s ended: exit %d" % (name, machine.target, s["exit"]))
return s["exit"]
_sr_say(" session %s on %s stopped without an exit code" % (name, machine.target))
return code or 1
def _sr_remote(tool_file, tool_name, rest, o, upload, entry, before=None):
target = _sr_target(o, tool_name)
_sr_split_target(target)
machine = RemoteMachine(target)
try:
# RemoteMachine.open() in two halves, so the target is remembered once it let us in:
# a typo is never offered again; a machine that let us in but lacks python3 still is.
machine.link.open()
_sr_remember(target, tool_name)
_sr_python(machine.link)
files = upload() if upload else [(os.path.abspath(tool_file), os.path.basename(tool_file))]
entry = entry or os.path.basename(tool_file)
folder = machine.code(files, tool_name)
me = os.path.basename(tool_file)
if o["sessions"]:
rows = machine.sessions(folder, entry)
sys.stdout.write("sessions on %s:\n" % target)
for s in rows or []:
state = s["state"] + (" (exit %s)" % s["exit"] if s.get("exit") is not None else "")
words = s.get("argv", [])[3:]
if words[:1] == ["--local"]:
words = words[1:]
sys.stdout.write(" %-34s %-18s %s %s\n" % (s["name"], state, s.get("started", ""),
" ".join(words)[:80]))
if not rows:
sys.stdout.write(" none\n")
return 0
name = o["attach"]
if name == "": # --attach alone: this tool's newest one there
mine = [s for s in machine.sessions(folder, entry)
if s["name"].startswith(_sr_short(tool_name) + "-")]
running = [s for s in mine if s["state"] == "running"]
pick = (running or mine or [None])[-1]
if pick is None:
raise RemoteError("no session of %s on %s to attach to" % (tool_name, target))
name = pick["name"]
if name is None:
if before is not None:
# The caller's last word before a NEW session starts (never for --attach or
# --sessions): the login, the python there and the upload are done. It may send
# more, and may change the arguments; a RemoteError from it starts nothing.
changed = before(machine, folder, list(rest))
if changed is not None:
rest = list(changed)
name = machine.start(folder, entry, rest, _sr_session_name(tool_name))
_sr_say(" %s runs there as session %s -- Ctrl-] leaves it running" % (entry, name))
hint = "\n back to it: %s --remote=%s --attach=%s" % (me, target, name)
code = machine.attach(folder, entry, name, hint)
if code is None: # Ctrl-]: still running there -- not "done"
return _SR_DETACHED
return _sr_after_attach(machine, folder, entry, name, code, hint)
except RemoteError as e:
_sr_say("ERROR: %s" % e)
return 1
finally:
machine.close()
def where_to_run(argv, tool_file, tool_name, upload=None, entry=None, before=None):
"""The first line of every tool's main(): -> the argv to run HERE, or it runs the tool on
another machine and exits with what it exited with there (or 75 / 255 / 130 / 1: the
module's docstring). argv None is sys.argv[1:]. upload() -> [(path here, path there)]
when more than the tool file goes; entry = which of them runs there; before: the docstring."""
rest, o = _sr_take(list(sys.argv[1:] if argv is None else argv))
if o["keeper"] is not None:
sys.exit(_sr_keeper_main(o["keeper"], rest, o["size"]))
if o["keeper_attach"] is not None:
sys.exit(_sr_attach_here(o["keeper_attach"], o["size"]))
if o["keeper_list"]:
sys.exit(_sr_list_here())
where = o["where"]
if where is None and (o["attach"] is not None or o["sessions"]):
where = "remote"
if where is None and _sr_wants_question(rest):
where = _sr_ask_where()
if where != "remote":
return rest
try:
code = _sr_remote(tool_file, tool_name, rest, o, upload, entry, before)
except RemoteError as e:
_sr_say("ERROR: %s" % e)
code = 1
except KeyboardInterrupt:
code = 130
sys.exit(code)
# <<< satellite_remote <<<
# ------------------------------------------------------------------------ output
class UI(object):
color = sys.stdout.isatty() and "NO_COLOR" not in os.environ
tty = sys.stdout.isatty()
stream = None # None: stdout. With --json, stderr: stdout is the JSON alone.
def _c(code, text):
return "\033[%sm%s\033[0m" % (code, text) if UI.color else text
def out(text=""):
s = UI.stream or sys.stdout
try:
s.write(text + "\n")
except UnicodeEncodeError: # a C locale meeting U+FFFD from errors="replace"
s.write(text.encode("ascii", "replace").decode("ascii") + "\n")
s.flush()
def emit_json(data, default=None, indent=1):
"""The one thing --json puts on stdout."""
sys.stdout.write(json.dumps(data, indent=indent, sort_keys=True, default=default) + "\n")
sys.stdout.flush()
def json_mode():
"""--json (the toolkit contract): stdout is the JSON and nothing else; every message,
an ERROR included, goes to stderr."""
UI.stream = sys.stderr
UI.tty = False
UI.color = sys.stderr.isatty() and "NO_COLOR" not in os.environ
def say(msg):
out("\n" + _c("1", "==> " + msg))
def info(msg):
out(" " + msg)
def ok(msg):
out(" " + _c("32", "ok ") + " " + msg)
def note(msg):
out(" -- " + msg)
def bad(msg):
out(" " + _c("31", "FAIL") + " " + msg)
def warn(msg):
# stdout, not stderr: with both redirected to one log file the two streams
# interleave out of order, and a warning that lands three sections late is
# worse than no warning. (With --json everything is on stderr, together.)
out(_c("33", "warning:") + " " + msg)
class ToolError(Exception):
"""A failure with a message fit to show the user. No traceback."""
# ----------------------------------------------------------------------- process
def which(name, path=None):
return shutil.which(name, path=path)
def run(cmd, cwd=None, env=None, check=True, capture=True, stdin_data=None,
interactive=False, timeout=None):
"""subprocess.run with 3.6-safe arguments. stdin is /dev/null unless asked
for, so nothing downstream can sit waiting on a prompt nobody will see."""
kw = dict(cwd=cwd, env=env, universal_newlines=True, errors="replace",
timeout=timeout)
if capture and not interactive:
kw["stdout"] = subprocess.PIPE
kw["stderr"] = subprocess.STDOUT
if stdin_data is not None:
kw["input"] = stdin_data
elif not interactive:
kw["stdin"] = subprocess.DEVNULL
try:
p = subprocess.run(cmd, **kw)
except FileNotFoundError:
raise ToolError("command not found: %s" % cmd[0])
except PermissionError as e:
# ^C while a root-owned `sudo` child runs: subprocess.run's clean-up
# kill() is refused (EPERM) and the PermissionError REPLACES the
# KeyboardInterrupt -- a traceback instead of the ^C path. sudo relays
# the terminal's SIGINT to its child itself. (EACCES is the other
# PermissionError: a file that is not executable.)
if e.errno == errno.EPERM:
raise KeyboardInterrupt()
raise ToolError("cannot run %s: %s" % (cmd[0], e))
except OSError as e:
# ENOEXEC: a file named like a compiler that the kernel cannot run (no
# shebang, another architecture's binary) in ~/opt or ~/.local/bin. It
# used to escape from detect_machine() as a traceback and stop EVERY
# command, `doctor` included.
raise ToolError("cannot run %s: %s" % (cmd[0], e))
except subprocess.TimeoutExpired:
raise ToolError("timed out: %s" % " ".join(cmd))
if check and p.returncode != 0:
tail = (p.stdout or "").strip()[-2000:]
raise ToolError("command failed (exit %d): %s\n%s"
% (p.returncode, " ".join(cmd), tail))
return p
# Environment for every make invocation. An ALLOW-list: the traps that cost
# whole builds on real machines were all inherited variables nobody remembered
# exporting -- CC/CXX pointing at a private clang, LD_RUN_PATH baked into every
# host tool's RPATH, LOCALVERSION appended a second time by setlocalversion,
# PYTHON* steering kbuild's scripts into another interpreter.
# XDG_*: where THIS tool keeps its keys and state. The sign hook in a tree runs the
# tool again from inside make; without these it would look in another place.
ENV_KEEP = ("HOME", "USER", "LOGNAME", "TERM", "TMPDIR", "SHELL", "CCACHE_DIR",
"XDG_CONFIG_HOME", "XDG_STATE_HOME")
SYSTEM_PATH = ["/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin"]
def scrubbed_env(extra_path=None):
env = dict((k, os.environ[k]) for k in ENV_KEEP if k in os.environ)
env["ELKT_NO_SIGN"] = "1" # the tree's wrapper signs for OTHER callers; we do it ourselves
env["ELKT_NO_PIN"] = "1" # ...and pins for them: our makes NAME their toolchain, ccache and all
path = ([extra_path] if extra_path else []) + SYSTEM_PATH
env["PATH"] = ":".join(path)
env["LC_ALL"] = "C"
return env
def dkms_env(pkg_manager):
"""What an out-of-tree build sees when DKMS runs it: root, via sudo or a
package hook, with sudo's secure_path and none of the user's exports."""
path = ["/usr/sbin", "/usr/bin", "/sbin", "/bin"]
if pkg_manager == "apt-get":
path = ["/usr/local/sbin", "/usr/local/bin"] + path
return {"PATH": ":".join(path), "LC_ALL": "C", "HOME": "/root", "TERM": "dumb"}
def env_traps():
"""Inherited settings that would have leaked into a build. Reported by
`doctor` so the user knows they exist; neutralised by scrubbed_env()."""
found = []
for var in ("LLVM", "LLVM_IAS", "CC", "CXX", "HOSTCC", "HOSTCXX", "LD", "CFLAGS", "LDFLAGS", "KCFLAGS",
"LD_RUN_PATH", "LD_LIBRARY_PATH", "LIBRARY_PATH", "CPATH",
"C_INCLUDE_PATH", "LOCALVERSION", "ARCH", "CROSS_COMPILE",
"PYTHONPATH", "PYTHONHOME", "KCONFIG_CONFIG", "KBUILD_OUTPUT"):
if var in os.environ:
val = os.environ[var]
found.append("%s=%s" % (var, val if len(val) < 60 else val[:57] + "..."))
py = which("python3")
if py and os.path.realpath(py) != os.path.realpath(PLATFORM_PYTHON):
found.append("python3 on PATH is %s, not %s" % (py, PLATFORM_PYTHON))
gcc = which("gcc")
if gcc and os.path.realpath(gcc) != os.path.realpath("/usr/bin/gcc") \
and os.path.exists("/usr/bin/gcc"):
found.append("gcc on PATH is %s, not /usr/bin/gcc" % gcc)
return found
def read_text(path, default=""):
try:
with open(path, "r", errors="replace") as f:
return f.read()
except (OSError, IOError):
return default
def rp(root, path):
return os.path.join(root, path.lstrip("/"))
def human(nbytes):
for unit in ("B", "KB", "MB", "GB", "TB"):
if nbytes < 1024 or unit == "TB":
return "%.1f %s" % (nbytes, unit) if unit != "B" else "%d B" % nbytes
nbytes /= 1024.0
return "%d" % nbytes
def free_bytes(path):
p = path
while p and not os.path.exists(p):
p = os.path.dirname(p)
try:
return shutil.disk_usage(p or "/").free
except OSError:
return 0
# ---------------------------------------------------------------------- versions
def version_key(v):
"""Sortable key. 7.3-rc3 < 7.3 < 7.3.1; anything unparseable sorts first."""
m = re.match(r"^v?(\d+)\.(\d+)(?:\.(\d+))?(?:-rc(\d+))?", v or "")
if not m:
return (0, 0, 0, 0, 0)
a, b, c, rc = m.groups()
return (int(a), int(b), int(c or 0), 0 if rc else 1, int(rc or 0))
def series_of(v):
k = version_key(v)
return "%d.%d" % (k[0], k[1])
# -------------------------------------------------------------------- kernel.org
def http_get(url, timeout=30):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
except (urllib.error.URLError, OSError) as e:
raise ToolError("could not fetch %s: %s" % (url, e))
def parse_releases(raw):
data = json.loads(raw)
rels = []
for r in data.get("releases", []):
rels.append({
"moniker": r.get("moniker", ""),
"version": r.get("version", ""),
"eol": bool(r.get("iseol")),
"date": (r.get("released") or {}).get("isodate", ""),
"source": r.get("source") or "",
})
latest = (data.get("latest_stable") or {}).get("version", "")
return latest, rels
def get_releases():
return parse_releases(http_get(KORG_RELEASES).decode("utf-8", "replace"))
SUM_LINE = re.compile(r"^([0-9a-f]{64})\s+linux-(\d+\.\d+(?:\.\d+)?)\.tar\.xz\s*$")
def signed_body(text):
"""Only what the PGP signature covers. gpg verifies the clearsigned block
and ignores whatever surrounds it, so a checksum line appended AFTER the
signature would sail through `gpg --verify` and still be parsed -- unless
the parser stops where the signature does. The same goes for text put
BEFORE it: gpg starts at the first line that IS the armor header, so must
this (`^`, not anywhere in a line), and a file that has a signed block but
does not begin with it is refused outright rather than half-trusted."""
if "-----BEGIN PGP SIGNED MESSAGE-----" not in text:
return text
if not text.lstrip("\r\n").startswith("-----BEGIN PGP SIGNED MESSAGE-----"):
raise ToolError("the checksum file has text BEFORE its PGP-signed block -- refusing it: "
"a signature check would pass while unsigned lines were read")
m = re.search(r"^-----BEGIN PGP SIGNED MESSAGE-----\r?\n(?:[^\r\n]+\r?\n)*\r?\n(.*?)"
r"\r?\n-----BEGIN PGP SIGNATURE-----", text, re.S | re.M)
return m.group(1) if m else ""
def parse_sha256sums(text):
sums = {}
for line in signed_body(text).splitlines():
m = SUM_LINE.match(line)
if m and m.group(2) not in sums: # first wins; a second entry is suspect
sums[m.group(2)] = m.group(1)
return sums
def get_series_sums(major):
"""One fetch yields every point release in vN.x AND its checksum."""
url = "%s/v%d.x/sha256sums.asc" % (KORG_CDN, major)
text = http_get(url, timeout=60).decode("utf-8", "replace")
return parse_sha256sums(text), text
def resolve_kernel(selector, latest, rels, sums_for):
"""selector: stable | longterm | mainline | X.Y | X.Y.Z | X.Y-rcN
sums_for(major) -> (dict version->sha256, signed text). Injected so the
resolution rules can be tested without a network."""
sel = (selector or "stable").strip().lower()
by_ver = dict((r["version"], r) for r in rels)
ver = None
if sel in ("stable", "latest", "latest-stable"):
ver = latest or next((r["version"] for r in rels if r["moniker"] == "stable"), None)
elif sel in ("longterm", "lts", "latest-longterm"):
ver = next((r["version"] for r in rels
if r["moniker"] == "longterm" and not r["eol"]), None)
elif sel == "mainline":
ver = next((r["version"] for r in rels if r["moniker"] == "mainline"), None)
elif re.match(r"^\d+\.\d+$", sel):
sums, _ = sums_for(int(sel.split(".")[0]))
cands = [v for v in sums if series_of(v) == sel]
if not cands:
raise ToolError("kernel.org has no %s.y releases" % sel)
ver = max(cands, key=version_key)
elif re.match(r"^\d+\.\d+(\.\d+)?(-rc\d+)?$", sel):
ver = sel
else:
raise ToolError("cannot read kernel selector '%s' -- use stable, longterm, "
"mainline, a series (6.12) or a version (6.12.9)" % selector)
if not ver:
raise ToolError("kernel.org's release feed has no '%s' entry right now" % sel)
major = version_key(ver)[0]
if major < 3:
raise ToolError("kernels before 3.0 are not supported")
target = {"version": ver, "sha256": None, "signed_text": None,
"eol": by_ver.get(ver, {}).get("eol", False),
"moniker": by_ver.get(ver, {}).get("moniker", "")}
if "-rc" in ver:
src = by_ver.get(ver, {}).get("source", "")
if not src:
raise ToolError("%s is not the current mainline -rc; kernel.org only "
"publishes a tarball for the newest one" % ver)
target["url"] = src
target["tarball"] = os.path.basename(src)
else:
sums, text = sums_for(major)
if ver not in sums:
raise ToolError("kernel.org has no linux-%s.tar.xz (series %s has %d releases)"
% (ver, series_of(ver),
len([v for v in sums if series_of(v) == series_of(ver)])))
target["sha256"] = sums[ver]
target["signed_text"] = text
target["tarball"] = "linux-%s.tar.xz" % ver
target["url"] = "%s/v%d.x/%s" % (KORG_CDN, major, target["tarball"])
return target
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def pgp_check(signed_text):
"""(state, detail). state: good | nokey | bad | unavailable."""
gpg = which("gpg") or which("gpg2")
if not gpg:
return "unavailable", "gpg is not installed"
fd, tmp = tempfile.mkstemp(prefix="elkt-sums-", suffix=".asc")
try:
with os.fdopen(fd, "w") as f:
f.write(signed_text)
p = run([gpg, "--batch", "--status-fd", "1", "--verify", tmp], check=False)
finally:
os.unlink(tmp)
o = p.stdout or ""
if "[GNUPG:] BADSIG" in o:
return "bad", "BAD signature on sha256sums.asc"
m = re.search(r"\[GNUPG:\] GOODSIG \S+ (.*)", o)
if m and "[GNUPG:] VALIDSIG" in o:
return "good", m.group(1).strip()
m = re.search(r"\[GNUPG:\] NO_PUBKEY (\S+)", o)
if m:
return "nokey", m.group(1)
return "unavailable", "gpg could not verify the file"
def download(url, dest, sha256=None):
"""To dest.part, hashed as it streams, renamed only once it checks out --
an interrupted download can never be mistaken for a finished one."""
part = dest + ".part"
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
h = hashlib.sha256()
try:
with urllib.request.urlopen(req, timeout=60) as r, open(part, "wb") as f:
total = int(r.headers.get("Content-Length") or 0)
done, last = 0, -1
while True:
chunk = r.read(1 << 20)
if not chunk:
break
f.write(chunk)
h.update(chunk)
done += len(chunk)
pct = int(done * 100 / total) if total else 0
if UI.tty:
sys.stdout.write("\r downloading %3d%% %s / %s "
% (pct, human(done), human(total)))
sys.stdout.flush()
elif pct // 25 != last:
last = pct // 25
info("downloading %3d%%" % pct)
if UI.tty:
sys.stdout.write("\n")
if total and done != total:
# read() returns short, silently, when the peer closes early.
raise OSError("connection closed after %d of %d bytes" % (done, total))
except (urllib.error.URLError, OSError) as e:
if os.path.exists(part):
os.unlink(part)
raise ToolError("download failed: %s: %s" % (url, e))
if sha256 and h.hexdigest() != sha256:
os.unlink(part)
raise ToolError("sha256 MISMATCH for %s\n expected %s\n got %s\n"
"The file has been deleted. Do not retry blindly if this repeats."
% (os.path.basename(dest), sha256, h.hexdigest()))
os.replace(part, dest)
def extract(tarball, tree):
"""Into a scratch directory, then one rename. A half-extracted tree has a
Makefile long before it has drivers/, so its mere existence proves nothing;
the rename is what makes `tree exists` mean `tree is complete`."""
work = os.path.dirname(tree)
tmp = tempfile.mkdtemp(prefix=".extracting-", dir=work)
try:
run(["tar", "-xf", tarball, "-C", tmp])
tops = os.listdir(tmp)
if len(tops) != 1 or not os.path.isfile(os.path.join(tmp, tops[0], "Makefile")):
raise ToolError("%s does not look like a kernel source archive" % tarball)
os.rename(os.path.join(tmp, tops[0]), tree)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def pgp_problem(state, detail):
"""What --require-pgp says to a signature that is not good -- the same words
whether the tree is about to be downloaded or is already here."""
if state == "bad":
return detail + " -- refusing to use these checksums"
return ("sha256sums.asc signature NOT verified (%s).\n Import the kernel.org keys, e.g.\n"
" gpg --locate-keys autosigner@kernel.org torvalds@kernel.org gregkh@kernel.org"
% ("key %s is not in your keyring" % detail if state == "nokey" else detail))
def fetch_source(target, workdir, require_pgp=False):
tree = os.path.join(workdir, "linux-" + target["version"])
if os.path.isfile(os.path.join(tree, "Makefile")):
if require_pgp:
# A tree proves nothing by existing. What can still be checked: the
# signature, and the tarball beside it against the signed checksum.
if not target.get("signed_text"):
raise ToolError("--require-pgp cannot be satisfied for -rc snapshots")
state, detail = pgp_check(target["signed_text"])
if state != "good":
raise ToolError(pgp_problem(state, detail))
tarball = os.path.join(workdir, target["tarball"])
if not os.path.isfile(tarball) or sha256_file(tarball) != target["sha256"]:
raise ToolError("--require-pgp, and %s already exists: it can be vouched for only "
"through the tarball it came from, and %s. Move the tree away to "
"have it downloaded and verified again." % (
tree, "%s is not beside it" % target["tarball"]
if not os.path.isfile(tarball) else
"%s does not match the signed checksum" % target["tarball"]))
ok("PGP : sha256sums.asc signed by %s; %s matches it" % (detail, target["tarball"]))
info("source : %s (already extracted)" % tree)
return tree
if os.path.exists(tree):
raise ToolError("%s exists but is not a kernel tree (no Makefile). This tool "
"never leaves one like that; move it away or use another --workdir."
% tree)
os.makedirs(workdir, exist_ok=True)
for stale in glob.glob(os.path.join(workdir, ".extracting-*")):
# Old ones only: a young one is another instance, mid-extract.
if time.time() - os.path.getmtime(stale) > 6 * 3600:
shutil.rmtree(stale, ignore_errors=True)
if target.get("signed_text"):
state, detail = pgp_check(target["signed_text"])
if state == "good":
ok("PGP : sha256sums.asc signed by %s" % detail)
elif state == "bad" or require_pgp:
raise ToolError(pgp_problem(state, detail))
else:
msg = ("sha256sums.asc signature NOT verified (%s)." % (
"key %s is not in your keyring" % detail if state == "nokey" else detail))
note("PGP : " + msg)
note(" The checksum still proves the download is intact; it")
note(" does not prove who published it. --require-pgp enforces.")
else:
warn("%s is a git snapshot: kernel.org publishes no checksum for it."
% target["tarball"])
if require_pgp:
raise ToolError("--require-pgp cannot be satisfied for -rc snapshots")
tarball = os.path.join(workdir, target["tarball"])
have = os.path.isfile(tarball)
if have and target["sha256"] and sha256_file(tarball) != target["sha256"]:
warn("cached %s fails its checksum -- downloading again" % target["tarball"])
have = False
if not have:
say("Downloading %s" % target["tarball"])
download(target["url"], tarball, target["sha256"])
if target["sha256"]:
ok("sha256 : %s %s" % (target["sha256"][:16] + "...", target["tarball"]))
info("extracting : %s" % tree)
try:
extract(tarball, tree)
except ToolError:
if not target["sha256"] and os.path.exists(tarball):
os.unlink(tarball) # nothing vouches for it; never reuse it
raise
return tree
# --------------------------------------------------------------------- detection
GPU_VENDORS = {0x1002: "amd", 0x10de: "nvidia", 0x8086: "intel"}
NPU_VENDORS = {0x1022: "amd", 0x8086: "intel"}
OTHER_VIDEO = {0x1af4: "virtio", 0x15ad: "vmware", 0x1b36: "qxl", 0x1234: "bochs",
0x1a03: "aspeed-bmc", 0x102b: "matrox-bmc", 0x1414: "hyperv",
0x1013: "cirrus", 0x80ee: "virtualbox"}
# A display device's driver on a new kernel when none is bound to it now (a bound one
# names its own). AMD: amdgpu alone -- radeon drives nothing newer than 2013, and
# amdgpu-dkms ships a `blacklist radeon` of its own.
DISPLAY_MODULES = {0x1002: ("amdgpu",), 0x8086: ("i915", "xe"), 0x10de: ("nouveau",),
0x1a03: ("ast",), 0x102b: ("mgag200",), 0x1af4: ("virtio_gpu",),
0x1234: ("bochs",), 0x1b36: ("qxl",), 0x15ad: ("vmwgfx",), 0x1414: ("hyperv_drm",)}
LENT = ("vfio_pci", "pci_stub") # bound to one of these, a device is handed to a VM
def pci_name(vendor, device, root="/"):
"""Best-effort lookup in pci.ids. Purely cosmetic -- decisions use the ids."""
want_v, want_d = "%04x" % vendor, "%04x" % device
for cand in ("usr/share/hwdata/pci.ids", "usr/share/misc/pci.ids",
"usr/share/pci.ids"):
path = rp(root, cand)
if not os.path.isfile(path):
continue
vname, inside = "", False
with open(path, "r", errors="replace") as f:
for line in f:
if line.startswith("#") or not line.strip():
continue
if not line.startswith("\t"):
if inside:
return vname
inside = line.startswith(want_v)
if inside:
vname = line[4:].strip()
elif inside and not line.startswith("\t\t") and line[1:5] == want_d:
return "%s %s" % (vname, line[5:].strip())
if vname:
return vname
return ""
def scan_pci(root="/"):
"""Display controllers (class 03xx) and processing accelerators (1200).
Class and vendor ids from sysfs: no lspci needed, and no matching on the
marketing name, where 'AMD' or 'ATI' can appear in somebody else's string."""
base = rp(root, "sys/bus/pci/devices")
devs = []
try:
names = sorted(os.listdir(base))
except OSError:
return devs
for name in names:
d = os.path.join(base, name)
try:
cls = int(read_text(d + "/class").strip() or "0", 16)
ven = int(read_text(d + "/vendor").strip() or "0", 16)
dev = int(read_text(d + "/device").strip() or "0", 16)
except ValueError:
continue
if cls >> 16 == 0x03:
kind = "display"
elif cls >> 8 == 0x1200:
kind = "accelerator"
else:
continue
drv = mod = ""
if os.path.islink(d + "/driver"):
drv = os.path.basename(os.readlink(d + "/driver"))
# The module, from the driver's own directory (where <dev>/driver points): a
# driver's name is not always its module's -- bochs-drm is bochs.ko.
link = rp(root, "sys/bus/pci/drivers/%s/module" % drv)
mod = os.path.basename(os.readlink(link)) if os.path.islink(link) else \
DRIVER_MODULE.get(drv) or drv.replace("-", "_")
# virtio-pci is the transport under virtio_gpu and every other virtio device: it
# names no display module, and DISPLAY_MODULES does.
if mod == "virtio_pci":
mod = ""
devs.append({"addr": name, "class": cls, "vendor": ven, "device": dev,
"kind": kind, "driver": drv, "module": mod,
"boot_vga": read_text(d + "/boot_vga").strip() == "1", # what the firmware lit
"name": pci_name(ven, dev, root)})
return devs
def gpu_vendors_present(devs):
found = set()
for d in devs:
table = GPU_VENDORS if d["kind"] == "display" else NPU_VENDORS
if d["vendor"] in table:
found.add(table[d["vendor"]])
return found
def parse_os_release(text):
rel = {}
for line in text.splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
rel[k.strip()] = v.strip().strip('"').strip("'")
return rel
PKG_MANAGERS = ("dnf", "yum", "apt-get", "zypper", "pacman")
BUILD_DEPS = {
"dnf": "gcc make flex bison bc openssl openssl-devel elfutils-libelf-devel dwarves "
"ncurses-devel rsync cpio perl python3 xz zstd tar kmod",
"apt-get": "build-essential flex bison bc openssl libssl-dev libelf-dev dwarves "
"libncurses-dev rsync cpio perl python3 xz-utils zstd tar kmod",
"zypper": "gcc make flex bison bc openssl libopenssl-devel libelf-devel dwarves "
"ncurses-devel rsync cpio perl python3 xz zstd tar kmod",
"pacman": "base-devel bc openssl libelf pahole ncurses rsync cpio perl python xz "
"zstd tar kmod",
}
BUILD_DEPS["yum"] = BUILD_DEPS["dnf"]
INSTALL_CMD = {
"dnf": ["dnf", "install", "-y"], "yum": ["yum", "install", "-y"],
"apt-get": ["apt-get", "install", "-y"],
"zypper": ["zypper", "--non-interactive", "install"],
"pacman": ["pacman", "-S", "--needed", "--noconfirm"],
}
def find_pkg_manager():
for pm in PKG_MANAGERS:
if which(pm, path=":".join(SYSTEM_PATH)):
return pm
return ""
OWNER_QUERY = {"apt-get": ["dpkg-query", "-S"], "dnf": ["rpm", "-qf", "--qf", "%{NAME}\\n"],
"pacman": ["pacman", "-Qoq"]}
OWNER_QUERY["yum"] = OWNER_QUERY["zypper"] = OWNER_QUERY["dnf"]
DPKG_OWNER = re.compile(r"^([\w.+:-]+(?:, [\w.+:-]+)*): (/.*)$") # 'pkg[, pkg]: /path'
def file_owner(path, pm):
"""The package that owns PATH: '' when none does, None when that cannot be told (no
package tool, or it failed). Asked of the REAL path first: dpkg knows
/usr/lib/modprobe.d/x.conf, not the same file as /lib/modprobe.d/x.conf. Then of the
name it was given and of its merged-/usr alias, which a package built for the old
layout still uses -- amdgpu-dkms-firmware owns /lib/firmware/updates/amdgpu/*, and
dpkg knows no /usr/lib/firmware/updates/amdgpu. Only an answer about the path asked
counts: stderr comes merged in, with dpkg's warnings, and a diverted file has
'diversion by ...' lines of its own."""
q = OWNER_QUERY.get(pm)
if not q or not which(q[0], path=":".join(SYSTEM_PATH)):
return None
real = os.path.realpath(path)
names = [real]
for x in (path, "/" + real[len("/usr/"):] if real.startswith("/usr/") else ""):
if x and x not in names and os.path.realpath(x) == real:
names.append(x)
answer = None
for asked in names:
try:
p = run(q + [asked], check=False, env=scrubbed_env(), timeout=60)
except ToolError:
return None
if p.returncode == 1: # dpkg-query, rpm and pacman alike: owned by nothing
answer = ""
continue
if p.returncode:
return None
for line in (p.stdout or "").splitlines():
if q[0] == "dpkg-query":
m = DPKG_OWNER.match(line.strip())
if m and m.group(2) == asked:
return m.group(1)
elif re.match(r"^[\w.+-]+$", line.strip()): # rpm, pacman: the name alone
return line.strip()
return None
return answer
X86_LEVELS = (
("x86-64-v2", ("cx16", "lahf_lm", "popcnt", "sse4_1", "sse4_2", "ssse3")),
("x86-64-v3", ("avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "abm", "movbe", "xsave")),
("x86-64-v4", ("avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl")),
)
def x86_level(flags):
"""The highest x86-64 micro-architecture level these /proc/cpuinfo flags
meet -- what a kernel built with -march=x86-64-vN may assume of the CPU."""
level = "x86-64"
for name, need in X86_LEVELS:
if not all(f in flags for f in need):
break
level = name
return level
def cpulist_len(text):
"""'0-15' -> 16, '16-23,30' -> 9."""
n = 0
for part in text.strip().split(","):
a, _, b = part.partition("-")
if a.strip().isdigit():
n += int(b) - int(a) + 1 if b.strip().isdigit() else 1
return n
def cpu_info(root="/"):
text = read_text(rp(root, "proc/cpuinfo"))
first = text.split("\n\n", 1)[0]
fields = {}
for line in first.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
flags = set((fields.get("flags") or fields.get("Features") or "").split())
try:
n = len(os.sched_getaffinity(0))
except (AttributeError, OSError):
n = os.cpu_count() or 1
# Intel's hybrid parts (Alder Lake on) register one PMU per core type.
core = read_text(rp(root, "sys/devices/cpu_core/cpus"))
atom = read_text(rp(root, "sys/devices/cpu_atom/cpus"))
return {"vendor": fields.get("vendor_id", ""), "model": fields.get("model name", ""),
"flags": flags, "threads": n, "hypervisor": "hypervisor" in flags,
"level": x86_level(flags) if "lm" in flags else "",
"hybrid": [cpulist_len(core), cpulist_len(atom)] if core.strip() and atom.strip() else None}
def mem_total(root="/"):
m = re.search(r"^MemTotal:\s+(\d+) kB", read_text(rp(root, "proc/meminfo")), re.M)
return int(m.group(1)) * 1024 if m else 0
def secure_boot(root="/"):
"""'on' | 'off' | 'n/a' (not an EFI boot). Straight from the EFI variable:
four attribute bytes, then the value. No mokutil needed."""
if not os.path.isdir(rp(root, "sys/firmware/efi")):
return "n/a"
pat = rp(root, "sys/firmware/efi/efivars/SecureBoot-*")
for path in glob.glob(pat):
try:
with open(path, "rb") as f:
raw = f.read()
return "on" if len(raw) >= 5 and raw[4] == 1 else "off"
except OSError:
pass
return "off"
X86_ARCHES = ("x86_64", "i686", "i386")
# The architectures whose Kconfig has EFI at all. Not the --secure-boot option: that is
# on by default everywhere, and an EFI framebuffer asked of a POWER build never comes out.
EFI_ARCHES = X86_ARCHES + ("aarch64", "riscv64", "loongarch64", "armv7l")
def efi_booted(machine):
"""Did this machine boot through EFI? secure_boot() says 'n/a' when it did not; a
dict without the key (a test's machine) counts as EFI, as nearly every machine is."""
return machine.get("secure_boot", "off") != "n/a"
# The boot path, from the EFI variables -- read as you: efivarfs makes these 0644. Which entry
# booted (BootCurrent), the order the firmware tries them in (BootOrder), each Boot####, the
# Secure Boot state, and what shim leaves behind when it ran: its MOK variables. Whether MOK
# keys count for modules is decided by those variables, never by a file's name.
EFI_GLOBAL = "8be4df61-93ca-11d2-aa0d-00e098032b8c"
SHIM_LOCK = "605dab50-e046-4300-abb6-3dd810dd8b23" # shim's own variables: MokListRT ...
SHIM_FILE = re.compile(r"^shim\w*\.efi$", re.I) # shimx64.efi, shimaa64.efi
def efi_var(name, root="/", guid=EFI_GLOBAL):
"""EFI variable NAME's value -- without the four attribute bytes efivarfs shows first -- or
None: not set, or not readable as you."""
try:
with open(rp(root, "sys/firmware/efi/efivars/%s-%s" % (name, guid)), "rb") as f:
raw = f.read()
except (OSError, IOError):
return None
return raw[4:] if len(raw) >= 4 else None
def _efi_option(raw):
"""(attributes, description, nodes) of an EFI_LOAD_OPTION, a Boot#### variable's value:
attributes (u32), the length of its device paths (u16), a NUL-terminated UCS-2 description,
then device-path nodes -- type, subtype, a u16 length that counts the four bytes of all
three. nodes: [(type, subtype, data)] of the FIRST device path, which a node of type 0x7f
ends; the ones after it are the OS's business."""
attrs, size = struct.unpack_from("<IH", raw, 0)
end = 6
while end + 1 < len(raw) and raw[end:end + 2] != b"\x00\x00":
end += 2
desc = raw[6:end].decode("utf-16-le", "replace")
pos, stop, nodes = end + 2, min(end + 2 + size, len(raw)), []
while pos + 4 <= stop:
kind, sub, n = struct.unpack_from("<BBH", raw, pos)
if kind == 0x7f or n < 4:
break
nodes.append((kind, sub, raw[pos + 4:pos + n]))
pos += n
return attrs, desc, nodes
def efi_load_option(raw):
"""-> (description, path) of a Boot#### variable. path: the media file-path nodes (4/4) of
its first device path, joined -- '' for an entry that names no file (a legacy BBS entry, a
firmware application). ('', '') for what cannot be read."""
try:
_, desc, nodes = _efi_option(raw)
except (struct.error, TypeError):
return "", ""
path = ""
for k, s, d in nodes:
if (k, s) == (4, 4):
part = d.decode("utf-16-le", "replace").rstrip("\x00")
path += part if not path or path.endswith("\\") or part.startswith("\\") else "\\" + part
return desc, path
def _efi_disk(nodes):
"""The partition a device path's hard-drive node (4/1) names: its number and signature (a
GPT partition's GUID), as one string -- '' without one."""
for k, s, d in nodes:
if (k, s) == (4, 1) and len(d) >= 38:
return "%d:%s" % (struct.unpack_from("<I", d, 0)[0], d[20:36].hex())
return ""
def efi_boot(root="/"):
"""How this machine booted, from its EFI variables -- {} on a BIOS boot, else:
secure_boot, setup_mode: the firmware's own state; in Setup Mode no keys are installed and
nothing is checked. moksb_off: shim was told to stop checking (mokutil
--disable-validation), which the kernel counts as Secure Boot off.
current, order: BootCurrent and BootOrder ('0022'); entries: {id: {desc, path, active, disk}}.
shim_path: the entry that booted is shim, by its file name. shim_entry: the shim to put
first -- the first one in BootOrder in the booted loader's own directory, on its partition
(a shim elsewhere chains to another system's GRUB); with no BootCurrent, the first at all;
'' for none.
mokvar, mok_listrt: shim ran this boot -- its MOK table, its MokListRT. mok_trusted:
MokListTrustedRT is set (mokutil --trust-mok), and MOK keys may count for modules.
mok_counts() goes by these variables, not by a file name: a shim copied to
\\EFI\\BOOT\\BOOTX64.EFI has another one."""
if not os.path.isdir(rp(root, "sys/firmware/efi")):
return {}
def first_byte(name, guid=EFI_GLOBAL):
v = efi_var(name, root, guid)
return bytearray(v)[0] if v else None
def ids(v):
return ["%04X" % struct.unpack_from("<H", v, i)[0] for i in range(0, len(v or b"") - 1, 2)]
def is_shim(e):
return bool(SHIM_FILE.match(e["path"].split("\\")[-1]))
entries = {}
for f in sorted(glob.glob(rp(root, "sys/firmware/efi/efivars/Boot[0-9A-F][0-9A-F][0-9A-F][0-9A-F]-"
+ EFI_GLOBAL))):
eid = os.path.basename(f)[4:8]
raw = efi_var("Boot" + eid, root)
try:
attrs, _, nodes = _efi_option(raw)
except (struct.error, TypeError):
continue
desc, path = efi_load_option(raw)
entries[eid] = {"desc": desc, "path": path, "active": bool(attrs & 1), "disk": _efi_disk(nodes)}
current, order = (ids(efi_var("BootCurrent", root)) or [""])[0], ids(efi_var("BootOrder", root))
here = entries.get(current)
shims = [i for i in order if i in entries and entries[i]["active"] and is_shim(entries[i])]
if here and here["path"]:
home = here["path"].rsplit("\\", 1)[0].lower()
shims = [i for i in shims if entries[i]["disk"] == here["disk"]
and entries[i]["path"].rsplit("\\", 1)[0].lower() == home]
mok = rp(root, "sys/firmware/efi/mok-variables")
return {"secure_boot": first_byte("SecureBoot") == 1, "setup_mode": first_byte("SetupMode") == 1,
"moksb_off": first_byte("MokSBStateRT", SHIM_LOCK) == 1,
"current": current, "order": order, "entries": entries,
"shim_path": bool(here) and is_shim(here), "shim_entry": (shims or [""])[0],
"mokvar": os.path.isdir(mok), "mok_trusted": os.path.exists(os.path.join(mok, "MokListTrustedRT")),
"mok_listrt": os.path.exists(rp(root, "sys/firmware/efi/efivars/MokListRT-" + SHIM_LOCK))}
def shim_first_order(efi):
"""BootOrder with efi_boot()'s shim_entry moved to the front, for `efibootmgr --bootorder`;
[] when there is no shim entry to move."""
first = (efi or {}).get("shim_entry")
return [first] + [i for i in efi.get("order") or [] if i != first] if first else []
def shim_ran(efi):
"""Did shim boot this machine? Its MOK variables say so, whatever its file is called."""
return bool(efi and (efi.get("shim_path") or efi.get("mokvar") or efi.get("mok_listrt")))
def secure_boot_counts(machine):
"""Does the kernel count Secure Boot as on here: the firmware's SecureBoot, out of Setup Mode,
shim not told to stop checking (efi_boot())? machine['secure_boot'] says where the
variables were not read."""
efi = machine.get("efi")
if efi is None:
return machine.get("secure_boot") == "on"
return bool(efi.get("secure_boot") and not efi.get("setup_mode") and not efi.get("moksb_off"))
def mok_counts(machine, cfg=None):
"""'' when MOK keys count for modules on a kernel with .config CFG -- None: one not built yet,
whose bases have both keyrings -- else why they do not. They count only when shim booted the
machine with Secure Boot on and MokListTrustedRT set, into a kernel with the machine and the
secondary keyring; under INTEGRITY_CA_MACHINE_KEYRING only CA keys get there, which is not
told here. Never the platform keyring: that one is for kexec, not modules."""
efi = machine.get("efi")
if efi == {}:
return "a BIOS boot has no MOK"
if efi is None:
return "the EFI variables were not read"
if not secure_boot_counts(machine):
return "Secure Boot is off%s" % (": the platform is in Setup Mode (no keys)" if efi.get("setup_mode") else
": shim's checking is switched off" if efi.get("moksb_off") else "")
if not shim_ran(efi):
return "shim does not boot this machine"
if not efi.get("mok_trusted"):
return "MokListTrustedRT is not set (sudo mokutil --trust-mok)"
if cfg is not None:
if cfg.get("INTEGRITY_MACHINE_KEYRING") != "y" or cfg.get("SECONDARY_TRUSTED_KEYRING") != "y":
return "this kernel has no machine keyring (INTEGRITY_MACHINE_KEYRING, SECONDARY_TRUSTED_KEYRING)"
if cfg.get("INTEGRITY_CA_MACHINE_KEYRING") == "y":
return "INTEGRITY_CA_MACHINE_KEYRING=y: only CA keys reach the machine keyring -- not told here"
return ""
def boot_path_lines(machine):
"""show_machine()'s lines for the boot path (efi_boot()): the entry that booted, whether that
is shim, and the Secure Boot state that decides whether MOK keys count."""
efi = machine.get("efi")
if efi is None:
return []
if not efi:
return ["boot path : a BIOS boot -- no Secure Boot, no MOK"]
e = efi["entries"].get(efi["current"])
head = "Boot%s '%s' %s" % (efi["current"], e["desc"], e["path"] or "(no file)") if e else \
"Boot%s" % efi["current"] if efi["current"] else "BootCurrent unreadable"
if efi["shim_path"]:
what = "shim"
elif shim_ran(efi):
what = "shim under another name (its MOK variables are here)"
else:
what = "not shim: MOK keys are not loaded (%s)" % (
"Boot%s is shim" % efi["shim_entry"] if efi["shim_entry"] else "no shim entry for this boot loader")
sb = "Secure Boot %s" % ("on" if efi["secure_boot"] else "off")
sb += ", platform in Setup Mode (no keys)" if efi["setup_mode"] else ""
sb += ", shim's checking switched off" if efi["moksb_off"] else ""
if shim_ran(efi):
sb += "; " + ("MOK keys count for modules (MokListTrustedRT)" if not mok_counts(machine) else
"MokListTrustedRT is set: MOK keys count once Secure Boot is on" if efi["mok_trusted"] else
"no MokListTrustedRT: MOK keys do not count for modules (sudo mokutil --trust-mok)")
return ["boot path : %s -- %s" % (head, what), " : " + sb]
def now_facts(root="/"):
"""What the RUNNING kernel does, read from /sys as you: {lockdown, mem_sleep, iommu,
battery, tpm}. lockdown, mem_sleep: the bracketed word of /sys/kernel/security/lockdown
and /sys/power/mem_sleep ('' without the file); iommu: the IOMMUs it set up (dmar0 ...);
battery: an ACPI battery is fitted; tpm: the TPMs it found."""
def picked(path):
m = re.search(r"\[(\w+)\]", read_text(rp(root, path)))
return m.group(1) if m else ""
def listed(path):
try:
return sorted(os.listdir(rp(root, path)))
except OSError:
return []
return {"lockdown": picked("sys/kernel/security/lockdown"), "mem_sleep": picked("sys/power/mem_sleep"),
"iommu": listed("sys/class/iommu"),
"battery": any(n.startswith("BAT") for n in listed("sys/class/power_supply")),
"tpm": [n for n in listed("sys/class/tpm") if n.startswith("tpm")]}
def running_config(running, root="/"):
"""The running kernel's .config -- /boot/config-RUNNING, else /proc/config.gz -- or None."""
path = rp(root, "boot/config-" + running) if running else ""
if path and os.path.isfile(path):
return DotConfig(path)
try:
with gzip.open(rp(root, "proc/config.gz"), "rt", errors="replace") as f:
return DotConfig.of_text(f.read())
except (OSError, IOError, EOFError):
return None
def cet_support(flags, running_cfg):
"""The CET features this CPU has, of ibt and user_shstk -- or 'unknown': a kernel built
without X86_KERNEL_IBT or X86_USER_SHADOW_STACK hides those flags in /proc/cpuinfo."""
if running_cfg is None or not all(running_cfg.get(s) == "y"
for s in ("X86_KERNEL_IBT", "X86_USER_SHADOW_STACK")):
return "unknown"
return [f for f in ("ibt", "user_shstk") if f in flags]
# The early initrds that carry CPU microcode: GRUB_EARLY_INITRD_LINUX_STOCK's names, and Arch's.
MICROCODE_INITRDS = ("intel-uc.img", "intel-ucode.img", "amd-uc.img", "amd-ucode.img", "early_ucode.cpio",
"microcode.cpio")
def acpi_override(running, root="/"):
"""'' or where this machine replaces its firmware's ACPI tables at boot -- with tables an
initramfs brings, which the kernel reads only with ACPI_TABLE_UPGRADE: dracut's
acpi_override, mkinitcpio's hook of that name, tables in /lib/firmware/acpi, an early initrd
of its own -- GRUB_EARLY_INITRD_LINUX_CUSTOM (10_linux puts it on every entry), or a BLS
entry's initrd before the kernel's own -- that is not CPU microcode, or kernel/firmware/acpi/
in the running kernel's initramfs (where you may read it)."""
for f in [rp(root, "etc/dracut.conf")] + sorted(glob.glob(rp(root, "etc/dracut.conf.d/*.conf"))):
if re.search(r"""^\s*acpi_override\s*=\s*["']?yes""", read_text(f), re.M):
return "acpi_override=yes in /" + os.path.relpath(f, root)
if re.search(r"^\s*HOOKS=.*\bacpi_override\b", read_text(rp(root, "etc/mkinitcpio.conf")), re.M):
return "the acpi_override hook in /etc/mkinitcpio.conf"
if [p for p in glob.glob(rp(root, "lib/firmware/acpi/*")) if os.path.isfile(p)]:
return "tables in /lib/firmware/acpi"
for f, words in grub_settings("GRUB_EARLY_INITRD_LINUX_CUSTOM", root):
for x in [x for w in words for x in w.split()]:
if os.path.basename(x) not in MICROCODE_INITRDS and os.path.isfile(rp(root, "boot/" + x.lstrip("/"))):
return "GRUB_EARLY_INITRD_LINUX_CUSTOM=%s in /%s" % (x, os.path.relpath(f, root))
for f in sorted(glob.glob(rp(root, "boot/loader/entries/*.conf")) +
glob.glob(rp(root, "efi/loader/entries/*.conf"))):
# several initrd lines, or several paths on one (GRUB's BLS); $tuned_initrd and the like are GRUB's
paths = [x for line in re.findall(r"^initrd\s+(.*)$", read_text(f), re.M) for x in line.split()
if not x.startswith("$")]
early = [x for x in paths[:-1] if os.path.basename(x) not in MICROCODE_INITRDS]
if early:
return "initrd %s before the kernel's own in /%s" % (early[0], os.path.relpath(f, root))
img = initramfs_path(running, root) if running else ""
listing = image_listing(img) if img else None
if listing and re.search(r"(?:^|[\s/])kernel/firmware/acpi/\S", listing, re.M):
return "kernel/firmware/acpi/ in %s" % os.path.basename(img)
return ""
def parse_mounts(text):
out_ = []
for line in text.splitlines():
parts = line.split()
if len(parts) >= 3:
out_.append((parts[0], parts[1].replace("\\040", " "), parts[2]))
return out_
def whole_disk(name, root="/"):
"""sda3 -> sda, md126p3 -> md126, nvme0n1p2 -> nvme0n1. A partition's own
sysfs directory has no slaves/ and no md/; its disk's does. (Reading
md/level on a PARTITION of an Intel RST array gave 'md:?', and no RAID
level was required at all.)"""
sysb = rp(root, "sys/class/block/" + name)
if os.path.exists(os.path.join(sysb, "partition")):
return os.path.basename(os.path.dirname(os.path.realpath(sysb)))
return name
def device_drivers(path, root="/"):
"""[(driver, module)] from a sysfs device directory up to the top of the
device tree, nearest first. For a disk: its own driver (sd, nvme,
virtio_blk), its controller's (ahci, megaraid_sas), and whatever THAT sits
behind -- an Intel VMD domain, a USB host controller, the PCIe host bridge
of an ARM board. module is '' where sysfs does not say: a built-in driver
usually has no `module` link."""
top = os.path.realpath(rp(root, "sys/devices"))
res, p = [], path
while p.startswith(top + os.sep):
drv = os.path.join(p, "driver")
if os.path.islink(drv):
mod = os.path.join(drv, "module")
res.append((os.path.basename(os.readlink(drv)),
os.path.basename(os.readlink(mod)) if os.path.islink(mod) else ""))
p = os.path.dirname(p)
return res
def disk_chain(disk, root="/"):
"""-> (drivers between one whole disk and the CPU, behind an Intel VMD?).
NVMe with native multipathing (RHEL's default) hangs the namespace off a
VIRTUAL subsystem device; multipath/ lists the per-controller paths, and
those are what lead down to a driver. A VMD domain is numbered from
0x10000 up -- five hex digits, where every ordinary PCI domain has four."""
sysb = rp(root, "sys/class/block/" + disk)
try:
paths = sorted(os.listdir(os.path.join(sysb, "multipath")))
except OSError:
paths = []
starts = [os.path.realpath(os.path.join(sysb, "multipath", n)) for n in paths] or \
[os.path.realpath(sysb)]
chain = []
for s in starts:
for d in device_drivers(s, root):
if d not in chain:
chain.append(d)
vmd = any(re.match(r"^pci[0-9a-f]{5,}:", part) for s in starts for part in s.split(os.sep)) \
or any(drv == "vmd" for drv, _ in chain)
return chain, vmd
# On every path and not a choice: the USB core's own device driver, the USB hub
# driver, the PCIe port services. Each is part of a subsystem the controller's
# own driver already needs.
INFRA_DRIVERS = ("usb", "hub", "pcieport")
# Driver names that are not the module's name. xhci_hcd (underscore) is the PCI
# glue in xhci-pci.ko; xhci-hcd (dash) is the platform one. Checked BEFORE
# dashes become underscores, which would make them one. bochs-drm, QEMU's
# standard VGA, is scan_pci()'s.
DRIVER_MODULE = {"sd": "sd_mod", "sr": "sr_mod", "xhci_hcd": "xhci_pci",
"xhci-hcd": "xhci_plat_hcd", "mmcblk": "mmc_block", "vbd": "xen_blkfront",
"bochs-drm": "bochs"}
def module_paths(running, root="/"):
"""module name -> where its source was in the tree that built the RUNNING
kernel ('drivers/ata/ahci'), from modules.dep (loadable) and
modules.builtin.modinfo / modules.builtin (built in). The NEW tree is then
asked, through its own Makefile there, which CONFIG_ option builds it."""
base = rp(root, "lib/modules/" + running)
paths = {}
def put(rel, name=None):
rel = re.sub(r"\.ko(\.\w+)?$", "", rel.strip())
if rel.startswith("kernel/"):
rel = rel[len("kernel/"):]
if rel:
paths.setdefault(name or os.path.basename(rel).replace("-", "_"), rel)
for line in read_text(os.path.join(base, "modules.dep")).splitlines():
put(line.split(":", 1)[0])
info_ = read_text(os.path.join(base, "modules.builtin.modinfo"))
for m in re.finditer(r"([\w-]+)\.file=([^\x00\n]+)", info_):
put(m.group(2), m.group(1).replace("-", "_"))
for line in read_text(os.path.join(base, "modules.builtin")).splitlines():
put(line)
return paths
def storage_stack(root="/", running=None):
"""What this machine needs in order to mount its filesystems at boot: the
filesystem types of every /dev-backed mount, the block layers under them
(LVM, dm-crypt, md), and the DRIVERS between each disk and the CPU --
sd_mod, ahci, nvme, and on boards like an ASUS Z790 an Intel VMD in front
of the SATA controller. A kernel that lacks any one of these builds fine,
installs fine, and drops to an emergency shell -- or, without VMD, cannot
even see the disk. Swap counts too: resume= waits for it at boot."""
mounts = [m for m in parse_mounts(read_text(rp(root, "proc/mounts")))
if m[0].startswith("/dev/")
and not m[1].startswith(("/run/media", "/media", "/mnt"))]
swaps = [l.split()[0] for l in read_text(rp(root, "proc/swaps")).splitlines()[1:]
if l.startswith("/dev/") and l.split()[1:2] == ["partition"]]
fstypes, layers, rootfs = set(), set(), ""
seen, disks = set(), []
def walk(dev):
dev = whole_disk(dev, root)
if dev in seen:
return
seen.add(dev)
sysb = rp(root, "sys/class/block/" + dev)
if dev.startswith("dm-"):
uuid = read_text(sysb + "/dm/uuid").strip()
layers.add("lvm" if uuid.startswith("LVM-") else
"crypt" if uuid.startswith("CRYPT-") else "dm")
elif re.match(r"^md\d", dev):
layers.add("md:" + (read_text(sysb + "/md/level").strip() or "?"))
try:
slaves = sorted(os.listdir(sysb + "/slaves"))
except OSError:
slaves = []
for s in slaves:
walk(s)
if not slaves and not re.match(r"^(dm-|md\d|loop|zram|ram|nbd)", dev):
disks.append(dev)
def block_name(src, mnt):
if root != "/":
return os.path.basename(src)
name = os.path.basename(os.path.realpath(src))
if not os.path.exists("/sys/class/block/" + name):
# '/dev/root': a root mounted by the kernel itself, with no initramfs,
# is listed under a name that is no device. Its device NUMBER is real.
try:
st = os.stat(mnt)
link = "/sys/dev/block/%d:%d" % (os.major(st.st_dev), os.minor(st.st_dev))
if os.path.exists(link):
name = os.path.basename(os.path.realpath(link))
except OSError:
pass
return name
for src, mnt, fstype in mounts:
fstypes.add(fstype)
if mnt == "/":
rootfs = fstype
walk(block_name(src, mnt))
for src in swaps:
walk(os.path.basename(os.path.realpath(src) if root == "/" else src))
paths = module_paths(running or os.uname().release, root)
drivers, vmd = [], False
for disk in disks:
chain, behind = disk_chain(disk, root)
vmd = vmd or behind
for drv, mod in chain:
if drv in INFRA_DRIVERS and not mod:
continue
name = mod or DRIVER_MODULE.get(drv) or drv.replace("-", "_")
entry = next((d for d in drivers if d["module"] == name), None)
if entry is None:
entry = {"driver": drv, "module": name, "path": paths.get(name, ""), "disks": []}
drivers.append(entry)
if disk not in entry["disks"]:
entry["disks"].append(disk)
return {"rootfs": rootfs, "fstypes": sorted(fstypes), "layers": sorted(layers),
"disks": disks, "drivers": drivers, "vmd": vmd}
def firmware_compression(root="/"):
exts = set()
for sub in ("amdgpu", "i915", "nvidia", ""):
d = rp(root, "lib/firmware/" + sub)
try:
for n in os.listdir(d)[:400]:
if n.endswith(".xz"):
exts.add("xz")
elif n.endswith(".zst"):
exts.add("zstd")
except OSError:
pass
return sorted(exts)
# NVIDIA. nouveau needs NVIDIA's signed firmware from Maxwell's second generation
# (GM20x) on, and from Turing on it can run the GPU through GSP firmware -- the
# only way at all from Ada on. Measured in 7.2.6: tu102_gsp_load_rm() defaults
# NvGspRm to true, and without the files it falls back to its own code, which
# cannot reclock: the card stays at its boot clocks.
NV_FIRMWARE_FROM = ("gm2", "gp", "gv", "tu", "ga", "ad", "gb", "gh")
NV_GSP = ("tu", "ga", "ad", "gb", "gh")
NV_GSP_ONLY = ("ad", "gb", "gh")
NVIDIA_FW_PKG = {"dnf": "nvidia-gpu-firmware (linux-firmware on older releases)",
"yum": "linux-firmware", "apt-get": "firmware-misc-nonfree (Debian) / "
"linux-firmware (Ubuntu)", "zypper": "kernel-firmware-nvidia",
"pacman": "linux-firmware-nvidia"}
NVIDIA_DKMS_PKG = "nvidia-open-dkms / kmod-nvidia-open-dkms (open, Turing and newer) or nvidia-dkms"
def nvidia_chip(name):
"""'tu117' out of pci.ids' 'NVIDIA Corporation TU117 [GeForce GTX 1630]': the
directory nouveau's firmware lives in. Mobile and workstation parts (TU117M,
GA102GL) use their chip's; GK208B is a chip of its own. pci.ids is cosmetic
everywhere else in this tool -- here it only says where to LOOK."""
m = re.search(r"\b([A-Z]{2}\d{3})(B?)[A-Z]*\s*\[", name or "")
return (m.group(1) + m.group(2)).lower() if m else ""
def nonempty_dir(path):
try:
return bool(os.listdir(path))
except OSError:
return False
# Where kmod reads modprobe.d, in its order.
MODPROBE_DIRS = ("etc", "run", "usr/local/lib", "usr/lib", "lib")
# modeset=, on the command line or in modprobe.d, per driver (7.2.11's sources):
# amdgpu: 0 does nothing; any value but -1 overrides nomodeset (amdgpu_drv.c 2388).
# MODESET_DRIVERS: 0 switches the driver off; any value but -1 and 0 overrides nomodeset
# (drm_module.h's _if_modeset, i915_module.c, nouveau_drm.c, radeon_drv.c, virtgpu_drv.c).
# every other driver has no modeset= at all (xe, vmwgfx, hyperv_drm, cirrus_qemu): it is
# ignored, and nomodeset always counts.
MODESET_DRIVERS = ("i915", "nouveau", "radeon", "ast", "mgag200", "bochs", "qxl", "virtio_gpu")
# On the kernel command line, each a comma list of modules: the kernel's, kmod's, dracut's.
BLACKLIST_PARAMS = ("module_blacklist", "modprobe.blacklist", "rd.driver.blacklist")
# Where module_switched_off() says a word is that GRUB's own file holds and no boot has had yet.
# Never a path: a hit whose `where` starts with '/' is a modprobe.d file.
NEXT_LINE = "GRUB_CMDLINE_LINUX* (the next boot's command line)"
def modprobe_conf(root="/"):
"""[(file, n, line)]: the modprobe.d files kmod reads, the way it reads them. A file hides
any of the same name further down MODPROBE_DIRS (/etc/modprobe.d/x.conf overrides
/usr/lib's), merged /usr's lib/ is read once, and a line ending in a backslash goes on
in the next. file is named as the machine names it: /etc/modprobe.d/x.conf; n is the
number of the line it starts on, as an editor counts -- a fix says 'delete line n'."""
names, reals, res = set(), set(), []
for d in MODPROBE_DIRS:
for path in sorted(glob.glob(rp(root, d + "/modprobe.d/*.conf"))):
real = os.path.realpath(path)
if os.path.basename(path) in names or real in reals:
continue
names.add(os.path.basename(path))
reals.add(real)
n = 1
for part in re.split(r"(?<!\\)\n", read_text(path)):
res.append(("/" + os.path.relpath(path, root), n, part.replace("\\\n", " ")))
n += part.count("\n") + 1
return res
def other_options(line):
"""The parameters of an `options` line besides modeset=0: what deleting the line would
take away with it."""
w = line.split("#", 1)[0].split()
return [p for p in w[2:] if p != "modeset=0"] if w[:1] == ["options"] else []
def loaded_by_name(name, root="/"):
"""'' or what loads module NAME by its name in the initramfs: initramfs-tools' module
lists, dracut's force_drivers, rd.driver.pre=. Each runs a plain modprobe, and a
`blacklist` holds back only loading by alias (modprobe.d(5))."""
for f in [rp(root, "etc/initramfs-tools/modules")] + \
sorted(glob.glob(rp(root, "usr/share/initramfs-tools/modules.d/*"))):
for line in read_text(f).splitlines():
w = line.split("#", 1)[0].split()
if w and w[0].replace("-", "_") == name:
return "/" + os.path.relpath(f, root)
for f in [rp(root, "etc/dracut.conf")] + sorted(glob.glob(rp(root, "etc/dracut.conf.d/*.conf"))):
for m in re.finditer(r"^\s*force_drivers\+?=(.*)$", read_text(f), re.M):
try:
words = " ".join(shlex.split(m.group(1), comments=True)).split()
except ValueError:
words = m.group(1).strip("\"' ").split()
if name in [x.replace("-", "_") for x in words]:
return "/%s (force_drivers)" % os.path.relpath(f, root)
for w in read_text(rp(root, "proc/cmdline")).split():
k, _, v = w.partition("=")
if k == "rd.driver.pre" and name in [x.replace("-", "_") for x in v.split(",")]:
return "the kernel command line (%s)" % w
return ""
def module_switched_off(name, root="/"):
"""[{where, line, hard, why}]: whatever keeps module NAME from loading -- on a NEW kernel
too: modprobe.d goes into every initramfs built while it is there, and the command line
comes along (grubby copies it to every entry). Files first, then /proc/cmdline.
where: the file, or 'kernel command line'; line: that line, or that word. '-' and '_'
are one character in module names, as for kmod and the kernel. A file's hit also has
n, its line number, and alone: the file holds nothing else kmod acts on.
hard False -- why says why -- warns and never refuses: nomodeset (every kernel alike, and
the boot console stays); rd.driver.blacklist= (dracut's, the initramfs only); /run
(emptied at every boot); a blacklist of a module the initramfs loads by name; on GRUB
without grubby, a word typed at the menu for one boot. There, a word of GRUB_CMDLINE_LINUX*
not booted yet counts too (where: NEXT_LINE): update-grub writes it into every entry,
the new kernel's included."""
name = name.replace("-", "_")
conf, words, kept = modprobe_conf(root), read_text(rp(root, "proc/cmdline")).split(), grub_cmdline(root)
booted = len(words) # the words after these: GRUB's, not booted yet
words += sorted(w for w in kept if w not in words) if kept is not None else []
def mine(w): # 'blacklist NAME', 'options virtio-gpu modeset=0' ...
return len(w) > 1 and w[1].replace("-", "_") == name
# modeset= as the driver gets it: the command line's comes last, and wins.
modeset = None
for _, _, line in conf:
w = line.split("#", 1)[0].split()
for p in w[2:] if w[:1] == ["options"] and mine(w) else []:
if p.startswith("modeset="):
modeset = p[len("modeset="):]
for word in words:
k, _, v = word.partition("=")
if k.replace("-", "_") == name + ".modeset":
modeset = v
off = name in MODESET_DRIVERS and modeset == "0"
keeps = modeset not in (None, "-1") and (name == "amdgpu" or (name in MODESET_DRIVERS and not off))
hits = []
for where, n, line in conf:
w = line.split("#", 1)[0].split()
if not mine(w) or not (w[0] == "blacklist" or (off and w[0] == "options" and "modeset=0" in w)
or (w[0] == "install" and w[2:] and os.path.basename(w[2]) in ("false", "true"))):
continue
why = ""
if where.startswith("/run/"):
why = "under /run, which is emptied at every boot: this boot only"
elif w[0] == "blacklist":
src = loaded_by_name(name, root)
why = "but %s loads it by name, and a blacklist stops only loading by alias" % src if src else ""
hits.append({"where": where, "line": line.strip(), "n": n, "hard": not why, "why": why})
# A fix may delete a file outright only when nothing else is in it: each line kmod acts on
# is one of these, and an options line holds modeset=0 alone.
for h in hits:
same = [x for x in hits if x["where"] == h["where"]]
acts = [x for f, _, x in conf if f == h["where"] and x.split("#", 1)[0].split()]
h["alone"] = len(acts) == len(same) and not any(other_options(x["line"]) for x in same)
for i, word in enumerate(words):
k, _, v = word.partition("=")
k = k.replace("-", "_")
if not ((k in BLACKLIST_PARAMS and name in [x.replace("-", "_") for x in v.split(",")])
or (off and k == name + ".modeset" and v == "0") or (word == "nomodeset" and not keeps)):
continue
why = ""
if kept is not None and word not in kept:
why = "typed for this boot only: it is not in GRUB_CMDLINE_LINUX*, from which update-grub " \
"writes every entry"
elif word == "nomodeset":
why = "every kernel's display drivers stand aside under it, and only the boot console's " \
"framebuffer shows"
elif k == "rd.driver.blacklist":
why = "dracut's, for the initramfs only: the driver loads once the root filesystem is up"
hits.append({"where": "kernel command line" if i < booted else NEXT_LINE, "line": word,
"hard": not why, "why": why})
return hits
def hit_text(h):
"""A module_switched_off() hit as it reads: '/etc/modprobe.d/x.conf: blacklist amdgpu'."""
return "%s: %s" % (h["where"], h["line"])
def nouveau_blacklisted(root="/"):
"""'' or where nouveau is switched off for good, as 'where: line' -- NVIDIA's own
packages install such a blacklist. The first HARD hit: under nomodeset or an
rd.driver.blacklist= nouveau is not gone."""
return next((hit_text(h) for h in module_switched_off("nouveau", root) if h["hard"]), "")
def nvidia_state(devs, dkms_mods, running, root="/"):
"""{} without an NVIDIA GPU. Otherwise: each GPU's chip and what nouveau
would need for it, NVIDIA's own driver for the RUNNING kernel and how it got
there (DKMS, akmods, or a prebuilt kmod), and whether nouveau is switched off --
for good (nouveau_off), or only softly (nouveau_soft: nomodeset and the like)."""
gpus = []
for d in devs:
if d["vendor"] != 0x10de or d["kind"] != "display":
continue
chip = nvidia_chip(d["name"])
fw = rp(root, "lib/firmware/nvidia/" + chip) if chip else ""
gpus.append({"addr": d["addr"], "chip": chip, "driver": d["driver"],
"needs_firmware": chip.startswith(NV_FIRMWARE_FROM),
"firmware": bool(fw) and nonempty_dir(fw),
"gsp_capable": chip.startswith(NV_GSP), "gsp_only": chip.startswith(NV_GSP_ONLY),
"gsp": bool(fw) and nonempty_dir(os.path.join(fw, "gsp"))})
if not gpus:
return {}
own, base = [], rp(root, "lib/modules/" + running)
for dirpath, dirnames, files in os.walk(base):
if dirpath == base:
dirnames[:] = [x for x in dirnames if x not in ("build", "source")]
# Ubuntu's names (nvidia.ko), Debian's (nvidia-current.ko, nvidia-legacy-390xx.ko,
# nvidia-tesla-470.ko) -- never the kernel's own nvidia-wmi-ec-backlight.ko.
own.extend(os.path.join(dirpath, f) for f in files
if re.match(r"^nvidia(-current|-legacy-\w+|-tesla(-\w+)?)?(-drm|-modeset|-uvm|-peermem)?\.ko", f))
return {"gpus": gpus, "running": running, "own_driver": sorted(own),
"dkms": ["%s/%s" % (m["name"], m["version"]) for m in dkms_mods
if m["name"].startswith("nvidia")],
"akmods": bool(glob.glob(rp(root, "usr/src/akmods/*nvidia*"))),
"nouveau_off": nouveau_blacklisted(root),
"nouveau_soft": [h for h in module_switched_off("nouveau", root) if not h["hard"]]}
def nvidia_outlook(nv, pm, release="", machine=None):
"""-> [(ok | note | warn, text)]: who drives each NVIDIA GPU once a kernel
built here boots. NVIDIA's own driver follows a machine onto a new kernel
only through DKMS: akmods builds against an RPM-installed kernel-devel, which
a kernel installed from a tree does not have, and a prebuilt kmod is made for
one kernel. Where it cannot follow, nouveau takes over -- unless nouveau is
switched off, and then the GPU has NO driver: a bare framebuffer at best.
MACHINE and RELEASE, where given, say whether DKMS builds it for RELEASE
(nvidia_follows()): its dkms.conf may leave that release out."""
if not nv:
return []
res, new = [], release or "the new kernel"
follows, skips = nvidia_follows(machine, release) if machine is not None and nv["dkms"] \
else (bool(nv["dkms"]), "")
if follows:
return [("ok", "NVIDIA's driver (%s, DKMS) is rebuilt for %s when it is installed; the "
"out-of-tree check says whether it compiles" % (", ".join(nv["dkms"]), new))]
if nv["own_driver"] or skips:
how = skips or ("akmods builds it against an RPM-installed kernel-devel, and a kernel installed "
"from a tree has none" if nv["akmods"] else "it was built for %s only" % nv["running"])
get = "a version of it that DKMS builds for %s" % new if skips else \
"NVIDIA's DKMS package first (%s)" % NVIDIA_DKMS_PKG
if nv["nouveau_off"]:
return [("warn", "NVIDIA's driver will NOT load on %s (%s), and nouveau is switched off "
"(%s): this GPU will have no driver -- a bare framebuffer at best. Install "
"%s, or remove that blacklist so nouveau takes over."
% (new, how, nv["nouveau_off"], get))]
res.append(("note", "NVIDIA's driver will not load on %s (%s): nouveau, built here as a "
"module, takes over. To keep NVIDIA's own: %s." % (
new, how, get if skips else NVIDIA_DKMS_PKG)))
elif nv["nouveau_off"]:
return [("warn", "nouveau is switched off (%s) and NVIDIA's driver is not installed: this "
"GPU has no driver, on this kernel or any other" % nv["nouveau_off"])]
# nouveau is to drive it. A soft switch is said as what it is -- not a blacklist, and
# not 'no driver': nomodeset leaves the boot console, and after rd.driver.blacklist=
# nouveau still loads, from the root filesystem.
res += [("warn", "nouveau: %s -- %s" % (hit_text(h), h["why"])) for h in nv.get("nouveau_soft") or []]
for g in nv["gpus"]:
label = "%s at %s" % (g["chip"].upper() or "NVIDIA GPU", g["addr"])
where = "/lib/firmware/nvidia/%s" % g["chip"]
if g["needs_firmware"] and not g["firmware"]:
res.append(("warn", "%s: nothing in %s -- nouveau %s. Install %s." % (
label, where, "cannot drive it at all" if g["gsp_only"] else
"runs it without acceleration", NVIDIA_FW_PKG.get(pm, "linux-firmware"))))
elif g["gsp_capable"] and not g["gsp"]:
res.append(("warn" if g["gsp_only"] else "note",
"%s: no GSP firmware (%s/gsp) -- nouveau %s" % (
label, where, "cannot drive it" if g["gsp_only"] else
"falls back to its own code, which cannot reclock: the card stays "
"at its boot clocks")))
else:
res.append(("ok", "%s: nouveau, built as a module%s" % (
label, "; firmware present, GSP too" if g["gsp"] else
"; firmware present" if g["needs_firmware"] else "")))
return res
def show_outlook(lines):
for kind, text in lines:
{"ok": ok, "note": note, "warn": warn, "bad": bad}[kind](text)
def initramfs_path(release, root="/"):
"""RELEASE's initramfs, '' when /boot has none: dracut's name, initramfs-tools', and
openSUSE's (its dracut writes initrd-RELEASE)."""
return next((p for p in (rp(root, "boot/initramfs-%s.img" % release),
rp(root, "boot/initrd.img-%s" % release),
rp(root, "boot/initrd-%s" % release)) if os.path.exists(p)), "")
def in_initramfs(path, listing):
"""Is modprobe.d file PATH in this initramfs listing? lsinitrd prints `ls -l` lines,
lsinitramfs bare paths, neither with a leading '/'; initramfs-tools puts /lib's files
under usr/lib. x.conf.dpkg-old is not x.conf."""
rel = path.lstrip("/")
return any(re.search(r"(?:^|[\s/])%s$" % re.escape(p), listing, re.M)
for p in [rel] + (["usr/" + rel] if rel.startswith("lib/") else []))
def lent_ids(root="/"):
"""{'vendor:device'} handed to vfio-pci or pci-stub by id, on the kernel command line or
in modprobe.d: devices for a VM, which the host's own driver is never to have."""
ids = []
for word in read_text(rp(root, "proc/cmdline")).split():
k, _, v = word.partition("=")
if k.replace("-", "_") in ("vfio_pci.ids", "pci_stub.ids"):
ids += v.split(",")
for _, _, line in modprobe_conf(root):
w = line.split("#", 1)[0].split()
if w[:1] == ["options"] and len(w) > 1 and w[1].replace("-", "_") in LENT:
ids += [x for p in w[2:] if p.startswith("ids=") for x in p[len("ids="):].split(",")]
# vendor:device[:subvendor:subdevice...] -- the first two name the device
return set(":".join(i.lower().split(":")[:2]) for i in ids if i)
def changed_at(path):
"""When file PATH last came or changed, 0 when it is not there: its ctime too -- dpkg and
rpm give a file its package's mtime."""
try:
st = os.stat(path)
except OSError:
return 0
return max(st.st_mtime, st.st_ctime)
def display_drivers(devs, running, root="/"):
"""-> [{addr, vendor, name, driver, module, boot_vga, modules, lent, off, loaded, stale}],
one per display device: what drives it now, and whether its driver is switched off for
a NEW kernel. Plain data: detect --json shows it as it is.
modules: the one bound to it, else DISPLAY_MODULES' -- NVIDIA's is always nouveau, its
own driver being nvidia_outlook()'s. lent: handed to a VM (bound to vfio-pci or
pci-stub, a driver_override, ids=): its host driver is MEANT to be off, and it gets no
hits. off: module_switched_off()'s hits for those modules, each with its module -- and,
read anywhere but /, with that root, so that a fix text reads the same files.
loaded: bound now to a module that is switched off. stale: a hard file hit is newer
than RUNNING's initramfs -- which is how it still loads; None without an initramfs."""
ids, img = lent_ids(root), initramfs_path(running, root)
built = os.stat(img).st_mtime if img else None
tag = {"root": root} if root != "/" else {}
res = []
for d in devs:
if d["kind"] != "display":
continue
bound = d.get("module", "")
override = read_text(rp(root, "sys/bus/pci/devices/%s/driver_override" % d["addr"])).strip()
lent = bound in LENT or override.replace("-", "_") in LENT \
or "%04x:%04x" % (d["vendor"], d["device"]) in ids
mods = [bound] if bound and bound not in LENT and d["vendor"] != 0x10de \
else list(DISPLAY_MODULES.get(d["vendor"], ()))
off = [] if lent else [dict(h, module=m, **tag) for m in mods for h in module_switched_off(m, root)]
loaded = bool(bound) and any(h["module"] == bound for h in off)
files = [h["where"] for h in off if h["hard"] and h["module"] == bound and h["where"].startswith("/")]
res.append({"addr": d["addr"], "vendor": d["vendor"], "name": d.get("name", ""),
"driver": d.get("driver", ""), "module": bound, "boot_vga": d.get("boot_vga", False),
"modules": mods, "lent": lent, "off": off, "loaded": loaded,
"stale": None if built is None else
loaded and any(changed_at(rp(root, f)) > built for f in files)})
return res
def owner_note(h):
"""' (no package owns it)' and the like, for a file hit whose owner was asked."""
if "owner" not in h:
return ""
return " (owner unknown)" if h["owner"] is None else " (no package owns it)" if not h["owner"] \
else " (from %s)" % h["owner"]
def loaded_now(r, running):
"""What a display_drivers() record whose driver is switched off says of NOW."""
if not r["loaded"]:
return "" if r["driver"] else "It has no driver now either."
files = [h["where"] for h in r["off"] if h["hard"] and h["module"] == r["module"]
and h["where"].startswith("/")]
if r["stale"] and files:
return ("%s is loaded now only because the initramfs of %s is older than %s; every "
"initramfs built since -- for any kernel, a distribution update's included -- "
"carries %s." % (r["module"], running, files[0] if len(files) == 1 else
"the newest of " + ", ".join(files), "it" if len(files) == 1 else "them"))
return "%s is loaded now all the same." % r["module"]
def display_outlook(machine, release=""):
"""-> [(note | warn, text)]: each display device here whose driver is switched off, and
what that means once RELEASE boots. NVIDIA's are nvidia_outlook()'s, which knows
NVIDIA's own driver too: said there, and not twice."""
res, new = [], release or "the new kernel"
for r in machine.get("display_drivers") or []:
if r["vendor"] == 0x10de or not r["off"]:
continue
label = "%s at %s" % (" / ".join(r["modules"]), r["addr"])
hard = [h for h in r["off"] if h["hard"]]
gone = [m for m in r["modules"] if any(h["module"] == m for h in hard)]
if hard and len(gone) == len(r["modules"]):
res.append(("warn", ("%s gets NO driver on %s -- switched off by %s. %s" % (
label, new, "; ".join(hit_text(h) + owner_note(h) for h in hard),
loaded_now(r, machine.get("running") or "this kernel"))).strip()
+ "\n Fix: " + switched_off_fix(hard, machine, release)))
elif hard: # an Intel device xe or i915 can drive: one is left
res.append(("note", "%s: %s switched off (%s); %s drives it" % (
r["addr"], " / ".join(gone), "; ".join(hit_text(h) for h in hard),
" / ".join(m for m in r["modules"] if m not in gone))))
res += [("warn", "%s: %s -- %s" % (label, hit_text(h), h["why"])) for h in r["off"] if not h["hard"]]
return res
# ------------------------------------------ the screen's driver switched off: fix and refusal
# One initramfs, rebuilt for one release. Advice here never rebuilds them all (`-k all`,
# --regenerate-all, -P): that rebuilds the one image that still shows a picture as well -- and
# while amdgpu-dkms-firmware is installed, it puts AMD's own firmware into that image too, ahead
# of the distribution's (initramfs-tools and dracut look in /lib/firmware/updates first).
REGEN_ONE = {"update-initramfs": "sudo update-initramfs -u -k {0}", "dracut": "sudo dracut -f --kver {0}",
"mkinitcpio": "sudo mkinitcpio -k /boot/vmlinuz-{0} -g /boot/initramfs-{0}.img"}
# What writes grub.cfg again once /etc/default/grub has changed: the first one there is.
GRUB_MKCONFIG = (("update-grub", "update-grub"), ("grub-mkconfig", "grub-mkconfig -o /boot/grub/grub.cfg"),
("grub2-mkconfig", "grub2-mkconfig -o /boot/grub2/grub.cfg"))
_LISTINGS = {} # (image, mtime, size) -> its file list, or None: an image is listed once
def regen_one(gen, release, root="/"):
"""The command that rebuilds RELEASE's initramfs and no other (REGEN_ONE), '' for a generator
it does not know. Arch names its own kernels after their mkinitcpio preset (/boot/vmlinuz-linux:
release 'linux'), which -k cannot take as a version: `mkinitcpio -p linux` rebuilds that
preset's images, its fallback's too. -k takes an image's path for any other."""
if gen == "mkinitcpio" and os.path.isfile(rp(root, "etc/mkinitcpio.d/%s.preset" % release)):
return "sudo mkinitcpio -p %s" % release
return REGEN_ONE[gen].format(release) if gen in REGEN_ONE else ""
def line_span(path, n, root="/"):
"""(n, m): the lines that line n of modprobe.d file PATH takes up -- kmod goes on into
the next line after a backslash, so m > n then."""
lines = read_text(rp(root, path)).split("\n")
m = n
while m < len(lines) and lines[m - 1].endswith("\\"):
m += 1
return n, m
def image_listing(img):
"""Initramfs IMG's file list, read as you: lsinitrd (dracut) or lsinitramfs
(initramfs-tools) on an image you may read -- Debian's are 0644. None when it cannot be
had: no lister, an image only root may read (EL's are 0600), a lister that failed."""
try:
st = os.stat(img)
except OSError:
return None
key = (img, st.st_mtime, st.st_size)
if key not in _LISTINGS:
path = ":".join(SYSTEM_PATH)
lister = next((x for x in (which(t, path=path) for t in ("lsinitrd", "lsinitramfs")) if x), "")
listing = None
if lister and os.access(img, os.R_OK):
try:
p = run([lister, img], check=False, env=scrubbed_env(), timeout=120)
listing = p.stdout if p.returncode == 0 and p.stdout else None
except ToolError:
pass
_LISTINGS[key] = listing
return _LISTINGS[key]
def image_carries(img, path, root="/"):
"""Does initramfs IMG carry modprobe.d file PATH? Its file list says, where you may read
the image. Where not, an image built after the file last came or changed counts as
carrying it: a guess, which misses an image built before a REWRITE of a file it carried
already (measured 2026-10-21: an initramfs built at 12:43 carried the blacklist that
amdgpu-dkms wrote again at 13:20). So a readable image is always listed, whatever its age."""
listing = image_listing(img)
if listing is not None:
return in_initramfs(path, listing)
try:
return os.stat(img).st_mtime > changed_at(rp(root, path))
except OSError:
return False
def carriers(path, root="/"):
"""[(release, carries)]: each kernel in /boot that has an initramfs, oldest first, and
whether that image carries modprobe.d file PATH (image_carries())."""
res = []
for rel in boot_images(root):
img = initramfs_path(rel, root)
if img:
res.append((rel, image_carries(img, path, root)))
return res
def amdgpu_dkms_purge(pm, root="/"):
"""The first step of the fix for amdgpu-dkms's leftover /etc/modprobe.d/blacklist-amdgpu.conf:
the packages that write it go first. Its postinst writes the file, builds the module under
`set -e`, and removes the file only once that build succeeds -- so while the package stays
half-configured, every apt, dpkg or PackageKit run writes it again. AMD's firmware package
is named only when the package manager says it is the one here."""
fw = sorted(f for f in glob.glob(rp(root, "lib/firmware/updates/amdgpu/*")) if os.path.isfile(f))
fw_pkg = file_owner(fw[0], pm) if fw else None
remove = {"apt-get": "sudo apt purge %s", "dnf": "sudo dnf remove %s", "yum": "sudo yum remove %s",
"zypper": "sudo zypper remove %s"}.get(pm, "")
if pm == "apt-get":
text = ("amdgpu-dkms writes this file again whenever its DKMS build fails -- on every apt, dpkg "
"or PackageKit run while it stays half-configured. Remove the two packages that write "
"it first, and read apt's list before you answer Y: `sudo apt-mark manual dkms dwarves "
"&& sudo apt purge amdgpu amdgpu-dkms` (the apt-mark keeps an autoremove from taking "
"dkms and pahole; amdgpu-install: pass --no-dkms).")
else:
text = ("amdgpu-dkms wrote this file when its DKMS build failed, and writes it again the next "
"time that happens. Remove it first: %s." % (
"`%s`" % (remove % "amdgpu-dkms") if remove else "the amdgpu-dkms package"))
if fw_pkg:
text += (" %s is optional: its files in /lib/firmware/updates/amdgpu go into every new "
"initramfs, ahead of the distribution's firmware -- %s too, unless you want them."
% (fw_pkg, "`%s`" % (remove % fw_pkg) if remove else "remove it"))
return text
def _line_at(path, h, root):
a, b = line_span(path, h["n"], root)
return ("line %d" % a if a == b else "lines %d-%d" % (a, b)) + " (`%s`)" % h["line"]
def switched_off_fix(hits, machine, release="", nvidia=False, root=None):
"""-> one line: what to run so that HITS -- module_switched_off()'s hard ones, each with
its module -- switch the driver off no more, on any kernel's next boot. A file goes
(`sudo rm`) when it holds nothing else kmod acts on, else its line does; a package's file
under /usr/lib or /lib is hidden by a namesake in /etc instead, which no update of that
package undoes. A command-line word comes out through grubby or GRUB's own file, a comma
list keeping its other modules. Then each initramfs that carries one of the files is
rebuilt, one release at a time (REGEN_ONE), and no other. amdgpu-dkms's leftover gets
its packages removed first. NVIDIA (nvidia=True): NVIDIA's own driver, through DKMS, is
the fix, not nouveau. root: where the hits were read (display_drivers() says), else /."""
if root is None:
root = next((h["root"] for h in hits if h.get("root")), "/")
if nvidia:
why = nvidia_follows(machine, release, root)[1]
return ("%s, then run this again: NVIDIA's driver then follows to %s -- under lockdown DKMS's "
"certificate must be built into it (--trust-dkms on, the default). Remove the nouveau "
"blacklist only if you are dropping NVIDIA's driver." % (
"%s: install a version of NVIDIA's driver that DKMS builds for %s" % (why, release or "it")
if why else "install NVIDIA's DKMS package first (%s)" % NVIDIA_DKMS_PKG,
release or "every new kernel"))
boot, path = machine.get("boot") or {}, ":".join(SYSTEM_PATH)
# amdgpu-dkms's leftover: its packages go first. Installed -- registered with DKMS, or only
# unpacked in /usr/src after a `dkms remove` -- its next configure writes the file again.
amd = any(h["where"].endswith("/blacklist-amdgpu.conf") for h in hits) and (
any(m.get("name") == "amdgpu" for m in machine.get("dkms") or []) or bool(glob.glob(rp(root, AMDGPU_CONFS))))
steps, files = [], []
for f in [h["where"] for h in hits if h["where"].startswith("/")]:
if f in files:
continue
files.append(f)
mine = [h for h in hits if h["where"] == f]
owner = mine[0].get("owner")
if f.startswith(("/usr/lib/", "/lib/")) and owner != "":
etc = "/etc/modprobe.d/" + os.path.basename(f)
whose = "%s's" % owner if owner else "its package's"
if all(h.get("alone") for h in mine):
steps.append("sudo touch %s: an empty file there hides %s, and %s next update cannot "
"bring it back" % (etc, f, whose))
else:
steps.append("copy %s to %s %s: a file in /etc hides its namesake, and %s next update "
"cannot bring the line back" % (f, etc, " and ".join(
("with modeset=0 taken out of " if other_options(h["line"]) else "without ")
+ _line_at(f, h, root) for h in mine), whose))
elif all(h.get("alone") for h in mine):
steps.append("sudo rm " + shlex.quote(f))
else: # bottom up: a line deleted first leaves the numbers above it as they are
steps += ["in %s %s, take modeset=0 out" % (f, _line_at(f, h, root)) if other_options(h["line"])
else "delete %s from %s" % (_line_at(f, h, root), f)
for h in sorted(mine, key=lambda h: -h.get("n", 0))]
# One step per word: module_blacklist=i915,xe is a hit for i915 and one for xe, and the word that
# stays holds neither.
words = []
for h in [h for h in hits if not h["where"].startswith("/")]:
mod = h.get("module", "").replace("-", "_")
same = [w for w in words if w[0] == h["line"]]
if same:
same[0][1].add(mod)
else:
words.append((h["line"], set([mod])))
for word, mods in words:
k, _, v = word.partition("=")
rest = [x for x in v.split(",") if x and x.replace("-", "_") not in mods] \
if k.replace("-", "_") in BLACKLIST_PARAMS else []
keep = "%s=%s" % (k, ",".join(rest)) if rest else ""
what = "change %s to %s in" % (word, keep) if keep else "take %s out of" % word
if boot.get("grubby"): # every entry, and what grubby gives a new one
steps.append("sudo grubby --update-kernel=ALL --remove-args=%s%s" % (
shlex.quote(word), " --args=" + shlex.quote(keep) if keep else ""))
elif os.path.isfile(rp(root, "etc/default/grub")):
var, src = next(((n, f) for n in ("GRUB_CMDLINE_LINUX", "GRUB_CMDLINE_LINUX_DEFAULT")
for f, words in grub_settings(n, root)
if word in [x for w in words for x in w.split()]),
("GRUB_CMDLINE_LINUX", rp(root, "etc/default/grub")))
mk = next((cmd for t, cmd in GRUB_MKCONFIG if which(t, path=path)), "")
steps.append("%s %s in %s, then %s" % (what, var, "/" + os.path.relpath(src, root),
"`sudo %s`" % mk if mk else "write grub.cfg again"))
else:
steps.append("%s /etc/kernel/cmdline or /boot/loader/entries/*.conf" % what)
if files:
rels, carry = [], set()
for f in files:
for rel, yes in carriers(f, root):
if rel not in rels:
rels.append(rel)
if yes:
carry.add(rel)
# amdgpu-dkms's postrm rebuilds the initramfs of each kernel its build once succeeded for
# (its /var/tmp/amdgpu-dkms-kernels) -- during the purge, while the file is still there.
purged = [r for r in read_text(rp(root, "var/tmp/amdgpu-dkms-kernels")).split()
if r in rels and r not in carry] if amd else []
carry |= set(purged)
on, left = [r for r in rels if r in carry], [r for r in rels if r not in carry]
it, gen = "it" if len(files) == 1 else "one of them", boot.get("initramfs") or ""
cmds = [regen_one(gen, r, root) for r in on]
if not on:
steps.append("no initramfs here carries %s: nothing to rebuild" % it)
else:
steps.append("then rebuild each initramfs that carries %s: %s%s%s" % (
it, " && ".join(cmds) if all(cmds) else "the initramfs of " + ", ".join(on),
" (the purge rebuilds %s while the file is still there)" % " and ".join(r + "'s" for r in purged)
if purged else "",
" (%s %s not: leave %s alone)" % (" and ".join(r + "'s" for r in left),
"does" if len(left) == 1 else "do",
"it" if len(left) == 1 else "them") if left else ""))
text = "; ".join(steps)
if amd:
text = amdgpu_dkms_purge(machine.get("pkg_manager") or "", root) + " Then: " + text
return text
def lost_driver_hits(r):
"""The hard hits that leave display_drivers() record R with no driver on a new kernel:
those on the module bound to it -- or, with none bound, on each module that could drive
it, when every one of them has some. [] when a driver is left."""
hard = [h for h in r["off"] if h["hard"]]
if r["module"] and r["module"] in r["modules"]:
return [h for h in hard if h["module"] == r["module"]]
gone = set(h["module"] for h in hard)
return hard if r["modules"] and all(m in gone for m in r["modules"]) else []
def screen_records(machine):
"""The display_drivers() records of what shows the screen: the devices the firmware lit
(boot_vga) -- or every one, when none says so -- that are not lent to a VM."""
recs = [r for r in machine.get("display_drivers") or [] if not r["lent"]]
return [r for r in recs if r.get("boot_vga")] or recs
def nvidia_follows(machine, release="", root="/"):
"""(follows, why): does NVIDIA's own driver follow this machine onto RELEASE? Only DKMS takes
it there (nvidia_outlook()): it follows when dkms autoinstall builds an nvidia* module for
RELEASE (dkms_after_cap()) or one is installed for RELEASE already. With no RELEASE told --
or no DKMS entry here to judge by -- one registered counts. why: what DKMS does instead,
'' when it follows or nothing of NVIDIA's is registered."""
nv = machine.get("nvidia") or {}
if not nv.get("dkms"):
return False, ""
regs = [m for m in machine.get("dkms") or [] if m["name"].startswith("nvidia")]
if not release or not regs:
return True, ""
skipped = []
if dkms_after_cap(dict(machine, dkms=regs), release, None, root, skipped) or \
any(k == release and s == "installed" for m in regs for k, s in m.get("kernels") or []):
return True, ""
return False, "DKMS skips " + "; ".join("%s -- %s" % s for s in skipped)
def screen_without_driver(machine, release="", root="/"):
"""[(record, hits, kind)]: the screen's devices that get NO driver on a new kernel (RELEASE,
when it is told), which install refuses. 'generic': bound now to a module with a hard hit
-- the fix is the hit's. 'nvidia': nouveau has a hard hit and NVIDIA's own driver does not
follow to RELEASE (nvidia_follows(): no DKMS, or DKMS skips it there), whether nouveau or
NVIDIA's module drives it now -- the fix is NVIDIA's DKMS package; 'generic' only where
nouveau drives it and nothing of NVIDIA's is here. A device lent to a VM, one that has no
driver now and one that does not show the screen never refuse: display_outlook() warns
about them."""
nv, res, follows = machine.get("nvidia") or {}, [], None
for r in screen_records(machine):
if not r["module"]:
continue
if r["vendor"] != 0x10de:
hits = lost_driver_hits(r)
if hits:
res.append((r, hits, "generic"))
continue
hits = [h for h in r["off"] if h["hard"] and h["module"] == "nouveau"]
if hits and follows is None:
follows = nvidia_follows(machine, release, root)[0]
if hits and not follows: # with NVIDIA's own driver there, the blacklist is NVIDIA's
res.append((r, hits, "generic" if r["module"] == "nouveau" and not nv.get("own_driver")
and not nv.get("dkms") else "nvidia"))
return res
def screen_problem(r, hits, kind, release="", machine=None):
"""One line -- doctor --json carries it as it is: which device gets no driver on
RELEASE, and why. r, hits, kind: one of screen_without_driver()'s; MACHINE, where given,
says what DKMS does with NVIDIA's driver instead."""
said = "; ".join(hit_text(h) + owner_note(h) for h in hits)
if kind == "nvidia":
why = nvidia_follows(machine, release)[1] if machine is not None else ""
return ("NVIDIA at %s gets no driver on %s: NVIDIA's driver does not follow to it (%s), "
"and nouveau is switched off -- %s" % (r["addr"], release or "a new kernel", why or "no DKMS", said))
return "%s at %s gets no driver on %s: %s" % (r["module"] or " / ".join(r["modules"]), r["addr"],
release or "a new kernel", said)
def display_refusal_text(r, hits, kind, machine, release=""):
"""install_preflight()'s refusal for one of screen_without_driver()'s devices: what, why,
what it says of now, the fix, and what --yes does."""
new, said = release or "the new kernel", "; ".join(hit_text(h) + owner_note(h) for h in hits)
if kind == "nvidia":
lines = ["the screen's device -- NVIDIA at %s -- gets NO driver on %s: NVIDIA's driver does not "
"follow to %s (%s) and nouveau is switched off (%s)." % (
r["addr"], new, new, nvidia_follows(machine, release)[1] or "no DKMS", said)]
else:
lines = ["the screen's device -- %s at %s -- gets NO driver on %s: %s." % (
r["module"], r["addr"], new, said)]
now = loaded_now(r, machine.get("running") or "this kernel")
lines += [now] if now else []
return "\n ".join(lines + ["Fix: " + switched_off_fix(hits, machine, release, kind == "nvidia"),
"--yes installs anyway: on that kernel the screen shows its boot console "
"at best."])
def screen_file_hits(machine):
"""The hard modprobe.d hits that leave the screen with no driver (screen_records(),
lost_driver_hits(); NVIDIA's aside -- its own driver is another matter): the files that no
initramfs may carry. Only for a device bound now, screen_without_driver()'s rule: one with
no driver now (a BMC's VGA blacklisted on purpose) stops no install, before it or after
it -- display_outlook() warns about it."""
return [h for r in screen_records(machine) if r["vendor"] != 0x10de and r["module"]
for h in lost_driver_hits(r) if h["where"].startswith("/")]
def initramfs_screen_hits(release, machine, root="/"):
"""-> (hits, listing): the screen_file_hits() that RELEASE's NEW initramfs carries -- with
one of them in it, the screen's driver never loads there, whatever /etc says by then --
and the image's file list, read through sudo (an install holds it) for
post_install_checks() to use again. ([], None) when there is nothing to look for, or no
way to look: said, and never a reason to stop."""
files = screen_file_hits(machine)
img = initramfs_path(release, root)
if not files or not img:
return [], None
lister = next((t for t in ("lsinitrd", "lsinitramfs") if which(t, path=":".join(SYSTEM_PATH))), "")
if not lister:
warn("no lsinitrd or lsinitramfs here: whether %s carries %s cannot be told -- look before you "
"boot it" % (img, " or ".join(sorted(set(h["where"] for h in files)))))
return [], None
try:
p = sudo([lister, img], check=False, capture=True)
except ToolError as e:
warn("could not list %s: %s" % (img, str(e).splitlines()[0]))
return [], None
if p is None or p.returncode != 0: # its error text is no file list
warn("could not list %s%s: whether it carries %s cannot be told -- look before you boot it" % (
img, " (%s)" % (p.stdout or "").strip().splitlines()[0] if p is not None and (p.stdout or "").strip()
else "", " or ".join(sorted(set(h["where"] for h in files)))))
return [], None
listing = p.stdout or ""
return [h for h in files if in_initramfs(h["where"], listing)], listing
# dpcpp is a C++ driver: it belongs to CXX_NAME below.
COMPILER_NAME = re.compile(r"^(gcc|clang)(-\d+(\.\d+)*)?$|^(icx|icc|amdclang|nvc|armclang)$")
# kbuild's HOSTCXX. It compiles the GCC plugins (against the plugin headers of
# the chosen gcc, so it has to MATCH that gcc) and xconfig. `c++` is left out:
# it is g++ under a second name and a second inode.
CXX_NAME = re.compile(r"^(g\+\+|clang\+\+)(-\d+(\.\d+)*)?$|^(icpx|dpcpp|amdclang\+\+)$")
PYTHON_NAME = re.compile(r"^(python3(\.\d+)?|pypy3?)$")
LLVM_TOOLS = ("ld.lld", "llvm-ar", "llvm-nm", "llvm-objcopy", "llvm-objdump",
"llvm-readelf", "llvm-strip")
# Where compilers get installed without anybody adding them to PATH.
TOOL_GLOBS = ("/usr/local/bin", "/opt/*/bin", "/opt/*/*/bin", "~/opt/*/bin", "~/.local/bin",
"/usr/lib/llvm-*/bin", "/usr/lib64/llvm*/bin", "/opt/rh/*/root/usr/bin",
"/opt/intel/oneapi/compiler/*/bin", "/opt/rocm/bin", "/opt/rocm/llvm/bin",
"/opt/AMD/aocc-compiler-*/bin", "/opt/nvidia/hpc_sdk/Linux_*/*/compilers/bin")
# The satellite toolkit's prefixes, <opt-root>/<name>-<version>/bin and <name>-current: ~/opt
# (above) unless --opt-root names another, which is then searched as well, ahead of it. This
# tool installs nothing there; it only looks.
OPT_ROOT = ["~/opt"]
TRIPLE_ARCH = {"ppc64le": "powerpc64le", "ppc64": "powerpc64", "armv7l": "arm", "i686": "i"}
def numver(text):
"""(14, 3, 1) out of a version banner. The number after a closing paren
wins -- 'gcc (crosstool-NG 1.26.0) 13.2.0' -- else the first dotted one,
else the first integer ('kmod version 31')."""
m = re.search(r"\)\s+(\d+(?:\.\d+)+)", text) or re.search(r"(\d+(?:\.\d+)+)", text) \
or re.search(r"(\d+)", text)
return tuple(int(x) for x in m.group(1).split(".")) if m else ()
def ver_ge(have, need):
n = max(len(have), len(need), 3)
pad = lambda v: tuple(v) + (0,) * (n - len(v))
return pad(have) >= pad(need)
def vstr(v):
return ".".join(str(x) for x in v) if v else "?"
def tool_search_dirs(root="/", home=None, path_env=None, opt_root=None):
"""/usr/bin first, so a system tool is met under its plain name before any
alias of it; then the caller's PATH, in their order; then TOOL_GLOBS, with
--opt-root's <dir>/*/bin in front of ~/opt's."""
home = os.path.expanduser("~") if home is None else home
path_env = os.environ.get("PATH", "") if path_env is None else path_env
opt_root = OPT_ROOT[0] if opt_root is None else opt_root
pats = [home + p[1:] if p.startswith("~") else rp(root, p) for p in TOOL_GLOBS]
if opt_root and opt_root.rstrip("/") != "~/opt":
# A directory named on the command line: a real path, never under a fake `root`.
opt = home + opt_root[1:] if opt_root.startswith("~") else os.path.abspath(opt_root)
pats.insert(TOOL_GLOBS.index("~/opt/*/bin"), os.path.join(glob.escape(opt), "*", "bin"))
dirs = [rp(root, "usr/bin")] + [d for d in path_env.split(":") if d]
for pat in pats:
dirs.extend(sorted(glob.glob(pat)))
seen, res = set(), []
for d in dirs:
real = os.path.realpath(d)
if real not in seen and os.path.isdir(d):
seen.add(real)
res.append(d)
return res
def scan_tool_dirs(dirs):
"""-> (C compilers, pythons, C++ compilers): executable FILES whose names
look the part, one per real file PER KIND -- clang++ is a symlink to the
very binary clang is. PATH entries like ~/opt/gcc-17/lib hold a DIRECTORY
named gcc; the isfile() is not decoration."""
found = {"cc": [], "py": [], "cxx": []}
seen = {"cc": set(), "py": set(), "cxx": set()}
for d in dirs:
try:
names = sorted(os.listdir(d), key=lambda n: (len(n), n)) # 'clang' before 'clang-21'
except OSError:
continue
for name in names:
key = "cc" if COMPILER_NAME.match(name) else "cxx" if CXX_NAME.match(name) \
else "py" if PYTHON_NAME.match(name) else None
if not key:
continue
path = os.path.join(d, name)
real = os.path.realpath(path)
if real in seen[key] or not os.path.isfile(path) or not os.access(path, os.X_OK):
continue
seen[key].add(real)
found[key].append(path)
return found["cc"], found["py"], found["cxx"]
def compiler_info(path):
"""What a compiler says it is -- and whether kbuild can use it.
kbuild supports GCC and Clang, nothing else: ICC support was REMOVED from
Linux in 6.2. Intel's icx/DPC++ is LLVM-based and defines __clang__, so
kbuild's own probe would file it as 'Clang 22' and carry on; its banner
says neither 'gcc' nor 'clang', so a name test would file it as gcc. Hence
Intel is looked for FIRST, and vendor forks of clang are marked as such."""
try:
p = run([path, "--version"], check=False, timeout=20)
except ToolError:
return None
lines = [l.strip() for l in (p.stdout or "").splitlines()
if l.strip() and "warning:" not in l]
if p.returncode != 0 or not lines:
return None
line, real = lines[0], os.path.realpath(path)
inst = re.search(r"^InstalledDir:\s*(\S+)", p.stdout or "", re.M)
low = line.lower()
if "intel" in low or "(ifx)" in low or "(icc)" in low:
kind = "intel"
elif "clang" in low:
kind = "clang"
elif "gcc" in low or "free software foundation" in " ".join(lines).lower():
kind = "gcc"
else:
kind = "other"
usable, why = "yes", ""
if real.startswith(("/usr/bin/", "/usr/lib", "/usr/libexec/")):
origin = "system"
elif "/opt/rh/" in real or "gcc-toolset" in real or "devtoolset" in real:
origin = "Red Hat gcc-toolset (vendor-supported)"
elif "/rocm" in real or os.path.basename(path) == "amdclang":
origin, usable = "ROCm -- AMD's fork of LLVM, built for HIP", "fork"
why = "a vendor fork of clang: nobody tests kernels with it"
elif "/aocc" in real.lower():
origin, usable = "AOCC -- AMD's fork of LLVM", "fork"
why = "a vendor fork of clang: nobody tests kernels with it"
elif "/opt/intel" in real:
origin = "Intel oneAPI"
elif "/opt/nvidia" in real:
origin = "NVIDIA HPC SDK"
else:
origin = "local build"
if kind == "intel":
usable = "no"
why = ("kbuild supports GCC and Clang only: ICC support was removed in Linux 6.2, "
"and icx/DPC++ -- LLVM underneath -- is not a supported kernel compiler")
elif kind == "other":
usable, why = "no", "neither GCC nor Clang"
triple = ""
if usable != "no":
try:
triple = (run([path, "-dumpmachine"], check=False, timeout=20).stdout or "").strip()
except ToolError:
pass
arch = os.uname().machine
if triple and not triple.startswith(TRIPLE_ARCH.get(arch, arch)):
usable, why = "no", "targets %s, not this machine (%s)" % (triple, arch)
return {"path": path, "real": real, "kind": kind, "version": line, "numver": numver(line),
"system": origin == "system", "origin": origin, "usable": usable, "why": why,
"triple": triple, "installed": inst.group(1) if inst else "",
"experimental": bool(re.search(r"experimental|prerelease|\dgit\b", line))}
def compiler_works(path, extra_path=None):
"""Can it turn a C file into an executable IN THE SCRUBBED ENVIRONMENT the
build will run in? A private compiler that needs LD_LIBRARY_PATH to find
its own libisl works in your shell and dies at the first object here."""
try:
p = run([path, "-x", "c", "-", "-o", os.devnull], check=False, timeout=60,
stdin_data="int main(void) { return 0; }\n", env=scrubbed_env(extra_path))
except ToolError as e:
return False, str(e).splitlines()[0]
if p.returncode == 0:
return True, ""
return False, ((p.stdout or "").strip().splitlines() or ["exit %d" % p.returncode])[-1][:120]
def llvm_companions(clang_path):
"""-> (LLVM= argument, extra PATH dir or None), or None when the set beside
this clang is incomplete. kbuild takes LLVM=1 (plain names on PATH),
LLVM=/dir/ (a prefix) or LLVM=-18 (a suffix: Debian's clang-18, ld.lld-18)."""
d, name = os.path.split(clang_path)
m = re.match(r"^clang(-\d+(?:\.\d+)*)$", name)
suffix = m.group(1) if m else ""
if suffix and not all(os.access(os.path.join(d, t + suffix), os.X_OK) for t in LLVM_TOOLS) \
and os.path.realpath(os.path.join(d, "clang")) == os.path.realpath(clang_path):
# An upstream LLVM install: bin/clang-21 is the file, bin/clang a link to it, and
# ld.lld, llvm-ar ... carry no suffix. The realpath of <opt>/clang-current/bin/clang
# (what the satellite toolkit passes) ends in clang-21: it is that prefix's clang.
suffix = ""
if not all(os.access(os.path.join(d, t + suffix), os.X_OK) for t in LLVM_TOOLS):
return None
on_path = os.path.realpath(d) in [os.path.realpath(x) for x in SYSTEM_PATH[:4]]
if suffix:
return "LLVM=" + suffix, None if on_path else d
if on_path:
return "LLVM=1", None
return "LLVM=" + d + "/", None
def own_binutils(cc_path):
"""The directory, when a compiler ships its own ld AND as beside it (every
gcc-toolset does) -- they were built and tested together."""
d = os.path.dirname(cc_path)
both = all(os.access(os.path.join(d, t), os.X_OK) for t in ("ld", "as"))
return d if both and os.path.realpath(d) != os.path.realpath("/usr/bin") else ""
def _collect(cands, plain):
"""One entry per COMPILER, not per name and not per banner. amdclang is
ROCm's clang and dpcpp is icx: same banner, same InstalledDir -- one entry,
under the plain name. Two installs built from one commit have the same
banner too, and are two compilers: InstalledDir tells them apart. (Merging
on the banner alone hid exactly those.)"""
cands = sorted(cands, key=lambda c: os.path.basename(c) not in plain)
found, keys = [], set()
for c in cands:
ci = compiler_info(c)
if not ci:
continue
key = (ci["version"], ci["installed"]) if ci["installed"] else ("", ci["real"])
if key not in keys:
keys.add(key)
found.append(ci)
rank = {"yes": 0, "fork": 1, "no": 2}
found.sort(key=lambda t: (rank[t["usable"]], not t["system"], t["kind"] != "gcc",
tuple(-x for x in t["numver"])))
return found
def find_toolchains(dirs=None):
dirs = tool_search_dirs() if dirs is None else dirs
return _collect(scan_tool_dirs(dirs)[0], ("gcc", "clang", "icx"))
def find_cxx(dirs=None):
dirs = tool_search_dirs() if dirs is None else dirs
return _collect(scan_tool_dirs(dirs)[2], ("g++", "clang++", "icpx"))
def cxx_partner(cc_path):
"""The C++ driver installed beside a C compiler: gcc-13 -> g++-13."""
d, name = os.path.split(cc_path)
m = re.match(r"^(gcc|clang)(-\d+(?:\.\d+)*)?$", name)
if not m:
return ""
cand = os.path.join(d, ("g++" if m.group(1) == "gcc" else "clang++") + (m.group(2) or ""))
if m.group(2) and not os.access(cand, os.X_OK) \
and os.path.realpath(os.path.join(d, m.group(1))) == os.path.realpath(cc_path):
# bin/clang-21 reached through the realpath of bin/clang: the partner is bin/clang++
cand = os.path.join(d, "g++" if m.group(1) == "gcc" else "clang++")
return cand if os.access(cand, os.X_OK) else ""
def find_binutils(dirs=None):
"""Every GNU binutils set: a directory holding both ld and as."""
dirs = tool_search_dirs() if dirs is None else dirs
sets, inodes = [], set()
for d in sorted(dirs, key=lambda x: (os.path.realpath(x) != os.path.realpath("/usr/bin"), len(x))):
ld = os.path.join(d, "ld")
if not all(os.access(os.path.join(d, t), os.X_OK) for t in ("ld", "as")):
continue
st = os.stat(ld)
if (st.st_dev, st.st_ino) in inodes:
continue # <prefix>/<triple>/bin/ld is a hard link to <prefix>/bin/ld
inodes.add((st.st_dev, st.st_ino))
try:
line = ((run([ld, "-v"], check=False, timeout=20).stdout or "").splitlines() or [""])[0]
except ToolError:
continue
# STARTS with: lld's banner is "LLD 18.1.3 (compatible with GNU linkers)",
# and gold's is "GNU gold" -- an `ld` that is really either is not bfd.
if not line.startswith("GNU ld"):
continue
if any(b["version"] == line.strip() and os.path.commonpath([b["dir"], d]) not in ("/", "")
and os.path.dirname(b["dir"]) in d for b in sets):
continue # ...or a copy of it, under the same prefix
sets.append({"dir": d, "version": line.strip(), "numver": numver(line),
"system": os.path.realpath(d) == os.path.realpath("/usr/bin")})
return sets
def find_pythons(dirs=None):
dirs = tool_search_dirs() if dirs is None else dirs
res = []
# (index 1 of scan_tool_dirs: the pythons)
probe = ("import sys, platform; print(platform.python_implementation(), "
"'.'.join(str(x) for x in sys.version_info[:3]))")
for path in scan_tool_dirs(dirs)[1]:
try:
o = (run([path, "-c", probe], check=False, timeout=20).stdout or "").strip()
except ToolError:
continue
m = re.match(r"^(\w+) (\d+\.\d+\.\d+)$", o.splitlines()[-1] if o else "")
if not m or int(m.group(2).split(".")[0]) < 3:
continue
res.append({"path": path, "impl": m.group(1), "version": m.group(2),
"numver": numver(m.group(2)),
"platform": os.path.realpath(path) == os.path.realpath(PLATFORM_PYTHON)})
res.sort(key=lambda y: (not y["platform"], y["impl"] != "CPython",
tuple(-x for x in y["numver"])))
return res
MISC_TOOLS = (("make", "--version"), ("ld.bfd", "-v"), ("ld.lld", "-v"), ("ld.gold", "-v"),
("mold", "-v"), ("ccache", "--version"), ("pahole", "--version"),
("rustc", "--version"), ("bindgen", "--version"))
NOT_FOR_KERNELS = {"ld.gold": "the kernel refuses gold outright",
"mold": "kbuild does not support mold"}
def find_misc_tools():
res = {}
for name, flag in MISC_TOOLS:
path = which(name, path=":".join(SYSTEM_PATH))
if path:
try:
o = run([path, flag], check=False, timeout=20).stdout or ""
except ToolError:
o = ""
res[name] = {"path": path, "version": (o.strip().splitlines() or [""])[0][:70]}
return res
DKMS_LINE = re.compile(r"^([^/,\s]+)[/,]\s*([^,\s]+),\s*([^,\s]+),\s*[^:]+:\s*(\S+)")
def parse_dkms_status(text):
mods = {}
for line in text.splitlines():
m = DKMS_LINE.match(line.strip())
if m:
name, ver, kernel, state = m.groups()
mods.setdefault((name, ver), []).append((kernel, state))
return mods
def parse_dkms_conf(text):
lim = {}
for key in ("BUILD_EXCLUSIVE_KERNEL_MIN", "BUILD_EXCLUSIVE_KERNEL_MAX",
"BUILD_EXCLUSIVE_KERNEL"):
m = re.search(r"^%s=[\"']?([^\"'\n]+)[\"']?\s*$" % key, text, re.M)
if m:
lim[key] = m.group(1)
return lim
def dkms_will_build(limits, release):
"""Mirror dkms's own rule: skip when VER(kernel) > MAX or < MIN, or when
the BUILD_EXCLUSIVE_KERNEL regex does not match. Note 6.18.52 > '6.18'."""
k = version_key(release)[:3]
if "BUILD_EXCLUSIVE_KERNEL_MAX" in limits:
if k > version_key(limits["BUILD_EXCLUSIVE_KERNEL_MAX"])[:3]:
return False, "capped at %s" % limits["BUILD_EXCLUSIVE_KERNEL_MAX"]
if "BUILD_EXCLUSIVE_KERNEL_MIN" in limits:
if k < version_key(limits["BUILD_EXCLUSIVE_KERNEL_MIN"])[:3]:
return False, "needs at least %s" % limits["BUILD_EXCLUSIVE_KERNEL_MIN"]
if "BUILD_EXCLUSIVE_KERNEL" in limits:
try:
if not re.search(limits["BUILD_EXCLUSIVE_KERNEL"], release):
return False, "BUILD_EXCLUSIVE_KERNEL does not match"
except re.error:
pass
return True, ""
def dkms_modules(root="/"):
"""From /var/lib/dkms itself -- <module>/<version>/<kernel>/, with a
kernel-<k>-<arch> symlink per INSTALLED build. `dkms status` says the same
in three seconds of bash; this is every command's start-up path."""
base = rp(root, "var/lib/dkms")
try:
names = sorted(n for n in os.listdir(base) if os.path.isdir(os.path.join(base, n)))
except OSError:
names = None
if names is None:
if not which("dkms", path=":".join(SYSTEM_PATH)):
return []
p = run(["dkms", "status"], check=False, env=scrubbed_env(), timeout=60)
return [{"name": n, "version": v, "kernels": k,
"limits": parse_dkms_conf(read_text(rp(root, "usr/src/%s-%s/dkms.conf" % (n, v))))}
for (n, v), k in sorted(parse_dkms_status(p.stdout or "").items())]
mods = []
for name in names:
mdir = os.path.join(base, name)
installed = set()
for entry in os.listdir(mdir):
full = os.path.join(mdir, entry)
if entry.startswith("kernel-") and os.path.islink(full):
parts = os.readlink(full).split("/")
if len(parts) >= 2:
installed.add((parts[0], parts[1]))
for ver in sorted(os.listdir(mdir)):
vdir = os.path.join(mdir, ver)
if os.path.islink(vdir) or not os.path.isdir(vdir) or ver == "original_module":
continue
kernels = [(k, "installed" if (ver, k) in installed else "built")
for k in sorted(os.listdir(vdir))
if k not in ("source", "build") and os.path.isdir(os.path.join(vdir, k))
and not os.path.islink(os.path.join(vdir, k))]
conf = read_text(os.path.join(vdir, "source", "dkms.conf")) or \
read_text(rp(root, "usr/src/%s-%s/dkms.conf" % (name, ver)))
mods.append({"name": name, "version": ver, "kernels": kernels,
"limits": parse_dkms_conf(conf)})
return mods
# ------------------------------------------------------------- the amdgpu-dkms cap
# fix-amdgpu-dkms.sh, generalised: it capped ONE pinned package (6.19.14-2364437.el10) at
# "6.18". Here: every /usr/src/amdgpu-*/dkms.conf, at the series it serves + .999 -- the
# rule dkms_check() has always printed -- because dkms compares versions so that 6.18.52
# sorts ABOVE "6.18": the legacy value excluded every point release of the very series the
# package was installed for.
AMDGPU_CONFS = "usr/src/amdgpu-*/dkms.conf"
CAP_KEY = "BUILD_EXCLUSIVE_KERNEL_MAX"
CAP_BACKUP = ".pre-elkt-dkms-cap"
CAP_COMMENT = """
# Added by %s dkms-cap, %s. Kernels after %s are built with the in-tree
# amdgpu as a MODULE (CONFIG_DRM_AMDGPU=m), newer than this package, which does not build
# against them; dkms skips every kernel that sorts ABOVE this cap. The file as it was
# before: %s
"""
def amdgpu_cap_for(kernels, running, series=""):
"""BUILD_EXCLUSIVE_KERNEL_MAX for an amdgpu-dkms package: the series of the newest
kernel it is built or installed for (else the running one) + '.999' -- dkms skips a
kernel that sorts ABOVE the cap, and 6.12.5 sorts above '6.12'. SERIES (--max) names
the series instead; a full X.Y.Z is taken as it is."""
if series:
return series if series.count(".") >= 2 else series + ".999"
return series_of(max(list(kernels) or [running], key=version_key)) + ".999"
def amdgpu_dkms_packages(root="/", dkms=None):
"""Every amdgpu-dkms here, one per /usr/src/amdgpu-<version>/dkms.conf -> [{version,
conf, real, text, limits, kernels, registered}]. `real` is the file behind the name:
AMD's packaging makes dkms.conf a SYMLINK into amd/dkms/, and the cap goes into the
file, the backup is a copy of the file (build_everything.sh learned that `cp -a`
copies the link -- a 'backup' that then shows the modified text)."""
dkms = dkms_modules(root) if dkms is None else dkms
pkgs = []
for conf in sorted(glob.glob(rp(root, AMDGPU_CONFS))):
ver = os.path.basename(os.path.dirname(conf))[len("amdgpu-"):]
real = os.path.realpath(conf)
if not os.path.isfile(real) or not os.access(real, os.R_OK):
warn("%s: %s -- left alone" % (conf, "cannot be read" if os.path.isfile(real)
else "a link to nothing (%s)" % real))
continue
text = read_text(real)
reg = next((m for m in dkms if m["name"] == "amdgpu" and m["version"] == ver), None)
pkgs.append({"version": ver, "conf": conf, "real": real, "text": text,
"limits": parse_dkms_conf(text), "kernels": (reg or {}).get("kernels", []),
"registered": reg is not None})
return pkgs
def cap_amdgpu_dkms(pkgs, running, series="", force=False, dry=False, root="/", before_write=None):
"""Cap each package: a DEREFERENCED backup <real>.pre-elkt-dkms-cap, once (the first one
is what the vendor shipped); BUILD_EXCLUSIVE_KERNEL_MAX="<cap>" appended to the real file
-- an existing value is left as it is unless `force`; then the stale build directory a
failed DKMS build leaves, /var/lib/dkms/amdgpu/<version>/build, removed (never `dkms
remove`: a scoped remove that finds no state is a needless risk to the kernels that use
it). sudo for each write -- `before_write()` once, first; with `dry` the commands are
printed instead. A failed write is said, and the next package still gets its turn.
-> [{version, conf, real, backup, max_before, max_after, action, build_removed, error}]"""
results, primed = [], [False]
def write(cmd, **kw):
if before_write and not dry and not primed[0]:
before_write()
primed[0] = True
return sudo(cmd, dry=dry, **kw)
for p in pkgs:
label = "amdgpu/%s" % p["version"]
have = p["limits"].get(CAP_KEY)
cap = amdgpu_cap_for([k for k, _ in p["kernels"]], running, series)
backup = p["real"] + CAP_BACKUP
res = {"version": p["version"], "conf": p["conf"], "real": p["real"], "backup": backup,
"max_before": have, "max_after": have, "action": "", "build_removed": False,
"error": ""}
results.append(res)
lost = [k for k, st in p["kernels"] if st == "installed"
and version_key(k)[:3] > version_key(cap)[:3]]
try:
if have and not force:
res["action"] = "kept"
ok("%s: already capped, %s=\"%s\" -- left as it is (--force replaces it)"
% (label, CAP_KEY, have))
elif lost and not force:
res["action"], res["error"] = "refused", "would exclude " + ", ".join(lost)
bad("%s: a cap of %s would exclude %s, which it is INSTALLED for -- not capped. "
"--max a higher series, or --force" % (label, cap, ", ".join(lost)))
elif have == cap:
res["action"] = "kept"
ok("%s: already capped at %s" % (label, cap))
else:
if os.path.lexists(backup):
note("%s: the backup is already there, %s -- kept (the first one is the "
"vendor's file)" % (label, backup))
else:
write(["cp", "-aL", p["real"], backup])
(info if dry else ok)("%s: %s %s" % (label, "backup would go to" if dry
else "backup ->", backup))
if have:
text = re.sub(r"^%s=.*$" % CAP_KEY, '%s="%s"' % (CAP_KEY, cap), p["text"], flags=re.M)
write(["tee", p["real"]], capture=True, stdin_data=text)
res["action"] = "replaced"
else:
block = ("" if p["text"].endswith("\n") or not p["text"] else "\n") + CAP_COMMENT % (
TOOL, time.strftime("%Y-%m-%d"), cap[:-4] if cap.endswith(".999") else cap,
backup) + '%s="%s"\n' % (CAP_KEY, cap)
write(["tee", "-a", p["real"]], capture=True, stdin_data=block)
res["action"] = "capped"
if dry:
for line in block.strip("\n").splitlines():
info(" | " + line)
res["max_after"] = cap
(info if dry else ok)("%s: %s=\"%s\" %s %s%s" % (
label, CAP_KEY, cap, "would go into" if dry else "is now in", p["real"],
" (was \"%s\")" % have if have else ""))
build = rp(root, "var/lib/dkms/amdgpu/%s/build" % p["version"])
if os.path.isdir(build) and not os.path.islink(build):
write(["rm", "-rf", build])
res["build_removed"] = True
(info if dry else ok)("%s: %s the stale %s (what a failed build leaves)"
% (label, "would remove" if dry else "removed", build))
except ToolError as e:
res["action"], res["error"] = "failed", str(e).splitlines()[0]
bad("%s: %s" % (label, res["error"]))
return results
def dkms_status_lines():
"""`dkms status`, as it says it (a non-root caller can read it)."""
if not which("dkms", path=":".join(SYSTEM_PATH)):
return []
try:
p = run(["dkms", "status"], check=False, env=scrubbed_env(), timeout=120)
except ToolError as e:
return ["(dkms status: %s)" % str(e).splitlines()[0]]
return [l for l in (p.stdout or "").splitlines() if l.strip()]
def cap_before_install(machine, release, dry=False, root="/"):
"""PLAN.md section 4 and build_everything.sh's stage order: the cap goes on BEFORE the
kernel's own `make install`, whose hooks run `dkms autoinstall` for the new release --
when a registered amdgpu-dkms would otherwise try (and fail) to build for it. Inside the
install's sudo session. -> the results of cap_amdgpu_dkms(), [] when nothing needed it."""
regs = [m for m in machine.get("dkms") or [] if m["name"] == "amdgpu"
and dkms_will_build(m.get("limits") or {}, release)[0]]
if not regs:
return []
todo = []
for p in amdgpu_dkms_packages(root, dkms=machine.get("dkms") or []):
if not any(m["version"] == p["version"] for m in regs):
continue
cap = amdgpu_cap_for([k for k, _ in p["kernels"]], machine["running"])
if p["limits"].get(CAP_KEY):
note("amdgpu/%s has its own cap, %s=\"%s\", and DKMS will build it for %s: left as "
"it is" % (p["version"], CAP_KEY, p["limits"][CAP_KEY], release))
elif dkms_will_build(dict(p["limits"], **{CAP_KEY: cap}), release)[0]:
note("amdgpu/%s: %s is in the series it serves (%s) -- DKMS builds it for this kernel "
"too; not capped" % (p["version"], release, cap))
else:
todo.append(p)
if not todo:
return []
say("amdgpu-dkms would try -- and fail -- to build for %s: capping it first (dkms-cap)" % release)
results = cap_amdgpu_dkms(todo, machine["running"], dry=dry, root=root)
for r in results:
for m in machine.get("dkms") or []:
if m["name"] == "amdgpu" and m["version"] == r["version"] and r["max_after"]:
m["limits"] = dict(m.get("limits") or {}, **{CAP_KEY: r["max_after"]})
return results
def dkms_conf_builds(text):
"""(autoinstall, modules) of a dkms.conf. autoinstall: AUTOINSTALL as dkms reads it -- set
by yes, in any case, and by nothing else. modules: the names of the modules it builds,
BUILT_MODULE_NAME[n] (PACKAGE_NAME for a package that names none), '-' as '_'. (None, [])
when there is no text to read. Apart from parse_dkms_conf(): that one is BUILD_EXCLUSIVE's."""
if not (text or "").strip():
return None, []
def values(key):
res = []
for raw in re.findall(r"^\s*%s=(.*)$" % key, text, re.M):
try:
res.append((shlex.split(raw, comments=True) or [""])[0])
except ValueError:
res.append(raw.strip().strip("\"'"))
return res
auto = values("AUTOINSTALL")
pkg = (values("PACKAGE_NAME") or [""])[-1]
names = [re.sub(r"\$\{?PACKAGE_NAME\}?", lambda _: pkg, n) for n in values(r"BUILT_MODULE_NAME\[\d+\]")] or [pkg]
return bool(auto) and auto[-1].lower() == "yes", [n.replace("-", "_") for n in names if n]
def dkms_after_cap(machine, release, opts=None, root="/", skipped=None):
"""The DKMS modules that `make install`'s hooks will build for RELEASE -- dkms autoinstall's
own choice, made as the install will make it, with nothing written: install_preflight()
asks before the cap goes on. An amdgpu-dkms without a cap of its own is capped first, as
cap_before_install() caps it (not with --no-dkms-cap); then dkms skips what BUILD_EXCLUSIVE
excludes, what its dkms.conf does not mark AUTOINSTALL=yes, and what is installed for RELEASE
already (a reinstall of the same release: autoinstall leaves those files as they are, and
they are judged as files). With no RELEASE (it cannot be told yet) only what builds for
every kernel counts. -> [the machine's dkms entries, each with 'modules': what it builds];
SKIPPED, a list, gets (name/version, why) of the others."""
res = []
for m in machine.get("dkms") or []:
label, limits = "%s/%s" % (m["name"], m["version"]), dict(m.get("limits") or {})
conf = rp(root, "usr/src/%s-%s/dkms.conf" % (m["name"], m["version"]))
if m["name"] == "amdgpu" and CAP_KEY not in limits and os.path.isfile(conf) \
and not (opts or {}).get("no_dkms_cap"):
limits[CAP_KEY] = amdgpu_cap_for([k for k, _ in m.get("kernels") or []], machine.get("running") or "")
auto, names = dkms_conf_builds(read_text(rp(root, "var/lib/dkms/%s/%s/source/dkms.conf"
% (m["name"], m["version"]))) or read_text(conf))
if auto is False:
why = "its dkms.conf does not say AUTOINSTALL=yes"
elif not release:
why = "it builds only for some kernels (BUILD_EXCLUSIVE), and which release comes is not told yet" \
if limits else ""
elif any(k == release and s == "installed" for k, s in m.get("kernels") or []):
why = "installed for %s already: dkms autoinstall leaves its files as they are" % release
else:
will, why = dkms_will_build(limits, release)
why = "" if will else why
if why:
if skipped is not None:
skipped.append((label, why))
else:
res.append(dict(m, modules=names or [m["name"].replace("-", "_")]))
return res
def detect_machine():
osr = parse_os_release(read_text("/etc/os-release"))
devs = scan_pci()
virt = ""
if which("systemd-detect-virt"):
virt = (run(["systemd-detect-virt"], check=False).stdout or "").strip()
cpu = cpu_info()
if not virt or virt == "none":
virt = "yes (unidentified)" if cpu["hypervisor"] else "none"
tool_dirs = tool_search_dirs()
initramfs = next((t for t in ("dracut", "update-initramfs", "mkinitcpio")
if which(t, path=":".join(SYSTEM_PATH))), "")
dkms = dkms_modules()
pm = find_pkg_manager()
m = {
"os": osr.get("PRETTY_NAME", osr.get("ID", "unknown")),
"os_id": osr.get("ID", ""), "os_like": osr.get("ID_LIKE", ""),
"os_version": osr.get("VERSION_ID", ""),
"arch": os.uname().machine, "running": os.uname().release,
"pkg_manager": pm,
"cpu": {"vendor": cpu["vendor"], "model": cpu["model"],
"threads": cpu["threads"],
"simd": [f for f in ("avx512f", "avx2", "avx", "sse4_2") if f in cpu["flags"]][:1],
"level": cpu["level"], "hybrid": cpu["hybrid"],
"cet": cet_support(cpu["flags"], running_config(os.uname().release))},
"memory": mem_total(), "virt": virt,
"pci": devs, "gpu_vendors": sorted(gpu_vendors_present(devs)),
"storage": storage_stack(running=os.uname().release),
"firmware_compression": firmware_compression(),
"secure_boot": secure_boot(), "efi": efi_boot(),
"boot": {"grubby": bool(which("grubby", path=":".join(SYSTEM_PATH))),
"bls": os.path.isdir("/boot/loader/entries"), "initramfs": initramfs},
"toolchains": find_toolchains(tool_dirs), "cxx": find_cxx(tool_dirs),
"binutils": find_binutils(tool_dirs),
"pythons": find_pythons(tool_dirs), "dkms": dkms,
"nvidia": nvidia_state(devs, dkms, os.uname().release),
"display_drivers": display_facts(devs, os.uname().release, pm),
"loaded_modules": len(read_text("/proc/modules").splitlines()),
"now": now_facts(), "acpi_override": acpi_override(os.uname().release),
}
m["default_entry"] = default_entry_health(m)
m["boot_line"] = boot_line_facts(m)
m["dkms_cert"] = dkms_certificate(machine=m)
return m
def display_facts(devs, running, pm, root="/"):
"""display_drivers(), and which package put each switch-off file there (owner_note()):
asked once per file, as dpkg-query and rpm each take a moment."""
shown, owners = display_drivers(devs, running, root), {}
for h in [h for r in shown for h in r["off"] if h["where"].startswith("/")]:
if h["where"] not in owners:
owners[h["where"]] = file_owner(rp(root, h["where"]), pm)
h["owner"] = owners[h["where"]]
return shown
def refresh_machine(machine, root="/"):
"""MACHINE with what can change while a build runs read again: install_kernel()'s first
step. cmd_build() detects the machine once, and the install comes an hour later -- while
one 45-minute build ran (2026-10-21), PackageKit retried a half-configured amdgpu-dkms
about a dozen times, each time writing its blacklist again, and an autoremove took dkms
away. Read again: the display drivers and who owns their switch-off files, DKMS and
its certificate, NVIDIA's driver, Secure Boot and the boot path, what the running kernel
does, the default boot entry's health. The slow probes (compilers, pythons) stay as they
were. A copy: the caller's dict is left alone."""
m = dict(machine)
running = m.get("running") or os.uname().release
devs = scan_pci(root)
m["dkms"] = dkms_modules(root)
m["nvidia"] = nvidia_state(devs, m["dkms"], running, root)
m["display_drivers"] = display_facts(devs, running, m.get("pkg_manager") or "", root)
m["secure_boot"], m["efi"] = secure_boot(root), efi_boot(root)
m["now"] = now_facts(root)
m["dkms_cert"] = dkms_certificate(root, m)
m["default_entry"] = default_entry_health(m, root)
return m
# ----------------------------------------------------------------------- .config
class DotConfig(object):
"""A kernel .config, edited in memory and written atomically.
set() REMOVES the symbol's existing line and APPENDS the new one. Kconfig
resolves a choice group by 'the last =y read wins', and scripts/config
edits in place -- so enabling DEBUG_INFO_NONE there rewrote a line sitting
ABOVE the distro's DWARF selection and silently lost. Appending makes the
value we set the last one read, for every choice, without special cases."""
LINE = re.compile(r"^(?:CONFIG_([A-Za-z0-9_]+)=(.*)|# CONFIG_([A-Za-z0-9_]+) is not set)\s*$")
def __init__(self, path):
self.path = path
self.lines = read_text(path).splitlines()
@classmethod
def of_text(cls, text):
"""A .config that is no file of its own (/proc/config.gz's): read, never written."""
cfg = cls.__new__(cls)
cfg.path, cfg.lines = "", text.splitlines()
return cfg
def _sym(self, line):
m = self.LINE.match(line)
if not m:
return None, None
if m.group(1):
return m.group(1), m.group(2)
return m.group(3), "n"
def get(self, sym):
"""'y' | 'm' | 'n' | the unquoted value | None when absent entirely."""
val = None
for line in self.lines:
s, v = self._sym(line)
if s == sym:
val = v
if val is not None and len(val) >= 2 and val[0] == '"' and val[-1] == '"':
val = val[1:-1].replace('\\"', '"')
return val
def set(self, sym, rendered):
"""rendered: 'y' 'm' 'n', or the literal right-hand side."""
self.lines = [l for l in self.lines if self._sym(l)[0] != sym]
if rendered == "n":
self.lines.append("# CONFIG_%s is not set" % sym)
else:
self.lines.append("CONFIG_%s=%s" % (sym, rendered))
def count(self, value):
return sum(1 for l in self.lines if self._sym(l)[1] == value)
def valid(self):
return any(self._sym(l)[0] for l in self.lines)
def write(self):
tmp = "%s.tmp.%d" % (self.path, os.getpid())
with open(tmp, "w") as f:
f.write("\n".join(self.lines) + "\n")
os.replace(tmp, self.path)
SRCARCH = {"x86_64": "x86", "i686": "x86", "i386": "x86", "aarch64": "arm64",
"ppc64le": "powerpc", "ppc64": "powerpc", "s390x": "s390",
"riscv64": "riscv", "armv7l": "arm", "loongarch64": "loongarch"}
KCONFIG_DEF = re.compile(r"^\s*(?:menu)?config\s+([A-Za-z0-9_]+)\s*$", re.M)
_KCONFIG = {}
def kconfig_symbols(tree, machine_arch):
"""Every symbol THIS tree defines for THIS architecture. It is what lets
verify tell 'not in this kernel version' (DRM_XE before 6.8 -- a note) from
'present, requested, and dropped' (a dependency is off -- a failure)."""
key = (os.path.realpath(tree), machine_arch)
if key not in _KCONFIG:
_KCONFIG[key] = _kconfig_symbols(tree, machine_arch)
return _KCONFIG[key]
def _kconfig_symbols(tree, machine_arch):
arch = SRCARCH.get(machine_arch, machine_arch)
syms = set()
for dirpath, dirnames, filenames in os.walk(tree):
rel = os.path.relpath(dirpath, tree)
if rel == "arch":
dirnames[:] = [d for d in dirnames if d == arch]
elif rel == ".":
dirnames[:] = [d for d in dirnames
if d not in ("Documentation", "tools", "samples", ".git")]
for fn in filenames:
if fn.startswith("Kconfig"):
syms.update(KCONFIG_DEF.findall(read_text(os.path.join(dirpath, fn))))
return syms
def tree_version(tree):
"""(major, minor) from the tree's own Makefile, without running make."""
text = read_text(os.path.join(tree, "Makefile"))
v = re.search(r"^VERSION\s*=\s*(\d+)", text, re.M)
p = re.search(r"^PATCHLEVEL\s*=\s*(\d+)", text, re.M)
return (int(v.group(1)), int(p.group(1))) if v and p else (0, 0)
LEVEL_RANK = {"x86-64": 1, "x86-64-v2": 2, "x86-64-v3": 3, "x86-64-v4": 4}
LEVEL_MACROS = (("x86-64-v2", ("__SSE4_2__", "__POPCNT__", "__SSSE3__")),
("x86-64-v3", ("__AVX2__", "__BMI__", "__BMI2__", "__FMA__", "__MOVBE__")),
("x86-64-v4", ("__AVX512F__", "__AVX512BW__", "__AVX512DQ__", "__AVX512VL__")))
LEVEL_CPUS = {"x86-64": "any x86-64 CPU", "x86-64-v2": "Nehalem / Jaguar or newer",
"x86-64-v3": "Haswell / Zen 1 or newer (AVX2, BMI2, MOVBE)",
"x86-64-v4": "an AVX-512 CPU (Skylake-X, Ice Lake, Zen 4 ...)"}
def march_level(cc, march, env=None):
"""The x86-64 level that code built with -march=MARCH assumes, asked of the
compiler itself -- the macros it predefines -- so a CPU NAME (raptorlake,
znver4) and 'native' are judged like x86-64-v3. None: the compiler does not
accept that -march at all."""
try:
p = run([cc, "-march=" + march, "-dM", "-E", "-x", "c", "-"], stdin_data="",
check=False, env=env, timeout=20)
except ToolError:
return None
if p.returncode != 0:
return None
have = set(re.findall(r"^#define (__\w+__) 1\s*$", p.stdout or "", re.M))
level = "x86-64"
for name, need in LEVEL_MACROS:
if not all(x in have for x in need):
break
level = name
return level
def cpu_flags(cpu, tree, arch):
"""-> (CONFIG_X86_NATIVE_CPU?, KCFLAGS). Upstream has ONE Kconfig switch for
this: X86_NATIVE_CPU, -march=native, from 6.16 on. Everything else -- an
x86-64 level, a CPU by name, native on an older tree -- goes in KCFLAGS,
which kbuild appends AFTER its own -march=x86-64: the last -march wins, and
the -mno-sse -mno-avx ... that keep SIMD out of kernel code still apply (an
explicit -mno- beats whatever a -march implies). gcc 14 and clang 21 both
take every level with -m32 too: the 32-bit vDSO is built with these flags."""
if cpu == "generic":
return False, ""
if cpu == "native" and "X86_NATIVE_CPU" in kconfig_symbols(tree, arch):
return True, ""
if cpu.startswith("x86-64"):
return False, "-march=" + cpu
return False, "-march=%s -mtune=%s" % (cpu, cpu)
def apply_cpu(tc, opts, tree, arch):
"""Put the CPU choice into the make arguments, now that the tree is known."""
native, flags = cpu_flags(opts["cpu"], tree, arch)
tc["args"] = [a for a in tc["args"] if not a.startswith("KCFLAGS=")] + \
(["KCFLAGS=" + flags] if flags else [])
tc["kcflags"] = flags
opts["_native_kconfig"] = native
return flags
def fit_llvm_to_tree(tc, tree):
"""kbuild learned LLVM=<prefix>/ and LLVM=-<suffix> in 5.18. Before that ANY
value means LLVM=1: clang, ld.lld ... by their plain names on PATH -- the
SYSTEM's clang, not the one chosen. So an older tree gets LLVM=1 with the
chosen clang's directory first on PATH. LLVM= itself arrived in 5.7."""
arg = next((a for a in tc["args"] if a.startswith("LLVM=")), None)
ver = tree_version(tree)
if arg is None or ver == (0, 0) or ver >= (5, 18):
return
if ver < (5, 7):
raise ToolError("clang builds go through kbuild's LLVM=, which Linux has from 5.7 on; "
"this tree is %d.%d -- build it with gcc" % ver)
val = arg[len("LLVM="):]
if val == "1":
return
if val.startswith("-"): # Debian's clang-18 -> /usr/lib/llvm-18/bin, plain names inside
d = os.path.dirname(os.path.realpath(os.path.join(os.path.dirname(tc["path"]), "clang" + val)))
else:
d = val.rstrip("/")
if not all(os.access(os.path.join(d, t), os.X_OK) for t in ("clang", "ld.lld")):
raise ToolError("this %d.%d tree reads LLVM= as LLVM=1, and %s has no plain clang and "
"ld.lld to put first on PATH instead" % (ver[0], ver[1], d))
tc["args"] = ["LLVM=1" if a == arg else a for a in tc["args"]]
tc["extra_path"] = ":".join([d] + ([tc["extra_path"]] if tc.get("extra_path") else []))
note("%d.%d predates LLVM=<dir>/: building with LLVM=1 and %s first on PATH" % (ver[0], ver[1], d))
class Req(object):
"""One thing asked of the config.
want : 'y' 'm' 'n', 'm|y' (at least a module), ('str', s) or ('val', n)
level : required -> unmet is fatal | wanted -> a warning | optional -> a note
absent: what a tree that does not have the symbol at all gets -- note, or warn (a
hardening option an old LTS tree predates: built without it, loudly). Never a
failure: the symbol cannot be asked of that kernel version."""
def __init__(self, sym, want, level, why="", absent="note"):
self.sym, self.want, self.level, self.why, self.absent = sym, want, level, why, absent
def rendered(self, current, attempt=1):
if self.want == "m|y":
# 'm' first; a bool symbol (HYPERV from 6.18 on) rejects it, and the
# second pass asks for 'y' instead.
return current if current in ("m", "y") else ("m" if attempt == 1 else "y")
if isinstance(self.want, tuple):
return '"%s"' % self.want[1] if self.want[0] == "str" else str(self.want[1])
return self.want
def met(self, got):
if self.want == "n":
return got in (None, "n")
if self.want == "m|y":
return got in ("m", "y")
if isinstance(self.want, tuple):
# Asked to be EMPTY (a path to a file the tarball does not ship):
# absent -- its parent option is off -- is every bit as good.
if self.want[1] == "" and got is None:
return True
return got == str(self.want[1])
return got == self.want
def shown(self):
return self.want[1] if isinstance(self.want, tuple) else self.want
FS_SYMBOLS = {"xfs": "XFS_FS", "ext4": "EXT4_FS", "ext3": "EXT4_FS", "ext2": "EXT4_FS",
"btrfs": "BTRFS_FS", "vfat": "VFAT_FS", "f2fs": "F2FS_FS",
"bcachefs": "BCACHEFS_FS", "exfat": "EXFAT_FS", "jfs": "JFS_FS",
"nilfs2": "NILFS2_FS", "erofs": "EROFS_FS", "ntfs3": "NTFS3_FS"}
MD_SYMBOLS = {"raid0": "MD_RAID0", "raid1": "MD_RAID1", "raid10": "MD_RAID10",
"raid4": "MD_RAID456", "raid5": "MD_RAID456", "raid6": "MD_RAID456"}
# Kept alive under --modules trimmed. localmodconfig keeps what is LOADED NOW;
# none of these is loaded on a healthy bare-metal box, and each is what you
# reach for on the day it is not healthy: a USB stick, a rescue ISO, a VM.
RESCUE_SYMBOLS = ("USB_STORAGE", "USB_UAS", "BLK_DEV_LOOP", "ISO9660_FS", "SQUASHFS",
"VFAT_FS", "NLS_CODEPAGE_437", "NLS_ISO8859_1", "NLS_UTF8", "EXT4_FS",
"XFS_FS", "BLK_DEV_NVME", "SATA_AHCI", "BLK_DEV_DM", "VIRTIO_PCI",
"VIRTIO_BLK", "VIRTIO_NET", "SCSI_VIRTIO", "HID_GENERIC", "USB_HID")
PREEMPT_CHOICE = {"none": "PREEMPT_NONE", "voluntary": "PREEMPT_VOLUNTARY",
"full": "PREEMPT", "lazy": "PREEMPT_LAZY"}
HZ_CHOICE = ("100", "250", "300", "1000")
# --lockdown: the member of the kernel's default-lockdown choice each mode sets. A forced
# mode is built in: the boot line can raise it (lockdown=confidentiality), never lower it.
LOCKDOWN_CHOICE = {"integrity": "LOCK_DOWN_KERNEL_FORCE_INTEGRITY",
"confidentiality": "LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY",
"off": "LOCK_DOWN_KERNEL_FORCE_NONE"}
# The IOMMU's default domain type, one choice: --iommu on is lazy (the upstream x86
# default), strict flushes the IOTLB on every unmap. Passthrough is never asked for.
IOMMU_DMA = ("IOMMU_DEFAULT_PASSTHROUGH", "IOMMU_DEFAULT_DMA_STRICT", "IOMMU_DEFAULT_DMA_LAZY")
# The certificates this tool builds into the kernel's keyring (SYSTEM_TRUSTED_KEYS), in the
# tree: DKMS's and your image certificate. Neither clean nor mrproper deletes a .pem.
TRUSTED_PEM = "certs/elkt-trusted.pem"
# Where kexec_file_load can check a signature at all (ARCH_SUPPORTS_KEXEC_SIG). The
# kernel/ Kconfig defines KEXEC_SIG for every architecture, so a REQUIRED one elsewhere
# would refuse every ppc64le, riscv64 or loongarch64 build.
KEXEC_SIG_ARCHES = ("x86_64", "aarch64", "s390x")
# The CPU vendors whose IOMMU is AMD's (Hygon's Dhyana is a Zen).
AMD_VENDORS = ("AuthenticAMD", "HygonGenuine")
def intel_display(machine):
"""Is there an Intel display device here -- an integrated GPU, as a rule?"""
return any(d.get("vendor") == 0x8086 and d.get("kind") == "display" for d in machine.get("pci") or [])
def builtin_cmdline(base, opts, machine):
"""The built-in command line (CONFIG_CMDLINE) a build asks for: the base's own words --
when it has a built-in line -- less the ones this tool owns (mem_sleep_default=*,
intel_iommu=igfx_off), then mem_sleep_default=s2idle (--suspend s2idle) and
intel_iommu=igfx_off (--iommu on or strict, with an Intel display device here: what
Debian's INTEL_IOMMU_DEFAULT_ON_INTGPU_OFF does, which upstream lacks). '' for none;
None when the base's line holds a quote or a backslash, which Req writes back unescaped:
that line is left as it is. x86 only ('' elsewhere). The boot loader's words come after
these and win: mem_sleep_default=deep typed at the GRUB menu still gives S3."""
if (machine.get("arch") or os.uname().machine) not in X86_ARCHES:
return ""
words = []
if base is not None and base.get("CMDLINE_BOOL") == "y":
text = base.get("CMDLINE") or ""
if '"' in text or "\\" in text:
return None
words = [w for w in text.split() if not w.startswith("mem_sleep_default=") and w != "intel_iommu=igfx_off"]
if opts.get("suspend", "s2idle") == "s2idle":
words.append("mem_sleep_default=s2idle")
if opts.get("iommu", "on") != "off" and intel_display(machine):
words.append("intel_iommu=igfx_off")
return " ".join(words)
def gpu_vendors_for(arch):
"""Intel's GPU drivers (i915) are x86-only: on an ARM or POWER box a REQUIRED
DRM_I915 can never come out as asked, and the build was refused for it."""
return {"amd", "nvidia", "intel"} if arch in ("x86_64", "i686", "i386") else {"amd", "nvidia"}
def resolve_gpu(selection, detected, arch=None):
"""-> (set of vendors, explanation)."""
possible = gpu_vendors_for(arch or os.uname().machine)
sel = (selection or "auto").strip().lower()
if sel == "auto":
if detected:
return set(detected) & possible, "detected"
return set(possible), \
"none detected (headless, VM or unknown) -- all vendors, so it boots anywhere"
if sel == "all":
return set(possible), "all, as asked"
vendors = set()
for v in sel.split(","):
v = v.strip()
if v in ("amd", "intel"):
vendors.add(v)
elif v in ("nvidia", "nv"):
vendors.add("nvidia")
elif v not in ("vm", "none", ""):
raise ToolError("unknown GPU vendor '%s' (use: auto all none amd nvidia intel)" % v)
if vendors - possible:
raise ToolError("Intel's GPU drivers (i915, xe) exist for x86 only, not for %s"
% (arch or os.uname().machine))
return vendors, "as asked"
_MAKEFILE_OBJS = {}
def makefile_objects(path):
"""{object: [CONFIG symbols]} from one Makefile's `obj-$(CONFIG_X) += a.o b.o`
lines -- the same reading streamline_config.pl (localmodconfig) does."""
if path not in _MAKEFILE_OBJS:
objs = {}
text = read_text(path).replace("\\\n", " ")
for m in re.finditer(r"^[ \t]*obj-\$\(CONFIG_([A-Za-z0-9_]+)\)[ \t]*[:+]?=(.*)$", text, re.M):
for tok in m.group(2).split():
if tok.endswith(".o"):
objs.setdefault(tok[:-2].replace("-", "_"), []).append(m.group(1))
_MAKEFILE_OBJS[path] = objs
return _MAKEFILE_OBJS[path]
def symbol_for_module(tree, name, rel=""):
"""-> the CONFIG symbols that build module `name` in THIS tree: from the
Makefile in the directory the running kernel had it in, else from any
Makefile under drivers/ or block/ that names it (files move between
versions). One symbol is an answer; several are not."""
obj = os.path.basename(rel).replace("-", "_") if rel else name
if rel:
for mk in ("Makefile", "Kbuild"):
syms = makefile_objects(os.path.join(tree, os.path.dirname(rel), mk)).get(obj)
if syms:
return sorted(set(syms))
found = set()
for top in ("drivers", "block"):
for dirpath, _, files in os.walk(os.path.join(tree, top)):
for mk in ("Makefile", "Kbuild"):
if mk in files:
found.update(makefile_objects(os.path.join(dirpath, mk)).get(obj, ()))
return sorted(found)
def disk_requests(tree, machine):
"""-> ([(symbol, module, disks)], [modules that no single option builds]):
the drivers between this machine's disks and its CPU, as options of THIS
tree. A VMD domain is required even if its driver was not seen by name."""
st = machine.get("storage") or {}
res, unknown = [], []
for d in st.get("drivers", []):
syms = symbol_for_module(tree, d["module"], d.get("path", ""))
if len(syms) == 1:
if syms[0] not in [s for s, _, _ in res]:
res.append((syms[0], d["module"], d["disks"]))
else:
unknown.append("%s%s" % (d["module"], " (any of %s)" % ", ".join(syms) if syms else ""))
if st.get("vmd") and "VMD" not in [s for s, _, _ in res]:
res.append(("VMD", "vmd", st.get("disks", [])))
return res, unknown
def build_requests(opts, vendors, machine, disk=(), base=None):
"""disk: from disk_requests(). base: the config being worked on, for the
requests that depend on what is already there: --debug-info on, which
driver is the boot console (DRM=y keeps simpledrm; anything else, None
included, gets efifb and vesafb), whether kexec_file_load is built (KEXEC_SIG),
the built-in command line. Pure: it reads no file -- what it needs of the
machine is in MACHINE -- and an option's 'off' asks for the opposite setting,
since on a rerun the base is this tree's own .config."""
R = []
arch = machine.get("arch") or os.uname().machine
x86 = arch in X86_ARCHES
efi_arch = arch in EFI_ARCHES
def add(sym, want, level, why="", absent="note"):
R.append(Req(sym, want, level, why, absent))
# GPU-compute plumbing. DEPENDENCIES, not preferences: HSA_AMD_SVM needs
# DEVICE_PRIVATE, HSA_AMD_P2P needs PCI_P2PDMA, both need ZONE_DEVICE, and
# ZONE_DEVICE needs memory hotplug -- which a distro config has and
# `make defconfig` does not, so from that base the whole chain used to
# vanish without a line in .config to show for it.
add("MEMORY_HOTPLUG", "y", "wanted", "ZONE_DEVICE depends on it")
add("MEMORY_HOTREMOVE", "y", "wanted", "ZONE_DEVICE depends on it")
add("ZONE_DEVICE", "y", "required", "device memory; under HMM, P2P, UVM")
add("DEVICE_PRIVATE", "y", "required", "HSA_AMD_SVM / nouveau SVM depend on it")
add("PCI_P2PDMA", "y", "required", "GPU peer-to-peer over PCIe")
add("DRM_ACCEL", "y", "wanted", "the subsystem both NPUs live under")
# Out-of-tree modules -- DKMS -- must be buildable and loadable.
add("MODULES", "y", "required", "no modules, no DKMS")
add("MODULE_UNLOAD", "y", "wanted")
add("MODULE_SIG_FORCE", "n", "required", "the boot line can raise it (module.sig_enforce=1); built in, "
"an untrusted module would be refused even with --lockdown off")
# A console before any GPU driver binds, and the VM / BMC adapters: a few
# hundred KB that separate 'boots on my machine' from 'boots anywhere'.
# localmodconfig switches DRM itself off on a headless box (no DRM module
# loaded), and every driver below then fails for want of its parent.
add("DRM", "m|y", "required", "parent of every graphics driver")
# That console is BUILT IN (see the file header). It shows the firmware's
# framebuffer from the first line of boot until the GPU's driver takes over, and
# all along when that driver never binds. It has to be: simpledrm.ko has
# device-tree aliases only and never loads on x86, and efifb and vesafb cannot be
# modules at all. simpledrm is built in only where DRM is, so the base's DRM
# decides, and DRM alone -- on a rerun the base is this tree's own .config,
# whatever an older run left in it:
# DRM=y (EL, Fedora, Ubuntu, defconfig): simpledrm, fed by SYSFB_SIMPLEFB.
# anything else (Debian's m; n after localmodconfig, back as m above): efifb
# and vesafb, with SYSFB_SIMPLEFB off. On, it makes the framebuffer a device
# only simpledrm or simplefb binds -- and with simpledrm a module, as this tool
# once asked, a GPU driver that did not load meant a black screen.
if base is not None and base.get("DRM") == "y":
if efi_arch:
add("SYSFB_SIMPLEFB", "y", "required", "the EFI/VESA framebuffer, handed to simpledrm")
add("DRM_SIMPLEDRM", "y", "required", "the boot console -- as a module it never loads on x86")
add("DRM_FBDEV_EMULATION", "y", "wanted")
add("FRAMEBUFFER_CONSOLE", "y", "wanted")
else:
efi = efi_booted(machine)
add("SYSFB_SIMPLEFB", "n", "required", "with it on, efifb and vesafb have nothing to bind")
add("FB", "y", "required", "efifb and vesafb are built in or not at all")
# Both where they exist, so the kernel boots either way; the one this
# machine boots with is required.
if efi_arch:
add("FB_EFI", "y", "required" if efi else "wanted", "the boot console on an EFI boot")
if x86:
add("FB_VESA", "y", "wanted" if efi else "required", "the boot console on a BIOS boot")
add("DRM_FBDEV_EMULATION", "y", "wanted")
# FRAMEBUFFER_CONSOLE exists only under VT, which a minimal config may leave off.
vt = base is None or base.get("VT") == "y"
add("FRAMEBUFFER_CONSOLE", "y", "required" if vt else "wanted",
"the text console on efifb / vesafb")
add("DRM_VIRTIO_GPU", "m", "required", "QEMU/KVM")
for sym in ("DRM_QXL", "DRM_BOCHS", "DRM_AST", "DRM_MGAG200", "DRM_UDL"):
add(sym, "m", "wanted")
add("HYPERV", "m|y", "optional", "DRM_HYPERV depends on it")
# Not DRM_EFIDRM / DRM_VESADRM: they bind the same devices as efifb and vesafb,
# and as modules nothing ever loads them (no alias).
for sym in ("DRM_VMWGFX", "DRM_HYPERV"):
add(sym, "m", "optional")
# Firmware arrives compressed on every current distro (.xz on EL and Fedora,
# .zst on Ubuntu and Arch). Without the matching decompressor the driver
# loads, asks for firmware, and gets nothing -- the GPU, and just as much the
# Wi-Fi (iwlwifi), the network card (r8169) and Bluetooth. So not only when
# a GPU is asked for: `--gpu none` used to leave these to chance.
have = machine.get("firmware_compression", [])
add("FW_LOADER", "y", "required")
add("FW_LOADER_COMPRESS", "y", "required" if have else "wanted", "distro firmware is compressed")
add("FW_LOADER_COMPRESS_XZ", "y", "required" if "xz" in have else "wanted")
add("FW_LOADER_COMPRESS_ZSTD", "y", "required" if "zstd" in have else "wanted")
if x86:
add("MICROCODE", "y", "wanted", "CPU microcode loaded early, from the initramfs")
if (machine.get("cpu") or {}).get("hybrid"):
# P-cores and E-cores (Alder Lake and later): without these the scheduler
# treats all of them alike and long jobs land on E-cores.
add("SCHED_MC_PRIO", "y", "wanted", "hybrid CPU: P-cores first (ITMT)")
add("INTEL_HFI_THERMAL", "y", "wanted", "hybrid CPU: the hardware's own placement hints")
if "amd" in vendors:
add("DRM_AMDGPU", "m", "required", "MODULE, never built-in -- see the file header")
add("DRM_AMD_DC", "y", "required", "atomic KMS, DP/HDMI audio")
add("DRM_AMDGPU_USERPTR", "y", "required", "ROCm, GL/Vulkan interop")
add("DRM_AMDGPU_SI", "y", "wanted")
add("DRM_AMDGPU_CIK", "y", "wanted")
add("HSA_AMD", "y", "required", "KFD: /dev/kfd, ROCm's way in")
add("HSA_AMD_SVM", "y", "required", "HIP managed memory")
# Up to 6.x HSA_AMD_P2P also depends on DMABUF_MOVE_NOTIFY. 7.x dropped
# the symbol, so a 7.x config carried back to an LTS tree lacks it and
# P2P silently disappears. On 7.x this is a 'not in this version' note.
add("DMABUF_MOVE_NOTIFY", "y", "optional", "HSA_AMD_P2P depends on it before 7.x")
add("HSA_AMD_P2P", "y", "required", "GPU-to-GPU transfers")
add("AMD_IOMMU", "y", "optional", "x86 only; DRM_ACCEL_AMDXDNA needs it")
add("DRM_ACCEL_AMDXDNA", "m", "optional", "Ryzen AI NPU; 6.14+")
if "nvidia" in vendors:
# A module even if NVIDIA's own driver is the plan: that stack needs
# nouveau absent at load time, and only a module can be blacklisted.
add("DRM_NOUVEAU", "m", "required")
if "intel" in vendors:
add("DRM_I915", "m", "required")
add("DRM_XE", "m", "optional", "6.8+")
add("DRM_ACCEL_IVPU", "m", "optional", "Meteor Lake+ NPU")
# What this machine needs to mount its own disks -- first the drivers the
# disks are reached through, then what is on them.
for sym, mod, disks in disk:
add(sym, "m|y", "required",
"Intel VMD sits in front of %s: without it they are invisible" % ",".join(disks)
if sym == "VMD" else "%s: %s is reached through it" % (mod, ",".join(disks)))
st = machine.get("storage", {})
for fs in st.get("fstypes", []):
if fs in FS_SYMBOLS:
add(FS_SYMBOLS[fs], "m|y", "required", "a mounted filesystem here is %s" % fs)
if fs == "vfat":
for nls in ("NLS_CODEPAGE_437", "NLS_ISO8859_1", "NLS_ASCII", "NLS_UTF8"):
add(nls, "m|y", "wanted", "vfat mount options")
for layer in st.get("layers", []):
if layer in ("lvm", "dm", "crypt"):
add("BLK_DEV_DM", "m|y", "required", "storage here sits on device-mapper")
if layer == "crypt":
add("DM_CRYPT", "m|y", "required", "storage here is dm-crypt")
if layer.startswith("md:"):
add("BLK_DEV_MD", "m|y", "required", "storage here is md raid")
if layer[3:] in MD_SYMBOLS:
add(MD_SYMBOLS[layer[3:]], "m|y", "required", layer)
if opts["modules"] == "trimmed":
for sym in RESCUE_SYMBOLS + tuple(opts.get("keep_symbols", ())):
add(sym, "m|y", "wanted", "rescue set, kept through the trim")
# Things that break a distro config carried to an upstream tree: signing
# keys, revocation lists and the SBAT file all live in the distro's source
# package, and each is a hard stop late in the build. WERROR is right for
# the one compiler a distro tests and wrong for every other pairing. The certificates
# built in instead are this tool's own file in the tree (write_trusted_pem()), or none.
pem = opts.get("_trusted_pem", "")
add("SYSTEM_TRUSTED_KEYS", ("str", pem), "required",
"this tool's certificates -- DKMS's, your image certificate -- built into the kernel's keyring" if pem else "")
add("SYSTEM_REVOCATION_KEYS", ("str", ""), "required")
add("EFI_SBAT_FILE", ("str", ""), "wanted")
add("WERROR", "n", "required")
add("FRAME_WARN", ("val", opts["frame_warn"]), "wanted",
"amdgpu's DML files build ~2.4K frames")
# The running kernel should carry its own config. Its absence is what made
# the old script lose its base config on the second use.
add("IKCONFIG", "y", "wanted")
add("IKCONFIG_PROC", "y", "wanted", "/proc/config.gz")
# Module signing. Under lockdown -- and under Secure Boot, where IMA's arch policy checks
# as well -- a module the kernel cannot verify does not load. The in-tree ones are signed
# at modules_install with the tree's own throw-away key, whose certificate is compiled in
# (what distros do); clean_tree() keeps that key, so a rebuild signs with the same one.
# DKMS signs with its own key: write_trusted_pem() builds that certificate in.
lockdown = opts.get("lockdown", "integrity")
if lockdown != "off" or opts.get("secure_boot", "on") == "on":
add("MODULE_SIG", "y", "required", "lockdown and Secure Boot load signed modules only")
add("MODULE_SIG_ALL", "y", "required", "every module signed at modules_install -- without it ext4 "
"itself is refused")
add("MODULE_SIG_KEY", ("str", "certs/signing_key.pem"), "wanted",
"kbuild generates this key per tree; a distro path here is a missing file")
if opts.get("secure_boot", "on") == "on":
# shim boots a SIGNED PE image, so the kernel must BE one (EFI_STUB). kernel.org
# kernels do not lock down under Secure Boot by themselves (LOCK_DOWN_IN_EFI_SECURE_BOOT
# is a distribution patch): --lockdown builds it in. With Secure Boot on, IMA's arch
# policy (IMA_ARCH_POLICY) refuses an untrusted module even without lockdown.
add("EFI", "y", "required", "Secure Boot is a UEFI feature")
add("EFI_STUB", "y", "required", "shim verifies and boots a PE image")
add("MODULE_SIG_SHA512", "y", "wanted")
add("SECONDARY_TRUSTED_KEYRING", "y", "wanted")
# Without these a defconfig base drops the two keyrings below with a warning.
add("SYSTEM_BLACKLIST_KEYRING", "y", "wanted", "the firmware's dbx: revoked keys and images")
add("INTEGRITY_SIGNATURE", "y", "wanted", "the platform and machine keyrings depend on it")
add("INTEGRITY_ASYMMETRIC_KEYS", "y", "wanted", "the platform and machine keyrings depend on it")
add("INTEGRITY_PLATFORM_KEYRING", "y", "wanted", "firmware db keys")
add("INTEGRITY_MACHINE_KEYRING", "y", "wanted", "MOK keys: trusted for modules only with shim, Secure "
"Boot on and MokListTrustedRT (mokutil --trust-mok)")
add("LOAD_UEFI_KEYS", "y", "wanted")
# Lockdown, built in (--lockdown, default integrity): what not even root may do to the
# running kernel. The LSM is wanted with --lockdown off too, so that lockdown= at the
# boot line still turns it on; EARLY starts it before the boot parameters are parsed.
sec = "wanted" if lockdown == "off" else "required"
add("SECURITY", "y", sec, "lockdown is a security module")
add("SECURITYFS", "y", sec, "/sys/kernel/security/lockdown")
add("SECURITY_LOCKDOWN_LSM", "y", sec, "the lockdown LSM", "warn")
add("SECURITY_LOCKDOWN_LSM_EARLY", "y", sec, "started before the boot parameters are read -- without it "
"lockdown waits for CONFIG_LSM to name it", "warn")
if lockdown == "off":
# n is met by absence: a tree without the lockdown LSM is fine here.
for sym in ("LOCK_DOWN_KERNEL_FORCE_INTEGRITY", "LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY"):
add(sym, "n", "required", "--lockdown off")
add("LOCK_DOWN_KERNEL_FORCE_NONE", "y", "wanted", "--lockdown off: lockdown= at the boot line raises it")
else:
for key, sym in sorted(LOCKDOWN_CHOICE.items()):
if key != lockdown:
add(sym, "n", "optional")
add(LOCKDOWN_CHOICE[lockdown], "y", "required", "--lockdown %s, built in" % lockdown, "warn")
if lockdown != "off":
# Without KEXEC_SIG kexec_file_load checks no signature, and lockdown has nothing to
# refuse. Only three architectures can have it at all.
kexec_file = base is None or base.get("KEXEC_FILE") == "y"
sig = arch in KEXEC_SIG_ARCHES and kexec_file
add("KEXEC_SIG", "y", "required" if sig else "optional",
"under lockdown kexec_file_load takes only an image it can verify")
if sig and arch in ("x86_64", "aarch64"):
# The image verifier depends on SIGNED_PE_FILE_VERIFICATION: that one first.
add("SIGNED_PE_FILE_VERIFICATION", "y", "wanted", "the kexec image verifier depends on it")
add("KEXEC_BZIMAGE_VERIFY_SIG" if arch == "x86_64" else "KEXEC_IMAGE_VERIFY_SIG", "y", "wanted",
"kexec of a signed image: kdump, and this tool's own kernels")
# The two ways around lockdown it cannot see: acpi_rsdp= (there with KEXEC) and the
# ACPI tables an initrd brings are both read before even the early LSM starts.
# kexec_load is refused under lockdown anyway; --lockdown off asks for both back.
add("KEXEC", "n", "wanted", "acpi_rsdp= comes with it, read before lockdown starts; kexec_load is "
"refused under lockdown anyway")
if not machine.get("acpi_override"):
add("ACPI_TABLE_UPGRADE", "n", "wanted", "the ACPI tables an initrd brings are read before "
"lockdown starts")
else:
add("KEXEC", "y", "optional", "--lockdown off")
add("ACPI_TABLE_UPGRADE", "y", "optional", "--lockdown off")
if x86:
# One built-in command line for both (builtin_cmdline()): no Kconfig option sets the
# default suspend state, and none leaves an Intel iGPU outside the IOMMU upstream.
line = builtin_cmdline(base, opts, machine)
ours = any(w == "intel_iommu=igfx_off" or w.startswith("mem_sleep_default=") for w in (line or "").split())
if line:
add("CMDLINE_BOOL", "y", "required", "a built-in command line: %s" % line, "warn")
add("CMDLINE", ("str", line), "required", "built in; the boot loader's words come after it, and win")
if ours:
add("CMDLINE_OVERRIDE", "n", "required", "the boot loader's line still counts")
elif line == "" and base is not None and base.get("CMDLINE_BOOL") == "y":
# --suspend deep takes this tool's word back out, and with it the line.
add("CMDLINE", ("str", ""), "optional")
add("CMDLINE_BOOL", "n", "wanted", "no built-in command line left")
# The IOMMU (--iommu, default on): DMA remapping from the moment the kernel starts.
# Every IOMMU option sits inside IOMMU_SUPPORT. AMD's is on by itself once built;
# Intel's needs INTEL_IOMMU_DEFAULT_ON, which a Debian base has off.
iommu = opts.get("iommu", "on")
amd = (machine.get("cpu") or {}).get("vendor") in AMD_VENDORS
if iommu != "off":
add("IOMMU_SUPPORT", "y", "required", "every IOMMU option sits inside it")
add("PCI_MSI", "y", "wanted", "INTEL_IOMMU depends on it")
add("INTEL_IOMMU", "y", "wanted" if amd or iommu == "off" else "required", "Intel's DMA remapping (VT-d)")
if iommu == "off":
add("INTEL_IOMMU_DEFAULT_ON", "n", "wanted", "--iommu off: intel_iommu=on at the boot line still "
"switches it on")
else:
add("INTEL_IOMMU_DEFAULT_ON", "y", "wanted" if amd else "required",
"a Debian base has it off: its INTGPU_OFF choice is not upstream", "warn")
if arch == "x86_64":
add("AMD_IOMMU", "y", "required" if amd else "wanted", "AMD's IOMMU, on by itself once built")
win = "IOMMU_DEFAULT_DMA_STRICT" if iommu == "strict" else "IOMMU_DEFAULT_DMA_LAZY"
for sym in IOMMU_DMA:
if sym != win:
add(sym, "n", "optional")
add(win, "y", "required", "--iommu %s: %s" % (iommu, "every unmap flushed at once" if iommu == "strict"
else "lazy flushing, the upstream x86 default"))
if arch == "x86_64":
# CET: acts only on CPUs that have it. An old binary driver that crashes with
# 'Missing ENDBR' needs ibt=off at the boot line.
add("X86_KERNEL_IBT", "y", "wanted", "CET: indirect-branch tracking in the kernel")
add("X86_USER_SHADOW_STACK", "y", "wanted", "CET: shadow stacks for programs built for them")
if x86 or arch == "aarch64":
# The TPM 2.0 drivers: measured boot, keys sealed to it. On a machine without one
# they cost nothing.
add("TCG_TPM", "m|y", "wanted", "TPM 2.0")
if x86:
add("TCG_TIS", "m|y", "wanted", "a TPM on the LPC or SPI bus")
add("TCG_CRB", "m|y", "wanted", "a firmware TPM (Intel PTT, AMD fTPM)")
add("HW_RANDOM_TPM", "y", "optional")
if opts["debug_info"] == "off":
for sym in ("DEBUG_INFO_BTF", "DEBUG_INFO_BTF_MODULES",
"DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT", "DEBUG_INFO_DWARF4",
"DEBUG_INFO_DWARF5"):
add(sym, "n", "optional")
# Before 5.18 there is no DEBUG_INFO_NONE: DEBUG_INFO itself is the switch
# (5.15 LTS). From 5.18 on it has no prompt and follows the choice below.
add("DEBUG_INFO", "n", "optional")
add("DEBUG_INFO_NONE", "y", "required", "else 10x module size, far slower build")
elif opts["debug_info"] == "on":
# DWARF in the kernel. This used to mean "whatever the base has" -- and
# when the base is this tree's own .config from an earlier `off` run,
# that is DEBUG_INFO_NONE. A DWARF version the base already chose stays.
add("DEBUG_KERNEL", "y", "wanted", "the debug-info choice lives under it")
add("DEBUG_INFO", "y", "wanted", "before 5.18 the DWARF choice lives under it")
add("DEBUG_INFO_NONE", "n", "required", "--debug-info on")
if base is None or not any(base.get(s) == "y" for s in (
"DEBUG_INFO_DWARF4", "DEBUG_INFO_DWARF5", "DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT")):
add("DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT", "y", "required", "--debug-info on")
elif opts["debug_info"] == "btf":
add("DEBUG_KERNEL", "y", "wanted", "the debug-info choice lives under it")
add("DEBUG_INFO", "y", "wanted", "before 5.18 the DWARF choice lives under it")
# BTF is made FROM DWARF by pahole, so DWARF has to exist at build time;
# modules are stripped of it at install (INSTALL_MOD_STRIP keeps .BTF).
# Without BTF a kernel with BPF_LSM logs 'bpf-restrict-fs: Failed to
# load BPF object' from systemd on every boot, and bpftrace and every
# CO-RE tool (Cilium, Falco, Tetragon agents) cannot run.
for sym in ("DEBUG_INFO_NONE", "DEBUG_INFO_REDUCED", "DEBUG_INFO_SPLIT",
"DEBUG_INFO_DWARF4", "DEBUG_INFO_DWARF5"):
add(sym, "n", "optional")
add("DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT", "y", "required", "BTF is generated from DWARF")
add("DEBUG_INFO_BTF", "y", "required", "systemd BPF filters, bpftrace, CO-RE tools")
add("DEBUG_INFO_BTF_MODULES", "y", "wanted")
if opts["preempt"] != "keep":
for key, sym in sorted(PREEMPT_CHOICE.items()):
if key != opts["preempt"]:
add(sym, "n", "optional")
add(PREEMPT_CHOICE[opts["preempt"]], "y", "wanted", "preemption model")
if opts["hz"] != "keep":
for hz in HZ_CHOICE:
if hz != opts["hz"]:
add("HZ_" + hz, "n", "optional")
add("HZ_" + opts["hz"], "y", "wanted", "timer frequency")
if opts["cpu"] == "native" and opts.get("_native_kconfig", True):
# Required, not wanted: as `wanted` an old clang turned it into a warning
# and a GENERIC kernel. The tree makes it depend on CC_HAS_MARCH_NATIVE,
# which clang before 19.1 does not get (it miscompiled networking code).
add("X86_NATIVE_CPU", "y", "required", "-march=native: gcc, or clang 19.1 or newer")
else:
add("X86_NATIVE_CPU", "n", "optional")
add("LOCALVERSION", ("str", opts["localversion"]), "required")
add("LOCALVERSION_AUTO", "n", "required")
return R
def verify_requests(cfg, reqs, symbols):
"""-> list of (req, got, status): ok | fail | warn | note | absent."""
res = []
for r in reqs:
got = cfg.get(r.sym)
if r.met(got):
res.append((r, got, "ok"))
elif r.sym not in symbols:
res.append((r, got, "absent"))
else:
res.append((r, got, {"required": "fail", "wanted": "warn"}.get(r.level, "note")))
return res
def config_series(path):
"""'6.12' from the '# Linux/x86 6.12.0 Kernel Configuration' header, or ''."""
try:
opener = gzip.open if path.endswith(".gz") else open
with opener(path, "rt", errors="replace") as f:
head = "".join(f.readline() for _ in range(6))
except (OSError, IOError, EOFError):
return ""
m = re.search(r"^# Linux/\S+ (\d+\.\d+)", head, re.M)
return m.group(1) if m else ""
def richest(paths):
"""The candidate with the most modules. /boot also holds configs THIS tool
installed for trimmed kernels; 'newest' would pick one of those."""
return max(paths, key=lambda p: (DotConfig(p).count("m"),
version_key(os.path.basename(p)[7:])))
def find_base_config(tree, mode, running=None, root="/", target_series="",
skip_own=False, want_full=False):
"""-> (kind, path, label). kind: file | gz | defconfig.
The chain is long on purpose. /boot/config-$(uname -r) and /proc/config.gz
cover a DISTRO kernel. They do not cover a kernel built by a tool like
this one: EL's `make install` does not copy the config to /boot. That
kernel's config is in its build tree, which /lib/modules/<rel>/build still
points at. Falling through to defconfig silently is how you build a kernel
without your root filesystem in it, so that never happens unasked.
skip_own : the tree's .config came from a TRIMMED run and this one is full.
want_full: prefer a full distro config over a running kernel that is trimmed."""
rel = running or os.uname().release
own = os.path.join(tree, ".config")
if mode == "defconfig":
return "defconfig", "", "make defconfig (asked for)"
if mode not in ("auto", "running", "distro"):
path = os.path.expanduser(mode)
if not os.path.isfile(path):
raise ToolError("--config-base %s is not a readable file (expected auto, "
"running, defconfig or a path)" % mode)
return ("gz" if path.endswith(".gz") else "file"), path, path
if mode == "auto" and not skip_own and os.path.isfile(own) and DotConfig(own).valid():
return "file", own, "the .config already in this tree"
chain = ["boot/config-" + rel, "proc/config.gz",
"lib/modules/%s/build/.config" % rel, "lib/modules/%s/config" % rel,
"usr/src/kernels/%s/.config" % rel,
"usr/src/linux-headers-%s/.config" % rel]
others = [c for c in glob.glob(rp(root, "boot/config-*")) if DotConfig(c).valid()]
mine = ""
for c in chain:
path = rp(root, c)
if not (os.path.isfile(path) and os.access(path, os.R_OK)):
continue
if skip_own and os.path.realpath(path) == os.path.realpath(own):
continue
if path.endswith(".gz") or DotConfig(path).valid():
mine = path
break
# A config from the SAME series as the tree lines up symbol for symbol. One
# from a different series loses every option renamed or re-parented between
# them -- carrying 7.2 back to 6.12 drops HYPERV, DMABUF_MOVE_NOTIFY and
# with it GPU peer-to-peer. So a same-series distro config beats the
# running kernel's, unless the user says `--config-base running`.
if mode == "auto" and target_series and (not mine or config_series(mine) != target_series):
close = [c for c in others if config_series(c) == target_series]
if want_full and close:
# ...but not a TRIMMED one -- this tool writes /boot/config-<rel> for
# every kernel it installs, trimmed ones too, and a "full" build seeded
# from 140 modules is a trimmed build that says it is full.
fullest = DotConfig(richest(others)).count("m")
close = [c for c in close if DotConfig(c).count("m") * 2 >= fullest]
if close:
best = richest(close)
return "file", best, (
"%s (a %s config, like this tree%s; --config-base running overrides)"
% (best, target_series,
" -- the running kernel's is %s" % config_series(mine) if mine else ""))
if mode == "auto" and want_full and mine and not mine.endswith(".gz") and others:
best = richest(others)
if DotConfig(mine).count("m") * 2 < DotConfig(best).count("m"):
return "file", best, ("%s (the running kernel's config is a trimmed one; "
"this is the fullest here)" % best)
if mine:
return ("gz" if mine.endswith(".gz") else "file"), mine, \
"%s (the running kernel's)" % mine
if others:
best = richest(others)
return "file", best, \
"%s (NOT the running kernel's -- the fullest distro config found)" % best
raise ToolError("no base config found for %s.\n Looked in /boot, /proc/config.gz, "
"/lib/modules/%s and /usr/src.\n Pass --config-base <file>, or "
"--config-base defconfig if you really mean a bare defconfig."
% (rel, rel))
class Make(object):
"""make, in one tree, with one toolchain and one scrubbed environment.
The toolchain arguments go on EVERY call: Kconfig's cc-option tests run the
compiler, so an olddefconfig without LLVM= resolves the config for gcc,
verify approves it, and the build then quietly re-resolves it for clang."""
def __init__(self, tree, toolchain, jobs):
self.tree, self.tc, self.jobs = tree, toolchain, jobs
self.env = scrubbed_env(toolchain.get("extra_path"))
def argv(self, targets, parallel=False):
cmd = ["make"] + (["-j%d" % self.jobs] if parallel else [])
return cmd + list(self.tc["args"]) + list(targets)
def call(self, targets, extra_env=None, check=True, stdin_data=None):
env = dict(self.env)
env.update(extra_env or {})
return run(self.argv(targets), cwd=self.tree, env=env, check=check,
stdin_data=stdin_data)
def value(self, target):
return (self.call(["-s", target]).stdout or "").strip().splitlines()[-1]
def _find_lld(ci, machine):
cands = [os.path.join(os.path.dirname(ci["path"]), "ld.lld"),
which("ld.lld", path=":".join(SYSTEM_PATH)) or ""]
cands += [os.path.join(os.path.dirname(t["path"]), "ld.lld")
for t in machine.get("toolchains", []) if t["kind"] == "clang"]
return next((c for c in cands if c and os.access(c, os.X_OK)), "")
def resolve_toolchain(opts, machine=None):
"""The user's choices -> one compiler, one set of make arguments, one PATH.
Four independent choices: the COMPILER (--cc, or --toolchain llvm), the
BINUTILS (--binutils auto | gnu | llvm | <dir with ld and as>), the LINKER
(--linker default | bfd | lld) and the PYTHON kbuild's scripts run under.
'auto' binutils means: what was built to go with that compiler -- LLVM's
own for a clang that has the full set beside it, a gcc-toolset's own, and
the system's otherwise."""
machine = machine if isinstance(machine, dict) else {}
if opts["cc"]:
want = os.path.expanduser(opts["cc"])
path = want if os.sep in want else (which(want) or "")
if not path or not os.path.exists(path):
raise ToolError("--cc %s: no such compiler. (Falling back to the system "
"gcc without saying so is how a build ends up made by a "
"compiler nobody chose.)" % opts["cc"])
path = os.path.abspath(path)
elif opts["toolchain"] == "llvm":
prefix = os.path.expanduser(opts["llvm_prefix"])
if prefix and not prefix.endswith("/"):
prefix += "/"
path = (prefix + "clang") if prefix else (which("clang", path=":".join(SYSTEM_PATH)) or "")
else:
path = "/usr/bin/gcc" if os.path.exists("/usr/bin/gcc") else \
(which("gcc", path=":".join(SYSTEM_PATH)) or "")
ci = compiler_info(path) if path else None
if not ci:
raise ToolError("no working compiler at %s" % (path or "/usr/bin/gcc"))
if ci["usable"] == "no":
raise ToolError("%s cannot build a kernel: %s.\n (%s)" % (path, ci["why"], ci["version"]))
if opts["toolchain"] == "llvm" and ci["kind"] != "clang":
raise ToolError("--toolchain llvm, but %s is %s" % (path, ci["version"]))
kind, args, extra = ci["kind"], [], []
binutils = opts.get("binutils", "auto")
llvm = llvm_companions(ci["path"]) if kind == "clang" else None
if binutils == "auto":
binutils = ("llvm" if llvm else "gnu") if kind == "clang" else (own_binutils(path) or "gnu")
if binutils == "llvm":
if kind != "clang":
raise ToolError("--binutils llvm needs clang as the compiler; %s is gcc" % path)
if not llvm:
d = os.path.dirname(ci["path"])
raise ToolError("no complete LLVM tool set beside %s (missing: %s) -- use "
"--binutils gnu" % (path, " ".join(
t for t in LLVM_TOOLS if not os.access(os.path.join(d, t), os.X_OK))))
# HOSTLDFLAGS: tools/lib/bpf is built non-PIC by gcc and linked by an
# ld.lld that defaults to PIE; resolve_btfids dies on R_X86_64_32.
args = [llvm[0], "HOSTLDFLAGS=-no-pie"]
if llvm[1]:
extra.append(llvm[1])
ci["binutils_label"] = "LLVM (%s)" % llvm[0]
else:
if binutils != "gnu":
bdir = os.path.abspath(os.path.expanduser(binutils))
if not all(os.access(os.path.join(bdir, t), os.X_OK) for t in ("ld", "as")) \
and all(os.access(os.path.join(bdir, "bin", t), os.X_OK) for t in ("ld", "as")):
# A PREFIX (the satellite toolkit's contract: <opt>/binutils-<ver>), not its bin/
bdir = os.path.join(bdir, "bin")
if not all(os.access(os.path.join(bdir, t), os.X_OK) for t in ("ld", "as")):
raise ToolError("--binutils %s: no ld and as in that directory (expected "
"auto, gnu, llvm, a binutils bin directory or its prefix)" % binutils)
if os.path.realpath(bdir) != os.path.realpath("/usr/bin"):
extra.append(bdir)
sysgcc = ci["real"] == os.path.realpath("/usr/bin/gcc")
if not sysgcc or extra:
# By absolute path. A directory put ahead of /usr/bin for its ld may
# hold a `gcc` of its own, and a bare CC=gcc would silently become it.
args.append("CC=" + ci["path"])
if extra and kind == "gcc":
args.append("HOSTCC=" + ("/usr/bin/gcc" if os.path.exists("/usr/bin/gcc") else ci["path"]))
ci["binutils_label"] = "GNU (%s)" % (extra[0] if extra else "system")
linker = opts.get("linker", "default")
if linker == "lld" and binutils != "llvm":
lld = _find_lld(ci, machine)
if not lld:
raise ToolError("--linker lld: no ld.lld found on this system")
args.append("LD=" + lld)
elif linker == "bfd" and binutils == "llvm":
args.append("LD=ld.bfd")
ci["python"] = ""
if opts.get("python"):
py = os.path.abspath(os.path.expanduser(opts["python"]))
try:
o = (run([py, "-c", "import sys; print(sys.version_info[0])"], check=False,
env=scrubbed_env(), timeout=20).stdout or "").strip()
except ToolError:
o = ""
if o != "3":
raise ToolError("--python %s does not run as a Python 3 in a clean environment"
% opts["python"])
args.append("PYTHON3=" + py)
ci["python"] = py
partner = cxx_partner(ci["path"])
if opts.get("cxx"):
want = os.path.expanduser(opts["cxx"])
cxx = want if os.sep in want else (which(want) or "")
xi = compiler_info(cxx) if cxx and os.path.exists(cxx) else None
if not xi:
raise ToolError("--cxx %s: no such compiler" % opts["cxx"])
if xi["usable"] == "no":
raise ToolError("%s cannot be kbuild's C++ compiler: %s.\n (%s)"
% (cxx, xi["why"], xi["version"]))
cxx = os.path.abspath(cxx)
args.append("HOSTCXX=" + cxx)
if kind == "gcc" and partner and os.path.realpath(cxx) != os.path.realpath(partner):
ci["cxx_note"] = ("GCC plugins are compiled by this against %s's plugin headers; "
"the matching one is %s" % (os.path.basename(ci["path"]), partner))
elif binutils == "llvm":
cxx = partner or "clang++" # kbuild sets HOSTCXX=clang++ itself under LLVM=
elif kind == "gcc" and partner and ci["real"] != os.path.realpath("/usr/bin/gcc"):
# A gcc that is not the system's: ITS g++ builds ITS plugins.
cxx = partner
args.append("HOSTCXX=" + cxx)
else:
cxx = which("g++", path=":".join(extra + SYSTEM_PATH)) or ""
ci["cxx"] = cxx
if opts.get("ccache") == "on":
cc = which("ccache", path=":".join(SYSTEM_PATH))
if not cc:
raise ToolError("--ccache on, but ccache is not installed")
args = [a for a in args if not a.startswith("CC=")] + ["CC=%s %s" % (cc, ci["path"])]
ci["args"], ci["extra_path"] = args, (":".join(extra) or None)
ci["binutils"], ci["linker"], ci["ccache"] = binutils, linker, opts.get("ccache") == "on"
good, why = compiler_works(ci["path"], ci["extra_path"])
if not good:
raise ToolError("%s cannot build an executable in the clean environment the "
"build runs in:\n %s\n If it needs LD_LIBRARY_PATH to find its "
"own libraries, that is the cause." % (ci["path"], why))
ci["cpu"], ci["cpu_level"], ci["kcflags"] = opts.get("cpu", "generic"), "", ""
if ci["cpu"] == "native" and kind == "clang" and tuple(ci["numver"][:2]) < (19, 1):
# What the tree's own Kconfig refuses from 6.16 on (CC_HAS_MARCH_NATIVE): the
# KCFLAGS route for older trees must not slip past it.
raise ToolError("--cpu native with clang %s: the kernel itself refuses -march=native "
"before clang 19.1 (an internal compiler error in networking code, "
"llvm-project#72026). Use gcc, a newer clang, or --cpu x86-64-v3."
% vstr(ci["numver"]))
if ci["cpu"] != "generic":
if os.uname().machine != "x86_64":
raise ToolError("--cpu %s: CPU tuning is for x86_64 only; this is %s"
% (ci["cpu"], os.uname().machine))
ci["cpu_level"] = march_level(ci["path"], ci["cpu"], scrubbed_env(ci["extra_path"]))
if ci["cpu_level"] is None:
raise ToolError("%s does not accept -march=%s. x86-64-v2/v3/v4 need gcc 11 or clang "
"12; for CPU names see `gcc -Q --help=target` or `clang "
"--print-supported-cpus`." % (ci["path"], ci["cpu"]))
return ci
def describe_tools(tc):
"""What the build will REALLY run, asked of the tools themselves in the
environment the build will have. -> [(label, text)]"""
env = scrubbed_env(tc.get("extra_path"))
path = env["PATH"]
def banner(argv):
try:
o = run(argv, check=False, env=env, timeout=20).stdout or ""
except ToolError:
return "NOT FOUND"
return ([l for l in o.splitlines() if l.strip()] or ["?"])[0].strip()[:60]
rows = []
if tc["kind"] == "clang":
rows.append(("assembler", "clang's integrated assembler"))
else:
name = (run([tc["path"], "-print-prog-name=as"], check=False, env=env).stdout or "as").strip()
full = name if os.sep in name else (which(name, path=path) or name)
rows.append(("assembler", "%s (%s)" % (os.path.realpath(full), banner([full, "--version"]))))
ld = next((a[3:] for a in tc["args"] if a.startswith("LD=")), "")
if not ld:
arg = next((a[5:] for a in tc["args"] if a.startswith("LLVM=")), None)
if arg is None:
ld = "ld"
else:
ld = "ld.lld" if arg == "1" else "ld.lld" + arg if arg.startswith("-") else arg + "ld.lld"
full = ld if os.sep in ld else (which(ld, path=path) or ld)
rows.append(("linker", "%s (%s)" % (full, banner([full, "-v"]))))
py = tc.get("python") or which("python3", path=path) or "python3"
rows.append(("python3", "%s (%s)" % (py, banner([py, "--version"]))))
mk = which("make", path=path) or "make"
rows.append(("make", "%s (%s)" % (mk, banner([mk, "--version"]))))
cxx = tc.get("cxx", "")
full = cxx if os.sep in cxx else (which(cxx, path=path) or "") if cxx else ""
rows.append(("c++ (host)", "%s (%s)" % (full, banner([full, "--version"])) if full else
"NONE FOUND -- GCC plugins and xconfig cannot be built"))
if tc.get("cxx_note"):
rows.append(("", tc["cxx_note"]))
if tc.get("ccache"):
rows.append(("ccache", "on"))
tc["_ld"], tc["_python"] = full, py
return rows
REQ_RELEVANT = ("bash", "bc", "bindgen", "binutils", "bison", "Clang/LLVM", "flex", "GNU awk",
"GNU C", "GNU make", "GNU tar", "kmod", "openssl", "pahole", "Python", "Rust")
def parse_requirements(text):
"""The 'Current Minimal Requirements' table in a tree's own
Documentation/process/changes.rst -> [(program, minimum, check command)].
The tree says what IT needs; nothing here hardcodes a version."""
rows, rulers = [], 0
for line in text.splitlines():
if re.match(r"^=+\s+=+\s+=+\s*$", line):
rulers += 1
if rulers == 3:
break
elif rulers == 2:
parts = re.split(r"\s{2,}", line.strip(), maxsplit=2)
if len(parts) == 3:
rows.append(tuple(parts))
return rows
def check_tree_requirements(tree, tc, verbose=False, need_pahole=True):
"""-> number of blocking problems. Checks the tools that were CHOSEN --
this compiler, this ld, this python -- not whatever answers to `gcc`."""
rows = parse_requirements(read_text(os.path.join(tree, "Documentation/process/changes.rst")))
if not rows:
return 0
if "_ld" not in tc:
describe_tools(tc)
env = scrubbed_env(tc.get("extra_path"))
problems, fine, absent = 0, [], []
for prog, minimum, cmd in rows:
if not prog.startswith(REQ_RELEVANT):
continue # e2fsprogs, GRUB, iptables: runtime, not build
optional = "(optional)" in prog
name = prog.replace("(optional)", "").strip()
if name == "pahole" and not need_pahole:
optional = True # --debug-info off: kbuild never runs it
argv = shlex.split(cmd.split("||")[0])
if name == "GNU C":
if tc["kind"] != "gcc":
continue
argv = [tc["path"], "--version"]
elif name == "Clang/LLVM":
if tc["kind"] != "clang":
continue
argv, optional = [tc["path"], "--version"], False
elif name == "binutils":
if "lld" in os.path.basename(tc["_ld"]):
continue # LLD answers to the Clang/LLVM row
argv = [tc["_ld"], "-v"]
elif name == "Python":
argv = [tc["_python"], "--version"]
try:
o = run(argv, check=False, env=env, timeout=20)
have = numver(o.stdout or "") if o.returncode == 0 else ()
except ToolError:
have = ()
need = numver(minimum)
if not have:
if optional:
absent.append(name)
else:
problems += 1
bad("%s: not found (this tree needs >= %s; checked with `%s`)"
% (name, minimum, " ".join(argv)))
elif not ver_ge(have, need):
if optional:
absent.append("%s (%s < %s)" % (name, vstr(have), minimum))
else:
problems += 1
bad("%s %s is too old: this tree needs >= %s (`%s`)"
% (name, vstr(have), minimum, " ".join(argv)))
else:
fine.append("%s %s" % (name, vstr(have)))
if verbose:
ok("%-20s %-10s (needs >= %s)" % (name, vstr(have), minimum))
if fine and not verbose:
ok("%d build tools meet this tree's own minimums (changes.rst)" % len(fine))
if absent:
note("optional, not available: %s" % ", ".join(absent))
return problems
COMPRESSORS = (("KERNEL_GZIP", "gzip"), ("KERNEL_BZIP2", "bzip2"), ("KERNEL_LZMA", "lzma"),
("KERNEL_XZ", "xz"), ("KERNEL_LZO", "lzop"), ("KERNEL_LZ4", "lz4"),
("KERNEL_ZSTD", "zstd"), ("MODULE_COMPRESS_GZIP", "gzip"),
("MODULE_COMPRESS_XZ", "xz"), ("MODULE_COMPRESS_ZSTD", "zstd"))
def check_compressors(cfg, path):
"""The config picks how the image and the modules are compressed; the binary
has to exist. Found here, this costs a second. Found by the build, it is
the very last step, after every object has been compiled."""
missing = [(sym, tool) for sym, tool in COMPRESSORS
if cfg.get(sym) == "y" and not which(tool, path=path)]
for sym, tool in missing:
bad("CONFIG_%s=y, but there is no `%s` on this machine" % (sym, tool))
return len(missing)
HOLD = ".config.elkt-hold"
HOLD_NONE = ".config.elkt-hold.none"
STAGING = ".config.elkt-staging"
def lsmod_file(path, root="/"):
with open(path, "w") as f:
f.write("Module Size Used by\n")
for line in read_text(rp(root, "proc/modules")).splitlines():
p = line.split()
if len(p) >= 3:
f.write("%-23s %s %s\n" % (p[0], p[1], p[2]))
def recover_held_config(tree):
"""Undo whatever a run killed inside trim_to_loaded() left behind -- or inside
clean_tree(), before it put the module-signing key back. Called before ANYTHING reads,
backs up or cleans the tree's .config."""
keep = os.path.join(tree, MODULE_KEY_KEEP)
if os.path.isdir(keep):
for f in MODULE_KEY_FILES:
kept = os.path.join(keep, os.path.basename(f).lstrip("."))
if os.path.isfile(kept) and not os.path.exists(os.path.join(tree, f)):
os.makedirs(os.path.dirname(os.path.join(tree, f)), exist_ok=True)
shutil.copy2(kept, os.path.join(tree, f))
shutil.rmtree(keep, ignore_errors=True)
note("put back the module-signing key an interrupted clean had set aside")
real = os.path.join(tree, ".config")
hold, none = os.path.join(tree, HOLD), os.path.join(tree, HOLD_NONE)
if os.path.exists(hold):
os.replace(hold, real)
note("restored the .config an interrupted run had set aside")
elif os.path.exists(none) and os.path.exists(real):
# That run had NO .config of its own: what sits there is its unverified
# staging copy, and `auto` would adopt it as the user's.
os.unlink(real)
note("removed an unverified .config left by an interrupted run")
if os.path.exists(none):
os.unlink(none)
def trim_to_loaded(mk, staging):
"""`make localmodconfig` against the staged config. That target hardcodes
'.config' (streamline_config.pl ignores KCONFIG_CONFIG), so the tree's real
.config is held aside for the duration and put back in a finally -- and a
marker says so when there was none, for the kill the finally never sees."""
tree = mk.tree
real, hold = os.path.join(tree, ".config"), os.path.join(tree, HOLD)
none = os.path.join(tree, HOLD_NONE)
lsmod = os.path.join(tree, ".elkt-lsmod")
had = os.path.exists(real)
if had:
os.replace(real, hold)
else:
open(none, "w").close()
try:
shutil.copyfile(staging, real)
lsmod_file(lsmod)
# oldconfig underneath asks about every new symbol; feed it Enters.
mk.call(["localmodconfig"], extra_env={"LSMOD": lsmod}, stdin_data="\n" * 20000)
os.replace(real, staging)
finally:
if os.path.exists(lsmod):
os.unlink(lsmod)
if had:
os.replace(hold, real)
else:
if os.path.exists(real):
os.unlink(real)
os.unlink(none)
def configure(tree, opts, machine, mk, vendors):
"""Everything happens in a STAGING file via KCONFIG_CONFIG. The tree's
.config is replaced only by a config that passed verification, so a failed
run leaves nothing behind for the next run to adopt as 'the user's'."""
recover_held_config(tree)
staging = os.path.join(tree, STAGING)
kenv = {"KCONFIG_CONFIG": STAGING}
kind, path, label = find_base_config(tree, opts["config_base"],
target_series=series_of(mk.value("kernelversion")),
skip_own=opts.get("_skip_own", False),
want_full=opts["modules"] == "full")
info("base : %s" % label)
if kind == "defconfig":
if os.path.exists(staging):
os.unlink(staging)
mk.call(["defconfig"], extra_env=kenv)
elif kind == "gz":
with gzip.open(path, "rb") as src, open(staging, "wb") as dst:
shutil.copyfileobj(src, dst)
else:
shutil.copyfile(path, staging)
if not DotConfig(staging).valid():
raise ToolError("the base config %s has no CONFIG_ lines in it" % label)
mk.call(["olddefconfig"], extra_env=kenv)
before = DotConfig(staging)
if opts["modules"] == "trimmed":
trim_to_loaded(mk, staging)
after = DotConfig(staging)
info("trimmed : %d -> %d modules (to what is loaded now)"
% (before.count("m"), after.count("m")))
symbols = kconfig_symbols(tree, machine["arch"])
disk, unknown = disk_requests(tree, machine)
if disk:
info("disk path : %s" % ", ".join("%s (%s)" % (s, m) for s, m, _ in disk))
for u in unknown:
note("no single option of this tree builds %s -- not enforced" % u)
base = DotConfig(staging)
for text in base_hardening_notes(base, opts, machine):
warn(text)
reqs = build_requests(opts, vendors, machine, disk, base)
results = []
for attempt in (1, 2):
cfg = DotConfig(staging)
for r in reqs:
if r.sym in symbols:
cfg.set(r.sym, r.rendered(cfg.get(r.sym), attempt))
cfg.write()
mk.call(["olddefconfig"], extra_env=kenv)
results = verify_requests(DotConfig(staging), reqs, symbols)
# A second pass catches a symbol whose prompt only became visible once
# the first pass had switched its dependency on.
if all(s == "ok" or s == "absent" for _, _, s in results):
break
return staging, reqs, results, symbols
def report_verify(results):
fails, seen = 0, set()
for r, got, status in results:
if (r.sym, status) in seen:
continue # asked twice (a disk driver that is in the rescue set too)
seen.add((r.sym, status))
shown = "CONFIG_%-28s" % r.sym
gots = "<absent>" if got is None else got
if status == "ok":
if r.want != "n" and r.level != "optional":
ok("%s = %s" % (shown, gots if r.want == "m|y" else r.shown() or '""'))
elif status == "absent":
if r.absent == "warn":
# A hardening option older than this tree: never a failure, so an old LTS
# tree still builds -- loudly.
warn("CONFIG_%s is not in this kernel version%s -- built without it"
% (r.sym, ": " + r.why if r.why else ""))
elif r.want != "n":
note("%s not in this kernel version%s"
% (shown, " (%s)" % r.why if r.why else ""))
elif status == "fail":
fails += 1
bad("%s want %s, got %s%s" % (shown, r.shown() or '""', gots,
" -- " + r.why if r.why else ""))
elif status == "warn":
warn("CONFIG_%s: wanted %s, got %s%s"
% (r.sym, r.shown() or '""', gots, " (%s)" % r.why if r.why else ""))
elif r.want != "n":
note("%s wanted %s, got %s" % (shown, r.shown(), gots))
return fails
def boot_console(cfg, efi=True, x86=True):
"""-> (driver, problem) for a built .config: the built-in driver that shows the
firmware's framebuffer before a GPU driver takes over ('' when none is), and what
is wrong with it ('' when nothing is). With SYSFB_SIMPLEFB=y the framebuffer is a
simple-framebuffer device, and efifb gets only the modes that conversion refuses:
it is never the answer then."""
if cfg.get("SYSFB_SIMPLEFB") == "y":
what = "SYSFB_SIMPLEFB=y makes the firmware's framebuffer a simple-framebuffer"
cands = (("simpledrm", "DRM_SIMPLEDRM"), ("simplefb", "FB_SIMPLE"))
elif efi:
what = "an EFI boot"
cands = (("efifb", "FB_EFI"), ("efidrm", "DRM_EFIDRM"))
elif x86:
what = "a BIOS boot"
cands = (("vesafb", "FB_VESA"), ("vesadrm", "DRM_VESADRM"), ("vgacon", "VGA_CONSOLE"))
else:
# Device-tree machines describe a simple-framebuffer; POWER has its own.
what = "a boot without EFI"
cands = (("simpledrm", "DRM_SIMPLEDRM"), ("simplefb", "FB_SIMPLE"),
("ofdrm", "DRM_OFDRM"), ("offb", "FB_OF"))
drv = next((d for d, sym in cands if cfg.get(sym) == "y"), "")
if not drv:
return "", "%s, and none of its drivers is built in: %s" % (what, ", ".join(
"%s (%s=%s)" % (d, sym, cfg.get(sym) or "n") for d, sym in cands))
if drv == "vgacon":
return drv, "" # a text console by itself
# On the others the text console is fbcon, which a DRM driver reaches through
# its fbdev emulation.
need = (["DRM_FBDEV_EMULATION"] if drv.endswith("drm") else []) + ["FRAMEBUFFER_CONSOLE"]
off = [s for s in need if cfg.get(s) != "y"]
return drv, ("%s off: a picture, but no text console on it" % " and ".join(off)) if off else ""
# ----------------------------------------------------------- hardened by default
LOCKDOWN_COST = {
"integrity": "refused even for root: modules the kernel does not trust, /dev/mem, MSR writes, "
"unsigned kexec, hibernation",
"confidentiality": "refused even for root: what integrity refuses, and kernel memory -- no perf, "
"kprobes, bpftrace"}
HARDENING_KEYS = ("lockdown", "suspend", "iommu", "trust_dkms")
BATTERY_TEXT = ("this laptop suspends with S3 today: s2idle without the firmware's S0ix drains the battery, "
"and an AMD APU's GPU skips its own suspend -- --suspend deep")
def hardening_state(cfg):
"""What a .config builds in of the hardening options, as plain values.
lockdown: integrity | confidentiality -- forced, built in; none -- lockdown= at the boot
line raises it, and so does Secure Boot where the distribution's LOCK_DOWN_IN_EFI_SECURE_BOOT
is set (sb_lockdown); inactive -- forced, and the LSM never starts (neither
SECURITY_LOCKDOWN_LSM_EARLY nor 'lockdown' in CONFIG_LSM), so it locks nothing; '' -- no
lockdown LSM at all. early: it starts before the boot parameters are read.
modsig, sig_all: modules signed -- every one, at modules_install. kexec_open: kexec_file_load
checks no signature (KEXEC_FILE without KEXEC_SIG); kexec_load: KEXEC, which brings
acpi_rsdp=; acpi_upgrade: the ACPI tables an initrd brings are read.
suspend: the built-in mem_sleep_default word, else deep (the firmware's default: S3 where it
has it). cmdline: the built-in command line, '' for none; quoted: it holds a quote or a
backslash; override: it replaces the boot loader's.
iommu: Intel's DMA remapping at boot -- on; on-not-igpu (an integrated GPU left out: Debian's
INTGPU_OFF, or intel_iommu=igfx_off built in); off; '' (not built). amd_iommu: AMD's, on
by itself once built. dma: lazy | strict | passthrough | ''.
ibt, shstk: CET. tpm: the TPM driver. ima_arch: IMA's arch policy, which under Secure Boot
refuses an unsigned module even without lockdown. trusted: SYSTEM_TRUSTED_KEYS."""
def on(sym):
return cfg.get(sym) == "y"
lockdown = ""
if on("SECURITY_LOCKDOWN_LSM"):
forced = "confidentiality" if on("LOCK_DOWN_KERNEL_FORCE_CONFIDENTIALITY") else \
"integrity" if on("LOCK_DOWN_KERNEL_FORCE_INTEGRITY") else ""
started = on("SECURITY_LOCKDOWN_LSM_EARLY") or "lockdown" in (cfg.get("LSM") or "").split(",")
lockdown = "none" if not forced else forced if started else "inactive"
cmdline = (cfg.get("CMDLINE") or "") if cfg.get("CMDLINE_BOOL") in ("y", None) else ""
words = cmdline.split()
sleep = [w.split("=", 1)[1] for w in words if w.startswith("mem_sleep_default=")]
intel = [x for w in words if w.startswith("intel_iommu=") for x in w.split("=", 1)[1].split(",")]
iommu = ""
if on("INTEL_IOMMU"):
up = on("INTEL_IOMMU_DEFAULT_ON") or on("INTEL_IOMMU_DEFAULT_ON_INTGPU_OFF")
for x in intel: # left to right, as the kernel reads them
up = True if x == "on" else False if x == "off" else up
iommu = "off" if not up else "on-not-igpu" if on("INTEL_IOMMU_DEFAULT_ON_INTGPU_OFF") \
or "igfx_off" in intel else "on"
dma = next((name for name, sym in (("lazy", "IOMMU_DEFAULT_DMA_LAZY"), ("strict", "IOMMU_DEFAULT_DMA_STRICT"),
("passthrough", "IOMMU_DEFAULT_PASSTHROUGH")) if on(sym)), "")
return {"lockdown": lockdown, "sb_lockdown": on("LOCK_DOWN_IN_EFI_SECURE_BOOT"),
"early": on("SECURITY_LOCKDOWN_LSM_EARLY"), "modsig": on("MODULE_SIG"), "sig_all": on("MODULE_SIG_ALL"),
"kexec_open": on("KEXEC_FILE") and not on("KEXEC_SIG"), "kexec_load": on("KEXEC"),
"acpi_upgrade": on("ACPI_TABLE_UPGRADE"), "suspend": sleep[-1] if sleep else "deep",
"cmdline": cmdline, "quoted": '"' in cmdline or "\\" in cmdline, "override": on("CMDLINE_OVERRIDE"),
"iommu": iommu, "amd_iommu": on("AMD_IOMMU"), "dma": dma,
"ibt": on("X86_KERNEL_IBT"), "shstk": on("X86_USER_SHADOW_STACK"),
"tpm": cfg.get("TCG_TPM") in ("y", "m"), "ima_arch": on("IMA_ARCH_POLICY"),
"trusted": cfg.get("SYSTEM_TRUSTED_KEYS") or ""}
def iommu_words(st):
"""hardening_state()'s IOMMU in a few words: 'on (lazy)', "Intel's off, AMD's on (lazy)" ..."""
dma = " (%s)" % st["dma"] if st["dma"] else ""
if st["iommu"] in ("on", "on-not-igpu"):
return ("on" if st["iommu"] == "on" else "on, not for an iGPU") + dma
if st["amd_iommu"]:
return ("Intel's off, AMD's on" if st["iommu"] == "off" else "AMD's only") + dma
return "off" if st["iommu"] == "off" else "not built"
def hardening_summary(st, arch=None, dkms=None):
"""One line: 'lockdown integrity, suspend s2idle, iommu on (lazy)[, dkms certificate built in]'.
dkms: whether DKMS's certificate is built in (None: not said)."""
arch = arch or os.uname().machine
parts = ["lockdown %s" % (st["lockdown"] or "not built")]
if arch in X86_ARCHES:
parts += ["suspend %s" % st["suspend"], "iommu %s" % iommu_words(st)]
if dkms is not None:
parts.append("dkms certificate %s" % ("built in" if dkms else "not built in"))
return ", ".join(parts)
def boot_line_escapes(lockdown, s2idle, iommu, cet):
"""What undoes each hardening feature that is on, for one boot: the plan, the post-install
text and INSTALL.txt say it."""
res = (["suspend: mem_sleep_default=deep"] if s2idle else []) + \
(["IOMMU: intel_iommu=off, amd_iommu=off or iommu=pt"] if iommu else []) + \
(["CET: ibt=off"] if cet else []) + \
(["lockdown: no boot-line word lowers it -- boot another kernel (the distribution's)"] if lockdown else [])
return "; ".join(res)
def state_escapes(st, arch=None):
arch = arch or os.uname().machine
x86 = arch in X86_ARCHES
return boot_line_escapes(st["lockdown"] in ("integrity", "confidentiality"), x86 and st["suspend"] == "s2idle",
x86 and (st["iommu"] in ("on", "on-not-igpu") or st["amd_iommu"]),
arch == "x86_64" and st["ibt"])
def trusted_certs(tree, st=None):
"""The certificates TREE builds into the kernel's keyring from this tool's own file, as DER:
[] when its SYSTEM_TRUSTED_KEYS is not certs/elkt-trusted.pem."""
if st is None:
st = hardening_state(DotConfig(os.path.join(tree, ".config")))
return pem_certificates(read_text(os.path.join(tree, TRUSTED_PEM))) if tree and st["trusted"] == TRUSTED_PEM else []
def hardening_outlook(cfg, machine, opts=None, tree=""):
"""-> [(ok | note | warn | bad, text)]: what a built .config does about lockdown, suspend,
the IOMMU, CET, the TPM and the certificates it trusts -- on this machine. It reads no
machine state (MACHINE has it); with TREE, its certs/elkt-trusted.pem says which
certificates are built in. bad: forced lockdown without MODULE_SIG_ALL, the one a
post-build check counts. opts: the command's options, for what was asked (None: nothing)."""
st, res = hardening_state(cfg), []
arch = machine.get("arch") or os.uname().machine
x86, mode = arch in X86_ARCHES, st["lockdown"]
forced = mode in ("integrity", "confidentiality")
asked = (opts or {}).get("lockdown", "off") != "off"
again = "`%s` %s" % (rebuild_command(tree), AS_BUILT) if tree else "a build with this tool"
if forced:
if not st["sig_all"]:
res.append(("bad", "lockdown %s is built in, and MODULE_SIG_ALL is not: modules_install leaves every "
"module unsigned, and this kernel refuses them all -- its disk drivers too" % mode))
else:
res.append(("ok", "lockdown: %s, built in -- %s; no boot-line word lowers it" % (mode, LOCKDOWN_COST[mode])))
if not st["early"]:
res.append(("warn", "lockdown starts late (SECURITY_LOCKDOWN_LSM_EARLY is off): what the boot "
"parameters set up before it starts is not checked"))
if st["kexec_open"]:
res.append(("warn", "kexec_file_load is not checked by lockdown on %s: without KEXEC_SIG it verifies "
"no signature -- KEXEC_FILE=n closes it" % arch))
if st["kexec_load"]:
res.append(("warn", "KEXEC=y under lockdown: it brings acpi_rsdp=, which is read before lockdown starts"))
if st["acpi_upgrade"]:
why = machine.get("acpi_override")
res.append(("warn", "ACPI_TABLE_UPGRADE=y under lockdown: the ACPI tables an initrd brings are read before "
"lockdown starts%s" % (" -- kept, as an ACPI override is in use here (%s)" % why
if why else "")))
elif mode == "inactive":
res.append(("warn", "a lockdown mode is forced, and the lockdown LSM never starts -- neither "
"SECURITY_LOCKDOWN_LSM_EARLY nor 'lockdown' in CONFIG_LSM: it locks nothing down%s"
% ("; to build it in: %s" % again if asked else "")))
else:
sb = " (it locks itself down under Secure Boot: LOCK_DOWN_IN_EFI_SECURE_BOOT)" if st["sb_lockdown"] else ""
if asked:
res.append(("warn", "lockdown: not built into this kernel%s -- the tree was configured without it; to "
"build it in: %s" % (sb, again)))
else:
res.append(("note", "lockdown: not built in%s -- %s" % (sb, "lockdown=integrity at the boot line turns it on"
if mode else "this kernel has no lockdown LSM")))
words = (machine.get("boot_line") or {}).get("words") or []
if x86:
if st["quoted"]:
res.append(("warn", "the built-in command line holds a quote or a backslash: this tool leaves it as it is, "
"and builds in neither mem_sleep_default=s2idle nor intel_iommu=igfx_off"))
if st["override"] and st["cmdline"]:
res.append(("warn", "CMDLINE_OVERRIDE=y: the boot loader's command line is ignored, root= and all"))
if st["suspend"] == "s2idle":
res.append(("ok", "suspend: s2idle, built in (mem_sleep_default=s2idle) -- mem_sleep_default=deep at "
"the boot line gives S3"))
now = machine.get("now") or {}
if now.get("battery") and now.get("mem_sleep") == "deep":
res.append(("warn", BATTERY_TEXT))
else:
res.append(("note", "suspend: %s" % ("deep (S3), the firmware's default" if st["suspend"] == "deep" and
"mem_sleep_default=deep" not in st["cmdline"].split()
else "%s, built in (mem_sleep_default=%s)" % ((st["suspend"],) * 2))))
res += [("warn", "%s in %s comes after the built-in line, and wins" % (w, src)) for src, w in words
if w.startswith("mem_sleep_default=") and w.split("=", 1)[1] != st["suspend"]]
amd = (machine.get("cpu") or {}).get("vendor") in AMD_VENDORS
flush = {"lazy": ", lazy flushing", "strict": ", strict: every unmap flushed at once"}.get(st["dma"], "")
if st["dma"] == "passthrough":
res.append(("warn", "IOMMU: passthrough -- devices reach all memory"))
elif amd and st["amd_iommu"]:
res.append(("ok", "IOMMU: AMD's, on by itself%s -- amd_iommu=off at the boot line" % flush))
elif st["iommu"] in ("on", "on-not-igpu") and not amd:
res.append(("ok", "IOMMU: on at boot%s%s -- intel_iommu=off at the boot line" % (
flush, "; the integrated GPU stays outside it" if st["iommu"] == "on-not-igpu" else "")))
elif st["iommu"] == "off" and not amd:
res.append(("note", "IOMMU: not switched on at boot -- intel_iommu=on at the boot line does it"))
else:
res.append(("note", "IOMMU: %s's is not built in" % ("AMD" if amd else "Intel")))
undoes = {"intel_iommu=off": not amd and st["iommu"] in ("on", "on-not-igpu"),
"amd_iommu=off": amd and st["amd_iommu"], "iommu=off": True, "iommu=pt": st["dma"] != "passthrough"}
res += [("warn", "%s in %s comes after the built-in defaults, and wins: %s" % (
w, src, "passthrough, devices reach all memory" if w == "iommu=pt" else "the IOMMU stays off"))
for src, w in words if undoes.get(w)]
if arch == "x86_64":
cet = (machine.get("cpu") or {}).get("cet")
if st["ibt"] and st["shstk"]:
res.append(("ok", "CET: IBT and user shadow stacks built in -- they act only on CPUs that have them%s; "
"an old binary driver that crashes with 'Missing ENDBR' needs ibt=off at the boot line"
% ("" if cet in (None, "unknown") else " (this one has %s)" % " and ".join(cet) if cet
else " (this one has neither: they do nothing here)")))
else:
res.append(("note", "CET: %s not built in" % " and ".join(
s for s, k in (("X86_KERNEL_IBT", "ibt"), ("X86_USER_SHADOW_STACK", "shstk")) if not st[k])))
if x86 or arch == "aarch64":
now = machine.get("now")
res.append(("ok", "TPM 2.0 drivers built%s" % (" (none fitted here)" if now is not None and not now.get("tpm")
else "")) if st["tpm"] else ("note", "TPM: TCG_TPM is not built"))
certs = trusted_certs(tree, st)
dk = machine.get("dkms_cert") or {}
if certs:
res.append(("ok", "built into its keyring (%s): %s" % (TRUSTED_PEM, ", ".join(
"'%s'" % (cert_subject_cn(d) or "a certificate without a name") for d in certs))))
if dk.get("der") and dk["der"] not in certs and (forced or machine.get("dkms")):
res.append(("warn", "DKMS's certificate now (%s) is not among them: DKMS made a new one after the build; "
"to build it in: %s" % (dk["path"], again)))
elif forced and machine.get("dkms") and (tree or st["trusted"] != TRUSTED_PEM):
res.append(("warn", "DKMS's certificate is not built in: under lockdown the modules DKMS signs load only "
"through shim + Secure Boot + MOK"))
return res
def base_hardening_notes(base, opts, machine):
"""[text]: what this build's built-in command line changes in the BASE config's own --
warnings, said where the base is known (configure, plan)."""
if base is None or (machine.get("arch") or os.uname().machine) not in X86_ARCHES:
return []
line = builtin_cmdline(base, opts, machine)
had = (base.get("CMDLINE") or "") if base.get("CMDLINE_BOOL") == "y" else ""
if line is None:
return ["the base config's built-in command line holds a quote or a backslash: it is left as it is, so "
"neither mem_sleep_default=s2idle nor intel_iommu=igfx_off is built in"]
res = []
if opts.get("suspend", "s2idle") == "s2idle" and "mem_sleep_default=deep" in had.split():
res.append("the base config builds in mem_sleep_default=deep: --suspend s2idle replaces it "
"(--suspend deep keeps S3)")
if line and not had and base.get("CMDLINE_FROM_BOOTCONFIG") == "y":
res.append("the base config renders its embedded bootconfig as the command line (CMDLINE_FROM_BOOTCONFIG), "
"which needs CMDLINE empty: the built-in line (%s) switches that off" % line)
return res
def dkms_trust_text(opts, machine):
"""The plan's line for --trust-dkms."""
if opts.get("trust_dkms", "on") == "off":
return "not built in: under lockdown DKMS's modules load only through shim + Secure Boot + MOK"
c = machine.get("dkms_cert") or {}
if c.get("state") == "ok":
cn = cert_subject_cn(c["der"])
return "%s%s built into the kernel's keyring -- DKMS's modules load under lockdown (--trust-dkms off)" % (
c["path"], " ('%s')" % cn if cn else "")
if c.get("state") == "unreadable":
return "%s is not readable as you: %s" % (c["path"], "the install reads it through sudo" if opts.get("install")
else "`build --install` reads it through sudo; a build "
"alone goes without it")
if c.get("state") == "tree":
return "DKMS signs with the kernel tree's own key here (%s): built in already" % c.get("source")
if not machine.get("dkms"):
return "no DKMS certificate here, and no DKMS module: nothing to build in"
return "no DKMS certificate yet -- DKMS makes one the first time it signs: `sudo dkms generate_mok`, then build"
def hardening_plan(opts, machine):
"""-> ([(label, text)], [warning]): the plan's lines for the hardening options -- from the
options and the machine, before anything is configured."""
arch = machine.get("arch") or os.uname().machine
x86, lockdown, iommu = arch in X86_ARCHES, opts.get("lockdown", "integrity"), opts.get("iommu", "on")
s2idle = x86 and opts.get("suspend", "s2idle") == "s2idle"
amd = (machine.get("cpu") or {}).get("vendor") in AMD_VENDORS
lines = [("lockdown", {"integrity": "integrity, built in -- %s (--lockdown off)" % LOCKDOWN_COST["integrity"],
"confidentiality": "confidentiality, built in -- also kernel memory: no perf, kprobes, "
"bpftrace (--lockdown integrity | off)",
"off": "not built in -- lockdown=integrity at the boot line turns it on"}[lockdown])]
if x86:
lines.append(("suspend", "s2idle, built in (mem_sleep_default=s2idle) -- fwupd's Suspend To RAM passes; on "
"firmware without S0ix it saves little power (--suspend deep; mem_sleep_default=deep "
"at the boot line overrides it)" if s2idle else "deep (S3), the firmware's default"))
text = {"on": "on at boot (INTEL_IOMMU_DEFAULT_ON; AMD's is on by itself), lazy flushing -- fwupd's IOMMU "
"Protection passes (--iommu strict | off; intel_iommu=off at the boot line)",
"strict": "on at boot, strict: every unmap flushed at once (intel_iommu=off at the boot line)",
"off": "AMD's has no build switch: it stays on (amd_iommu=off at the boot line)" if amd
else "not switched on at boot: intel_iommu=on at the boot line does it"}[iommu]
if iommu != "off" and intel_display(machine):
text += "; intel_iommu=igfx_off built in: the Intel GPU stays outside it, as on Debian's kernels"
lines.append(("iommu", text))
else:
lines.append(("suspend", "not built in on %s: mem_sleep_default=s2idle at the boot line" % arch))
lines.append(("dkms trust", dkms_trust_text(opts, machine)))
esc = boot_line_escapes(lockdown != "off", s2idle, x86 and (iommu != "off" or amd), arch == "x86_64")
if esc:
lines.append(("boot line", "if one of these misbehaves -- " + esc))
warns, now, bl = [], machine.get("now") or {}, machine.get("boot_line") or {}
if s2idle and now.get("battery") and now.get("mem_sleep") == "deep":
warns.append(BATTERY_TEXT)
off = [w for w in IOMMU_OFF_WORDS if w != ("intel_iommu=off" if amd else "amd_iommu=off")]
for src, w in bl.get("words") or []:
k, _, v = w.partition("=")
if x86 and ((k == "mem_sleep_default" and v != ("s2idle" if s2idle else "deep")) or
(w in off and (iommu != "off" or amd))):
warns.append("%s in %s comes after the built-in line, and wins" % (w, src))
if bl.get("kernel_cmdline"):
warns.append("there is no /etc/kernel/cmdline, so kernel-install copies /proc/cmdline into each new boot "
"entry -- the running kernel's built-in words with it. Write one without them first: "
"echo %s | sudo tee /etc/kernel/cmdline" % shlex.quote(bl["kernel_cmdline"]))
return lines, warns
def hardening_doctor_notes(opts, machine, root="/"):
"""[text]: what the hardening options cost on this machine -- doctor says them, and never
counts them."""
lockdown, res = opts.get("lockdown", "integrity"), []
if lockdown == "confidentiality" and opts.get("debug_info") == "btf":
res.append("--lockdown confidentiality keeps BPF and perf out of kernel memory: what --debug-info btf "
"is built for (bpftrace, CO-RE tools) will not run")
if lockdown == "off":
return res
resume = resume_device(root)
if resume:
res.append("hibernation is refused under lockdown: the resume device here (%s) goes unused -- "
"--lockdown off brings hibernation back" % resume)
signed = opts.get("secure_boot", "on") == "on" and (machine.get("arch") or os.uname().machine) == "x86_64"
if not signed and glob.glob(rp(root, "etc/systemd/system/*.wants/kdump*.service")):
res.append("kdump is enabled here, and under lockdown kexec loads only an image the kernel trusts: this "
"image goes unsigned (%s) -- kdump will not load it" % (
"--secure-boot off" if opts.get("secure_boot", "on") != "on"
else "image signing exists for x86_64 only"))
return res
def drop_staging_litter(staging):
for extra in (staging + ".old", staging + ".tmp"):
if os.path.exists(extra):
os.unlink(extra)
def commit_config(tree, staging):
real = os.path.join(tree, ".config")
if os.path.exists(real):
shutil.copy2(real, os.path.join(tree, ".config.elkt-prev"))
os.replace(staging, real)
drop_staging_litter(staging)
# ------------------------------------------------------------------------- state
def state_dir(opts):
d = os.path.abspath(os.path.expanduser(opts.get("state_dir") or os.path.join(
os.environ.get("XDG_STATE_HOME") or "~/.local/state", TOOL)))
os.makedirs(d, exist_ok=True)
return d
class State(object):
"""One INI section per SOURCE TREE, flushed whole and atomically on every
change, so the record survives the kill -9 it exists to detect."""
def __init__(self, path):
self.path = path
self.cp = configparser.RawConfigParser()
self.cp.optionxform = str
if os.path.isfile(path):
try:
self.cp.read(path)
except configparser.Error:
warn("%s is unreadable -- starting a fresh state file" % path)
self.cp = configparser.RawConfigParser()
def get(self, tree):
return dict(self.cp.items(tree)) if self.cp.has_section(tree) else {}
def trees(self):
return self.cp.sections()
def update(self, tree, **kv):
# Lock, RE-READ, apply, write. Flushing the snapshot taken at start-up
# would let a second instance, building another tree, write 'building'
# back over this tree's 'built' -- and the next run would mrproper it.
with open(self.path + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
fresh = configparser.RawConfigParser()
fresh.optionxform = str
try:
fresh.read(self.path)
except configparser.Error:
fresh = configparser.RawConfigParser()
fresh.optionxform = str
if not fresh.has_section(tree):
fresh.add_section(tree)
for k, v in kv.items():
fresh.set(tree, k, str(v))
tmp = "%s.tmp.%d" % (self.path, os.getpid())
with open(tmp, "w") as f:
f.write("# %s -- build state, one [section] per source tree. "
"Safe to delete.\n" % TOOL)
fresh.write(f)
os.replace(tmp, self.path)
self.cp = fresh
def now():
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
_TREE_LOCKS = []
def lock_tree(tree):
"""One instance per tree, for the life of the process. The state file cannot
tell a LIVE 'building' from a run that was killed -- it records no pid -- so
without this a second `build` on a tree that is compiling reads the live
record as a kill -9 and runs mrproper under the running make. A flock dies
with its process: there is no stale lock to clean up."""
if os.environ.get("ELKT_TREE_LOCKED"):
return # the `sign` a tree's own GNUmakefile runs, under `package`
try:
f = open(os.path.join(tree, ".elkt-lock"), "a")
except (OSError, IOError):
return # a tree we cannot write to is not one we will build in
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
f.close()
raise ToolError("another %s is working in %s right now -- wait for it, or use "
"another tree (--workdir)" % (TOOL, tree))
_TREE_LOCKS.append(f)
def serving_conflict(tree, serving, localversion, cc_version):
"""'' or why building THIS in a tree that serves installed kernels would
break them. /lib/modules/<rel>/build is what DKMS and every vendor
installer compile against: a different LOCALVERSION there gives their
modules the wrong vermagic, a different compiler gives the wrong flags --
for a kernel that may be the default boot entry. The protection used to
stop at mrproper."""
if not serving:
return ""
cfg = DotConfig(os.path.join(tree, ".config"))
was_lv, was_cc = cfg.get("LOCALVERSION"), cfg.get("CC_VERSION_TEXT")
diff = []
if was_lv is not None and was_lv.strip('"') != localversion:
diff.append("LOCALVERSION %s -> %s" % (was_lv.strip('"') or "(none)", localversion or "(none)"))
if was_cc and cc_version and was_cc.strip('"') != cc_version:
diff.append("compiler %s -> %s" % (was_cc.strip('"')[:40], cc_version[:40]))
if not diff:
return ""
return ("this tree is the build directory of installed kernel(s) %s, and this run would "
"turn it into a different kernel (%s).\n Their /lib/modules/<release>/build would "
"then describe the wrong kernel, and DKMS or a vendor installer would build modules "
"they refuse to load.\n Build this one in its own tree: --workdir <another "
"directory> (or --clean force, if those kernels are going away)"
% (", ".join(serving), "; ".join(diff)))
def rebuild_command(tree, extra=""):
"""The command that configures and builds TREE again as the kernel it is: with its own
LOCALVERSION, without which serving_conflict() refuses a tree that serves an installed
kernel. The --profile or flags it was built with go on it too (AS_BUILT says so): only
its builder knows them."""
lv = DotConfig(os.path.join(tree, ".config")).get("LOCALVERSION") or ""
return "%s.py build --source %s --localversion %s%s" % (TOOL, tree, shlex.quote(lv), extra)
AS_BUILT = "(with the --profile or flags it was built with)"
def in_service(tree, root="/"):
"""Releases whose /lib/modules/<rel>/build is THIS tree. DKMS compiles
against that link; mrproper here breaks every out-of-tree build for an
installed -- possibly the running -- kernel until a rebuild succeeds."""
rels = []
for link in glob.glob(rp(root, "lib/modules/*/build")):
if os.path.realpath(link) == os.path.realpath(tree):
rels.append(os.path.basename(os.path.dirname(link)))
return sorted(rels)
def decide_clean(mode, has_objects, status, stage, serving):
"""-> (clean?, why). Raises when asked to clean a tree that is in service.
auto cleans in exactly one case: a run that recorded 'building' and never
recorded an end -- kill -9, OOM, power loss -- where make had no chance to
delete a half-written object. A failure we SAW (compile error, ^C) left
the tree consistent; make removes its partial target and kbuild's .cmd
files decide the rest, so the retry is incremental. And a failure AFTER
the build -- a sudo prompt that timed out overnight -- says nothing about
the tree at all; the old script answered it with mrproper."""
if mode not in ("auto", "always", "never", "force"):
raise ToolError("--clean must be auto, always, never or force")
if mode == "never":
return False, "--clean never"
if not has_objects and mode == "auto":
return False, "no object files present -- nothing to clean"
want, why = False, ""
if mode in ("always", "force"):
want, why = True, "--clean " + mode
elif status == "building":
want, why = True, ("the last run recorded 'building' and never recorded an "
"end (kill -9, OOM or power loss)")
elif status == "configuring":
return False, "the last run died while configuring; no object was touched"
elif status == "failed":
return False, ("the last run failed at stage '%s' where make could tidy up; "
"retrying incrementally" % (stage or "?"))
elif status in ("built", "installing", "installed", "configured"):
return False, "the last run finished cleanly (%s); incremental is correct" % status
else:
return False, "objects present, no record of them -- building incrementally"
if want and serving and mode != "force":
msg = ("this tree is the DKMS build directory of installed kernel(s) %s; "
"cleaning it would break out-of-tree module builds for them"
% ", ".join(serving))
if mode == "always":
raise ToolError(msg + ".\n Use --clean force if that is really intended.")
return False, msg + " -- NOT cleaning"
return want, why
# The tree's module-signing key, and what kbuild needs to keep using it: mrproper deletes the
# key and its x509.genkey, clean every .*.cmd -- and without .signing_key.pem.cmd, if_changed
# makes a NEW key. Kept aside under names clean leaves alone.
MODULE_KEY_FILES = ("certs/signing_key.pem", "certs/x509.genkey", "certs/.signing_key.pem.cmd")
MODULE_KEY_KEEP = ".elkt-module-key"
def clean_tree(mk, tree, restore=True):
"""mrproper, without losing the configuration to it -- nor the module-signing key, when it
is kbuild's own (MODULE_SIG_KEY certs/signing_key.pem): a key made anew would leave the
modules of the kernel installed from this tree, its .old entry and its initramfs on
another key than the image that loads them, which under lockdown refuses them all."""
real = os.path.join(tree, ".config")
keepas = None
keys = []
if os.path.isfile(real):
# The FIRST backup keeps the plain name: by the second clean .config
# may no longer be the user's original.
keepas = os.path.join(tree, ".config.before-clean")
if os.path.exists(keepas):
keepas += "." + time.strftime("%Y%m%d-%H%M%S")
shutil.copy2(real, keepas)
info("saved : .config -> %s" % os.path.basename(keepas))
if DotConfig(real).get("MODULE_SIG_KEY") == "certs/signing_key.pem":
keep = os.path.join(tree, MODULE_KEY_KEEP)
keys = [f for f in MODULE_KEY_FILES if os.path.isfile(os.path.join(tree, f))]
if keys:
os.makedirs(keep, mode=0o700, exist_ok=True)
for f in keys:
shutil.copy2(os.path.join(tree, f), os.path.join(keep, os.path.basename(f).lstrip(".")))
if "certs/signing_key.pem" in keys:
info("saved : the module-signing key (certs/signing_key.pem) -> %s" % MODULE_KEY_KEEP)
try:
mk.call(["mrproper"])
finally:
# Cleaning is about build PRODUCTS; mrproper deleting .config is
# incidental. Restore the copy made a moment ago -- by ITS name, not the
# fixed one, which on a second clean is an OLDER config -- and in a
# finally, so an interrupted mrproper does not lose it either.
if keepas and restore and not os.path.exists(real):
shutil.copy2(keepas, real)
keep = os.path.join(tree, MODULE_KEY_KEEP)
for f in keys:
if not os.path.exists(os.path.join(tree, f)):
os.makedirs(os.path.dirname(os.path.join(tree, f)), exist_ok=True)
shutil.copy2(os.path.join(keep, os.path.basename(f).lstrip(".")), os.path.join(tree, f))
if keys and all(os.path.exists(os.path.join(tree, f)) for f in keys):
shutil.rmtree(keep, ignore_errors=True)
# ------------------------------------------------------------------------- build
STEP = re.compile(r"^\s{2}([A-Z][A-Z0-9_.]*(?: \[M\])?)\s+(\S+)")
# 'error:' with the colon -- the bare word is also a file name (linux/can/error.h
# goes past twice in every build).
ERRORISH = re.compile(r"(\berror:|\*\*\* |undefined reference|No rule to make target|"
r"No space left|Permission denied|cannot find -l|Killed\b)")
def error_summary(log_path, limit=12):
"""The FIRST real errors. The tail of a -j24 build is twenty-three other
jobs finishing; the line that matters is hundreds above it."""
hits = []
try:
with open(log_path, "r", errors="replace") as f:
for n, line in enumerate(f, 1):
if ERRORISH.search(line) and "warning:" not in line:
hits.append("%7d: %s" % (n, line.rstrip()))
if len(hits) >= limit:
break
except OSError:
pass
return hits
def fit_line(text, cols=None):
"""A line redrawn with \\r has to fit on ONE terminal row. \\r returns to the
start of the LAST wrapped row only, so a longer line leaves a copy of itself
behind on every redraw -- a hundred-character status in an 80-column
terminal scrolled the whole build past as thousands of fragments."""
cols = cols or shutil.get_terminal_size((80, 24)).columns
return text[:max(cols - 1, 20)]
def run_build(mk, log_path, verbose=False, targets=(), extra_env=None):
if os.path.exists(log_path):
os.replace(log_path, log_path + ".prev")
started = time.time()
steps = warnings = shown_warn = 0
last, beat, drawn = "", started, 0.0
env = dict(mk.env)
env.update(extra_env or {})
proc = subprocess.Popen(mk.argv(list(targets), parallel=True), cwd=mk.tree, env=env,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, universal_newlines=True,
errors="replace", bufsize=1)
def status_line():
el = int(time.time() - started)
return fit_line(" [%02d:%02d:%02d] %6d steps %s" % (el // 3600, el // 60 % 60,
el % 60, steps, last))
try:
with open(log_path, "w") as log:
for line in proc.stdout:
log.write(line)
text = line.rstrip("\n")
m = STEP.match(text)
if m:
steps += 1
last = "%s %s" % (m.group(1), m.group(2))
if verbose:
out(text)
continue
important = False
if "warning:" in text:
warnings += 1
shown_warn += 1
important = shown_warn <= 15
if shown_warn == 16:
text, important = " (further warnings go to the log only)", True
elif ERRORISH.search(text) or text.startswith("Kernel:"):
important = True
if important:
if UI.tty:
sys.stdout.write("\r\033[K")
out(text)
if UI.tty:
if important or time.time() - drawn >= 0.1: # ten redraws a second is plenty
drawn = time.time()
sys.stdout.write("\r\033[K" + status_line())
sys.stdout.flush()
elif time.time() - beat >= 120:
beat = time.time()
out(status_line())
rc = proc.wait()
except BaseException:
# ^C reached make too (same process group); SIGTERM, SIGHUP and our own
# errors (disk full on the log) did not. Ask it to stop -- make removes
# its partial targets on SIGTERM -- and KEEP DRAINING the pipe, or its
# parting output blocks it until the timeout.
try:
proc.terminate()
except OSError:
pass
try:
proc.communicate(timeout=60)
except (subprocess.TimeoutExpired, ValueError):
proc.kill()
proc.wait()
raise
finally:
if UI.tty:
sys.stdout.write("\r\033[K")
return rc, {"steps": steps, "warnings": warnings, "seconds": int(time.time() - started)}
def module_names(path):
names = set()
for line in read_text(path).splitlines():
base = os.path.basename(line.strip())
if base:
names.add(re.sub(r"\.(ko|o)(\.\w+)?$", "", base).replace("-", "_"))
return names
def module_coverage(tree, root="/"):
"""Every module loaded on this machine now, checked against what the build
produced (as .ko or built-in). The general form of 'will this boot HERE':
it would have caught a defconfig kernel with no XFS before any reboot did."""
built = module_names(os.path.join(tree, "modules.order")) | \
module_names(os.path.join(tree, "modules.builtin"))
missing, oot = [], []
for line in read_text(rp(root, "proc/modules")).splitlines():
name = line.split()[0] if line.split() else ""
if not name or name in built:
continue
taint = read_text(rp(root, "sys/module/%s/taint" % name)).strip()
(oot if "O" in taint or "E" in taint else missing).append(name)
loaded = len(read_text(rp(root, "proc/modules")).splitlines())
return {"loaded": loaded, "missing": sorted(missing), "out_of_tree": sorted(oot)}
OOT_C = """#include <linux/module.h>
#include <linux/init.h>
static int __init elkt_probe_init(void) { return 0; }
static void __exit elkt_probe_exit(void) { }
module_init(elkt_probe_init);
module_exit(elkt_probe_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("enterprise_linux_kernel_tool out-of-tree build probe");
"""
def oot_probe(tree, env, make_args, jobs=4):
"""Compile a ten-line module against the tree, the way DKMS will: through
`make -C <tree> M=<dir>`. -> (ok, vermagic or the failing output)."""
# kbuild rejects M= paths with spaces, colons or %; $TMPDIR may have them,
# and a refusal here would read as "DKMS will not compile".
base = None if not re.search(r"[\s:%]", tempfile.gettempdir()) else "/tmp"
d = tempfile.mkdtemp(prefix="elkt-oot-", dir=base)
try:
with open(os.path.join(d, "elkt_probe.c"), "w") as f:
f.write(OOT_C)
with open(os.path.join(d, "Makefile"), "w") as f:
f.write("obj-m := elkt_probe.o\n")
p = run(["make", "-j%d" % jobs, "-C", tree, "M=" + d] + list(make_args) + ["modules"],
env=env, check=False, timeout=900)
ko = os.path.join(d, "elkt_probe.ko")
if p.returncode != 0 or not os.path.isfile(ko):
lines = [l for l in (p.stdout or "").splitlines() if l.strip()]
return False, "\n".join(lines[-12:])
vm = ""
modinfo = which("modinfo", path=":".join(SYSTEM_PATH))
if modinfo:
vm = (run([modinfo, "-F", "vermagic", ko], check=False).stdout or "").strip()
return True, vm
finally:
shutil.rmtree(d, ignore_errors=True)
def dkms_make_args(tree, dkms_script=None):
"""What THIS machine's dkms adds to its make command for this tree: None
without dkms, [] for an older one. dkms 3.x reads the kernel's config and,
for a clang-built kernel, appends LLVM=1 -- so it builds with the clang on
ROOT's PATH, which need not be the clang that built the kernel."""
script = dkms_script or which("dkms", path=":".join(SYSTEM_PATH))
if not script:
return None
if "CONFIG_CC_IS_CLANG" in read_text(script) and \
DotConfig(os.path.join(tree, ".config")).get("CC_IS_CLANG") == "y":
return ["LLVM=1"]
return []
def dkms_check(tree, toolchain, machine, release=""):
"""Two questions, kept apart. Will DKMS compile against this tree -- run
the way THIS dkms runs it? And will a PLAIN `make -C <tree> M=...`, which
is how Intel's sep5, VirtualBox and vendor installers build? -> False only
when something on this machine actually NEEDS the one that fails: a DKMS
module that will be built for this release, or an out-of-tree module loaded
right now. A deliberate choice of compiler is not an error by itself."""
say("Out-of-tree (DKMS) build check")
if not os.path.isfile(os.path.join(tree, "Module.symvers")):
note("Module.symvers is missing -- the tree is not fully built; modpost "
"will complain, the compile itself is still a fair test")
pin = read_text(os.path.join(tree, PIN_FILE))
if pin.startswith(PIN_MARK):
if "\noverride " in pin or "\nexport PATH" in pin:
note("this tree pins its toolchain (%s): whatever a caller passes, builds "
"through it use %s" % (PIN_FILE, toolchain["path"]))
m = re.search(r"^export KCFLAGS := (.*)$", pin, re.M)
if m:
note("this tree hands its CPU flags to builds that set none (%s): KCFLAGS=%s"
% (PIN_FILE, m.group(1)))
env = dkms_env(machine["pkg_manager"])
extra = dkms_make_args(tree)
plain_ok, plain_out = oot_probe(tree, env, [])
dkms_ok, dkms_out = oot_probe(tree, env, extra) if extra else (plain_ok, plain_out)
will_build = [m for m in machine.get("dkms", [])
if release and dkms_will_build(m["limits"], release)[0]]
loaded_oot = module_coverage(tree)["out_of_tree"]
needed = bool(will_build or loaded_oot)
how = ("dkms here adds %s for a clang-built kernel: ROOT's clang, not necessarily yours"
% " ".join(extra)) if extra else "root's PATH, system compiler"
def show(text):
for l in text.splitlines():
if "error" in l.lower() or "***" in l or "You are using" in l:
info(" | " + l.strip()[:150])
if extra is None:
note("no dkms on this machine")
elif dkms_ok:
ok("a module compiles the way dkms will run it (%s)" % how)
if release and dkms_out and not dkms_out.startswith(release + " "):
warn("vermagic '%s' does not start with %s" % (dkms_out, release))
else:
(bad if needed else warn)("a module does NOT compile the way dkms will run it (%s):" % how)
show(dkms_out)
if plain_ok:
if extra is None or extra:
ok("a plain `make -C <tree> M=...` compiles too")
else:
warn("a PLAIN out-of-tree build fails -- `make -C /lib/modules/%s/build M=...`, the way "
"Intel's sep5, VirtualBox and vendor installers build. It runs gcc against a "
"kernel built by %s:" % (release or "<release>", toolchain["kind"]))
show(plain_out)
if not (dkms_ok and plain_ok) and toolchain["args"]:
good2, _ = oot_probe(tree, scrubbed_env(toolchain.get("extra_path")), toolchain["args"])
if good2:
envargs = [shlex.quote(a) for a in toolchain["args"]
if a.startswith(("CC=", "LLVM=", "LD="))]
if toolchain.get("extra_path"):
envargs.insert(0, shlex.quote("PATH=%s:/usr/sbin:/usr/bin" % toolchain["extra_path"]))
info("The kernel is fine: the probe compiles with its own toolchain. Put it in the")
info("environment of whatever builds the module -- kbuild reads it from there:")
info(" sudo env %s <the build command, e.g. dkms autoinstall -k %s>"
% (" ".join(envargs), release or "<release>"))
if not needed and not (dkms_ok and plain_ok):
note("nothing here needs it today -- no DKMS module will be built for %s and no "
"out-of-tree module is loaded -- so this does not block an install"
% (release or "this kernel"))
elif needed and not dkms_ok:
bad("needed by: %s" % ", ".join(["%s/%s" % (m["name"], m["version"]) for m in will_build]
+ loaded_oot))
for m in machine.get("dkms", []):
if not release:
continue
will, why = dkms_will_build(m["limits"], release)
label = "%s/%s" % (m["name"], m["version"])
if not will:
note("%s: DKMS will skip %s cleanly (%s)" % (label, release, why))
if m["name"] == "amdgpu":
info(" the in-tree amdgpu built here serves the GPU instead")
elif m["name"] == "amdgpu":
warn("%s has no kernel cap: DKMS will try to build it for %s. Vendor "
"amdgpu-dkms targets older enterprise kernels; against a newer one "
"it usually fails, noisily but harmlessly -- the in-tree driver is "
"the newer of the two. To make DKMS skip it -- `install` does this "
"itself, first; `%s.py dkms-cap` does it now, with a backup:\n"
" echo 'BUILD_EXCLUSIVE_KERNEL_MAX=\"%s\"' | sudo tee -a "
"/usr/src/%s-%s/dkms.conf\n"
" (DKMS skips kernels that sort ABOVE the cap, and 6.12.5 sorts above "
"'6.12' -- hence .999, to keep every release of the series it works on.)"
% (label, release, TOOL,
amdgpu_cap_for([k for k, _ in m["kernels"]], machine["running"]),
m["name"], m["version"]))
else:
note("%s: DKMS will rebuild it for %s at install time" % (label, release))
return dkms_ok or not needed
def post_build_checks(tree, toolchain, machine, vendors, release, opts=None):
"""-> the number of problems, each one a reason for install to refuse (--yes overrides).
opts: the command's options, for checks of what was asked (None: nothing was)."""
say("Post-build checks")
problems = 0
ok("kernel release: %s" % release)
cov = module_coverage(tree)
if cov["missing"]:
share = 100.0 * len(cov["missing"]) / max(cov["loaded"], 1)
warn("%d of the %d modules loaded right now have no counterpart in this build:\n"
" %s%s" % (len(cov["missing"]), cov["loaded"],
" ".join(cov["missing"][:24]),
" ..." if len(cov["missing"]) > 24 else ""))
if share > 25:
bad("that is %.0f%% of them -- this config was not made for this machine" % share)
problems += 1
else:
ok("all %d modules loaded right now exist in this build" % cov["loaded"])
if cov["out_of_tree"]:
note("out-of-tree modules loaded now (DKMS must rebuild them): %s"
% " ".join(cov["out_of_tree"]))
tcfg = DotConfig(os.path.join(tree, ".config"))
if tcfg.get("DEBUG_INFO_BTF") != "y" and tcfg.get("BPF_LSM") == "y":
note("BTF is off but BPF_LSM is on: systemd will log 'bpf-restrict-fs: Failed "
"to load BPF object' at boot, and bpftrace / CO-RE tools will not run. "
"--debug-info btf fixes that, for a slower build.")
built = module_names(os.path.join(tree, "modules.order"))
for vendor, mod in (("amd", "amdgpu"), ("nvidia", "nouveau"), ("intel", "i915")):
if vendor in vendors:
if mod in built:
ok("%s built as a module" % mod)
else:
bad("%s was requested as a module and was not built" % mod)
problems += 1
if "nvidia" in vendors:
show_outlook(nvidia_outlook(machine.get("nvidia"), machine["pkg_manager"], release, machine))
show_outlook(display_outlook(machine, release))
# From the .config itself: `install --source` never configures again, and older
# versions of this tool built trees with none (SYSFB_SIMPLEFB=y, simpledrm a module).
# Only x86 and EFI boots are sure to have a firmware framebuffer to show.
x86, efi = (machine.get("arch") or os.uname().machine) in X86_ARCHES, efi_booted(machine)
con, why = boot_console(tcfg, efi, x86)
if con and not why:
ok("boot console: %s, built into the kernel image" % con)
elif con:
warn("boot console: %s -- %s" % (con, why))
elif x86 or efi:
bad("no boot console built into this kernel: %s. Until the GPU's driver takes over "
"-- and for good if it never does -- the screen stays black. `%s` configures one in %s."
% (why, rebuild_command(tree), AS_BUILT))
problems += 1
else:
note("boot console: none built in -- %s" % why)
# Lockdown, suspend, the IOMMU, CET, the TPM, the certificates: every line said, one
# counted -- a forced lockdown without MODULE_SIG_ALL refuses every module, ext4 too.
outlook = hardening_outlook(tcfg, machine, opts, tree)
show_outlook(outlook)
problems += len([k for k, _ in outlook if k == "bad"])
if not dkms_check(tree, toolchain, machine, release):
problems += 1
return problems
# ------------------------------------------------------------ the toolchain pin
PIN_FILE = "GNUmakefile"
PIN_MARK = "# elkt-toolchain-pin"
PINNED_VARS = ("LLVM", "CC", "LD", "AR", "NM", "OBJCOPY", "OBJDUMP", "READELF", "STRIP",
"HOSTCC", "HOSTCXX", "HOSTAR", "HOSTLD")
def pin_text(tc, sign=False):
"""A GNUmakefile that makes THIS TREE build everything that comes through it
with the toolchain the kernel was built with -- '' when that is simply the
system's.
GNU make reads GNUmakefile before Makefile. So `make -C
/lib/modules/<rel>/build M=...` -- DKMS, Intel's sep5, VirtualBox, a vendor
installer -- lands here first, whatever it passes or forgets to pass. That
is the whole problem with a kernel built by a private clang: root's builds
fall back to gcc, or (dkms 3.x, helpfully adding LLVM=1) to root's OLDER
clang, and both choke on flags the kernel's config recorded.
Two things make it stick. `override` beats a command-line LLVM=1 in this
make. But kbuild immediately re-invokes itself with `-f Makefile`, which
does NOT read this file -- so the caller's definitions are taken out of
MAKEOVERRIDES and the pinned ones put in, which is how make hands
command-line variables down. ELKT_NO_PIN=1 switches it all off, and it
switches itself off where the compiler does not exist (another machine)."""
pins = []
for a in tc["args"]:
k, _, v = a.partition("=")
if k in PINNED_VARS:
pins.append((k, tc["path"] if k == "CC" and tc.get("ccache") else v))
extra = tc.get("extra_path") or ""
kcflags = tc.get("kcflags") or ""
if not pins and not extra and not sign and not kcflags:
return ""
esc = lambda v: v.replace("$", "$$")
lines = [PIN_MARK + " -- written by %s; safe to delete." % TOOL,
"# This tree was built with: %s" % tc["version"]]
if pins or extra:
lines += ["# Every build that comes through this directory uses the same toolchain.",
"# Switch off with ELKT_NO_PIN=1 in the environment.",
"ifeq ($(ELKT_NO_PIN),)",
"ifneq ($(wildcard %s),)" % esc(tc["path"]),
# make keeps a command-line value with a blank in it as `CC=ccache\ gcc`
# -- TWO words to filter-out, which took the first and left the second
# behind as a goal of the sub-make ("Nothing to be done for 'gcc'",
# exit 0, nothing built). Hide escaped blanks while filtering.
"elkt-sp := @@elkt-sp@@",
"MAKEOVERRIDES := $(subst $(elkt-sp),\\ ,$(filter-out $(addsuffix =%%,%s),"
"$(subst \\ ,$(elkt-sp),$(MAKEOVERRIDES))))" % " ".join(PINNED_VARS)]
for k, v in pins:
lines += ["override %s := %s" % (k, esc(v)), "MAKEOVERRIDES += %s=%s" % (k, esc(v))]
# The ones NOT pinned must not arrive from the caller's ENVIRONMENT either: a
# shell that exports LLVM= (found on the machine this was written on) would
# turn a gcc-built tree's module builds into clang ones. unexport keeps them
# out of the sub-make, where the work happens; kbuild's own defaults apply.
if pins or extra:
loose = [v for v in PINNED_VARS if v not in [k for k, _ in pins]]
lines.append("unexport %s" % " ".join(loose))
if extra:
lines.append("export PATH := %s:$(PATH)" % esc(extra))
lines += ["endif", "endif"]
if kcflags:
# Not under ELKT_NO_PIN: these are not the caller's business either way.
# A make run by hand, or a package build, gets the CPU flags the tree was
# built with -- instead of recompiling all of it generic. A caller's own
# KCFLAGS wins; the tool's makes pass theirs explicitly.
lines += ["# Built for: KCFLAGS=%s (--cpu)" % kcflags,
"ifeq ($(origin KCFLAGS),undefined)",
"export KCFLAGS := %s" % esc(kcflags),
"endif"]
lines.append("include Makefile")
if sign:
# WHY HERE. `make binrpm-pkg` / `bindeb-pkg` run a plain, goal-less `make`
# in this directory as their build step -- with a NEW build number
# (scripts/build-version increments on every call), so vmlinux is relinked
# and bzImage rewritten: a signature put there beforehand is gone, and the
# package ships an UNSIGNED kernel. Measured, not assumed. So the
# goal-less make itself ends by signing. Builds with a goal, and
# out-of-tree builds (M=), are left exactly as they were. --local: this
# tree is HERE, and a `make` typed on a terminal must not stop to ask
# "run where?" (or send the signing to another machine).
tool = esc(os.path.abspath(__file__))
lines += ["# Secure Boot: a goal-less `make` here ends by signing the image again.",
"ifeq ($(KBUILD_EXTMOD)$(M)$(MAKECMDGOALS)$(ELKT_NO_SIGN),)",
"ifneq ($(wildcard %s),)" % tool,
".DEFAULT_GOAL := elkt-all",
".PHONY: elkt-all",
"elkt-all: __all",
"\t@ELKT_HOOK=1 %s -I %s --local sign --source $(CURDIR)" % (PLATFORM_PYTHON, tool),
"endif", "endif"]
lines.append("")
return "\n".join(lines)
def pin_toolchain(tree, tc, sign=False):
"""Write, refresh or REMOVE the pin -- on every build, because a stale pin
would quietly overrule the compiler chosen this time. -> True when pinned."""
path = os.path.join(tree, PIN_FILE)
ours = os.path.isfile(path) and read_text(path).startswith(PIN_MARK)
if os.path.exists(path) and not ours:
warn("%s exists and is not ours -- leaving it alone; the toolchain is NOT pinned" % path)
return False
text = pin_text(tc, sign)
if not text:
if ours:
os.unlink(path)
return False
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
return True
# ------------------------------------------------------------------- secure boot
def keys_dir():
return os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
TOOL, "keys")
def signing_key(create=True):
"""YOUR Secure Boot key: made once, kept, used for every image. Persistent on
purpose -- a machine enrols a CERTIFICATE in MOK once, and everything signed
by that key boots on it afterwards. That is also what you hand out with a
kernel other people download: the .der, never the .key.
RSA 2048 / SHA-256, as the distro and Microsoft Secure Boot certificates are:
shim takes larger keys, some firmware does not."""
base = os.path.join(keys_dir(), "secureboot")
k = {"key": base + ".key", "crt": base + ".crt", "der": base + ".der"}
if all(os.path.isfile(v) for v in k.values()):
return k
if os.path.isfile(k["key"]):
# NEVER make a new key over an existing one: machines have enrolled its
# certificate, and the old key would be gone for good. A missing .der
# (moved to a USB stick to enrol it elsewhere) is derived again; a
# missing .crt is for the owner to restore -- a new certificate for the
# old key would not be the one those machines trust.
if not os.path.isfile(k["crt"]):
raise ToolError("%s exists but %s does not. Restore the certificate from your "
"backup -- not making a new key over the one machines already "
"trust. (To really start again, move the .key away first.)"
% (k["key"], k["crt"]))
run(["openssl", "x509", "-in", k["crt"], "-outform", "DER", "-out", k["der"]],
env=scrubbed_env())
os.chmod(k["der"], 0o644)
note("%s was missing -- derived again from the certificate" % k["der"])
return k
if not create:
return None
os.makedirs(keys_dir(), exist_ok=True)
os.chmod(keys_dir(), 0o700)
who = re.sub(r"[^\w@.-]", "_", "%s@%s" % (os.environ.get("USER", "builder"),
os.uname().nodename))[:44] # CN <= 64 in all
old = os.umask(0o077)
try:
run(["openssl", "req", "-new", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes",
"-days", "3650", "-subj", "/CN=%s kernel signing key/" % who,
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "keyUsage=critical,digitalSignature",
"-addext", "extendedKeyUsage=codeSigning",
"-keyout", k["key"], "-out", k["crt"]], env=scrubbed_env())
run(["openssl", "x509", "-in", k["crt"], "-outform", "DER", "-out", k["der"]],
env=scrubbed_env())
finally:
os.umask(old)
os.chmod(k["der"], 0o644)
os.chmod(k["crt"], 0o644)
info("made a Secure Boot signing key: %s (keep the .key private; back it up)" % k["key"])
return k
def key_cn(k):
o = run(["openssl", "x509", "-in", k["crt"], "-noout", "-subject", "-nameopt", "multiline"],
env=scrubbed_env(), check=False).stdout or ""
m = re.search(r"commonName\s*=\s*(.+)", o)
return m.group(1).strip() if m else ""
def image_path(tree, arch=None):
arch = arch or os.uname().machine
rel = {"x86_64": "arch/x86/boot/bzImage"}.get(arch)
return os.path.join(tree, rel) if rel else ""
def signing_tools():
path = ":".join(SYSTEM_PATH)
if which("sbsign", path=path):
return "sbsign"
if all(which(t, path=path) for t in ("pesign", "certutil", "pk12util", "openssl")):
return "pesign"
return ""
def image_signers(image):
path = ":".join(SYSTEM_PATH)
if which("pesign", path=path):
return run(["pesign", "-S", "-i", image], check=False, env=scrubbed_env()).stdout or ""
if which("sbverify", path=path):
return run(["sbverify", "--list", image], check=False, env=scrubbed_env()).stdout or ""
return ""
def _cert_digests(pem):
return set(hashlib.sha256(base64.b64decode(b)).hexdigest() for b in re.findall(
r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", pem or "", re.S))
def image_signed_by(image, k):
"""Does IMAGE carry a signature made with k's CERTIFICATE? Compared by the
certificate's SHA-256, not by the name in it: two keys made on one machine
have the same CN, and an image signed by the other one is, to shim and to
the person who enrolled this .der, not signed at all."""
if not (k and image and os.path.isfile(image) and os.path.isfile(k["crt"])):
return False
ours = _cert_digests(read_text(k["crt"]))
if not ours:
return False
path, env = ":".join(SYSTEM_PATH), scrubbed_env()
if which("pesign", path=path) and which("openssl", path=path):
d = tempfile.mkdtemp(prefix="elkt-sig-")
try:
for n in range(8): # an image can carry several signatures
p7 = os.path.join(d, "sig%d.p7" % n)
p = run(["pesign", "-i", image, "-u", str(n), "-e", p7], check=False, env=env)
if p.returncode != 0 or not os.path.isfile(p7) or not os.path.getsize(p7):
break
pem = run(["openssl", "pkcs7", "-inform", "DER", "-in", p7, "-print_certs"],
check=False, env=env).stdout
if ours & _cert_digests(pem):
return True
finally:
shutil.rmtree(d, ignore_errors=True)
return False
if which("sbverify", path=path):
return run(["sbverify", "--cert", k["crt"], image], check=False, env=env).returncode == 0
return False
def sign_image(tree, k):
"""-> 'signed' | 'already'. The signed image REPLACES bzImage (the unsigned
one is kept beside it), because `make install` installs that file."""
img = image_path(tree)
if not img:
raise ToolError("image signing is implemented for x86_64 only, so far")
if not os.path.isfile(img):
raise ToolError("%s does not exist -- build first" % img)
if image_signed_by(img, k):
return "already"
tool = signing_tools()
if not tool:
raise ToolError("no signing tool: install sbsigntools, or pesign + nss-tools")
signed, env = img + ".signed", scrubbed_env()
if os.path.exists(signed):
os.unlink(signed)
if tool == "sbsign":
run(["sbsign", "--key", k["key"], "--cert", k["crt"], "--output", signed, img], env=env)
else:
# pesign signs from an NSS database only: a throw-away one, made for this.
db = tempfile.mkdtemp(prefix="elkt-nss-")
try:
p12 = os.path.join(db, "k.p12")
run(["certutil", "-N", "-d", "sql:" + db, "--empty-password"], env=env)
run(["openssl", "pkcs12", "-export", "-inkey", k["key"], "-in", k["crt"],
"-name", "elkt", "-out", p12, "-passout", "pass:"], env=env)
run(["pk12util", "-d", "sql:" + db, "-i", p12, "-W", ""], env=env)
run(["pesign", "-n", "sql:" + db, "-c", "elkt", "-s", "-i", img, "-o", signed], env=env)
finally:
shutil.rmtree(db, ignore_errors=True)
if not image_signed_by(signed, k):
raise ToolError("signed %s but cannot find this key's signature in it -- NOT using it" % img)
shutil.copy2(img, img + ".unsigned")
os.replace(signed, img)
return "signed"
def mok_enrolled(k):
"""True / False / None (cannot tell: no mokutil, or not an EFI boot)."""
mok = which("mokutil", path=":".join(SYSTEM_PATH))
if not mok or not os.path.isdir("/sys/firmware/efi"):
return None
o = run([mok, "--test-key", k["der"]], check=False, env=scrubbed_env()).stdout or ""
return True if "already enrolled" in o else False if "not enrolled" in o else None
def _der_item(data, pos):
"""(tag, start, end): the DER item at POS -- its tag byte, where its content starts and
where it ends. ValueError for one that is cut short or has no definite length."""
if pos + 2 > len(data):
raise ValueError("truncated DER")
tag, n, start = data[pos], data[pos + 1], pos + 2
if n & 0x80:
k = n & 0x7f
if not 0 < k <= 4 or start + k > len(data):
raise ValueError("bad DER length")
n, start = int.from_bytes(data[start:start + k], "big"), start + k
if start + n > len(data):
raise ValueError("truncated DER")
return tag, start, start + n
def _der_children(data, start, end):
res = []
while start < end:
res.append(_der_item(data, start))
start = res[-1][2]
return res
def _cert_tbs(der):
"""[(tag, start, end)]: the fields of an X.509 certificate's tbsCertificate, without its [0]
version -- serial, algorithm, issuer, validity, subject, key, then the optional ones."""
tbs = _der_item(der, _der_item(der, 0)[1])
items = _der_children(der, tbs[1], tbs[2])
return items[1:] if items and items[0][0] == 0xa0 else items
def cert_subject_cn(der):
"""The subject's common name of an X.509 certificate (DER), '' when it has none to read."""
try:
_, s, e = _cert_tbs(der)[4] # serial, algorithm, issuer, validity: SUBJECT
for _, rs, rend in _der_children(der, s, e): # each a SET of (type, value)
for _, ps, pend in _der_children(der, rs, rend):
(t1, s1, e1), (t2, s2, e2) = _der_children(der, ps, pend)[:2]
if t1 == 0x06 and der[s1:e1] == b"\x55\x04\x03": # 2.5.4.3, commonName
return der[s2:e2].decode("utf-16-be" if t2 == 0x1e else "utf-8", "replace")
except (ValueError, IndexError, TypeError):
pass
return ""
def _key_id(b):
return ":".join("%02X" % x for x in bytearray(b))
def cert_serial(der):
"""The certificate's serial number the way kmod prints a module's sig_key -- 'AB:CD:..', upper
case, without the 00 bytes a DER INTEGER may start with. A module that modules_install or
DKMS signed names its key so. '' when it cannot be read."""
try:
tag, s, e = _cert_tbs(der)[0]
except (ValueError, IndexError, TypeError):
return ""
return _key_id(der[s:e].lstrip(b"\x00")) if tag == 0x02 else ""
def cert_skid(der):
"""The certificate's subject key identifier (extension 2.5.29.14) as 'AB:CD:..' -- what a
module signed with `sign-file -k` names as its sig_key. '' when it has none."""
try:
for tag, s, e in _cert_tbs(der)[6:]:
if tag != 0xa3: # [3] extensions
continue
_, xs, xe = _der_item(der, s)
for _, es, ee in _der_children(der, xs, xe): # each (id, [critical,] value)
parts = _der_children(der, es, ee)
if parts and der[parts[0][1]:parts[0][2]] == b"\x55\x1d\x0e":
_, vs, _ = parts[-1] # an OCTET STRING, holding one: the id
_, ks, ke = _der_item(der, vs)
return _key_id(der[ks:ke])
except (ValueError, IndexError, TypeError):
pass
return ""
def cert_sha256(der):
return hashlib.sha256(der).hexdigest()
def der_certs(data):
"""The certificates in DATA, DER ones one after another -- a tree's certs/x509_certificate_list,
which extract-cert writes from SYSTEM_TRUSTED_KEYS. Whatever follows something that is not
one is left out."""
res, pos = [], 0
while pos < len(data) and data[pos:pos + 1] == b"\x30":
try:
end = _der_item(data, pos)[2]
except ValueError:
break
res.append(data[pos:end])
pos = end
return res
def pem_certificates(text):
"""The certificates of a PEM text, each as DER."""
res = []
for b in re.findall(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", text or "", re.S):
try:
res.append(base64.b64decode("".join(b.split())))
except (ValueError, TypeError):
pass
return res
def cert_der(data):
"""The first certificate in DATA (bytes) as DER: DATA is DER itself -- it starts with a
SEQUENCE, as /var/lib/dkms/mok.pub does -- or PEM. None when it is neither."""
if data[:1] == b"\x30":
try:
return data[:_der_item(data, 0)[2]]
except ValueError:
return None
certs = pem_certificates(data.decode("ascii", "replace"))
return certs[0] if certs else None
def der_to_pem(der):
b = base64.b64encode(der).decode("ascii")
return "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n" % "\n".join(
b[i:i + 64] for i in range(0, len(b), 64))
DKMS_MOK = "/var/lib/dkms/mok.pub"
def dkms_distribution(machine=None, root="/"):
"""What dkms calls this distribution: ID itself when it is ubuntu, else the first word of
ID_LIKE, else ID -- prepare_mok() picks Ubuntu's and Gentoo's keys by it."""
if machine and (machine.get("os_id") or machine.get("os_like")):
osid, like = machine.get("os_id") or "", machine.get("os_like") or ""
else:
osr = parse_os_release(read_text(rp(root, "etc/os-release")) or read_text(rp(root, "usr/lib/os-release")))
osid, like = osr.get("ID", ""), osr.get("ID_LIKE", "")
return osid if osid == "ubuntu" else (like.split() or [osid])[0]
def dkms_certificate(root="/", machine=None):
"""-> {path, source, der, state}: the certificate DKMS signs its modules with, looked up as
dkms 3.2's prepare_mok() does. mok_certificate= and mok_signing_key= in
/etc/dkms/framework.conf, then in each framework.conf.d/*.conf (the last one wins); with no
signing key named there, by distribution: Ubuntu's shim-signed MOK.der; Gentoo's
MODULES_SIGN_CERT (the environment, /etc/portage/make.conf) -- without a MODULES_SIGN_KEY
it signs with the kernel tree's own key; else /var/lib/dkms/mok.pub. state: ok (der: its
first certificate), missing (DKMS makes one the first time it signs), unreadable (root's,
or not a certificate), tree (the tree's own key is built in already: nothing to add)."""
key, cert, source = "", "", ""
for f in [rp(root, "etc/dkms/framework.conf")] + sorted(glob.glob(rp(root, "etc/dkms/framework.conf.d/*.conf"))):
for line in read_text(f).splitlines():
m = re.match(r"^\s*(mok_signing_key|mok_certificate)=(.*)$", line)
if not m:
continue
try:
val = (shlex.split(m.group(2), comments=True) or [""])[0]
except ValueError:
val = m.group(2).strip().strip("\"'")
if m.group(1) == "mok_signing_key":
key = val
else:
cert, source = val, "/" + os.path.relpath(f, root)
if not key:
dist = dkms_distribution(machine, root)
if dist.startswith("ubuntu"):
cert, source = "/var/lib/shim-signed/mok/MOK.der", "Ubuntu's shim-signed MOK"
elif dist.startswith("gentoo"):
make = read_text(rp(root, "etc/portage/make.conf"))
def var(name):
m = re.search(r"^%s=(.*)$" % name, make, re.M)
return os.environ.get(name) or (m.group(1).replace('"', "").strip() if m else "")
if not var("MODULES_SIGN_KEY"):
return {"path": "", "source": "the kernel tree's own key", "der": None, "state": "tree"}
cert, source = var("MODULES_SIGN_CERT"), "MODULES_SIGN_CERT"
if not cert:
cert, source = DKMS_MOK, "dkms's default"
res = {"path": cert, "source": source, "der": None, "state": "missing"}
if not os.path.exists(rp(root, cert)):
return res
try:
with open(rp(root, cert), "rb") as f:
res["der"] = cert_der(f.read())
except (OSError, IOError):
pass
res["state"] = "ok" if res["der"] else "unreadable"
return res
def write_trusted_pem(tree, machine, opts, root="/"):
"""Write TREE's certs/elkt-trusted.pem -> TRUSTED_PEM, the SYSTEM_TRUSTED_KEYS to build
with, or '' when there is nothing to build in. In it, in this order: DKMS's certificate
(--trust-dkms on: dkms_certificate()), so that the modules DKMS signs load under lockdown
without MOK, however the machine boots; your image certificate (--secure-boot on, where
images are signed), since kexec_file_load checks an image against the keyrings the built-in
certificates are on -- kexec and kdump of this tool's kernels work under lockdown (and a
module signed with your key loads too). A certificate only root may read is read through
sudo when this run installs: it holds sudo by then. Rewritten only when its bytes change --
a new file means a new certificate list, and vmlinux is linked again."""
ders = []
if opts.get("trust_dkms", "on") == "on":
c = dkms_certificate(root, machine)
der = c["der"]
if der is None and c["state"] == "unreadable" and opts.get("install") and not opts.get("dry_run"):
p = sudo(["base64", rp(root, c["path"])], check=False, capture=True)
if p is not None and p.returncode == 0:
try:
der = cert_der(base64.b64decode(p.stdout or ""))
except (ValueError, TypeError):
der = None
if der:
ders.append(der)
if opts.get("secure_boot", "on") == "on" and image_path(tree, machine.get("arch")):
ders += pem_certificates(read_text(signing_key(create=True)["crt"]))
text = "".join(der_to_pem(d) for i, d in enumerate(ders) if d not in ders[:i])
if not text:
return ""
path = os.path.join(tree, TRUSTED_PEM)
if read_text(path, None) != text:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
return TRUSTED_PEM
def secure_boot_sign(tree, machine):
say("Secure Boot signing")
# Run by a tree's GNUmakefile (under rpmbuild), this must use the key that
# exists -- never quietly make another one where a changed environment points.
k = signing_key(create=not os.environ.get("ELKT_HOOK"))
if not k:
raise ToolError("no signing key in %s -- not making one from inside a build" % keys_dir())
how = sign_image(tree, k)
ok("%s %s with '%s'" % (os.path.relpath(image_path(tree), tree),
"is signed" if how == "signed" else "was already signed", key_cn(k)))
enrolled = mok_enrolled(k)
if enrolled:
ok("that certificate is enrolled in this machine's MOK list")
else:
note("to boot it with Secure Boot ON, a machine must trust the certificate -- once:")
info(" sudo mokutil --import %s (choose a one-time password)" % k["der"])
info(" reboot; in the blue MOK Manager screen: Enroll MOK, that password")
info(" Anyone you give this kernel to needs that .der file too -- never the .key.")
return k
# ----------------------------------------------------------------------- install
class SudoKeepalive(object):
"""Ask for the password ONCE, up front, and keep the ticket warm. A prompt
that appears at the end of an unattended build times out after five
minutes, and the install 'fails' at 3 a.m. for no reason."""
def __init__(self):
self._stop = threading.Event()
def start(self):
if run(["sudo", "-v"], interactive=True, check=False).returncode != 0:
raise ToolError("sudo authentication failed")
t = threading.Thread(target=self._loop)
t.daemon = True
t.start()
def _loop(self):
while not self._stop.wait(50):
subprocess.call(["sudo", "-n", "-v"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop(self):
self._stop.set()
def sudo(cmd, check=True, capture=False, dry=False, stdin_data=None):
"""stdin_data: what `sudo tee` writes -- the password, when sudo asks, comes from the
terminal, never from stdin."""
if dry:
info("would run: sudo " + " ".join(shlex.quote(c) for c in cmd))
return None
if stdin_data is not None:
capture = True
return run(["sudo"] + cmd, check=check, capture=capture, interactive=not capture,
stdin_data=stdin_data)
# ------------------------------------------------------------ the module-trust guard
# Where out-of-tree modules go: dkms on Debian and Fedora (updates/dkms), dkms and kmods on EL
# (extra), EL's kABI links (weak-updates). module_paths() keeps these prefixes.
OOT_MODULE_DIRS = ("updates/", "extra/", "weak-updates/")
# A packed module is signed unpacked: sign-file appends its signature to whatever file it is given,
# and after a .ko.xz's stream that breaks it -- kmod's xz reader calls it corrupt, and the module
# never loads (tried 2026-10-21). Unpack, sign, pack again, the way modules_install packs.
PACKED_KO = {"xz": ("unxz", "xz --check=crc32 --lzma2=dict=1MiB"), "zst": ("unzstd --rm -q", "zstd --rm -q"),
"gz": ("gunzip", "gzip -n")}
BEFORE_BUILD = "\n (checked before the build: nothing has been built)"
# Why DKMS's certificate is not one the new kernel trusts: one line, then the refusal's own words.
TRUST_CAUSE = {
"missing": ("DKMS has no certificate yet: it makes one only when it first signs, after the build",
"DKMS has no certificate yet: it makes %(path)s the first time it signs -- inside this install, "
"after the build, too late to be built in."),
"unreadable": ("its certificate is not readable as you",
"%(path)s is not readable as you, and only a `build --install` that is not a dry run reads it "
"(through sudo) to build it in."),
"new-cert": ("DKMS made a new certificate after the build",
"DKMS made a new certificate after %(new)s was built: the one built in is the old one."),
"not-built-in": ("its certificate is not built in",
"%(new)s was built without it (--trust-dkms off, or before DKMS had one)."),
"off": ("--trust-dkms off leaves its certificate out", "--trust-dkms off leaves it out of %(new)s."),
}
def read_bytes(path):
try:
with open(path, "rb") as f:
return f.read()
except (OSError, IOError):
return b""
def release_guess(opts, tree=""):
"""The release a build is expected to make, before it is configured: TREE's (else --source's)
Makefile with --localversion, else the version its directory or --kernel names; '' when
nothing says (--kernel stable: kernel.org is not asked for this)."""
tree = tree or opts.get("source") or ""
lv, mk = opts.get("localversion") or "", read_text(os.path.join(tree, "Makefile")) if tree else ""
major, minor = tree_version(tree) if mk else (0, 0)
if major:
sub = re.search(r"^SUBLEVEL\s*=\s*(\d+)", mk, re.M)
return "%d.%d.%s%s" % (major, minor, sub.group(1) if sub else "0", lv)
m = re.match(r"^(?:linux-)?(\d+\.\d+)(\.\d+)?$", os.path.basename(tree) if tree else str(opts.get("kernel") or ""))
return "%s%s%s" % (m.group(1), m.group(2) or ".0", lv) if m else ""
def boot_critical(machine):
"""-> (mounts, screen): the module names a new kernel cannot come up without, of those that
come from outside its tree -- what mounts the root filesystem (zfs and spl under a root on ZFS;
a storage driver the running kernel loads from updates/ or extra/, storage_stack()'s), and
what drives the screen (screen_records())."""
st = machine.get("storage") or {}
mounts = set(("zfs", "spl")) if st.get("rootfs") == "zfs" else set()
mounts |= set(d["module"] for d in st.get("drivers") or [] if (d.get("path") or "").startswith(OOT_MODULE_DIRS))
screen = set(n for r in screen_records(machine) for n in ([r["module"]] if r["module"] else list(r["modules"])))
return mounts, screen
def module_sig_key(path):
"""The key module file PATH is signed with, as kmod names it (modinfo's sig_key: the signing
certificate's serial, or its subject key id), upper case -- '' when it is not signed, None
when that cannot be told: no modinfo (Debian keeps it in /usr/sbin, off your PATH), or it
failed."""
modinfo = which("modinfo", path=":".join(SYSTEM_PATH))
if not modinfo:
return None
try:
p = run([modinfo, "-F", "sig_key", path], check=False, env=scrubbed_env(), timeout=30)
except ToolError:
return None
text = (p.stdout or "").strip()
if p.returncode != 0 or (text and not re.match(r"^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2})*$", text)):
return None
return text.upper()
def oot_loaded(root="/"):
"""The modules loaded now that came from outside the kernel's tree (taint O)."""
names = [l.split()[0] for l in read_text(rp(root, "proc/modules")).splitlines() if l.split()]
return [n for n in names if "O" in read_text(rp(root, "sys/module/%s/taint" % n))]
def oot_files(release, root="/"):
"""The module files under /lib/modules/RELEASE/{updates,extra,weak-updates}: an earlier
install's of the same release, or what something other than DKMS put there."""
res = []
for d in OOT_MODULE_DIRS:
for dirpath, dirnames, files in os.walk(rp(root, "lib/modules/%s/%s" % (release, d))):
dirnames.sort()
res += [os.path.join(dirpath, f) for f in sorted(files) if re.search(r"\.ko(\.\w+)?$", f)]
return res
def sig_enforcing_word(machine, st=None):
"""(source, word) of a boot-line word that makes a kernel refuse a module it does not trust,
whatever it builds in -- lockdown=integrity or =confidentiality (with the lockdown LSM there to
act on it), module.sig_enforce -- or (). From what the boot loader gives every kernel
(boot_line_facts()), then the built-in command line of ST (hardening_state(); None: before
the build, which builds the LSM in either way)."""
words = list((machine.get("boot_line") or {}).get("words") or [])
words += [("its built-in command line", w) for w in (st["cmdline"].split() if st else [])]
for src, w in words:
k, _, v = w.partition("=")
k = k.replace("-", "_")
if (k == "lockdown" and v in ("integrity", "confidentiality")
and (st is None or st["lockdown"] in ("none", "integrity", "confidentiality"))) or \
(k == "module.sig_enforce" and (v == "" or v[:1].lower() in ("1", "y", "t") or v.lower() == "on")):
return src, w
return ()
def planned_keys(tree, opts, arch=None):
"""The certificates a build of TREE will trust for modules besides DKMS's, as far as they are
known before it runs: TREE's module key when the tree has one already -- kbuild makes one only
when it is missing, and clean_tree() keeps it -- and your image certificate, which
write_trusted_pem() builds in under --secure-boot on. For module_trust() before the build
(opts['_tree_keys']): a file signed with one of them is not refused for that."""
res = []
key = cert_der(read_bytes(os.path.join(tree, "certs/signing_key.x509"))) if tree else None
if key:
res.append(key)
if tree and opts.get("secure_boot", "on") == "on" and image_path(tree, arch):
res += [c for c in pem_certificates(read_text(os.path.join(keys_dir(), "secureboot.crt"))) if c not in res]
return res
def module_trust(machine, opts, release="", tree="", predicted="", root="/"):
"""Will the out-of-tree modules this machine needs load on the new kernel? -> {} when there
is nothing to look at (no TREE, no DKMS module registered), else {enforced, why, cert,
built_in, mok, mok_kernel, lockdown, ima, line, refuse, hard, warn, notes, ok}:
why: what makes the new kernel refuse a module it does not trust -- lockdown built in, Secure
Boot with IMA's arch policy, MODULE_SIG_FORCE, a boot-line word that does it for any kernel
(line: sig_enforcing_word()) -- '' when nothing does (enforced False): such a module loads,
and taints the kernel. From TREE's .config; before the build (PREDICTED, the release it
is expected to make), from the options and this machine.
cert: DKMS's certificate (machine['dkms_cert']). built_in: the certificates the kernel's own
keyring holds -- TREE's certs/x509_certificate_list and its module key; before the build,
what write_trusted_pem() puts in, and opts['_tree_keys'] (planned_keys()). mok: '' when MOK
keys count too, else why not (mok_counts()); mok_kernel: the kernel has the keyrings MOK needs.
refuse: [problem] that --allow-untrusted-modules overrides; hard: [problem] that nothing does,
a module that mounts /. Each {what, kind (dkms | file), text: one line, modules, root,
screen, cause, file}. warn, notes, ok: [text]. --yes overrides none of it."""
if not tree and not machine.get("dkms"):
return {}
rel = release or predicted
new = rel or "the new kernel"
cfg = DotConfig(os.path.join(tree, ".config")) if tree else None
st = hardening_state(cfg) if tree else None
sb = secure_boot_counts(machine)
if tree:
mode = st["lockdown"] if st["lockdown"] in ("integrity", "confidentiality") else ""
ima, force = sb and st["ima_arch"], cfg.get("MODULE_SIG_FORCE") == "y"
else:
mode = opts.get("lockdown", "integrity")
mode, ima, force = "" if mode == "off" else mode, sb, False
line = sig_enforcing_word(machine, st)
why = ("lockdown %s %s (%s)" % ("is built into" if tree else "goes into", new, mode) if mode else
"Secure Boot is on here, and IMA's arch policy refuses a module %s does not trust, even without lockdown"
% new if ima else "%s has MODULE_SIG_FORCE=y" % new if force else
"%s puts %s on the boot line: %s, booted with it, refuses a module it does not trust"
% (line[0], line[1], new) if line else "")
short = "lockdown %s" % mode if mode else "Secure Boot and IMA" if ima else "MODULE_SIG_FORCE" if force else \
"%s at the boot line" % line[1] if line else ""
cert = machine.get("dkms_cert") or {}
path, state, der = cert.get("path") or DKMS_MOK, cert.get("state"), cert.get("der")
if tree:
built = der_certs(read_bytes(os.path.join(tree, "certs/x509_certificate_list")))
key = cert_der(read_bytes(os.path.join(tree, "certs/signing_key.x509")))
built += [key] if key else []
else:
built = [der] if der is not None and opts.get("trust_dkms", "on") == "on" else []
built += [k for k in opts.get("_tree_keys") or [] if k not in built]
mok = mok_counts(machine, cfg)
res = {"enforced": bool(why), "why": why, "cert": cert, "built_in": built, "mok": mok,
"mok_kernel": cfg is None or (cfg.get("INTEGRITY_MACHINE_KEYRING") == "y"
and cfg.get("SECONDARY_TRUSTED_KEYRING") == "y"),
"lockdown": mode, "ima": bool(st and st["ima_arch"]), "line": line,
"refuse": [], "hard": [], "warn": [], "notes": [], "ok": []}
# Does the new kernel trust the certificate DKMS signs with? True, False -- or None: that
# cannot be told, and nothing is refused for it.
trusted, how, doubt = False, "", ""
if not state:
trusted, doubt = None, "DKMS's certificate was not looked up"
elif state == "tree":
trusted, how = True, "with the kernel tree's own key, which is built in"
elif der is not None and der in built:
trusted, how = True, "with %s, built in" % path
elif state == "unreadable" and tree:
trusted, doubt = None, "%s is not readable as you, so it cannot be compared with what %s built in" % (path, tree)
elif state == "unreadable" and opts.get("trust_dkms", "on") == "on" and opts.get("install") \
and not opts.get("dry_run"):
trusted, how = True, "with %s, which the build reads through sudo and builds in" % path
if trusted is False and not mok and der is not None:
try:
enrolled = mok_enrolled({"der": rp(root, path)})
except ToolError:
enrolled = None
if enrolled:
trusted, how = True, "with %s, enrolled in MOK" % path
elif enrolled is None:
trusted, doubt = None, "mokutil cannot say whether %s is enrolled in MOK" % path
cause = ""
if trusted is False:
if state in ("missing", "unreadable"):
cause = state
elif tree:
cn = cert_subject_cn(der) if der else ""
cause = "new-cert" if cn and cn in [cert_subject_cn(d) for d in trusted_certs(tree, st)] else "not-built-in"
else:
cause = "off" if opts.get("trust_dkms", "on") == "off" else "not-built-in"
skipped = []
builds = dkms_after_cap(machine, rel, opts, root, skipped)
mounts, screen = boot_critical(machine)
labels = ", ".join("%s/%s" % (m["name"], m["version"]) for m in builds)
rebuilt = set(n for m in builds for n in m["modules"])
# The root filesystem's own module mounts / when it comes from outside the tree: zfs, and
# bcachefs since 6.18 -- DKMS builds it, or a DKMS entry carries its name.
fs = ((machine.get("storage") or {}).get("rootfs") or "").replace("-", "_")
if fs and (fs in rebuilt or any(m["name"].replace("-", "_") == fs for m in machine.get("dkms") or [])):
mounts.add(fs)
if machine.get("dkms") and not builds:
res["notes"].append("DKMS modules are registered here, and none will be built for %s: %s"
% (new, "; ".join("%s -- %s" % s for s in skipped)))
elif builds and trusted:
res["ok"].append("DKMS signs %s for %s %s" % (labels, new, how))
elif builds and trusted is None:
res["warn"].append("whether %s trusts the key DKMS signs %s with cannot be told: %s -- if it does not, %s"
% (new, labels, doubt, "they will not load" if why else "they taint it (E)"))
elif builds and not why:
res["notes"].append("%s will load on %s and taint it (E): DKMS signs with %s, a key it does not trust -- "
"nothing there refuses such a module" % (labels, new, path))
elif builds:
for m in builds:
label = "%s/%s" % (m["name"], m["version"])
on_root, on_screen = sorted(set(m["modules"]) & mounts), sorted(set(m["modules"]) & screen)
res["hard" if on_root else "refuse"].append({
"what": label, "kind": "dkms", "modules": m["modules"], "root": on_root, "screen": on_screen,
"cause": cause, "file": "",
"text": "%s will not load on %s (%s): DKMS signs it with %s, a key that kernel does not trust -- %s%s%s"
% (label, new, short, path, TRUST_CAUSE[cause][0], "; it mounts /" if on_root else "",
"; it drives the screen" if on_screen else "")})
if not why:
return res
# What else is out of tree: files an earlier install of the same release left (or something
# other than DKMS put there), and what is loaded now from outside the kernel's tree.
ids = set(x for d in built for x in (cert_serial(d), cert_skid(d)) if x)
seen, unknown = set(), []
for f in oot_files(rel, root) if rel else []:
name = re.sub(r"\.ko(\.\w+)?$", "", os.path.basename(f)).replace("-", "_")
seen.add(name)
if name in rebuilt:
continue
key = module_sig_key(f)
if key is None:
unknown.append("/" + os.path.relpath(f, root))
continue
if key in ids:
continue
shown = "/" + os.path.relpath(f, root)
text = "%s is %s: %s (%s) refuses it" % (shown, "signed with key %s, which %s does not trust" % (key, new)
if key else "not signed", new, short)
p = {"what": shown, "kind": "file", "modules": [name], "root": [name] if name in mounts else [],
"screen": [name] if name in screen else [], "cause": "", "file": shown}
if key and not mok:
res["warn"].append(text + " -- unless that key is enrolled in MOK")
elif name in mounts:
res["hard"].append(dict(p, text=text + "; it mounts /"))
elif name in screen:
res["refuse"].append(dict(p, text=text + "; it drives the screen"))
else:
res["warn"].append(text)
if unknown:
res["warn"].append("whether %s trusts %s cannot be told (no modinfo here, or it failed): an untrusted one is "
"refused there" % (new, ", ".join(unknown)))
have = module_names(os.path.join(tree, "modules.order")) | module_names(os.path.join(tree, "modules.builtin")) \
if tree else set()
for name in oot_loaded(root):
if name not in rebuilt and name not in have and name not in seen:
res["warn"].append("%s is loaded now from outside the kernel's tree, and nothing here rebuilds it for %s: "
"whatever does must sign it with a key that kernel trusts" % (name, new))
return res
def module_trust_text(mt, machine, tree="", release=""):
"""install_preflight()'s refusal for module_trust() MT's problems, each clause said only where
the fact behind it holds: what will not load and why; how to trust the key -- built in (works
however the machine boots), enrolled in MOK (with this machine's own boot path: the entry that
boots it, shim's, Secure Boot, Setup Mode), or no lockdown; and what overrides it -- nothing,
for a module that mounts /. --yes never does."""
new, c = release or "the new kernel", mt.get("cert") or {}
path = c.get("path") or DKMS_MOK
cn = cert_subject_cn(c["der"]) if c.get("der") else ""
probs = mt["hard"] + mt["refuse"]
dk, files = [p for p in probs if p["kind"] == "dkms"], [p for p in probs if p["kind"] == "file"]
lines = []
if dk:
cause = dk[0]["cause"]
roots = sorted(set(x for p in dk for x in p["root"]))
screens = sorted(set(x for p in dk for x in p["screen"]))
lines.append("%s, and DKMS will build %s for it, signed with %s%s -- a key this kernel does not trust: %s will "
"not load%s%s. %s" % (
mt["why"], " and ".join(p["what"] for p in dk), path, " ('%s')" % cn if cn else "",
"it" if len(dk) == 1 else "they",
"; %s %s /: this kernel will not mount it" % (" and ".join(roots), "mount" if roots[1:]
else "mounts") if roots else "",
"; %s %s the screen: it shows its boot console at best" % (
" and ".join(screens), "drive" if screens[1:] else "drives") if screens else "",
TRUST_CAUSE[cause][1] % {"path": path, "new": new}))
lines += ["%s." % p["text"] for p in files]
if dk:
gen = "sudo dkms generate_mok, then " if cause == "missing" else ""
if tree:
cmd = "%s%s (--trust-dkms on is the default)" % (gen, rebuild_command(tree))
else:
cmd = {"off": "this command without --trust-dkms off",
"unreadable": "this command as a `build --install` that is not a dry run: it reads the certificate "
"through sudo"}.get(cause, gen + "this command again: --trust-dkms on (the default) "
"builds it in")
lines += [" Trust it, one of:", " build it in (works however this machine boots):", " " + cmd]
efi = machine.get("efi")
enrol = " or enrol it in MOK -- it counts only with Secure Boot ON, shim booting the machine and MokListTrustedRT"
if efi is None:
lines += [enrol + ":", " sudo mokutil --import %s && sudo mokutil --trust-mok (reboot: MOK Manager, "
"Enroll MOK)" % path]
elif efi and not mt["mok_kernel"]:
lines.append(" (MOK cannot count for it: this kernel has no machine keyring)")
elif efi:
lines.append(enrol + ".")
if not shim_ran(efi):
e = efi["entries"].get(efi["current"])
lines.append(" This one boots %s, Secure Boot %s%s:" % (
"Boot%s %s" % (efi["current"], e["path"] or "'%s'" % e["desc"]) if e else "an entry it does not name",
"on" if efi["secure_boot"] else "off", ", no keys (Setup Mode)" if efi["setup_mode"] else ""))
order = shim_first_order(efi)
lines.append(" sudo efibootmgr --bootorder %s (shim first: Boot%s %s)" % (
",".join(order), efi["shim_entry"], efi["entries"][efi["shim_entry"]]["path"]) if order else
" (no shim entry for its boot loader: install shim -- shim-signed, shim-x64 -- and put it "
"first with efibootmgr --bootorder)")
lines.append(" %ssudo mokutil --import %s%s (reboot: MOK Manager, Enroll MOK)" % (
"sudo dkms generate_mok && " if cause == "missing" else "", path,
"" if efi["mok_trusted"] else " && sudo mokutil --trust-mok"))
if efi["moksb_off"]:
lines.append(" sudo mokutil --enable-validation (shim checks again)")
if not secure_boot_counts(machine):
lines.append(" then in the BIOS: CSM off, %sSecure Boot on" % (
"the Secure Boot keys installed (that ends Setup Mode), " if efi["setup_mode"] else ""))
line = mt.get("line") or ()
if mt["lockdown"]:
lines.append(" or no lockdown: build with --lockdown off%s%s" % (
" (with Secure Boot on that is not enough: IMA refuses it too)" if mt["ima"] else "",
" (and take %s out of %s)" % (line[1], line[0]) if line else ""))
elif line:
lines.append(" or take %s out of %s: no kernel booted with it loads such a module" % (line[1], line[0]))
if files:
algo = (DotConfig(os.path.join(tree, ".config")).get("MODULE_SIG_HASH") if tree else "") or "sha512"
lines += [" Each such file: rebuild it for %s with what installed it, signed with a key this kernel trusts "
"-- this tree's own:" % new,
" sudo %s/scripts/sign-file %s %s/certs/signing_key.pem %s/certs/signing_key.x509 FILE"
% (tree or "TREE", algo, tree or "TREE", tree or "TREE")]
for z in sorted(set(m.group(1) for p in files for m in [re.search(r"\.ko\.(\w+)$", p["file"])] if m)):
unpack, pack = PACKED_KO.get(z, ("", ""))
lines.append(" a .ko.%s is signed unpacked -- sign-file on the packed file breaks it: %s" % (
z, "sudo %s FILE.ko.%s, sign FILE.ko as above, then sudo %s FILE.ko" % (unpack, z, pack) if unpack
else "unpack it, sign the .ko as above, pack it again"))
lines.append(" or remove it.")
if mt["hard"]:
lines.append(" Nothing overrides this: a kernel that cannot load what mounts / does not boot. "
"(--yes does not either.)")
else:
lines.append(" --allow-untrusted-modules installs anyway, and the module stays unloaded. (--yes does not.)")
return "\n".join(lines)
def old_image_check(release, tree, machine, root="/"):
"""{kind, text, after, fix} -- or None -- for installing RELEASE again. installkernel renames
/boot/vmlinuz-RELEASE to .old before its hooks run, and the boot menu lists that .old with the
NEW modules, which TREE signed with its own key. When the modules installed now carry another
key (a fresh tree of the same release makes a new one) and the image refuses a module it
does not trust -- lockdown built in, or Secure Boot with IMA's arch policy -- it refuses them
all: kind 'warn', a warning and never a refusal, as the distribution's kernel stays the
fallback. Else 'note': it loads them, tainted (E). Nothing is deleted here: fix says how.
A BLS menu lists entries, not files, and has none for a .old."""
if (machine.get("boot") or {}).get("bls") or not os.path.isfile(rp(root, "boot/vmlinuz-" + release)):
return None
new = cert_der(read_bytes(os.path.join(tree, "certs/signing_key.x509")))
ids = set(x for x in (cert_serial(new), cert_skid(new)) if x) if new else set()
mod = ""
for dirpath, dirnames, files in os.walk(rp(root, "lib/modules/%s/kernel" % release)):
dirnames.sort()
mod = next((os.path.join(dirpath, f) for f in sorted(files) if ".ko" in f), "")
if mod:
break
key = module_sig_key(mod) if mod and ids else None
if not key or key in ids:
return None # the same key, nothing signed, or no telling
conf = rp(root, "boot/config-" + release)
cfg = DotConfig(conf) if os.path.isfile(conf) else None
st = hardening_state(cfg) if cfg is not None else None
enforces = bool(st) and (st["lockdown"] in ("integrity", "confidentiality") or
secure_boot_counts(machine) and st["ima_arch"] or bool(sig_enforcing_word(machine, st)))
sym = FS_SYMBOLS.get((machine.get("storage") or {}).get("rootfs") or "")
rootfs = ", its root filesystem's too, so it cannot mount /" if enforces and sym and cfg.get(sym) == "m" else ""
mk = next((cmd for t, cmd in GRUB_MKCONFIG if which(t, path=":".join(SYSTEM_PATH))), "")
fix = "sudo rm %s%s" % (" ".join("/boot/%s-%s.old" % (n, release) for n in ("vmlinuz", "config", "System.map")),
" && sudo " + mk if mk else "")
if enforces:
return {"kind": "warn", "fix": fix,
"text": "installing %s again moves the image installed now to vmlinuz-%s.old, and the boot menu lists it "
"with the NEW modules -- signed with a key that image does not trust (the tree's, not %s): it "
"refuses every module%s." % (release, release, key, rootfs),
"after": "vmlinuz-%s.old, the image installed before, is in the boot menu with the NEW modules: it "
"refuses every one of them%s -- do not boot it." % (release, rootfs)}
return {"kind": "note", "fix": fix,
"text": "installing %s again moves the image installed now to vmlinuz-%s.old, which the boot menu lists "
"with the NEW modules, signed with another key than its own: it loads them, and the kernel is "
"tainted (E)." % (release, release),
"after": "vmlinuz-%s.old, the image installed before, boots the NEW modules tainted (E): they carry "
"another key than its own." % release}
def install_preflight(machine, opts, release="", tree="", predicted=""):
"""Everything that can refuse an install, checked BEFORE the long part when
`build --install` is used -- predicted: the release the tree is expected to make, for the
texts and for what DKMS will build for it -- and again with the real release afterwards.
-> {old_image: old_image_check()'s finding}, for post_install_checks() to say again."""
if release and release == machine["running"] and not opts["yes"]:
raise ToolError("the release being installed (%s) is the kernel running now.\n"
" Installing over it replaces the modules of a live kernel. "
"Change --localversion, or pass --yes to do it anyway." % release)
# A kernel that cannot load its own modules: lockdown forced, and modules_install signs none
# of them -- ext4 and the disk drivers included. Nothing overrides this, --yes included.
tst = hardening_state(DotConfig(os.path.join(tree, ".config"))) if tree else None
if tst and tst["lockdown"] in ("integrity", "confidentiality") and not tst["sig_all"]:
raise ToolError("lockdown %s is built into this kernel, and MODULE_SIG_ALL is not: modules_install leaves "
"every module unsigned, and this kernel refuses them all -- its disk drivers too.\n Build it "
"again: %s %s (MODULE_SIG_ALL is required there). Nothing overrides this, --yes "
"included." % (tst["lockdown"], rebuild_command(tree), AS_BUILT))
if machine["secure_boot"] == "on":
k = signing_key(create=False)
img = image_path(tree) if tree else ""
signed = image_signed_by(img, k)
if tree and not signed and not opts["allow_unsigned"]:
raise ToolError("Secure Boot is ON here and this image is not signed: shim will "
"refuse it.\n Run `%s.py sign --source %s`, or pass --allow-unsigned."
% (TOOL, tree))
if k and mok_enrolled(k) is False and not opts["yes"]:
raise ToolError("Secure Boot is ON here and your signing certificate is not enrolled:"
"\n sudo mokutil --import %s\n then reboot and enrol it in the "
"MOK Manager. --yes installs anyway (it will not boot until then)."
% k["der"])
ok("Secure Boot here: %s" % machine["secure_boot"])
lvl, here = opts.get("_cpu_level") or "", (machine.get("cpu") or {}).get("level") or ""
if lvl and here and LEVEL_RANK.get(lvl, 0) > LEVEL_RANK.get(here, 0) and not opts["yes"]:
raise ToolError("this kernel is built for %s, and this CPU is %s: it would not boot here.\n"
" Install it on a machine it was built for (`package` makes an RPM, a deb "
"or a tarball), or --yes." % (lvl, here))
if not machine["boot"]["initramfs"] and not opts["yes"]:
raise ToolError("no initramfs generator here (dracut, update-initramfs, mkinitcpio).\n"
" `make install` would install a kernel with no initramfs, and a root "
"on %s%s needs modules from one. Install one, or --yes to go ahead."
% (machine["storage"]["rootfs"] or "this filesystem",
" over " + ",".join(machine["storage"]["layers"])
if machine["storage"]["layers"] else ""))
ok("initramfs generator: %s" % (machine["boot"]["initramfs"] or "none (--yes given)"))
# The screen's driver, switched off by what follows the machine onto a new kernel (a
# modprobe.d file goes into every initramfs; the command line comes along): that kernel
# would come up with the boot console at best.
blocked = screen_without_driver(machine, release or predicted)
texts = [display_refusal_text(r, hits, kind, machine, release or predicted) for r, hits, kind in blocked]
if texts and not opts["yes"]:
raise ToolError("\n".join(texts) + ("" if release else "\n (checked before the build: nothing has "
"been built)"))
for text in texts:
warn(text)
stopped = [r["addr"] for r, _, _ in blocked]
screens = [r for r in screen_records(machine) if r["module"] and r["addr"] not in stopped]
for r in screens:
for h in [h for h in r["off"] if not h["hard"]]:
warn("%s at %s: %s -- %s" % (h["module"], r["addr"], hit_text(h), h["why"]))
if screens:
ok("display drivers: %s" % ", ".join("%s at %s" % (r["module"], r["addr"]) for r in screens))
# Out-of-tree modules the new kernel will refuse to load: a DKMS module signed with a key it
# does not trust, a file left in updates/ that drives the screen. --allow-untrusted-modules
# overrides, never --yes -- and nothing does for a module that mounts /.
mt = module_trust(machine, opts, release, tree, predicted)
if mt.get("hard") or (mt.get("refuse") and not opts.get("allow_untrusted_modules")):
raise ToolError(module_trust_text(mt, machine, tree, release or predicted) + ("" if release else BEFORE_BUILD))
for p in mt.get("refuse") or []:
warn("--allow-untrusted-modules: installing anyway -- " + p["text"])
for text in mt.get("warn") or []:
warn(text)
for text in mt.get("notes") or []:
note(text)
for text in mt.get("ok") or []:
ok(text)
# Installing a release again: the image there now becomes a .old the boot menu pairs with the
# new modules. Said, never refused.
old = old_image_check(release, tree, machine) if tree and release else None
if old:
(warn if old["kind"] == "warn" else note)("%s After the install: %s" % (old["text"], old["fix"]))
if release: # before the build, doctor has just said it
warn_default_entry(machine.get("default_entry"))
if free_bytes("/boot") < BOOT_NEED:
raise ToolError("/boot has %s free; a kernel plus initramfs needs about %s. %s"
% (human(free_bytes("/boot")), human(BOOT_NEED),
FREE_BOOT.get(machine["pkg_manager"], "Remove a kernel you no longer use.")))
ok("/boot has %s free" % human(free_bytes("/boot")))
return {"old_image": old}
def grubby_default(dry=False):
"""The default kernel's path, or ''. grubby exits 0 with junk when it
cannot read grubenv (unprivileged it prints a grep error, then '/boot'), and
stderr is merged in -- so only a line that names an existing FILE counts."""
p = sudo(["grubby", "--default-kernel"], check=False, capture=True, dry=dry)
for line in reversed((p.stdout or "").splitlines() if p else []):
line = line.strip()
if line.startswith("/") and os.path.isfile(line):
return line
return ""
def set_default(path, dry=False):
"""-> True only when grubby afterwards REPORTS that path as the default."""
sudo(["grubby", "--set-default", path], check=False, dry=dry)
return dry or grubby_default() == path
# Debian and Ubuntu have no grubby. `make install` runs installkernel, whose zz-update-grub
# hook rewrites grub.cfg -- and with GRUB_DEFAULT=0, the stock setting, entry 0 boots the
# NEWEST kernel by version: a newer kernel built here becomes the default just by sorting
# first. grubby's "read the default, put it back" has no equivalent there: GRUB remembers an
# entry only with GRUB_DEFAULT=saved. This tool changes /etc/default/grub for that ONLY when
# asked to (--keep-boot-default), and otherwise says, before and after, what will happen.
GRUB_CFGS = ("boot/grub/grub.cfg", "boot/grub2/grub.cfg")
GRUB_ENVS = ("boot/grub/grubenv", "boot/grub2/grubenv")
GRUB_MENU_LINE = re.compile(r"^(submenu|menuentry)\s+(['\"])(.*?)\2(.*)\{\s*$")
GRUB_LINUX_LINE = re.compile(r"^linux(?:efi|16)?\s+\S*?/vmlinu[xz]-(\S+)")
def grub_settings(name, root="/"):
"""[(file, words)]: each assignment to NAME (a regex) in /etc/default/grub, then in each
/etc/default/grub.d/*.cfg -- the order update-grub reads them in."""
res = []
for f in [rp(root, "etc/default/grub")] + sorted(glob.glob(rp(root, "etc/default/grub.d/*.cfg"))):
for line in read_text(f).splitlines():
m = re.match(r"^\s*(?:export\s+)?(?:%s)=(.*)$" % name, line)
if not m:
continue
try:
words = shlex.split(m.group(1), comments=True)
except ValueError:
words = [m.group(1).strip().strip("\"'")]
res.append((f, words))
return res
def grub_default_setting(root="/"):
"""-> (GRUB_DEFAULT's value, the file that sets it or ''). update-grub reads
/etc/default/grub, then each /etc/default/grub.d/*.cfg: the last assignment wins.
Unset or empty is 0 (grub-mkconfig's own rule)."""
val, src = "0", ""
for f, words in grub_settings("GRUB_DEFAULT", root):
val, src = (words[0] if words else "") or "0", f
return val, src
def grub_cmdline(root="/"):
"""None where /proc/cmdline stands for every kernel's command line (grubby copies it to
each new entry; no GRUB at all). Else, on GRUB without grubby, the words of
GRUB_CMDLINE_LINUX and GRUB_CMDLINE_LINUX_DEFAULT -- every assignment's, since a
grub.d file adds to them with "$GRUB_CMDLINE_LINUX ...": what update-grub writes into
every entry. A word of /proc/cmdline that is not among them was typed for one boot."""
if (root == "/" and which("grubby", path=":".join(SYSTEM_PATH))) \
or not os.path.isfile(rp(root, "etc/default/grub")):
return None
return set(x for _, words in grub_settings("GRUB_CMDLINE_LINUX(?:_DEFAULT)?", root)
for w in words for x in w.split())
# Boot-line words that undo -- or redo -- what a hardened build has built in; module.sig_enforce
# makes any kernel refuse a module it does not trust (sig_enforcing_word()).
BOOT_LINE_KEYS = ("mem_sleep_default", "intel_iommu", "amd_iommu", "iommu", "lockdown", "ibt", "module.sig_enforce")
IOMMU_OFF_WORDS = ("intel_iommu=off", "amd_iommu=off", "iommu=off", "iommu=pt")
def boot_line_facts(machine, root="/"):
"""What the boot loader gives every kernel, as far as the hardening options go:
{words: [(file, word)], kernel_cmdline}. words: the mem_sleep_default=, intel_iommu= ...
words of GRUB_CMDLINE_LINUX*, /etc/kernel/cmdline and the BLS entries you may read -- they
come after a built-in command line, and win. kernel_cmdline: on a BLS machine with no
/etc/kernel/cmdline while the running kernel has a built-in line, the line to write there
-- kernel-install copies /proc/cmdline into each new entry otherwise, built-in words and
all, and those words then outlive the kernel that built them in."""
words = []
def take(src, text):
for w in text.split():
if w.split("=", 1)[0].replace("-", "_") in BOOT_LINE_KEYS and (src, w) not in words:
words.append((src, w))
for f, ws in grub_settings("GRUB_CMDLINE_LINUX(?:_DEFAULT)?", root):
take("/" + os.path.relpath(f, root), " ".join(ws))
kernel_cmdline = rp(root, "etc/kernel/cmdline")
take("/etc/kernel/cmdline", read_text(kernel_cmdline))
for f in sorted(glob.glob(rp(root, "boot/loader/entries/*.conf"))):
m = re.search(r"^options\s+(.*)$", read_text(f), re.M)
if m:
take("/" + os.path.relpath(f, root), m.group(1))
line = ""
if (machine.get("boot") or {}).get("bls") and not os.path.exists(kernel_cmdline):
cfg = running_config(machine.get("running") or "", root)
built = (cfg.get("CMDLINE") or "").split() if cfg is not None and cfg.get("CMDLINE_BOOL") == "y" else []
if built:
have = read_text(rp(root, "proc/cmdline")).split()
for w in built:
if w in have:
have.remove(w)
line = " ".join(w for w in have if not w.startswith(("BOOT_IMAGE=", "initrd=")))
return {"words": words, "kernel_cmdline": line}
def resume_device(root="/"):
"""The hibernation (resume) device this machine names -- initramfs-tools' RESUME=, a
resume= word for every kernel -- or ''."""
m = re.search(r"^\s*RESUME=(\S+)", read_text(rp(root, "etc/initramfs-tools/conf.d/resume")), re.M)
if m and m.group(1).strip("\"'").lower() not in ("", "none"):
return "RESUME=" + m.group(1).strip("\"'")
grub = grub_cmdline(root)
for w in sorted(grub) if grub is not None else read_text(rp(root, "proc/cmdline")).split():
if w.startswith("resume=") and w != "resume=none":
return w
return ""
def grub_menu(text):
"""grub.cfg -> its menu as a tree: [{kind: menuentry|submenu, title, id, release,
children}]. Blocks that are neither (`function load_video {`) are followed only so
their closing brace closes the right thing."""
top = {"kind": "", "children": []}
stack = [top]
for line in text.splitlines():
s = line.strip()
m = GRUB_MENU_LINE.match(s)
if m:
mid = re.search(r"\$menuentry_id_option\s+(['\"])(.*?)\1", m.group(4))
node = {"kind": m.group(1), "title": m.group(3), "id": mid.group(2) if mid else m.group(3),
"release": "", "children": []}
stack[-1]["children"].append(node)
stack.append(node)
elif s.endswith("{"):
stack.append({"kind": "", "children": []})
elif s.startswith("}"):
if len(stack) > 1:
stack.pop()
elif stack[-1]["kind"] == "menuentry" and not stack[-1]["release"]:
k = GRUB_LINUX_LINE.match(s)
if k:
stack[-1]["release"] = k.group(1)
return top["children"]
def grub_find(items, ref, descend=True):
"""The menuentry a GRUB_DEFAULT / saved_entry value names: numbers, ids and titles,
joined by '>' through submenus. A submenu boots its first entry (with `descend` False:
the submenu itself). None if absent."""
level, node = items, None
for part in (ref or "0").split(">"):
part = part.strip()
if part.isdigit():
node = level[int(part)] if int(part) < len(level) else None
else:
node = next((n for n in level if part in (n["id"], n["title"])), None)
if node is None:
return None
level = node["children"]
while descend and node is not None and node["kind"] == "submenu":
node = node["children"][0] if node["children"] else None
return node
def grub_entries(items, parents=()):
"""-> [(menuentry, ref)], depth first; ref is what grub-set-default takes: the ids of
the submenus and the entry, joined by '>'."""
res = []
for n in items:
ids = tuple(parents) + (n["id"],)
if n["kind"] == "submenu":
res.extend(grub_entries(n["children"], ids))
else:
res.append((n, ">".join(ids)))
return res
def grub_env(root="/"):
"""grubenv's variables (saved_entry ...): a file every user can read on Debian."""
for f in GRUB_ENVS:
text = read_text(rp(root, f))
if text:
return dict(l.split("=", 1) for l in text.splitlines() if "=" in l and not l.startswith("#"))
return {}
def boot_images(root="/"):
"""The releases that have a kernel image in /boot (rescue images aside), oldest first."""
rels = set()
for pat in ("boot/vmlinuz-*", "boot/vmlinux-*", "boot/kernel-*"):
for p in glob.glob(rp(root, pat)):
rel = os.path.basename(p).split("-", 1)[1]
if not rel.startswith("0-rescue") and not rel.endswith((".old", ".bak")):
rels.add(rel)
# 6.8.0-9-generic before 6.8.0-45-generic: the numbers after x.y.z count too, as numbers
return sorted(rels, key=lambda r: (version_key(r), [int(x) if x.isdigit() else x
for x in re.split(r"(\d+)", r)]))
def grub_outlook(release="", root="/", cfg_text=None):
"""How GRUB picks its default on a machine without grubby, and what installing RELEASE
does to that. -> {setting, source, saved_entry, cfg, readable, current, follows_newest,
moves, becomes_default (True / False / None = cannot tell), keep_ref, root}.
follows_newest: the default is an entry that boots whatever kernel sorts first -- entry 0
(GRUB_DEFAULT=0, saved with nothing saved, a saved entry that IS that first entry), the
'Advanced options' submenu itself, named by number, id or title (GRUB boots a submenu's
first entry), or, counted by number, the first entry inside it. Counted by number
anywhere else in the kernel list the default MOVES when a kernel sorts in above it; named
by id or title it stays. keep_ref: the entry of the kernel booted by default NOW, by its
own id (inside 'Advanced options'), for grub-set-default."""
setting, source = grub_default_setting(root)
saved = grub_env(root).get("saved_entry", "").strip()
cfg = next((rp(root, c) for c in GRUB_CFGS if os.path.exists(rp(root, c))), "")
text = cfg_text if cfg_text is not None else read_text(cfg)
items = grub_menu(text) if text else []
ref = (saved or "0") if setting == "saved" else setting
# What counts is the LAST part: it picks inside its level ('1>gnulinux-6.8.0-45-...'
# names that kernel's own entry, through a submenu counted by number, and stays).
numeric = ref.split(">")[-1].strip().isdigit()
if items:
entry = grub_find(items, ref)
named = grub_find(items, ref, descend=False)
if entry is None: # GRUB's own fallback: entry 0
entry, named, numeric = grub_find(items, "0"), items[0], True
# A submenu as the default -- '1', 'gnulinux-advanced-UUID', 'Advanced options for
# Ubuntu' alike -- boots its FIRST entry: the newest kernel, whichever that is then.
submenu = named is not None and named["kind"] == "submenu"
firsts = [items[0]] + [n["children"][0] for n in items if n["kind"] == "submenu" and n["children"]]
follows = entry is not None and (entry is items[0] or submenu
or (numeric and any(entry is f for f in firsts)))
moves = numeric and not follows and bool(entry and entry["release"])
current = entry["release"] if entry else ""
else:
follows, moves = ref == "0", False # stock Debian: entry 0 is the newest kernel
current = (boot_images(root)[-1:] or [""])[0] if follows else ""
others = [r for r in boot_images(root) if r != release]
def cmp(a, b):
a, b = version_key(a)[:3], version_key(b)[:3]
return (a > b) - (a < b)
if not (follows or moves) or (release and current == release):
becomes = False
elif not release:
becomes = None
elif moves: # a number into the kernel list: what it counts shifts
becomes = None if cmp(release, current) >= 0 else False
elif not others or cmp(release, others[-1]) > 0:
becomes = True
elif cmp(release, others[-1]) == 0:
becomes = None # 6.8.0-elkt against 6.8.0-45-generic: sort -V decides
else:
becomes = False
first = items[0] if items else None
keep = [r for e, r in grub_entries(items) if current and e["release"] == current and e is not first
and "recovery" not in (e["id"] + e["title"]).lower()]
return {"setting": setting, "source": source, "saved_entry": saved, "cfg": cfg,
"readable": bool(text), "current": current, "follows_newest": follows, "moves": moves,
"becomes_default": becomes, "keep_ref": keep[0] if keep else "", "root": root}
def debian_grub_outlook(machine, release=""):
"""grub_outlook() where it applies -- no grubby, and Debian's update-grub with an
/etc/default/grub to read -- else None."""
if (machine.get("boot") or {}).get("grubby") or not os.path.isfile("/etc/default/grub") \
or not which("update-grub", path=":".join(SYSTEM_PATH)):
return None
return grub_outlook(release)
def grub_keep_commands(o):
"""What a person runs to keep the current default: what --keep-boot-default does."""
cmds = []
if o["setting"] != "saved":
f = o["source"] or rp(o["root"], "etc/default/grub")
if o["root"] != "/":
f = "/" + os.path.relpath(f, o["root"])
cmds.append("sudo cp -a %s %s.bak-$(date +%%Y%%m%%d)" % (f, f))
# `export GRUB_DEFAULT=0` and an indented line count too, as keep_grub_default() has it
cmds.append("sudo sed -i -E 's/^([[:space:]]*(export[[:space:]]+)?)GRUB_DEFAULT=.*/"
"\\1GRUB_DEFAULT=saved/' %s" % f if o["source"]
else "echo GRUB_DEFAULT=saved | sudo tee -a %s" % f)
cmds.append("sudo grub-set-default %s" % shlex.quote(o["keep_ref"]) if o["keep_ref"] else
"sudo grub-set-default '<the entry of %s: sudo grep -E \"submenu|menuentry\" "
"/boot/grub/grub.cfg>'" % (o["current"] or "the kernel you boot now"))
cmds.append("sudo update-grub")
return cmds
def warn_grub_default(o, release, after=False):
"""Loud, with the exact commands, when RELEASE becomes (or may become) the default."""
if not o or o["becomes_default"] is False:
return
label = "GRUB_DEFAULT=" + (o["setting"] if o["setting"] != "saved" else
"saved, saved_entry=%s" % (o["saved_entry"] or "(nothing)"))
if o["moves"]:
how = ("%s here COUNTS entries in the kernel list, and %s sorts in above %s: the count "
"moves" % (label, release or "the new kernel", o["current"]))
else:
how = ("%s here: that entry boots the NEWEST kernel, and %s %s" % (
label, release or "the new kernel", "sorts first" if o["becomes_default"] else "may sort first"))
sure = o["becomes_default"]
warn("%s -- %s the DEFAULT boot entry.\n To keep %s the default%s:\n %s"
% (how, ("it IS now" if sure else "it may now be") if after else
("once installed it becomes" if sure else "once installed it may become"),
o["current"] or "the kernel you boot now",
"" if after else " -- `install --keep-boot-default` does this -- or after the install, "
"before you reboot",
"\n ".join(grub_keep_commands(o))))
def keep_grub_problem(o, entry=True, refusing="installing"):
"""'' or why --keep-boot-default cannot keep O's default: what keep_grub_default() refuses,
asked by `build --install` too, BEFORE the build. `entry` False: grub.cfg could not be
read (0400, with a password in it), so whether it has an entry to pin waits for the
install, which reads it with sudo."""
cfg = o["cfg"] or "/boot/grub/grub.cfg"
if entry and not o["keep_ref"]:
return ("--keep-boot-default: %s has no entry of its own for %s, so there is "
"nothing to pin -- NOT %s. Look: sudo grep -E 'submenu|menuentry' %s"
% (cfg, o["current"] or "the kernel booted now", refusing, cfg))
dfile = rp(o["root"], "etc/default/grub")
if o["setting"] != "saved" and o["source"] and os.path.realpath(o["source"]) != os.path.realpath(dfile):
return ("--keep-boot-default: GRUB_DEFAULT=%s is set in %s, which wins over "
"/etc/default/grub -- this tool changes only /etc/default/grub. Set "
"GRUB_DEFAULT=saved there, or install without --keep-boot-default"
% (o["setting"], o["source"]))
return ""
def keep_grub_default(o, dry=False):
"""--keep-boot-default where there is no grubby: GRUB_DEFAULT=saved (a dated backup of
/etc/default/grub first), grub-set-default to the entry that boots now, update-grub.
BEFORE `make install`: one that dies half way leaves the old default too. Raises when
it cannot -- and then nothing is installed, since keeping the default was asked for."""
why = keep_grub_problem(o)
if why:
raise ToolError(why)
dfile = rp(o["root"], "etc/default/grub")
if o["setting"] != "saved":
text = read_text(dfile)
backup = "%s.elkt-%s" % (dfile, time.strftime("%Y%m%d-%H%M%S"))
sudo(["cp", "-a", dfile, backup], dry=dry)
(info if dry else ok)("backup of /etc/default/grub: %s" % backup)
if re.search(r"^\s*(?:export\s+)?GRUB_DEFAULT=", text, re.M):
new = re.sub(r"^(\s*(?:export\s+)?)GRUB_DEFAULT=.*$", r"\1GRUB_DEFAULT=saved", text, flags=re.M)
else:
new = text + ("" if text.endswith("\n") or not text else "\n") + "GRUB_DEFAULT=saved\n"
sudo(["tee", dfile], stdin_data=new, dry=dry)
(info if dry else ok)("GRUB_DEFAULT=saved in /etc/default/grub (it was %s)" % o["setting"])
sudo(["grub-set-default", o["keep_ref"]], dry=dry)
sudo(["update-grub"], capture=True, dry=dry)
(info if dry else ok)("default boot entry pinned to %s: %s" % (o["current"], o["keep_ref"]))
def default_boot_release(root="/", grubby=None):
"""The release this machine boots by default, as far as an unprivileged look can tell --
'' when it cannot (EL keeps grubenv root's). For `status`: no sudo, quick."""
if grubby is None:
grubby = root == "/" and bool(which("grubby", path=":".join(SYSTEM_PATH)))
if grubby:
try:
p = run(["grubby", "--default-kernel"], check=False, env=scrubbed_env(), timeout=20)
except ToolError:
p = None
for line in reversed((p.stdout or "").splitlines() if p else []):
m = re.match(r"^/boot/vmlinu[xz]-(\S+)$", line.strip())
if m and os.path.isfile(rp(root, line.strip())):
return m.group(1)
saved = grub_env(root).get("saved_entry", "").strip()
bls = rp(root, "boot/loader/entries/%s.conf" % saved) if saved and "/" not in saved else ""
if bls and os.path.isfile(bls): # EL / Fedora: saved_entry names a BLS entry
m = re.search(r"^version\s+(\S+)", read_text(bls), re.M) or \
re.search(r"^linux\s+\S*?/vmlinu[xz]-(\S+)", read_text(bls), re.M)
return m.group(1) if m else ""
if os.path.isfile(rp(root, "etc/default/grub")) and not grubby:
return grub_outlook(root=root)["current"]
return ""
# The ids Debian's /etc/grub.d/10_linux gives a kernel's entries: gnulinux-RELEASE-advanced-UUID
# and gnulinux-RELEASE-recovery-UUID, inside the submenu gnulinux-advanced-UUID.
GRUB_ENTRY_ID = re.compile(r"^gnulinux-(.+)-(advanced|recovery)-([^>]+)$")
NO_CONSOLE, CARRIES = "no boot console built in", "its initramfs carries "
def grub_default_ref(setting, saved_entry):
"""The entry GRUB_DEFAULT names by id or title -- grubenv's saved_entry for `saved` -- or ''
where it counts entries by number or names none: what can be read without grub.cfg."""
ref = (saved_entry or "").strip() if setting == "saved" else (setting or "").strip()
return "" if not ref or ref.split(">")[-1].strip().isdigit() else ref
def release_health(release, machine, root="/"):
"""[problem]: what leaves RELEASE's screen black, as far as you can tell without root -- no
boot console built in (its /boot/config), an initramfs that carries a hard switch-off of
the screen's driver (screen_file_hits()). [] when nothing says so: no config is no news."""
problems = []
x86, efi = (machine.get("arch") or os.uname().machine) in X86_ARCHES, efi_booted(machine)
cfg = rp(root, "boot/config-" + release)
if (x86 or efi) and os.path.isfile(cfg):
con, why = boot_console(DotConfig(cfg), efi, x86)
if not con:
problems.append("%s -- %s" % (NO_CONSOLE, why))
img = initramfs_path(release, root)
for h in screen_file_hits(machine) if img else []:
if image_carries(img, h["where"], root):
problems.append(CARRIES + hit_text(h))
return problems
def health_says(problems):
"""What release_health()'s PROBLEMS come to, in a few words."""
off = any(p.startswith(CARRIES) for p in problems)
if off and any(p.startswith(NO_CONSOLE) for p in problems):
return "it comes up with a black screen"
if off:
return "its screen gets no driver there -- the boot console's text at best"
return "its screen stays black until the GPU's driver takes over -- and for good if it never does"
def default_to(release, machine, root="/"):
"""What makes RELEASE the default boot entry: one line of commands, '' when there is no
telling. With grub.cfg readable its entry comes from there; where it is root's (Debian:
0600), from the pattern of grubenv's saved_entry; else the sudo-grep form."""
if (machine.get("boot") or {}).get("grubby"):
return "sudo grubby --set-default=/boot/vmlinuz-%s" % release
if not os.path.isfile(rp(root, "etc/default/grub")):
return ""
o = grub_outlook(root=root)
ref, named = "", grub_default_ref(o["setting"], o["saved_entry"]) or o["saved_entry"]
if o["readable"]:
items = grub_menu(read_text(o["cfg"]))
ref = next((r for e, r in grub_entries(items) if e["release"] == release and e is not items[0]
and "recovery" not in (e["id"] + e["title"]).lower()), "")
elif named:
parts = [x.strip() for x in named.split(">")]
m = GRUB_ENTRY_ID.match(parts[-1])
if m:
ref = ">".join(parts[:-1] + ["gnulinux-%s-advanced-%s" % (release, m.group(3))])
if o["setting"] == "saved" and ref:
return "sudo grub-set-default %s" % shlex.quote(ref)
return " && ".join(grub_keep_commands(dict(o, keep_ref=ref, current=release)))
def default_entry_health(machine, root="/"):
"""{release, problems, fix} when the kernel this machine boots by default comes up with no
picture (release_health()); {} when it does not, or which kernel that is cannot be told.
A warning, never a refusal: installing another kernel does not make that worse. The
release: default_boot_release() -- '' here when grub.cfg is root's (Debian: 0600) -- else
the last part of grubenv's saved_entry, read the way 10_linux writes it. fix: what makes
the kernel running now the default instead; '' when that one is no better. GRUB_DEFAULT
may name the entry itself, by its id, instead of `saved`: read the same way."""
release = default_boot_release(root)
named = grub_default_ref(grub_default_setting(root)[0], grub_env(root).get("saved_entry", "")) \
if not release else ""
if named:
last = named.split(">")[-1].strip()
m = GRUB_ENTRY_ID.match(last)
if m and m.group(1) in boot_images(root):
release = m.group(1)
elif last.startswith("gnulinux-simple-"): # the first entry: the newest kernel
release = (boot_images(root)[-1:] or [""])[0]
problems = release_health(release, machine, root) if release else []
if not problems:
return {}
running, fix = machine.get("running") or "", ""
if running and running != release and running in boot_images(root) \
and not release_health(running, machine, root):
fix = default_to(running, machine, root)
return {"release": release, "problems": problems, "fix": fix, "running": running}
def default_fix_text(de):
return "\n Fix -- boot %s, the kernel running now, by default: %s" % (de.get("running"), de["fix"]) \
if de.get("fix") else ""
def warn_default_entry(de):
"""default_entry_health()'s finding, said -- nothing when there is none."""
if de:
warn("the default boot entry is %s, and %s: %s%s" % (
de["release"], health_says(de["problems"]), "; ".join(de["problems"]), default_fix_text(de)))
def ok_default_kept(text, machine, root="/"):
"""ok(TEXT), about a default boot entry an install left where it was -- a warning instead
when the kernel it boots comes up without a picture (default_entry_health(): with
grub.cfg root's, grubenv says which kernel that is)."""
de = default_entry_health(machine, root)
if de:
warn("%s -- %s, and %s: %s%s" % (text, de["release"], health_says(de["problems"]),
"; ".join(de["problems"]), default_fix_text(de)))
else:
ok(text)
CHECK_CAVEATS = "/usr/libexec/microcode_ctl/check_caveats"
def microcode_force_needed(machine, release, running=None):
"""'' or the override that makes EL's microcode_ctl put Intel microcode into
RELEASE's initramfs. Its check compares kernel versions the way RHEL numbers
them; '7.2.6-elkt' passes no comparison, so the main `intel` caveat -- which
asks only for early-load support, there since 4.10 -- is dropped and the
kernel runs on the BIOS's microcode. Measured 2026-10-08: `check_caveats -e
-c intel` gives ok_cfgs intel for the running 6.12.0-211 and fail_cfgs intel
for 7.2.6-xeon-haswell-all-gpu-server-latency. Forced (the per-kernel
override its README documents) only when the running kernel passes and the
new one is 4.10 or later: then the version string is the only difference."""
if (machine.get("cpu") or {}).get("vendor") != "GenuineIntel" \
or not os.access(CHECK_CAVEATS, os.X_OK) or version_key(release)[:2] < (4, 10):
return ""
def passes(kver):
try:
o = run([CHECK_CAVEATS, "-e", "-k", kver, "-c", "intel"], check=False,
env=scrubbed_env(), timeout=60).stdout or ""
except ToolError:
return False
return bool(re.search(r"^ok_cfgs\b.*\bintel\b", o, re.M))
if passes(release) or not passes(running or machine["running"]):
return ""
return "/lib/firmware/%s/force-early-intel" % release
def install_kernel(tree, mk, machine, vendors, release, opts, state):
# Read again what may have changed while the tree built: the guards below act on it.
machine = refresh_machine(machine)
dry = opts["dry_run"]
say("Install preflight")
pre = install_preflight(machine, opts, release, tree) or {}
grubby = machine["boot"]["grubby"]
deb = None if grubby else debian_grub_outlook(machine, release)
if deb and not opts.get("keep_boot_default") and not opts["set_default"]:
warn_grub_default(deb, release) # before anything is written: said, loudly
elif opts.get("keep_boot_default") and not grubby and not deb:
note("--keep-boot-default: no grubby and no update-grub here -- nothing this tool can pin; "
"check which entry your bootloader defaults to before you reboot")
keep = SudoKeepalive()
if not dry:
keep.start()
try:
old_default = grubby_default(dry) if grubby else ""
if old_default:
info("default boot entry now: %s" % old_default)
elif grubby and not dry:
warn("grubby could not name the current default boot entry, so it cannot be put "
"back afterwards: installkernel may leave the NEW kernel as the default.")
pinned = False
if deb and opts.get("keep_boot_default"):
if not deb["readable"] and not dry: # a grub.cfg with a password in it is 0400
p = sudo(["cat", deb["cfg"] or "/boot/grub/grub.cfg"], check=False, capture=True)
if p is not None and p.returncode == 0:
deb = grub_outlook(release, root=deb["root"], cfg_text=p.stdout or "")
if deb["becomes_default"] is False:
ok_default_kept("GRUB_DEFAULT=%s: %s stays the default boot entry -- nothing to pin"
% (deb["setting"], deb["current"] or "the entry booted now"),
machine, deb["root"])
else:
say("Keeping the default boot entry (--keep-boot-default)")
keep_grub_default(deb, dry)
pinned = True
# build_everything.sh's stage order: the amdgpu-dkms cap BEFORE the kernel's own
# `make install`, whose hooks run `dkms autoinstall` for the new release.
if opts.get("no_dkms_cap"):
if any(m["name"] == "amdgpu" and dkms_will_build(m.get("limits") or {}, release)[0]
for m in machine.get("dkms") or []):
note("--no-dkms-cap: amdgpu-dkms is left as it is, and DKMS will try to build it "
"for %s" % release)
else:
try:
capped = cap_before_install(machine, release, dry)
except ToolError as e:
capped = [{"action": "failed", "error": str(e).splitlines()[0]}]
if any(r["action"] in ("failed", "refused") for r in capped):
warn("the amdgpu-dkms cap did not apply everywhere -- going on: it is a mitigation, "
"not a dependency. If DKMS then fails against %s, `%s.py dkms-cap` again."
% (release, TOOL))
say("Installing modules and kernel (sudo)")
if not dry:
state.update(tree, status="installing", stage="install")
# sudo resets the environment; the toolchain arguments ride on the
# command line so a clang-built tree is not re-probed with gcc.
tcfg = DotConfig(os.path.join(tree, ".config"))
strip = ["INSTALL_MOD_STRIP=1"] if tcfg.get("DEBUG_INFO_BTF") == "y" \
and state.get(tree).get("debug_info") == "btf" else []
installed = False
for m in machine.get("dkms") or []:
will, why = dkms_will_build(m.get("limits") or {}, release)
if not will:
note("dkms will now say it SKIPS %s for this kernel (BUILD_EXCLUSIVE: %s). That is "
"expected, not an error: the kernel's own in-tree module is used." % (m["name"], why))
force = microcode_force_needed(machine, release)
if force:
# Before `make install`: that is when dracut builds the initramfs.
note("microcode_ctl would leave the CPU microcode out of %s's initramfs -- its version "
"check does not understand that name. Telling it to apply its main Intel "
"caveat to this kernel too: %s" % (release, force))
sudo(["mkdir", "-p", os.path.dirname(force)], check=False, dry=dry)
sudo(["touch", force], check=False, dry=dry)
# installkernel renames an image of the same release to .old FIRST and then runs its
# hooks, stopping at the first that fails: a failed `make install` of a release that
# was here before leaves its entry pairing the new image with the old initramfs.
again, started = release in boot_images(), False
# hits: what the NEW initramfs carries that switches the screen's driver off -- the
# files are read at install time, but the image is what boots.
hits, listing = [], None
kept = False # grubby's default is the old one still: seen, not hoped
try:
sudo(mk.argv(["-C", tree] + strip + ["modules_install"], parallel=True), dry=dry)
started = True
sudo(mk.argv(["-C", tree, "install"]), dry=dry)
installed = True
if not dry:
hits, listing = initramfs_screen_hits(release, machine)
finally:
# In a finally because of WHEN installkernel moves the default: before
# it runs dracut. A `make install` that dies there (a full /boot, a
# failing hook) has already pointed the machine at a kernel with no
# initramfs -- the one failure that must not also skip the restore.
# --set-default asks for the new kernel as default once it is
# INSTALLED; it is no reason to leave a half-installed one there, nor
# one whose initramfs leaves the screen without its driver.
if old_default and not dry and (not opts["set_default"] or not installed or hits):
kept = grubby_default() == old_default
if not kept:
kept = set_default(old_default)
if kept:
ok("default boot entry put back to %s" % old_default)
else:
warn("could NOT put the default boot entry back to %s -- check "
"`sudo grubby --default-kernel` before you reboot." % old_default)
if started and not installed and again and not dry:
regen = regen_one(machine["boot"]["initramfs"], release)
warn("%s's boot entry now pairs the NEW image with the OLD %s: do not boot %s until "
"`%s` succeeds" % (release, os.path.basename(initramfs_path(release)) or "initramfs",
release, regen or "its initramfs is rebuilt"))
# Always, overwriting: EL's installkernel does not do this, its absence
# is how the NEXT build loses its base config, and a stale one from an
# earlier install of the same release would be worse than none.
p = sudo(["install", "-m", "0644", os.path.join(tree, ".config"),
"/boot/config-" + release], check=False, dry=dry)
if p is not None and p.returncode != 0:
warn("could not write /boot/config-%s; keep %s/.config safe" % (release, tree))
stays = "" # with hits: what to do so that the old default stays, '' when it does
if hits: # the fix: in the error this install ends with
# Kept for sure: grubby's default put back (and seen there), pinned before `make install`,
# or a GRUB default a newer kernel does not move. Else -- GRUB_DEFAULT=0 without
# --keep-boot-default, no boot-entry tool here -- that is said first, as it is.
kept = kept or bool(deb) and (pinned or deb["becomes_default"] is False)
if not kept and deb:
stays = "It %s the default boot entry now -- before you reboot, keep %s the default: %s" % (
"IS" if deb["becomes_default"] else "may be", deb["current"] or "the kernel you boot now",
" && ".join(grub_keep_commands(deb)))
elif not kept:
stays = "Check which entry boots by default before you reboot%s." % (
": sudo grubby --default-kernel" if grubby else "")
bad("%s's new initramfs carries %s: %s does not load when it boots%s" % (
release, "; ".join(hit_text(h) for h in hits), " / ".join(sorted(set(h["module"] for h in hits))),
", and the default boot entry is NOT moved to it" if kept else
" -- and it %s the DEFAULT boot entry now (GRUB_DEFAULT=%s): keep %s the default before you "
"reboot, as below" % ("IS" if deb["becomes_default"] else "may be", deb["setting"],
deb["current"] or "the kernel you boot now") if deb else
" -- whether the default boot entry moved to it cannot be told here: check it before you reboot"))
if opts["set_default"] and not hits:
if not grubby and deb and deb["becomes_default"]:
ok("%s is the default boot entry now, as asked: GRUB_DEFAULT=%s boots the newest "
"kernel" % (release, deb["setting"]))
elif not grubby:
note("no grubby here: set the default entry with your bootloader's own tool")
elif set_default("/boot/vmlinuz-" + release, dry):
ok("default boot entry is now %s, as asked" % release)
else:
warn("grubby did not accept /boot/vmlinuz-%s as the default" % release)
elif grubby:
if not old_default and not dry:
warn("check the default before you reboot: sudo grubby --default-kernel")
if not hits:
info("pick %s from the boot menu; once it has proved itself:" % release)
info(" sudo grubby --set-default=/boot/vmlinuz-%s" % release)
elif deb:
if pinned and not dry:
saved = grub_env(deb["root"]).get("saved_entry", "").strip()
if saved == deb["keep_ref"]:
ok_default_kept("the default boot entry is still %s (saved_entry=%s)"
% (deb["current"], saved), machine, deb["root"])
else:
warn("grubenv's saved_entry is '%s', not '%s' -- check `sudo grub-editenv list` "
"before you reboot" % (saved, deb["keep_ref"]))
elif not pinned and deb["becomes_default"] is False:
ok_default_kept("GRUB_DEFAULT=%s: the default boot entry is still %s"
% (deb["setting"], deb["current"] or "the one it was"), machine, deb["root"])
elif not pinned:
# GRUB_DEFAULT=0 and the like: no tool here keeps the old default without being
# asked -- with hits, these are the commands that do.
warn_grub_default(deb, release, after=True)
if not hits:
info("pick %s from the boot menu (Advanced options) to try it" % release)
else:
note("no grubby here: check which entry your bootloader now defaults to")
if not dry:
state.update(tree, status="installed", stage="done", finished=now())
# A finished install does not fail afterwards: a check that cannot run is said.
try:
post_install_checks(tree, machine, vendors, release, dry, listing=listing, opts=opts,
old_image=pre.get("old_image"))
except (ToolError, OSError) as e:
warn("post-install check: %s" % (str(e).splitlines() or [type(e).__name__])[0])
if hits: # the one exit 1 after an install: do not boot it as it is
raise ToolError("%s is installed, but its initramfs switches the screen's driver off (%s): "
"do not boot it.%s\n Fix: %s" % (release, "; ".join(hit_text(h) for h in hits),
"\n " + stays if stays else "",
switched_off_fix(hits, machine, release)))
finally:
keep.stop()
def post_install_checks(tree, machine, vendors, release, dry=False, root="/", listing=None, opts=None,
old_image=None):
"""What the installed kernel will meet at boot: its initramfs (microcode, firmware), the
build link DKMS needs, the GPU modules, the boot console, DKMS. listing: the
initramfs's file list when install_kernel() has it already; opts: the command's options
(None: nothing was asked); old_image: what old_image_check() found before the install,
said again once the .old is there."""
say("Post-install checks")
if dry:
info("(dry run -- nothing was installed, nothing to check)")
return
img = initramfs_path(release, root)
lister = next((t for t in ("lsinitrd", "lsinitramfs")
if which(t, path=":".join(SYSTEM_PATH))), "")
if not img:
warn("no initramfs found for %s in /boot -- do not reboot into it yet" % release)
elif lister or listing is not None:
if listing is None:
p = sudo([lister, img], check=False, capture=True)
if p is not None and p.returncode == 0:
listing = p.stdout or ""
else: # its error text is no file list: nothing to judge by
warn("could not list %s: whether it holds the CPU microcode and the GPU firmware cannot be "
"told -- look before you reboot into it" % img)
ucode = {"GenuineIntel": "GenuineIntel.bin", "AuthenticAMD": "AuthenticAMD.bin"}.get(
(machine.get("cpu") or {}).get("vendor"))
if ucode and listing:
if "kernel/x86/microcode/" + ucode in listing:
ok("CPU microcode is in %s: it loads before anything else runs"
% os.path.basename(img))
else:
warn("no CPU microcode in %s: this kernel runs on whatever microcode the BIOS "
"carries -- see `doctor`" % os.path.basename(img))
for vendor, fw in (("amd", "amdgpu"), ("intel", "i915"), ("nvidia", "nvidia")):
if vendor not in vendors or listing is None:
continue
n = len(re.findall(r"firmware/%s" % fw, listing))
if n:
ok("%d %s firmware files in %s" % (n, fw, os.path.basename(img)))
elif vendor not in (machine.get("gpu_vendors") or []):
# --gpu all BUILDS every vendor's driver; dracut packs firmware only
# for hardware that is present. No such GPU here: nothing is missing.
info("no %s firmware in the initramfs -- expected: this machine has no %s GPU"
% (fw, vendor))
elif vendor != "nvidia":
warn("no %s firmware in %s -- install linux-firmware and regenerate "
"the initramfs BEFORE rebooting" % (fw, img))
build = rp(root, "lib/modules/%s/build" % release)
if os.path.exists(build):
ok("%s -> %s (DKMS can build)" % (build, os.path.realpath(build)))
else:
warn("%s is missing; DKMS cannot build against this kernel" % build)
# FILE names keep their hyphens (virtio-gpu.ko); only the module NAME uses
# underscores. Compare normalised, or 40% of modules are 'missing'.
have = set()
for dirpath, _, files in os.walk(rp(root, "lib/modules/%s" % release)):
for fn in files:
if ".ko" in fn:
have.add(fn.split(".ko")[0].replace("-", "_"))
cfg = DotConfig(os.path.join(tree, ".config"))
# Not simpledrm: as a module it never loads on x86, and 'installed as a module'
# was an ok for a console that was not there. The console has its own line.
for mod, sym in (("amdgpu", "DRM_AMDGPU"), ("nouveau", "DRM_NOUVEAU"),
("i915", "DRM_I915"), ("xe", "DRM_XE"), ("virtio_gpu", "DRM_VIRTIO_GPU")):
val = cfg.get(sym)
if mod in have:
ok("%-11s installed as a module" % mod)
elif val == "y":
ok("%-11s built into the kernel image" % mod)
elif val == "m":
warn("%s: CONFIG_%s=m but no %s.ko was installed -- a real failure" % (mod, sym, mod))
# What the screen shows when the GPU's driver never binds: blacklisted, missing,
# or failed before it took the framebuffer over. One that fails AFTER that point
# has already removed the console.
x86, efi = (machine.get("arch") or os.uname().machine) in X86_ARCHES, efi_booted(machine)
con, why = boot_console(cfg, efi, x86)
if con and not why:
ok("boot console: %s, built into the kernel image -- a screen from the first line of "
"boot, and still one if the GPU's driver never takes over" % con)
elif con:
warn("boot console: %s -- %s" % (con, why))
elif x86 or efi:
warn("boot console: NONE built in -- %s. Until the GPU's driver takes over, and for "
"good if it never does, %s shows a black screen" % (why, release))
else:
note("boot console: none built in -- %s" % why)
# What it builds in of the hardening options, how to undo each for one boot, and how to
# see it at work once it runs.
show_outlook(hardening_outlook(cfg, machine, opts, tree))
arch = machine.get("arch") or os.uname().machine
esc = state_escapes(hardening_state(cfg), arch)
if esc:
info("if one of these misbehaves, at the boot line -- %s" % esc)
info("after the reboot: cat /sys/kernel/security/lockdown /sys/power/mem_sleep; ls /sys/class/iommu")
if old_image and os.path.isfile(rp(root, "boot/vmlinuz-%s.old" % release)):
(warn if old_image["kind"] == "warn" else note)("%s %s" % (old_image["after"], old_image["fix"]))
if machine.get("dkms"):
# dkms may be gone by now: an autoremove took it in the middle of one build
# (2026-10-21), and the bare call that followed failed an install that had worked.
dk = which("dkms", path=":".join(SYSTEM_PATH))
p = None
if not dk:
mods = ", ".join("%s/%s" % (m["name"], m["version"]) for m in machine["dkms"])
warn("dkms is gone, and modules are registered with it (%s): nothing rebuilds them for %s -- "
"install dkms again, then `sudo dkms autoinstall -k %s`" % (mods, release, release))
else:
try:
p = run([dk, "status", "-k", release], check=False, env=scrubbed_env(), timeout=120)
except ToolError as e:
warn("dkms status: %s" % str(e).splitlines()[0])
for line in (p.stdout or "").strip().splitlines() if p is not None else []:
info("dkms: " + line + (" (registered, not built for this kernel -- its own limit; "
"the in-tree module loads instead)"
if line.rstrip().endswith(": added") else ""))
say("Done. Keep your current kernel in the boot menu until %s is proven." % release)
# ------------------------------------------------------------------ options / UI
DEFAULTS = {
"kernel": "stable", "source": "", "workdir": "~/src", "gpu": "auto",
"toolchain": "gcc", "cc": "", "llvm_prefix": "", "config_base": "auto",
"modules": "full", "cpu": "generic", "preempt": "keep", "hz": "keep",
"debug_info": "off", "localversion": "-elkt", "frame_warn": "8192",
"jobs": "0", "clean": "auto", "binutils": "auto", "linker": "default",
"python": "", "ccache": "off", "cxx": "", "secure_boot": "on",
# Hardened by default: each one built in, each 'off' asking for the opposite setting.
"lockdown": "integrity", "suspend": "s2idle", "iommu": "on", "trust_dkms": "on",
}
PROFILE_KEYS = sorted(DEFAULTS)
CHOICES = {
"toolchain": ("gcc", "llvm"), "modules": ("full", "trimmed"),
"linker": ("default", "bfd", "lld"), "ccache": ("off", "on"), "secure_boot": ("on", "off"),
"preempt": ("keep",) + tuple(sorted(PREEMPT_CHOICE)),
"hz": ("keep",) + HZ_CHOICE, "debug_info": ("off", "btf", "on"),
"clean": ("auto", "always", "never", "force"),
"lockdown": ("integrity", "confidentiality", "off"), "suspend": ("s2idle", "deep"),
"iommu": ("on", "strict", "off"), "trust_dkms": ("on", "off"),
}
PROFILE_HEADER = "# %s profile" % TOOL
PROFILE_FLAGS = ("install", "set_default") # saved too, so a reused profile asks NOTHING
def profiles_dir():
return os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
TOOL, "profiles")
def profile_path(name, must_exist=False):
"""A bare name lives in the profiles directory; anything with a slash is a
path. (Bare names used to be written into whatever the current directory
was, where nothing ever looked for them again.)"""
name = os.path.expanduser(name)
if os.sep in name:
return name
base = os.path.join(profiles_dir(), name)
if must_exist:
# The profiles directory FIRST: a file that happens to have the same name
# in whatever directory you stand in used to win over the profile you saved.
found = next((c for c in (base, base + ".ini") if os.path.isfile(c)), "")
if not found and os.path.isfile(name):
found = name # earlier versions wrote bare names to the current directory
return found or base + ".ini"
return base if base.endswith(".ini") else base + ".ini"
def load_profile(path):
path = profile_path(path, must_exist=True)
cp = configparser.RawConfigParser()
try:
read = cp.read(path)
except configparser.Error:
read = []
if not read:
raise ToolError("cannot read profile %s" % path)
if not cp.has_section("build"):
raise ToolError("profile %s has no [build] section" % path)
return dict((k, v) for k, v in cp.items("build") if k in DEFAULTS or k in PROFILE_FLAGS)
def profile_summary(v):
cc = v.get("cc") or (v.get("llvm_prefix", "") and v["llvm_prefix"] + "clang") or \
("system clang" if v.get("toolchain") == "llvm" else "system gcc")
return "%s | %s | %s modules | cpu %s | %s | lockdown %s%s" % (
v.get("kernel", "stable"), cc.replace(os.path.expanduser("~"), "~"),
v.get("modules", "full"), v.get("cpu", "generic"), v.get("localversion", ""),
v.get("lockdown", DEFAULTS["lockdown"]), " | installs" if v.get("install") == "yes" else "")
def list_profiles(extra_dirs=None):
"""Newest first. The profiles directory, plus -- because that is where
earlier versions put them -- the current directory and the tool's own: any
small file that starts with the profile header, whatever it is called."""
if extra_dirs is None:
extra_dirs = [os.getcwd(), os.path.dirname(os.path.abspath(__file__))]
found, seen = [], set()
for d in [profiles_dir()] + list(extra_dirs):
try:
names = sorted(os.listdir(d))[:500]
except OSError:
continue
for n in names:
path = os.path.join(d, n)
real = os.path.realpath(path)
try:
if real in seen or not os.path.isfile(path) or os.path.getsize(path) > 8192:
continue
with open(path, "r", errors="replace") as f:
if not f.readline().startswith(PROFILE_HEADER):
continue
vals = load_profile(path)
except (OSError, ToolError):
continue
seen.add(real)
found.append({"name": n[:-4] if n.endswith(".ini") else n, "path": path, "dir": d,
"mtime": os.path.getmtime(path), "values": vals,
"summary": profile_summary(vals)})
found.sort(key=lambda pr: -pr["mtime"])
return found
def choose_profile(profs):
"""The FIRST thing the wizard shows. -> {path, name, as_is} or None (new)."""
if not profs:
return None
say("Saved profiles")
for i, pr in enumerate(profs, 1):
out(" %2d) %-24s %s %s" % (i, pr["name"], time.strftime(
"%Y-%m-%d %H:%M", time.localtime(pr["mtime"])), pr["summary"]))
if pr["dir"] != profiles_dir():
out(" (in %s)" % pr["dir"])
out(" n) new -- answer the questions")
while True:
ans = ask("profile", "1")
if ans.lower() in ("n", "new"):
return None
pick = profs[int(ans) - 1] if ans.isdigit() and 1 <= int(ans) <= len(profs) else \
next((pr for pr in profs if pr["name"] == ans), None)
if pick:
break
out(" a number from the list, a profile's name, or n")
how = ask("use it as it is, or edit it? (u/e)", "u")
return {"path": pick["path"], "name": pick["name"], "as_is": not how.lower().startswith("e")}
def save_profile(path, opts):
cp = configparser.RawConfigParser()
cp.add_section("build")
for k in PROFILE_KEYS:
# What was asked, not what it resolved to HERE: a profile is for other
# machines, where -j24 and /home/<me>/src are both wrong.
cp.set("build", k, str(opts.get("_raw", {}).get(k, opts[k])))
for k in PROFILE_FLAGS:
cp.set("build", k, "yes" if opts.get(k) else "no")
path = profile_path(path)
if os.path.dirname(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("%s. Replay with: %s.py build --profile %s\n"
% (PROFILE_HEADER, TOOL, os.path.basename(path)[:-4]
if os.path.dirname(path) == profiles_dir() else path))
cp.write(f)
info("profile saved: %s" % path)
def validate_options(opts):
"""For values from the command line, a profile AND the wizard alike."""
for k, allowed in CHOICES.items():
if opts[k] not in allowed:
raise ToolError("%s must be one of: %s (got '%s')" % (k, " ".join(allowed), opts[k]))
# Any -march the compiler knows; resolve_toolchain() asks it. Here: the shape.
opts["cpu"] = str(opts["cpu"]).strip().lower()
if not re.match(r"^[a-z0-9][a-z0-9_.+-]*$", opts["cpu"]):
raise ToolError("--cpu takes generic, native, x86-64-v2, x86-64-v3, x86-64-v4 or a CPU "
"name the compiler knows (raptorlake, znver4 ...) -- got '%s'" % opts["cpu"])
lv = opts["localversion"]
if lv and lv[0] not in "-+._":
# kbuild glues it straight on: 'machine-native' gives 7.2.6machine-native.
opts["localversion"] = "-" + lv
note("LOCALVERSION '%s' has no separator -- using '-%s'" % (lv, lv))
if not re.match(r"^[-+.\w]*$", opts["localversion"]):
raise ToolError("LOCALVERSION may only contain letters, digits, - + . _ (got '%s')"
% opts["localversion"])
if not re.match(r"^\d+$", str(opts["frame_warn"])):
raise ToolError("--frame-warn must be a number")
def merge_options(args):
"""Command line beats profile beats built-in default."""
opts = dict(DEFAULTS)
prof = load_profile(args.profile) if getattr(args, "profile", None) else {}
opts.update((k, v) for k, v in prof.items() if k in DEFAULTS)
for k in DEFAULTS:
v = getattr(args, k, None)
if v is not None:
opts[k] = v
validate_options(opts)
# What THIS command line (or its profile) chose, as opposed to defaults: a
# command run on a built tree takes everything else from the tree's record.
opts["_given"] = set(k for k in DEFAULTS if getattr(args, k, None) is not None or k in prof)
for k in ("yes", "verbose", "require_pgp", "install", "set_default", "configure_only",
"allow_unsigned", "dry_run", "install_deps", "json", "keep_boot_default", "no_dkms_cap",
"allow_untrusted_modules"):
opts[k] = bool(getattr(args, k, False))
for k in PROFILE_FLAGS:
opts[k] = opts[k] or prof.get(k) == "yes"
if opts["keep_boot_default"] and opts["set_default"]:
raise ToolError("--keep-boot-default and --set-default ask for opposite things: one of them")
opts["state_dir"] = getattr(args, "state_dir", None)
opts["save_profile"] = getattr(args, "save_profile", None)
opts["keep_symbols"] = [s.upper().replace("CONFIG_", "")
for s in (getattr(args, "keep_symbol", None) or [])]
opts["_raw"] = dict((k, opts[k]) for k in ("jobs", "workdir", "source"))
opts["workdir"] = os.path.abspath(os.path.expanduser(opts["workdir"]))
if opts["source"]:
opts["source"] = os.path.realpath(os.path.expanduser(opts["source"]))
try:
jobs = int(opts["jobs"])
except ValueError:
raise ToolError("--jobs must be a number")
opts["jobs"] = jobs if jobs > 0 else cpu_info()["threads"]
return opts
def ask(prompt, default=""):
try:
ans = input("%s [%s]: " % (prompt, default)).strip()
except EOFError:
ans = ""
return ans or default
def ask_choice(title, options, default):
"""options: list of (key, label). Accepts the number or the key."""
if default not in [k for k, _ in options]:
options = list(options) + [(default, "as given on the command line / in the profile")]
out("\n" + _c("1", title))
for i, (key, label) in enumerate(options, 1):
out(" %2d) %-12s %s%s" % (i, key, label, " <- default" if key == default else ""))
while True:
ans = ask("choose", default)
if ans.isdigit() and 1 <= int(ans) <= len(options):
return options[int(ans) - 1][0]
if ans in [k for k, _ in options]:
return ans
out(" please answer with a number or one of the keys")
def compiler_tags(t, machine, check=True):
tags = [t["origin"]]
if t["experimental"]:
tags.append("an experimental build")
if t["usable"] == "no":
return tags, False
if t["usable"] == "fork":
tags.append("NOT RECOMMENDED: " + t["why"])
if t["kind"] == "clang":
tags.append("full LLVM tool set beside it" if llvm_companions(t["path"])
else "no LLVM binutils beside it (would use GNU ld)")
elif own_binutils(t["path"]):
own = [b for b in machine.get("binutils", []) if b["dir"] == own_binutils(t["path"])]
tags.append("brings its own binutils%s" % (" " + vstr(own[0]["numver"]) if own else ""))
works = True
if check:
works, why = compiler_works(t["path"], own_binutils(t["path"]) or None)
if not works:
tags.append("BROKEN in a clean environment: " + why)
return tags, works
def show_compilers(machine, numbered=False, check=True, key="toolchains"):
"""-> the selectable ones, in the order they were numbered."""
pick = []
for t in machine.get(key, []):
tags, works = compiler_tags(t, machine, check)
label = " -"
if works and t["usable"] != "no":
pick.append(t)
label = "%3d)" % len(pick) if numbered else " "
fam = {"gcc": "g++", "clang": "clang++"}.get(t["kind"], t["kind"]) if key == "cxx" else t["kind"]
what = "%s %s" % (fam, vstr(t["numver"])) if t["usable"] != "no" \
else t["version"][:58] # say what it calls itself: "... DPC++/C++ Compiler"
out(" %s %-52s %s" % (label, t["path"], what))
out(" %s" % "; ".join(tags))
if t["usable"] == "no":
out(" cannot build a kernel: %s" % t["why"])
return pick
def wizard_toolchain(opts, machine):
out("\n" + _c("1", "Compilers found on this system"))
if not machine["toolchains"]:
# A fresh box: nothing to choose from until the dependencies are in.
out(" none yet -- keeping --toolchain %s" % opts["toolchain"])
return
pick = show_compilers(machine, numbered=True)
out(" or type the path of a compiler that is not listed")
out(" DKMS and every other out-of-tree module build runs as root with the SYSTEM")
out(" compiler. A kernel built by anything else can leave them unable to compile;")
out(" the post-build check tells you, before anything is installed.")
asked = bool(opts["cc"] or opts["llvm_prefix"] or opts["toolchain"] != DEFAULTS["toolchain"])
default = "keep" if asked else "1"
if asked:
out(" keep) what was asked for: %s" % (opts["cc"] or opts["llvm_prefix"] + "clang"
or "--toolchain " + opts["toolchain"]))
while True:
ans = ask("compiler", default)
if ans == "keep":
break
if ans.isdigit() and 1 <= int(ans) <= len(pick):
chosen = pick[int(ans) - 1]
else:
chosen = compiler_info(os.path.expanduser(ans)) if os.sep in ans else None
if not chosen or chosen["usable"] == "no":
out(" %s" % ("cannot build a kernel with that: " + chosen["why"] if chosen
else "a number from the list, or the full path of a gcc or clang"))
continue
opts["toolchain"] = "llvm" if chosen["kind"] == "clang" else "gcc"
opts["llvm_prefix"] = ""
opts["cc"] = "" if chosen["real"] == os.path.realpath("/usr/bin/gcc") else chosen["path"]
opts["binutils"] = "auto" # a new compiler: start from what goes with it
opts["cxx"] = ""
break
tc = resolve_toolchain(opts, machine)
if len(machine.get("cxx", [])) > 1:
out("\n" + _c("1", "C++ compilers found on this system"))
usable = show_compilers(machine, key="cxx")
out(" The kernel itself is C. kbuild uses a C++ compiler (HOSTCXX) for the GCC")
out(" plugins -- compiled against the chosen gcc's plugin headers, so it should be")
out(" the g++ that came with that gcc -- and for xconfig.")
keys = [("auto", "the one beside the C compiler: %s" % (tc["cxx"] or "none found"))] + \
[(x["path"], "%s %s" % (x["kind"], vstr(x["numver"]))) for x in usable]
ans = ask_choice("C++ compiler", keys, opts["cxx"] or "auto")
opts["cxx"] = "" if ans == "auto" else ans
tc = resolve_toolchain(opts, machine)
out("\n" + _c("1", "Build tools that go with %s" % tc["path"]))
for label, text in describe_tools(tc):
out(" %-10s %s" % (label, text))
if ask("change any of these? (y/n)", "n").lower().startswith("y"):
sets = [("gnu" if b["system"] else b["dir"],
"GNU binutils %s%s" % (vstr(b["numver"]), " (system)" if b["system"] else ""))
for b in machine.get("binutils", [])]
if tc["kind"] == "clang" and llvm_companions(tc["path"]):
sets.append(("llvm", "LLVM's own: ld.lld, llvm-ar, llvm-nm, llvm-objcopy ..."))
if len(sets) > 1:
opts["binutils"] = ask_choice("Binutils (assembler, linker, ar, nm, objcopy)",
sets, tc["binutils"])
if _find_lld(tc, machine):
opts["linker"] = ask_choice("Linker", [
("default", "what goes with the binutils chosen above"),
("bfd", "GNU ld.bfd"),
("lld", "LLVM's ld.lld%s" % ("" if tc["kind"] == "clang" else
" -- rarely tested with gcc-built kernels"))],
opts["linker"])
pys = machine.get("pythons", [])
if len(pys) > 1:
keys = [("default" if y["platform"] else y["path"],
"%s %s%s%s" % (y["impl"], y["version"],
" (the platform interpreter)" if y["platform"] else "",
"" if y["impl"] == "CPython" else " -- not CPython: untested with kbuild"))
for y in pys]
ans = ask_choice("Python for kbuild's scripts", keys, opts["python"] or "default")
opts["python"] = "" if ans == "default" else ans
if which("ccache", path=":".join(SYSTEM_PATH)):
opts["ccache"] = ask_choice("Compiler cache", [
("off", "every object is compiled"),
("on", "ccache: a rebuild after a clean reuses what it can")], opts["ccache"])
resolve_toolchain(opts, machine) # say so NOW if the combination cannot work
while True:
ans = ask("\nparallel make jobs", str(opts["jobs"]))
if ans.isdigit() and int(ans) > 0:
opts["jobs"] = int(ans)
break
out(" a positive number")
def wizard_hardening(opts, machine):
"""The hardening questions, after Secure Boot's -- each one a line of fwupd's security list."""
opts["lockdown"] = ask_choice("Kernel lockdown (fwupd: Linux Kernel Lockdown)", [
("integrity", "built in: modules the kernel does not trust, /dev/mem, MSR writes, unsigned kexec "
"and hibernation are refused even for root; the boot line can raise it, never lower it"),
("confidentiality", "also kernel memory -- no perf, kprobes, bpftrace"),
("off", "not built in; lockdown=integrity at the boot line still turns it on")], opts["lockdown"])
if machine.get("arch") in X86_ARCHES:
opts["suspend"] = ask_choice("Suspend (fwupd: Suspend To RAM)", [
("s2idle", "built in (mem_sleep_default=s2idle); on firmware without S0ix it saves little power"),
("deep", "S3, the firmware's default")], opts["suspend"])
opts["iommu"] = ask_choice("IOMMU DMA protection (fwupd: IOMMU Protection)", [
("on", "on at boot, lazy flushing"), ("strict", "on at boot, every unmap flushed at once"),
("off", "not switched on at boot; intel_iommu=on at the boot line still works")], opts["iommu"])
# With Secure Boot on, IMA's arch policy checks module signatures even without lockdown.
if machine.get("dkms") and (opts["lockdown"] != "off" or machine.get("secure_boot") == "on"):
opts["trust_dkms"] = ask_choice("DKMS modules under lockdown", [
("on", "DKMS's certificate built into the kernel: the modules DKMS signs load, however this "
"machine boots"),
("off", "not built in: they load only through shim + Secure Boot + MOK")], opts["trust_dkms"])
def wizard(opts, machine, latest, rels):
say("Kernel")
rows = [r for r in rels if r["moniker"] != "linux-next"]
for i, r in enumerate(rows, 1):
out(" %2d) %-9s %-12s %s%s%s" % (
i, r["moniker"], r["version"], r["date"], " EOL" if r["eol"] else "",
" <- default (latest stable)" if r["version"] == latest else ""))
out(" or type any version (6.12.9) or series (6.12 = its newest release)")
default = str(next((i for i, r in enumerate(rows, 1) if r["version"] == latest), 1))
if opts["kernel"] != DEFAULTS["kernel"]:
default = opts["kernel"] # --kernel / profile: Enter keeps it
ans = ask("kernel", default)
opts["kernel"] = rows[int(ans) - 1]["version"] \
if ans.isdigit() and 1 <= int(ans) <= len(rows) else ans
det = ", ".join(machine["gpu_vendors"]) or "none"
opts["gpu"] = ask_choice("GPU drivers (detected: %s)" % det, [
("auto", "what was detected; every vendor if nothing was"),
("all", "AMD + NVIDIA + Intel: one kernel for mixed hardware"),
("amd", "AMD only"), ("nvidia", "NVIDIA only (nouveau, as a module)"),
("intel", "Intel only"), ("none", "VM / BMC / rescue drivers only")], opts["gpu"])
wizard_toolchain(opts, machine)
opts["modules"] = ask_choice("Module set", [
("full", "everything the base config has -- boots on other hardware too"),
("trimmed", "only what is loaded now (%d) + GPU + rescue set: a far "
"shorter build, for THIS machine" % machine["loaded_modules"])],
opts["modules"])
if machine["arch"] == "x86_64":
here = machine["cpu"].get("level") or "x86-64"
hyb = machine["cpu"].get("hybrid")
rows = [("generic", "runs on any x86-64 CPU")]
for lvl in ("x86-64-v2", "x86-64-v3", "x86-64-v4"):
rows.append((lvl, LEVEL_CPUS[lvl] + (
"" if LEVEL_RANK[lvl] <= LEVEL_RANK.get(here, 1) else " -- NOT this CPU: would not boot here")))
rows.append(("native", "-march=native: this exact kind of CPU only"))
opts["cpu"] = ask_choice("CPU tuning (this CPU: %s%s; a CPU name such as raptorlake is "
"accepted by --cpu too)" % (here, ", hybrid P+E cores" if hyb else ""),
rows, opts["cpu"])
opts["preempt"] = ask_choice("Preemption model", [
("keep", "whatever the base config says"),
("none", "throughput: servers, batch"), ("voluntary", "the desktop/server middle"),
("full", "lowest latency: desktop, audio"),
("lazy", "full preemption, fewer needless switches (6.13+)")], opts["preempt"])
opts["hz"] = ask_choice("Timer frequency", [
("keep", "whatever the base config says"), ("100", "servers"),
("250", "balanced"), ("300", "video-friendly"), ("1000", "desktop, low latency")],
opts["hz"])
opts["debug_info"] = ask_choice("Debug info", [
("off", "no DWARF, no BTF: fastest build -- but systemd's BPF filters, "
"bpftrace and CO-RE agents will not work"),
("btf", "BTF kept, DWARF stripped at install: slower build, everything works"),
("on", "whatever the base config has, untouched: for kernel debugging")],
opts["debug_info"])
opts["secure_boot"] = ask_choice("Secure Boot (lockdown is its own question)", [
("on", "a Secure-Boot-ready config, and the kernel image signed with your own key; "
"a machine enrols your certificate once (mokutil) and it boots with SB enabled"),
("off", "unsigned: boots only where Secure Boot is disabled")], opts["secure_boot"])
wizard_hardening(opts, machine)
opts["localversion"] = ask("\nLOCALVERSION suffix (release becomes e.g. 7.2.6%s)"
% opts["localversion"], opts["localversion"])
opts["install"] = ask("\nInstall after a successful build? (y/n)",
"y" if opts["install"] else "n").lower().startswith("y")
if opts["install"]:
opts["set_default"] = ask("Make it the default boot entry? (y/n)",
"y" if opts["set_default"] else "n").lower().startswith("y")
if opts["set_default"]:
opts["keep_boot_default"] = False
prof = ask("\nSave these answers as a profile (a name; empty = no)",
opts.get("_profile_name", ""))
if prof:
opts["save_profile"] = prof
validate_options(opts) # typed answers get the same scrutiny as flags
return opts
# ---------------------------------------------------------------------- commands
def cmd_kernels(args):
latest, rels = get_releases()
if args.series:
sums, _ = get_series_sums(int(args.series.split(".")[0]))
vers = sorted([v for v in sums if series_of(v) == args.series], key=version_key)
if args.json:
emit_json(vers, indent=None)
return 0
say("kernel.org: %d releases in the %s series" % (len(vers), args.series))
for i in range(0, len(vers), 8):
out(" " + " ".join("%-10s" % v for v in vers[i:i + 8]))
return 0
if args.json:
emit_json({"latest_stable": latest, "releases": rels}, indent=2)
return 0
say("kernel.org right now")
for r in rels:
tag = "EOL" if r["eol"] else ""
if r["version"] == latest:
tag = "latest stable"
if r["moniker"] == "linux-next":
tag = "no tarball -- not buildable here"
out(" %-10s %-14s %-11s %s" % (r["moniker"], r["version"], r["date"], tag))
out("\n --kernel stable | longterm | mainline | 6.12 | 6.12.9"
" kernels --series 6.12 lists every point release")
return 0
def show_machine(m):
say("This machine")
info("os : %s (%s, %s)" % (m["os"], m["arch"], m["pkg_manager"] or "no known package manager"))
info("running : %s" % m["running"])
hyb = m["cpu"].get("hybrid")
info("cpu : %s x%d %s%s%s" % (
m["cpu"]["model"] or m["cpu"]["vendor"] or "?", m["cpu"]["threads"],
" ".join(m["cpu"]["simd"]), " " + m["cpu"]["level"] if m["cpu"].get("level") else "",
" hybrid: %d P-core threads + %d E-cores" % tuple(hyb) if hyb else ""))
info("memory : %s virtualisation: %s" % (human(m["memory"]), m["virt"]))
# A switched-off driver under its device. NVIDIA's: with the NVIDIA lines below.
shown = dict((r["addr"], r) for r in m.get("display_drivers") or [] if r["vendor"] != 0x10de)
for d in m["pci"]:
info("%-12s: %s [%04x:%04x] %s%s" % (
d["kind"], d["addr"], d["vendor"], d["device"], d["name"] or
OTHER_VIDEO.get(d["vendor"], ""), " driver=" + d["driver"] if d["driver"] else ""))
r = shown.get(d["addr"])
for h in r["off"] if r else []:
info(" %s switched off -- %s%s" % (
h["module"], hit_text(h), owner_note(h) if h["hard"] else " -- " + h["why"]))
if r and r["loaded"] and any(h["hard"] for h in r["off"]):
info(" " + loaded_now(r, m["running"]))
if not m["pci"]:
info("display : none found")
st = m["storage"]
info("storage : root is %s; filesystems %s; layers %s"
% (st["rootfs"] or "?", ",".join(st["fstypes"]) or "-", ",".join(st["layers"]) or "none"))
if st.get("drivers"):
info("disk path : %s: %s%s" % (",".join(st["disks"]), " <- ".join(
d["module"] for d in st["drivers"]), " [Intel VMD in front: a kernel without "
"CONFIG_VMD cannot see these disks]" if st.get("vmd") else ""))
for g in (m.get("nvidia") or {}).get("gpus", []):
info("nvidia : %s at %s, driven by %s now; firmware %s%s" % (
g["chip"].upper() or "?", g["addr"], g["driver"] or "nothing",
"present" if g["firmware"] else "MISSING" if g["needs_firmware"] else "not needed",
", GSP too" if g["gsp"] else ""))
nv = m.get("nvidia") or {}
if nv.get("own_driver"):
info(" NVIDIA's own driver installed (%s)" % (
"DKMS: " + ", ".join(nv["dkms"]) if nv["dkms"] else "akmods" if nv["akmods"]
else "a prebuilt kmod for %s" % nv["running"]))
if nv.get("nouveau_off"):
info(" nouveau switched off -- %s" % nv["nouveau_off"])
for h in nv.get("nouveau_soft") or []:
info(" nouveau: %s -- %s" % (hit_text(h), h["why"]))
info("firmware : compressed as %s" % (",".join(m["firmware_compression"]) or "not compressed / not found"))
info("secure boot : %s boot: %s%s, %s" % (
m["secure_boot"], "grubby" if m["boot"]["grubby"] else "no grubby",
"+BLS" if m["boot"]["bls"] else "", m["boot"]["initramfs"] or "no initramfs tool found"))
for line in boot_path_lines(m):
info(line)
nw = m.get("now")
if nw:
info("running now : lockdown %s suspend %s iommu %s" % (
nw.get("lockdown") or "?", nw.get("mem_sleep") or "?",
"on (%s)" % " ".join(nw["iommu"]) if nw.get("iommu") else "off"))
warn_default_entry(m.get("default_entry"))
for t in m["toolchains"]:
info("compiler : %-46s %s %s%s" % (
t["path"], t["kind"], vstr(t["numver"]),
"" if t["system"] else " [%s]" % ("unusable" if t["usable"] == "no" else "not system")))
info("c++ : %s" % (" ".join("%s %s%s" % (x["kind"] if x["usable"] != "no" else "intel",
vstr(x["numver"]),
"" if x["usable"] != "no" else " (unusable)")
for x in m.get("cxx", [])) or "none found"))
info("binutils : %s" % (" ".join("%s in %s" % (vstr(b["numver"]), b["dir"])
for b in m.get("binutils", [])) or "none found"))
info("python3 : %s" % (" ".join("%s %s%s" % (y["impl"], y["version"],
" (platform)" if y["platform"] else "")
for y in m.get("pythons", [])) or "none found"))
info(" (`toolchains` shows all of this in full)")
for d in m["dkms"]:
lim = d["limits"].get("BUILD_EXCLUSIVE_KERNEL_MAX")
info("dkms : %s/%s on %d kernel(s)%s" % (
d["name"], d["version"], len(d["kernels"]), ", capped at %s" % lim if lim else ""))
info("modules : %d loaded" % m["loaded_modules"])
def cmd_toolchains(args):
opts = merge_options(args)
machine = detect_machine()
if opts["json"]:
emit_json({"compilers": machine["toolchains"], "binutils": machine["binutils"],
"cxx": machine["cxx"], "pythons": machine["pythons"],
"tools": find_misc_tools()}, default=list, indent=2)
return 0
say("Compilers")
show_compilers(machine)
say("C++ compilers (kbuild's HOSTCXX: it builds the GCC plugins and xconfig)")
show_compilers(machine, key="cxx")
say("GNU binutils sets (a directory with both ld and as)")
for b in machine["binutils"]:
info("%-46s %s%s" % (b["dir"], b["version"], " [system]" if b["system"] else ""))
say("Python 3 interpreters")
for y in machine["pythons"]:
info("%-46s %-8s %s%s" % (y["path"], y["impl"], y["version"],
" [platform -- the default for kbuild's scripts]"
if y["platform"] else ""))
say("Other build tools")
tools = find_misc_tools()
for name, _ in MISC_TOOLS:
if name in tools:
info("%-10s %-24s %s%s" % (name, tools[name]["path"], tools[name]["version"],
" [%s]" % NOT_FOR_KERNELS[name]
if name in NOT_FOR_KERNELS else ""))
else:
info("%-10s -" % name)
say("What the current options resolve to")
tc = resolve_toolchain(opts, machine)
info("compiler : %s" % tc["version"])
info(" %s [%s]" % (tc["path"], tc["origin"]))
for label, text in describe_tools(tc):
info("%-12s: %s" % (label, text))
info("make args : %s" % (" ".join(shlex.quote(a) for a in tc["args"]) or "(none needed)"))
if tc.get("extra_path"):
info("PATH : %s:/usr/bin:..." % tc["extra_path"])
tree = opts["source"]
if not tree:
state = State(os.path.join(state_dir(opts), "state.ini"))
known = [t for t in state.trees() if os.path.isdir(t)]
tree = known[-1] if known else ""
if tree:
say("Checked against %s's own requirements" % os.path.basename(tree))
return 1 if check_tree_requirements(tree, tc, verbose=True) else 0
return 0
def cmd_profiles(args):
profs = list_profiles()
say("Saved profiles (%s)" % profiles_dir())
if not profs:
info("none yet -- the wizard offers to save one")
for pr in profs:
out(" %-24s %s %s" % (pr["name"], time.strftime("%Y-%m-%d %H:%M",
time.localtime(pr["mtime"])), pr["summary"]))
if pr["dir"] != profiles_dir():
out(" (in %s)" % pr["dir"])
out("\n build --profile <name> replays one without a single question")
return 0
def json_value(o):
"""What --json makes of a set (a list) and of bytes -- a certificate's DER: base64."""
return base64.b64encode(o).decode("ascii") if isinstance(o, bytes) else list(o)
def cmd_detect(args):
m = detect_machine()
if args.json:
emit_json(m, default=json_value, indent=2)
else:
show_machine(m)
return 0
NEEDED_TOOLS = ("make", "flex", "bison", "bc", "perl", "tar", "xz", "openssl", "rsync",
"cpio", "depmod")
NEEDED_HEADERS = (("openssl/opensslv.h", "openssl-devel / libssl-dev"),
("libelf.h", "elfutils-libelf-devel / libelf-dev"))
BOOT_NEED = 400 << 20
# The packages, named for the package manager in front of you -- not a list of
# every distribution's names to pick from.
SIGN_DEPS = {"dnf": "pesign nss-tools", "yum": "pesign nss-tools", "apt-get": "sbsigntool",
"zypper": "pesign mozilla-nss-tools", "pacman": "sbsigntools"}
MICROCODE_PKG = {"dnf": ("microcode_ctl", "amd-ucode-firmware"), "yum": ("microcode_ctl", "linux-firmware"),
"apt-get": ("intel-microcode", "amd64-microcode"),
"zypper": ("ucode-intel", "ucode-amd"), "pacman": ("intel-ucode", "amd-ucode")}
PAHOLE_PKG = {"dnf": "dwarves", "yum": "dwarves", "apt-get": "dwarves", "zypper": "dwarves",
"pacman": "pahole"}
MENUCONFIG_PKG = {"dnf": "ncurses-devel", "yum": "ncurses-devel", "apt-get": "libncurses-dev",
"zypper": "ncurses-devel", "pacman": "ncurses"}
FREE_BOOT = {"dnf": "List the kernels with `rpm -q kernel-core`; remove an old one with "
"`sudo dnf remove kernel-core-<version>`.",
"yum": "Remove an old kernel: `sudo yum remove kernel-<version>`.",
"apt-get": "Remove old kernels: `sudo apt autoremove --purge`.",
"zypper": "Remove old kernels: `sudo zypper purge-kernels`."}
NOT_FOR_TREES = ("vfat", "exfat", "msdos", "ntfs", "ntfs3", "fuseblk", "iso9660", "squashfs", "udf")
# doctor --json: one package per thing that is missing, for the package manager in front of you
# ("*": every one). The keys are build_dep_gaps()'s.
DEP_PKG = {
"make": {"*": "make"}, "flex": {"*": "flex"}, "bison": {"*": "bison"}, "bc": {"*": "bc"},
"perl": {"*": "perl"}, "tar": {"*": "tar"}, "xz": {"*": "xz", "apt-get": "xz-utils"},
"openssl": {"*": "openssl"}, "rsync": {"*": "rsync"}, "cpio": {"*": "cpio"}, "depmod": {"*": "kmod"},
"openssl/opensslv.h": {"dnf": "openssl-devel", "yum": "openssl-devel", "apt-get": "libssl-dev",
"zypper": "libopenssl-devel", "pacman": "openssl"},
"libelf.h": {"dnf": "elfutils-libelf-devel", "yum": "elfutils-libelf-devel", "apt-get": "libelf-dev",
"zypper": "libelf-devel", "pacman": "libelf"},
"pahole": PAHOLE_PKG, "sign": SIGN_DEPS, "menuconfig": MENUCONFIG_PKG,
"gcc": {"*": "gcc"}, "clang": {"*": "clang lld llvm"},
}
# Fedora and EL 10 split linux-firmware by vendor; Debian keeps GPU firmware in non-free-firmware.
GPU_FIRMWARE_PKG = {
"dnf": {"amd": "amd-gpu-firmware", "intel": "intel-gpu-firmware", "nvidia": "nvidia-gpu-firmware"},
"zypper": {"amd": "kernel-firmware-amdgpu", "intel": "kernel-firmware-i915",
"nvidia": "kernel-firmware-nvidia"},
"debian": {"amd": "firmware-amd-graphics", "intel": "firmware-misc-nonfree",
"nvidia": "firmware-misc-nonfree"},
}
# None of the kernel's build packages lives in CRB / PowerTools on EL 8-10 (all BaseOS or
# AppStream); the machinery is the gcc tool's, kept the same, for the day one does.
CRB_PACKAGES = ()
def dep_package(key, pm):
t = DEP_PKG.get(key, {})
return t.get(pm, t.get("*", "")) if pm else ""
def gpu_firmware_package(vendor, machine):
pm, osid = machine.get("pkg_manager") or "", machine.get("os_id", "")
major = (re.match(r"\d+", machine.get("os_version", "") or "") or re.match(r"", "")).group(0)
if pm == "dnf" and (osid == "fedora" or (major and int(major) >= 10)):
return GPU_FIRMWARE_PKG["dnf"][vendor]
if pm == "zypper":
return GPU_FIRMWARE_PKG["zypper"][vendor]
if pm == "apt-get" and osid == "debian":
return GPU_FIRMWARE_PKG["debian"][vendor]
return "linux-firmware" if pm else ""
def crb_repo(machine):
"""The id of the repository with texinfo and some -devel packages on Enterprise Linux:
crb (EL 9+ rebuilds), powertools (EL 8), codeready-builder-for-rhel-N-ARCH-rpms (RHEL
itself, a subscription-manager channel). '' elsewhere (Fedora has them in its main
repositories). The gcc tool's, unchanged."""
osid, like = machine.get("os_id", ""), machine.get("os_like", "")
major = (re.match(r"\d+", machine.get("os_version", "") or "") or re.match(r"", "")).group(0)
if osid == "rhel":
return "codeready-builder-for-rhel-%s-%s-rpms" % (major or "9", machine.get("arch", "x86_64"))
if osid in ("almalinux", "rocky", "centos") or (osid != "fedora" and "rhel" in like.split()):
return "powertools" if major == "8" else "crb"
return ""
def enable_repo_command(repo):
"""-> the argv that enables it: RHEL's channel through subscription-manager (dnf
config-manager cannot: 'No matching repo to modify'), the rebuilds' through dnf."""
if not repo:
return []
if repo.startswith("codeready-builder-for-rhel-"):
return ["sudo", "subscription-manager", "repos", "--enable", repo]
return ["sudo", "dnf", "config-manager", "--set-enabled", repo]
def cc_has_header(cc, header):
"""Does the compiler find this header -- asked of it, in the build's environment?"""
if not os.path.exists(cc):
return False
p = run([cc, "-E", "-x", "c", "-"], stdin_data="#include <%s>\n" % header,
check=False, env=scrubbed_env())
return p.returncode == 0
def build_dep_gaps(opts, toolchain=None):
"""What a build needs and this machine lacks -> [(key, label)]: `key` names the package
(DEP_PKG), `label` is how doctor says it."""
path = ":".join(SYSTEM_PATH)
gaps = [(t, t) for t in NEEDED_TOOLS if not which(t, path=path)]
if opts.get("debug_info") in ("btf", "on") and not which("pahole", path=path):
gaps.append(("pahole", "pahole"))
cc = (toolchain or {}).get("path") or "/usr/bin/gcc"
for header, pkg in NEEDED_HEADERS:
if not cc_has_header(cc, header):
gaps.append((header, "%s (%s)" % (header, pkg)))
if opts.get("secure_boot", "on") == "on" and not signing_tools():
gaps.append(("sign", "a signing tool (sbsigntools, or pesign + nss-tools)"))
return gaps
def microcode_missing(machine):
"""'' or the microcode package doctor asks for: none loads here on any kernel. (On EL,
microcode_ctl keeps /lib/firmware/intel-ucode empty ON PURPOSE -- not missing.)"""
ucode = {"GenuineIntel": "intel-ucode", "AuthenticAMD": "amd-ucode"}.get(
(machine.get("cpu") or {}).get("vendor"))
if not ucode or (ucode == "intel-ucode" and os.access(CHECK_CAVEATS, os.X_OK)) \
or glob.glob("/lib/firmware/%s/*" % ucode) or os.path.exists("/boot/%s.img" % ucode):
return ""
return MICROCODE_PKG.get(machine.get("pkg_manager") or "", ("intel-microcode", "amd64-microcode"))[
ucode != "intel-ucode"]
def build_room(opts):
"""-> bytes a build in --workdir wants. Measured: a full 2500-module tree without debug
info is 4.7 GB built, plus the tarball. DWARF multiplies that several times over."""
need = (4 if opts.get("modules") == "trimmed" else 10) << 30
return need * 4 if opts.get("debug_info") in ("btf", "on") else need
def doctor_report(opts, machine, toolchain=None, toolchain_error="", euid=None):
"""The toolkit's doctor contract (PLAN.md section 3), exactly: {tool, ok, package_manager,
missing: [{what, package, level}], enable_repos, install_command}. doctor()'s checks, as
data: a package where one helps, '' for what no package fixes (room on a disk, the path,
running as root). ok is false when anything required is missing -- root included: as
JSON this says so instead of refusing to answer. install_command installs what is
required and wanted, nothing else."""
pm = machine.get("pkg_manager") or ""
euid = os.geteuid() if euid is None else euid
path = ":".join(SYSTEM_PATH)
missing = []
def add(what, package, level):
missing.append({"what": what, "package": package, "level": level})
if euid == 0:
add("not root: this runs as root -- build as yourself (only installing uses sudo)", "", "required")
if toolchain is None:
why = (toolchain_error or "no usable compiler").splitlines()[0]
if not opts.get("cc") and opts.get("toolchain", "gcc") == "gcc" and not which("gcc", path=path):
add("gcc, the system C compiler", dep_package("gcc", pm), "required")
elif not opts.get("cc") and opts.get("toolchain") == "llvm" and not opts.get("llvm_prefix") \
and not which("clang", path=path):
add("clang, lld and the LLVM tools (--toolchain llvm)", dep_package("clang", pm), "required")
else:
add("the toolchain: " + why, "", "required")
for key, label in build_dep_gaps(opts, toolchain):
add(label, dep_package(key, pm), "required")
if opts.get("debug_info", "off") == "off" and not which("pahole", path=path):
add("pahole (`--debug-info btf` needs it)", dep_package("pahole", pm), "wanted")
cc = (toolchain or {}).get("path") or "/usr/bin/gcc"
if os.path.exists(cc) and not cc_has_header(cc, "ncurses.h"):
add("ncurses headers (`make menuconfig`; the build does not need them)", dep_package("menuconfig", pm),
"optional")
workdir = opts.get("workdir") or os.path.expanduser("~/src")
need, have = build_room(opts), free_bytes(workdir)
if have < need:
add("room: %s has %s free; this build wants about %s" % (workdir, human(have), human(need)),
"", "required")
if os.path.isdir("/boot") and free_bytes("/boot") < BOOT_NEED:
add("room in /boot: %s free; installing a kernel needs about %s" % (
human(free_bytes("/boot")), human(BOOT_NEED)), "", "required" if opts.get("install") else "wanted")
# A display device that gets no driver on a new kernel: required where install would
# refuse (the screen's, without --yes), else wanted. `what` is one line: the toolkit
# prints it as it is.
guess = opts.get("_release_guess") or release_guess(opts)
blocked = dict((r["addr"], (hits, kind)) for r, hits, kind in screen_without_driver(machine, guess))
for r in machine.get("display_drivers") or []:
if r["addr"] in blocked:
hits, kind = blocked[r["addr"]]
elif r["vendor"] != 0x10de:
hits, kind = lost_driver_hits(r), "generic" # none for a device lent to a VM
else:
continue # NVIDIA's own driver may follow it: nvidia_outlook()'s
if hits:
refused = r["addr"] in blocked and opts.get("install") and not opts.get("yes")
add(screen_problem(r, hits, kind, guess, machine) + " -- `doctor` prints the fix", "",
"required" if refused else "wanted")
# An out-of-tree module the new kernel will not trust: required where install would refuse
# it -- without --allow-untrusted-modules, and for a module that mounts / with it too.
mt = module_trust(machine, opts, predicted=guess)
for p in (mt.get("hard") or []) + (mt.get("refuse") or []):
refused = opts.get("install") and (p in mt["hard"] or not opts.get("allow_untrusted_modules"))
add(p["text"] + " -- `doctor` prints the fix", "", "required" if refused else "wanted")
if re.search(r"[\s:]", workdir):
add("a work directory without spaces or colons (kbuild): %s" % workdir, "", "required")
if fs_type(workdir) in NOT_FOR_TREES:
add("a work directory on a Linux filesystem: %s is on %s" % (workdir, fs_type(workdir)), "", "required")
fwdirs = {"amd": "amdgpu", "intel": "i915", "nvidia": "nvidia"}
for vendor in machine.get("gpu_vendors", []):
if vendor != "nvidia" and not glob.glob("/lib/firmware/%s/*" % fwdirs[vendor]):
add("firmware for the %s GPU (/lib/firmware/%s is empty)" % (vendor.upper(), fwdirs[vendor]),
gpu_firmware_package(vendor, machine), "wanted")
ucode = microcode_missing(machine)
if ucode:
add("CPU microcode (none loads here, on any kernel)", ucode if pm else "", "wanted")
pk = []
for m in missing:
if m["level"] in ("required", "wanted"):
for p in m["package"].split():
if p not in pk:
pk.append(p)
repo = crb_repo(machine) if pm in ("dnf", "yum") else ""
enable = [repo] if repo and any(p in CRB_PACKAGES for p in pk) else []
return {"tool": TOOL, "ok": not any(m["level"] == "required" for m in missing),
"package_manager": pm or None, "missing": missing, "enable_repos": enable,
"install_command": pkg_line(pm, " ".join(pk)) if pk and pm else ""}
def fs_type(path):
"""The filesystem type a path lives on: the longest mount point above it."""
p = os.path.realpath(path)
while p and not os.path.exists(p):
p = os.path.dirname(p)
best, fs = "", ""
for _, mnt, fstype in parse_mounts(read_text("/proc/mounts")):
if (p == mnt or p.startswith(mnt.rstrip("/") + "/")) and len(mnt) >= len(best):
best, fs = mnt, fstype
return fs
def pkg_line(pm, pkgs):
return "sudo %s %s" % (" ".join(INSTALL_CMD[pm]), pkgs) if pm in INSTALL_CMD else ""
def roomy_places(need):
"""[(directory, free)] where a build could go instead: writable, on a disk
filesystem that can hold a kernel tree (symlinks, case-sensitive names),
with `need` free. Your home first, then every mounted one."""
cands = [os.path.expanduser("~"), "/var/tmp"]
for src, mnt, fstype in parse_mounts(read_text("/proc/mounts")):
if src.startswith("/dev/") and fstype not in NOT_FOR_TREES and not mnt.startswith("/boot"):
cands.append(mnt)
seen, res = set(), []
for c in cands:
try:
dev = os.stat(c).st_dev
except OSError:
continue
if dev in seen or not os.access(c, os.W_OK):
continue
seen.add(dev)
if free_bytes(c) >= need:
res.append((c, free_bytes(c)))
return res
def doctor(opts, machine, toolchain=None, quiet_ok=False, gate=False):
"""-> number of blocking problems. gate: called as build's own preflight, whose install
preflight comes next and refuses with the whole text -- what install would refuse is
then neither said nor counted here."""
say("Preflight")
problems = 0
if os.geteuid() == 0:
raise ToolError("do not run this as root. The build runs as you; only the "
"install steps use sudo.")
path = ":".join(SYSTEM_PATH)
missing = [label for _, label in build_dep_gaps(opts, toolchain)]
cc = (toolchain or {}).get("path") or "/usr/bin/gcc"
pm = machine["pkg_manager"]
if missing:
problems += 1
bad("missing: " + ", ".join(missing))
if pm:
info(" " + pkg_line(pm, BUILD_DEPS[pm] + (
" " + SIGN_DEPS[pm] if opts.get("secure_boot", "on") == "on" else "")))
elif not quiet_ok:
ok("build tools and headers present")
if not quiet_ok:
# Named up front, before anyone needs them: on a fresh install neither is there.
if opts.get("debug_info") == "off" and not which("pahole", path=path):
note("pahole is not installed: `--debug-info btf` will need it (%s)"
% (pkg_line(pm, PAHOLE_PKG.get(pm, "pahole")) or "package dwarves / pahole"))
p = run([cc, "-E", "-x", "c", "-"], stdin_data="#include <ncurses.h>\n",
check=False, env=scrubbed_env()) if os.path.exists(cc) else None
if p is not None and p.returncode != 0:
note("no ncurses headers: `make menuconfig` will not start -- the build does not need it (%s)"
% (pkg_line(pm, MENUCONFIG_PKG.get(pm, "ncurses")) or "ncurses development files"))
need = build_room(opts)
have = free_bytes(opts["workdir"])
if have < need:
problems += 1
bad("%s has %s free; this build wants about %s" % (opts["workdir"], human(have), human(need)))
alt = roomy_places(need)
if alt:
info(" room elsewhere: %s" % " ".join(
"--workdir %s (%s free)" % (os.path.join(d, "src") if d == os.path.expanduser("~")
else d, human(f)) for d, f in alt[:3]))
else:
info(" no writable disk filesystem here has that much free")
elif not quiet_ok:
ok("%s free in %s" % (human(have), opts["workdir"]))
if os.path.isdir("/boot") and free_bytes("/boot") < BOOT_NEED:
msg = "/boot has %s free; installing a kernel needs about %s" % (
human(free_bytes("/boot")), human(BOOT_NEED))
if opts.get("install"):
problems += 1
bad(msg)
else:
warn(msg + " -- before `install`, make room")
info(" " + FREE_BOOT.get(pm, "Remove a kernel you no longer use."))
if re.search(r"[\s:]", opts["workdir"]):
problems += 1
bad("kbuild cannot work in a path containing spaces or colons: %s" % opts["workdir"])
if fs_type(opts["workdir"]) in NOT_FOR_TREES:
# A USB stick as it comes, or the EFI partition: the tarball itself fails
# to extract (it holds symlinks), or names that differ only in case clash.
problems += 1
bad("%s is on %s: a kernel tree needs symlinks and case-sensitive names -- use "
"--workdir on a Linux filesystem" % (opts["workdir"], fs_type(opts["workdir"])))
traps = env_traps()
if traps and not quiet_ok:
note("your shell exports things that would leak into a build; every make "
"here runs in a scrubbed environment instead:")
for t in traps:
info(" " + t)
fwdirs = {"amd": "amdgpu", "intel": "i915", "nvidia": "nvidia"}
for vendor in machine.get("gpu_vendors", []):
if not glob.glob("/lib/firmware/%s/*" % fwdirs[vendor]) and vendor != "nvidia":
warn("a %s GPU is present but /lib/firmware/%s is empty: no kernel, however "
"well configured, can start it. Install %s."
% (vendor.upper(), fwdirs[vendor], gpu_firmware_package(vendor, machine) or "linux-firmware"))
# A display driver switched off here: plan and build say it too, through this. Not a
# problem: those stop the build itself, which a blacklist leaves alone.
show_outlook(display_outlook(machine))
# ...but for `doctor --install` (and plan) what install would refuse is one: --json
# says the same. The fix: in that warning just above -- NVIDIA's is not among them.
guess = opts.get("_release_guess") or release_guess(opts)
if opts.get("install") and not gate:
for r, hits, kind in screen_without_driver(machine, guess):
line = screen_problem(r, hits, kind, guess, machine) + (
" Fix: " + switched_off_fix(hits, machine, guess, kind == "nvidia") if r["vendor"] == 0x10de
else " -- the fix is in the warning above")
if opts.get("yes"):
warn("install goes ahead under --yes: " + line)
else:
problems += 1
bad("install will refuse: " + line)
# Out-of-tree modules the new kernel will not trust (module_trust()): the fix said, and for
# `doctor --install` counted as install would refuse -- never what --allow-untrusted-modules
# lets through, always a module that mounts /. build's own gate leaves it to the preflight.
if not (gate and opts.get("install")):
mt = module_trust(machine, opts, predicted=guess)
probs = (mt.get("hard") or []) + (mt.get("refuse") or [])
if probs:
warn(module_trust_text(mt, machine, "", guess))
for p in probs if opts.get("install") else []:
if p not in mt["hard"] and opts.get("allow_untrusted_modules"):
warn("install goes ahead under --allow-untrusted-modules: " + p["text"])
else:
problems += 1
bad("install will refuse: %s -- the fix is in the warning above" % p["text"])
for text in mt.get("warn") or []:
warn(text)
for text in mt.get("notes") or []:
note(text)
warn_default_entry(machine.get("default_entry")) # said, never counted
for text in hardening_doctor_notes(opts, machine): # what lockdown costs here: said too
note(text)
# Measured on kernel builds: ~1 GB per job is comfortable, DWARF doubles it.
per_job = 2 if opts.get("debug_info") in ("btf", "on") else 1
if machine["memory"] and opts.get("jobs") and \
machine["memory"] < opts["jobs"] * per_job * (1 << 30) * 0.75:
fit = max(1, int(machine["memory"] * 0.75 / (per_job << 30)))
warn("-j%d with %s of RAM: a kernel build wants about %d GB per job%s. The OOM "
"killer ends such builds as 'make was killed by signal 9'. --jobs %d fits."
% (opts["jobs"], human(machine["memory"]), per_job,
" with debug info" if per_job == 2 else "", fit))
ucode = {"GenuineIntel": "intel-ucode", "AuthenticAMD": "amd-ucode"}.get(machine["cpu"]["vendor"])
caveats = ucode == "intel-ucode" and os.access(CHECK_CAVEATS, os.X_OK)
if ucode and not quiet_ok and caveats and not glob.glob("/lib/firmware/%s/*" % ucode):
# EL: microcode_ctl keeps /lib/firmware/intel-ucode empty ON PURPOSE and hands
# microcode out kernel by kernel, after its own checks -- which a kernel
# built here fails by its name alone. `install` sees to that.
note("CPU microcode comes from microcode_ctl here, kernel by kernel; `install` makes it "
"cover a kernel built here too, and checks the new initramfs for it")
elif ucode and not quiet_ok and not glob.glob("/lib/firmware/%s/*" % ucode) \
and not os.path.exists("/boot/%s.img" % ucode): # Arch loads it from /boot
pkg = MICROCODE_PKG.get(pm, ("intel-microcode", "amd64-microcode"))[ucode != "intel-ucode"]
# One initramfs, the running kernel's -- never `-k all`: with the screen's driver switched off
# in modprobe.d, every image rebuilt carries that, the one that still shows a picture too.
regen = regen_one(machine["boot"]["initramfs"], machine["running"])
off = sorted(set(h["where"] for h in screen_file_hits(machine)))
warn("/lib/firmware/%s is empty: no CPU microcode update loads here, on ANY kernel -- the "
"fixes for errata and security flaws that came after this BIOS. %sInstall it, then "
"rebuild the running kernel's initramfs (each other kernel's takes it in when its own is "
"next built):\n %s%s" % (
ucode, "First the display fix above: an initramfs built while %s is there carries it. "
"Then: " % " and ".join(off) if off else "", pkg_line(pm, pkg) or "install " + pkg,
" && " + regen if regen else ""))
if machine["secure_boot"] == "on":
if opts.get("secure_boot", "on") == "on":
note("Secure Boot is ON here: the image is signed with your key (%s); this machine "
"must trust its certificate once -- the build says how" % keys_dir())
else:
warn("Secure Boot is ON here, and --secure-boot off builds an UNSIGNED image: it "
"will not boot on this machine")
return problems
def install_deps(pm, dry=False, sign=False):
if not pm:
raise ToolError("no known package manager; install the build dependencies yourself")
say("Installing build dependencies (sudo %s)" % pm)
# Best-effort on purpose: one third-party repo with a bad GPG key makes dnf
# exit 1 having installed everything asked of it. Preflight is the gate.
p = sudo(INSTALL_CMD[pm] + BUILD_DEPS[pm].split() + (SIGN_DEPS[pm].split() if sign else []),
check=False, dry=dry)
if p is not None and p.returncode != 0:
warn("%s exited %d -- continuing; preflight decides whether anything is missing"
% (pm, p.returncode))
def resolve_tree(opts, fetch):
"""-> (tree, target). With fetch=False nothing is downloaded (plan)."""
if opts["source"]:
if not os.path.isfile(os.path.join(opts["source"], "Makefile")):
raise ToolError("no kernel source at %s" % opts["source"])
return opts["source"], None
latest, rels = get_releases()
cache = {}
def sums_for(major):
if major not in cache:
cache[major] = get_series_sums(major)
return cache[major]
target = resolve_kernel(opts["kernel"], latest, rels, sums_for)
info("kernel : %s%s%s" % (target["version"],
" (%s)" % target["moniker"] if target["moniker"] else "",
" EOL -- no more fixes upstream" if target["eol"] else ""))
if fetch:
return os.path.realpath(fetch_source(target, opts["workdir"], opts["require_pgp"])), target
return os.path.join(opts["workdir"], "linux-" + target["version"]), target
def show_plan(opts, machine, toolchain, vendors, why, tree):
say("Plan")
info("tree : %s%s" % (tree, "" if os.path.isdir(tree) else " (will be downloaded)"))
info("gpu drivers : %s (%s) + VM/BMC/rescue, always" % (" ".join(sorted(vendors)) or "none", why))
info("compiler : %s" % toolchain["version"])
info(" %s [%s]" % (toolchain["path"], toolchain["origin"]))
for label, text in describe_tools(toolchain):
info("%-12s: %s" % (label, text))
if toolchain["args"]:
info("make args : %s" % " ".join(shlex.quote(a) for a in toolchain["args"]))
if toolchain["usable"] == "fork":
warn("%s -- %s." % (toolchain["path"], toolchain["why"]))
if not toolchain["system"] or toolchain["experimental"]:
warn("%s is not the system compiler%s. DKMS builds as root with the system "
"one; mixing compilers between a kernel and its modules is the usual "
"reason DKMS stops compiling." % (
toolchain["path"], " and calls itself experimental" if toolchain["experimental"] else ""))
info("modules : %s cpu: %s preempt: %s hz: %s debug info: %s"
% (opts["modules"], opts["cpu"], opts["preempt"], opts["hz"], opts["debug_info"]))
if opts["cpu"] != "generic":
if opts.get("_native_kconfig"):
how = "CONFIG_X86_NATIVE_CPU (-march=native)"
elif toolchain.get("kcflags"):
how = "KCFLAGS=" + toolchain["kcflags"]
elif opts["cpu"] == "native": # `plan`: the tree may not be here yet
how = "CONFIG_X86_NATIVE_CPU from 6.16 on, KCFLAGS=-march=native before"
else:
how = "KCFLAGS=" + cpu_flags(opts["cpu"], "", machine["arch"])[1]
lvl, here = toolchain.get("cpu_level") or "", machine["cpu"].get("level") or ""
info("cpu tuning : %s, through %s -- needs %s" % (opts["cpu"], how, LEVEL_CPUS.get(lvl, lvl)))
if lvl and here and LEVEL_RANK.get(lvl, 0) > LEVEL_RANK.get(here, 0):
warn("this kernel needs %s and THIS machine is %s: it will not boot here (fine for "
"the machine it is meant for; `install` refuses it here)" % (lvl, here))
# The fix comes with doctor's lines and with the refusal itself, which follow.
guess = opts.get("_release_guess") or release_guess(opts, tree)
for r, hits, kind in screen_without_driver(machine, guess) if opts.get("install") else []:
warn("install will %s: %s -- `doctor` prints the fix" % (
"go ahead under --yes" if opts.get("yes") else "refuse", screen_problem(r, hits, kind, guess, machine)))
mt = module_trust(machine, opts, predicted=guess) if opts.get("install") else {}
for p in (mt.get("hard") or []) + (mt.get("refuse") or []):
warn("install will %s: %s -- `doctor` prints the fix" % (
"go ahead under --allow-untrusted-modules" if p not in mt["hard"] and opts.get("allow_untrusted_modules")
else "refuse", p["text"]))
if (machine.get("nvidia") or {}).get("gpus"):
if "nvidia" not in vendors:
note("an NVIDIA GPU is here, and --gpu %s does not ask for nouveau: it is built only "
"if the base config has it" % opts["gpu"])
show_outlook(nvidia_outlook(machine["nvidia"], machine["pkg_manager"]))
info("secure boot : %s" % ("ON -- Secure-Boot-ready config; image signed with your key (%s)"
% (keys_dir() + "/secureboot.*")
if opts["secure_boot"] == "on" else
"off -- the image will NOT boot where Secure Boot is enabled"))
lines, warns = hardening_plan(opts, machine)
for label, text in lines:
info("%-12s: %s" % (label, text))
for text in warns:
warn(text)
info("release : <version>%s jobs: %d clean: %s"
% (opts["localversion"], opts["jobs"], opts["clean"]))
m = re.search(r"linux-(\d+\.\d+)", os.path.basename(tree))
base = None
try:
kind, path, label = find_base_config(tree if os.path.isdir(tree) else "/nonexistent",
opts["config_base"],
target_series=m.group(1) if m else "",
skip_own=opts.get("_skip_own", False),
want_full=opts["modules"] == "full")
if kind == "file":
base = DotConfig(path)
elif kind == "gz":
try:
with gzip.open(path, "rt", errors="replace") as f:
base = DotConfig.of_text(f.read())
except (OSError, IOError, EOFError):
pass
except ToolError as e:
label = "NONE -- " + str(e).splitlines()[0]
info("base config : %s" % label)
for text in base_hardening_notes(base, opts, machine):
warn(text)
serving = in_service(tree)
if serving:
note("this tree is the DKMS build directory for installed kernel(s): %s"
% ", ".join(serving))
deb = None if machine["boot"]["grubby"] or opts.get("set_default") else debian_grub_outlook(machine)
takes_over = bool(deb and deb["follows_newest"] and not opts.get("keep_boot_default"))
info("then : %s" % ("install, %s the default boot entry" % (
"and make it" if opts["set_default"] else "and on this GRUB it may BECOME" if takes_over
else "WITHOUT changing") if opts["install"]
else "stop after building (install later with the `install` command)"))
if deb and deb["follows_newest"]:
(info if opts.get("keep_boot_default") or not opts.get("install") else warn)(
"boot entry : GRUB_DEFAULT=%s here boots the NEWEST kernel -- %s" % (
deb["setting"], "--keep-boot-default: GRUB_DEFAULT=saved and %s pinned, at install"
% (deb["current"] or "the current entry") if opts.get("keep_boot_default") else
"a newer kernel installed from here becomes the default; `install "
"--keep-boot-default` keeps %s" % (deb["current"] or "the current one")))
if opts.get("install") and not opts.get("no_dkms_cap") and any(
m["name"] == "amdgpu" and not (m.get("limits") or {}).get(CAP_KEY) for m in machine.get("dkms") or []):
info("dkms : amdgpu-dkms has no kernel cap: `install` caps it first, if it would "
"try to build for the new kernel (see `dkms-cap`)")
def cmd_plan(args):
opts = merge_options(args)
machine = detect_machine()
toolchain = resolve_toolchain(opts, machine)
vendors, why = resolve_gpu(opts["gpu"], machine["gpu_vendors"], machine["arch"])
say("Resolving")
tree, _ = resolve_tree(opts, fetch=False)
opts["_release_guess"] = release_guess(opts, tree) # what DKMS will be asked to build for
opts["_tree_keys"] = planned_keys(tree, opts, machine.get("arch"))
show_plan(opts, machine, toolchain, vendors, why, tree)
problems = doctor(opts, machine, toolchain)
if os.path.isdir(tree):
problems += check_tree_requirements(tree, toolchain,
need_pahole=opts["debug_info"] != "off")
out("")
info("nothing was downloaded, written or built." + (
"" if not problems else " %d blocking problem(s) above." % problems))
return 1 if problems else 0
def cmd_doctor(args):
opts = merge_options(args)
if opts["json"]:
# The toolkit's contract: what is missing, as data. No package manager is run, and
# nothing refuses before the answer -- running as root is one of the answers.
machine = detect_machine()
try:
toolchain, why = resolve_toolchain(opts, machine), ""
except ToolError as e:
toolchain, why = None, str(e)
report = doctor_report(opts, machine, toolchain, why)
emit_json(report)
return 0 if report["ok"] else 1
if opts["install_deps"]:
install_deps(find_pkg_manager(), opts["dry_run"], opts["secure_boot"] == "on")
machine = detect_machine()
try:
toolchain = resolve_toolchain(opts, machine)
except ToolError as e:
bad(str(e))
toolchain = None
problems = doctor(opts, machine, toolchain)
if (machine.get("nvidia") or {}).get("gpus"):
say("NVIDIA")
show_outlook(nvidia_outlook(machine["nvidia"], machine["pkg_manager"]))
return 1 if problems else 0
def cmd_build(args, interactive=False):
opts = merge_options(args)
if opts["install_deps"]:
# First: on a fresh box there is no compiler to detect until this ran.
install_deps(find_pkg_manager(), opts["dry_run"], opts["secure_boot"] == "on")
reuse = None
if interactive and not getattr(args, "profile", None):
reuse = choose_profile(list_profiles()) # FIRST: before anything else is asked
if reuse:
args.profile = reuse["path"]
opts = merge_options(args)
opts["_profile_name"] = reuse["name"]
machine = detect_machine()
if interactive and not (reuse and reuse["as_is"]):
show_machine(machine)
latest, rels = get_releases()
opts = wizard(opts, machine, latest, rels)
toolchain = resolve_toolchain(opts, machine)
opts["_cpu_level"] = toolchain.get("cpu_level", "")
vendors, why = resolve_gpu(opts["gpu"], machine["gpu_vendors"], machine["arch"])
if doctor(opts, machine, toolchain, quiet_ok=False, gate=True):
raise ToolError("preflight found blocking problems -- fix them, or re-run "
"with --install-deps to let the package manager try")
say("Source")
tree, _ = resolve_tree(opts, fetch=True)
if re.search(r"[\s:]", tree):
raise ToolError("kbuild cannot build in a path with spaces or colons: %s" % tree)
lock_tree(tree) # before the state is READ: see lock_tree()
fit_llvm_to_tree(toolchain, tree)
apply_cpu(toolchain, opts, tree, machine["arch"])
sdir = state_dir(opts)
state = State(os.path.join(sdir, "state.ini"))
prev = state.get(tree)
# A tree last configured TRIMMED must not seed a FULL build with its .config.
opts["_skip_own"] = opts["modules"] == "full" and prev.get("modules") == "trimmed"
if opts["_skip_own"]:
note("this tree's .config came from a --modules trimmed run; a full build "
"starts from a full base instead")
show_plan(opts, machine, toolchain, vendors, why, tree)
say("What this tree requires of the build tools")
if check_tree_requirements(tree, toolchain, need_pahole=opts["debug_info"] != "off"):
raise ToolError("the chosen tools do not meet linux-%s's own minimums -- see "
"`toolchains` for what else is installed" % os.path.basename(tree)[6:])
if opts["install"]:
say("Install preflight (now, not after the build)")
guess = release_guess(opts, tree)
# A guess, for the texts and for what DKMS will build: the running-release check waits
# for the real one. The keys the build will trust are judged as far as they are known.
opts["_tree_keys"] = planned_keys(tree, opts, machine.get("arch"))
install_preflight(machine, opts, predicted=guess)
deb = None if machine["boot"]["grubby"] else debian_grub_outlook(machine, guess)
if deb and not opts.get("keep_boot_default") and not opts["set_default"]:
warn_grub_default(deb, guess)
elif deb and opts.get("keep_boot_default"):
# What the install would refuse after hours of compiling: refused NOW. An
# unreadable grub.cfg leaves 'stays anyway' unknown, so a grub.d override counts.
why = keep_grub_problem(deb, entry=deb["readable"], refusing="building") \
if deb["becomes_default"] is not False or not deb["readable"] else ""
if why:
raise ToolError(why + "\n (checked before the build: nothing has been built)")
if opts["save_profile"]:
save_profile(opts["save_profile"], opts)
if interactive and not ask("\nProceed? (y/n)", "y").lower().startswith("y"):
info("stopped before touching the tree.")
return 0
log_path = os.path.join(sdir, "build-%s.log" % os.path.basename(tree))
mk = Make(tree, toolchain, opts["jobs"])
say("Previous attempt on this tree")
info("recorded : %s" % ("%s%s%s" % (
prev.get("status"), " at stage " + prev["stage"] if prev.get("stage") else "",
" (%s)" % prev["finished"] if prev.get("finished") else "")
if prev else "nothing"))
has_objects = False
for dirpath, dirnames, files in os.walk(tree):
dirnames[:] = [d for d in dirnames if not (dirpath == tree and d in ("scripts", "tools", ".git"))]
if any(f.endswith(".o") for f in files):
has_objects = True
break
do_clean, why_clean = decide_clean(opts["clean"], has_objects, prev.get("status", ""),
prev.get("stage", ""), in_service(tree))
info("%-12s: %s" % ("cleaning" if do_clean else "keeping", why_clean))
# Before the record, the pin or the .config is touched: a refusal must leave
# the tree exactly as the installed kernels need it.
conflict = serving_conflict(tree, in_service(tree), opts["localversion"], toolchain["version"])
if conflict and opts["clean"] != "force":
raise ToolError(conflict)
keeper = None
if opts["install"] and not opts["dry_run"]:
# Before the long part, not after it.
say("sudo, once, now -- so the install at the end does not wait on a prompt")
keeper = SudoKeepalive()
keeper.start()
stage = "configure"
state.update(tree, status="configuring", stage=stage, started=now(), finished="",
exit_code="", error="", last_error="", toolchain=toolchain["version"],
jobs=opts["jobs"], log=log_path)
try:
recover_held_config(tree) # before a backup could copy a staging file
if do_clean:
clean_tree(mk, tree, restore=opts["config_base"] == "auto")
# Image signing exists for x86_64 only. Elsewhere the config is still made
# Secure-Boot-ready, but say so NOW: it used to fail after the whole compile,
# and leave a sign hook behind that broke every goal-less make in the tree.
sign_here = opts["secure_boot"] == "on" and bool(image_path(tree))
if opts["secure_boot"] == "on" and not sign_here:
warn("image signing is not implemented for %s: the configuration will be Secure-"
"Boot-ready, the IMAGE will be unsigned." % os.uname().machine)
if pin_toolchain(tree, toolchain, sign_here) and pin_text(dict(toolchain, kcflags="")):
info("pinned : %s -- out-of-tree builds against this tree will use %s too"
% (PIN_FILE, toolchain["path"]))
# The certificates built into its keyring: DKMS's, and the one the image is signed with.
opts["_trusted_pem"] = write_trusted_pem(tree, machine, opts)
say("Configuring")
staging, reqs, results, _ = configure(tree, opts, machine, mk, vendors)
say("Verifying the configuration")
fails = report_verify(results)
if fails:
rejected = os.path.join(tree, ".config.elkt-rejected")
os.replace(staging, rejected)
drop_staging_litter(staging)
raise ToolError("%d required option(s) did not come out as intended -- NOT "
"building.\n Each FAIL is a dependency that is off, not a typo: "
"`make menuconfig`, then / to search the symbol.\n The tree's "
".config was left untouched; the rejected one is %s"
% (fails, rejected))
if check_compressors(DotConfig(staging), mk.env["PATH"]):
os.replace(staging, os.path.join(tree, ".config.elkt-rejected"))
drop_staging_litter(staging)
raise ToolError("the config needs a compressor that is not installed -- NOT "
"building. Install it, or choose another CONFIG_KERNEL_* / "
"CONFIG_MODULE_COMPRESS_* with menuconfig.")
commit_config(tree, staging)
# So kernelrelease can see LOCALVERSION. Before 4.19 the target was
# called silentoldconfig.
if mk.call(["syncconfig"], check=False).returncode != 0:
mk.call(["silentoldconfig"])
predicted = mk.value("kernelrelease")
cfg = DotConfig(os.path.join(tree, ".config"))
info("config : %d built-in, %d modules" % (cfg.count("y"), cfg.count("m")))
con, why = boot_console(cfg, efi_booted(machine), machine["arch"] in X86_ARCHES)
info("boot console: %s%s" % (con + ", built in" if con else "NONE built in",
" -- " + why if why else ""))
dk = (machine.get("dkms_cert") or {}).get("der")
info("hardening : %s" % hardening_summary(hardening_state(cfg), machine["arch"],
dk in trusted_certs(tree) if dk else None))
info("release : %s" % predicted)
# What was asked, for the record: install, package and pin read the tree's .config.
state.update(tree, release=predicted, config_base=opts["config_base"],
debug_info=opts["debug_info"], modules=opts["modules"],
**dict((k, opts[k]) for k in HARDENING_KEYS))
if opts["configure_only"]:
say("Configured, not built (--configure-only)")
info("review it: make -C %s menuconfig" % tree)
info("then build: the same command without --configure-only")
state.update(tree, status="configured", stage="done", finished=now())
return 0
say("Building %s with -j%d" % (predicted, opts["jobs"]))
info("full log : %s" % log_path)
stage = "build"
state.update(tree, status="building", stage=stage)
rc, stats = run_build(mk, log_path, opts["verbose"])
if rc != 0:
out("")
bad("the build failed (%s) after %dm%02ds. First errors in the log:"
% ("make exit %d" % rc if rc > 0 else "make was killed by signal %d" % -rc,
stats["seconds"] // 60, stats["seconds"] % 60))
for h in error_summary(log_path):
out(" " + h)
raise ToolError("build failed -- full log: %s\n Re-running continues "
"incrementally from where it stopped." % log_path)
# The authority, written by the build itself.
release = read_text(os.path.join(tree, "include/config/kernel.release")).strip() or predicted
ok("built in %dm%02ds: %d steps, %d warnings" % (
stats["seconds"] // 60, stats["seconds"] % 60, stats["steps"], stats["warnings"]))
# The record of what was BUILT -- written now, not when this run started:
# a run that fails or is cut off must not change what `install` trusts.
state.update(tree, status="built", stage="done", release=release,
finished=now(), seconds=stats["seconds"], warnings=stats["warnings"],
config_sha=sha256_file(os.path.join(tree, ".config")),
**choice_record(opts, toolchain))
stage = "checks"
if sign_here:
k = secure_boot_sign(tree, machine)
state.update(tree, signed="yes", certificate=k["der"])
problems = post_build_checks(tree, toolchain, machine, vendors, release, opts)
if not opts["install"]:
say("Built, not installed.")
# The exact flags, not "the same ones": install re-resolves the
# toolchain, and a clang-built tree re-probed with gcc is a bad day.
# `install` takes the compiler, its tools and the CPU flags from this
# tree's record by itself; they are spelled out for a reader.
same = "".join(" --%s %s" % (k.replace("_", "-"), shlex.quote(str(opts[k])))
for k in ("cc", "cxx", "llvm_prefix", "binutils", "linker", "python",
"ccache", "cpu")
if opts[k] != DEFAULTS[k])
if opts["toolchain"] != DEFAULTS["toolchain"] and not opts["cc"]:
same = " --toolchain %s%s" % (opts["toolchain"], same)
info("install with: %s install --source %s%s"
% (os.path.basename(sys.argv[0]), tree, same))
return 1 if problems else 0
if problems and not opts["yes"]:
raise ToolError("%d post-build check(s) failed -- NOT installing. The build "
"itself is intact; `install --yes` overrides." % problems)
stage = "install"
install_kernel(tree, mk, machine, vendors, release, opts, state)
return 0
except KeyboardInterrupt:
# As below: a ^C during the checks or the install says nothing about the
# BUILD. Recording 'failed' made `install`/`package`/`sign` without
# --source pass over this tree and quietly pick an older one.
if stage in ("configure", "build"):
state.update(tree, status="failed", stage=stage, exit_code=130, finished=now())
else:
state.update(tree, last_error="%s: interrupted" % stage)
raise
except Exception as e:
# 'built' must survive a failure that came after it: see decide_clean().
first = (str(e).splitlines() or [type(e).__name__])[0][:200]
if stage in ("configure", "build"):
state.update(tree, status="failed", stage=stage, finished=now(),
exit_code=1, error=first)
else:
state.update(tree, last_error="%s: %s" % (stage, first))
raise
finally:
if keeper:
keeper.stop()
TREE_CHOICES = ("toolchain", "cc", "llvm_prefix", "cxx", "binutils", "linker", "python", "ccache", "cpu")
def choice_record(opts, tc):
"""What a FINISHED build writes into the tree's record, for the commands that
run make in that tree later. A compiler given by bare name is recorded by the
path it resolved to: PATH may say otherwise tomorrow."""
rec = dict(("opt_" + k, opts[k]) for k in TREE_CHOICES)
if opts["cc"] and os.sep not in os.path.expanduser(opts["cc"]):
rec["opt_cc"] = tc["path"]
if opts["cxx"] and os.sep not in os.path.expanduser(opts["cxx"]) and tc.get("cxx"):
rec["opt_cxx"] = tc["cxx"]
rec.update(kcflags=tc.get("kcflags", ""), cpu_level=tc.get("cpu_level", ""))
return rec
def has_record(rec):
"""Was this tree finished by a build that records its choices?"""
return any(("opt_" + k) in rec for k in TREE_CHOICES)
def tree_kcflags(tree, rec):
"""The CPU flags TREE was built with: its record's, else what its GNUmakefile
hands on (a tree whose record is gone)."""
if has_record(rec):
return rec.get("kcflags", "")
m = re.search(r"^export KCFLAGS := (.*)$", read_text(os.path.join(tree, PIN_FILE)), re.M)
return m.group(1).replace("$$", "$").strip() if m else ""
def adopt_tree_choices(opts, rec, tree, machine):
"""install / package / pin / dkms-check on a built tree: every toolchain
choice THIS command line does not make is taken from the tree's record, so
`install --source TREE` alone runs the make that built it. A tree with no
record (built by hand, or by an older version of this tool) gets the
installed compiler whose banner its .config names."""
given, taken = opts.get("_given", ()), []
if has_record(rec):
for k in TREE_CHOICES:
v = rec.get("opt_" + k)
if v is not None and k not in given and str(opts[k]) != v:
opts[k] = v
taken.append("%s %s" % (k, v or "(default)"))
else:
if not ({"cc", "toolchain", "llvm_prefix"} & set(given)):
want = DotConfig(os.path.join(tree, ".config")).get("CC_VERSION_TEXT") or ""
t = next((t for t in machine.get("toolchains", [])
if want and t["version"] == want and t["usable"] != "no"), None)
if t and t["real"] != os.path.realpath("/usr/bin/gcc"):
opts["cc"] = t["path"]
taken.append("cc %s" % t["path"])
m = re.search(r"-march=(\S+)", tree_kcflags(tree, rec))
if m and "cpu" not in given and opts["cpu"] != m.group(1):
opts["cpu"] = m.group(1)
taken.append("cpu %s" % m.group(1))
if taken:
info("as built : %s" % ", ".join(taken))
return taken
def tree_toolchain(opts, machine, tree, rec, strict=True):
"""The toolchain for a command that runs make in a BUILT tree: the one that
built it, taken from the tree's record where this command line says nothing.
strict (package, pin): another compiler, or other CPU flags, are refused --
packaging compiles the whole kernel again, and a pin must name what built
the tree. Not strict (install, dkms-check): `make modules_install` and
`make install` compile nothing and never re-run Kconfig (kbuild lists
%install among its no-sync-config targets), so a compiler that changed since
the build -- a gcc errata update, clang-current pointed elsewhere -- is said,
not refused, and one that is gone falls back to the default."""
adopt_tree_choices(opts, rec, tree, machine)
validate_options(opts)
try:
tc = resolve_toolchain(opts, machine)
except ToolError as e:
if strict:
raise
warn("the toolchain this tree was built with cannot be set up now (%s) -- going on "
"with the default: installing compiles nothing" % str(e).splitlines()[0])
for k in TREE_CHOICES:
if k not in opts.get("_given", ()):
opts[k] = DEFAULTS[k]
tc = resolve_toolchain(opts, machine)
fit_llvm_to_tree(tc, tree)
apply_cpu(tc, opts, tree, machine["arch"])
built = DotConfig(os.path.join(tree, ".config")).get("CC_VERSION_TEXT")
if built and built != tc["version"]:
msg = "%s was built with\n %s\n not with %s" % (tree, built, tc["version"])
if strict:
raise ToolError(msg + ".\n This compiles again: pass the --cc it was built with.")
warn(msg + " -- that is fine for installing, which compiles nothing")
flags = tree_kcflags(tree, rec)
if strict and tc.get("kcflags", "") != flags:
raise ToolError("%s was built with %s, and this would compile it all again with %s. "
"Pass the --cpu it was built with." % (
tree, "KCFLAGS=" + flags if flags else "no CPU flags",
"KCFLAGS=" + tc["kcflags"] if tc.get("kcflags") else "none"))
level = rec.get("cpu_level", "") if has_record(rec) else ""
m = re.search(r"-march=(\S+)", flags)
if not level and m:
level = march_level(tc["path"], m.group(1), scrubbed_env(tc.get("extra_path"))) or ""
opts["_cpu_level"] = level
return tc
def tree_is_stale(tree, rec):
"""'' or why the kernel built in TREE is not the tree as it is configured now.
A finished build records a checksum of the .config it built -- file times
cannot say it: a change that touches only modules never relinks vmlinux.
A run that failed at CONFIGURE left the .config alone (a rejected one is set
aside), so the checksum decides there too."""
st, stage = rec.get("status", ""), rec.get("stage", "")
if st == "building":
return "the last build of this tree never finished (it recorded 'building')"
if st == "failed" and stage == "build":
return ("the last build of this tree FAILED (at build, %s): what is in it is partly that "
"build, partly an earlier one" % (rec.get("finished") or "?"))
cfg, sha = os.path.join(tree, ".config"), rec.get("config_sha")
if sha:
if os.path.isfile(cfg) and sha256_file(cfg) != sha:
return ".config has changed since this tree was last built"
return ""
if st == "configured":
return "this tree was configured again after its last build"
return ""
def hardening_given_note(opts, rec, tree, command):
"""--lockdown and the like, given to a command that takes a built tree as it is (install,
package): said when the tree's record says it was configured otherwise -- they take effect
through a build of that tree."""
given = [k for k in HARDENING_KEYS if k in opts.get("_given", ()) and rec.get(k) and rec[k] != opts[k]]
if given:
flags = "".join(" --%s %s" % (k.replace("_", "-"), opts[k]) for k in given)
note("this tree was configured with%s, and %s takes it as it was built:%s takes effect through `%s` %s"
% ("".join(" --%s %s" % (k.replace("_", "-"), rec[k]) for k in given), command, flags,
rebuild_command(tree, flags), AS_BUILT))
def built_tree(opts, state):
if opts["source"]:
return opts["source"]
done = [(state.get(t).get("finished", ""), t) for t in state.trees()
if state.get(t).get("status") in ("built", "installing", "installed")
and os.path.isdir(t)]
if not done:
raise ToolError("no built tree on record -- pass --source <tree>")
return sorted(done)[-1][1]
def cmd_install(args):
opts = merge_options(args)
if os.geteuid() == 0:
raise ToolError("do not run this as root; it calls sudo itself where needed")
machine = detect_machine()
state = State(os.path.join(state_dir(opts), "state.ini"))
tree = built_tree(opts, state)
lock_tree(tree)
release = read_text(os.path.join(tree, "include/config/kernel.release")).strip()
if not release or not os.path.isfile(os.path.join(tree, "vmlinux")):
raise ToolError("%s has not been built (no vmlinux / kernel.release)" % tree)
recover_held_config(tree) # a run killed mid-trim leaves its staging copy as .config
rec = state.get(tree)
stale = tree_is_stale(tree, rec)
if stale:
raise ToolError("%s -- NOT installing.\n Build it first: %s %s"
% (stale, rebuild_command(tree), AS_BUILT))
info("tree : %s" % tree)
info("release : %s" % release)
hardening_given_note(opts, rec, tree, "install")
toolchain = tree_toolchain(opts, machine, tree, rec, strict=False)
cfg = DotConfig(os.path.join(tree, ".config"))
vendors = set(v for v, s in (("amd", "DRM_AMDGPU"), ("nvidia", "DRM_NOUVEAU"),
("intel", "DRM_I915")) if cfg.get(s) in ("m", "y"))
mk = Make(tree, toolchain, opts["jobs"])
# `build --install` refuses to install a tree that failed these; so must this.
problems = post_build_checks(tree, toolchain, machine, vendors, release, opts)
if problems and not opts["yes"]:
raise ToolError("%d post-build check(s) failed -- NOT installing. --yes overrides."
% problems)
install_kernel(tree, mk, machine, vendors, release, opts, state)
return 0
def cmd_sign(args):
opts = merge_options(args)
machine = detect_machine()
state = State(os.path.join(state_dir(opts), "state.ini"))
tree = built_tree(opts, state)
lock_tree(tree)
info("tree : %s" % tree)
k = secure_boot_sign(tree, machine)
cfg = DotConfig(os.path.join(tree, ".config"))
for sym in ("EFI_STUB", "MODULE_SIG", "MODULE_SIG_ALL"):
if cfg.get(sym) != "y":
warn("CONFIG_%s is not set in this tree: signed or not, it is not Secure-Boot-"
"ready. Rebuild with --secure-boot on." % sym)
state.update(tree, signed="yes", certificate=k["der"])
return 0
PKG_TARGET = {"rpm": "binrpm-pkg", "deb": "bindeb-pkg", "tar": "tarxz-pkg"}
INSTALL_TXT = """{release} -- built {date} with {compiler}
Configuration: {summary}
INSTALL
{steps}
SECURE BOOT
{sb}
HARDENING
{hardening}
WHAT THIS KERNEL EXPECTS OF YOUR MACHINE
{expects}
Verify the download first: sha256sum -c SHA256SUMS
"""
def tree_summary(cfg, kcflags="", level=""):
"""What the TREE is, read from its config -- not what this command's options
happen to say (a trimmed build packaged with default options is still trimmed).
kcflags: the CPU flags it was built with, which are not in its config."""
preempt = next((name for name, sym in (("lazy", "PREEMPT_LAZY"), ("full", "PREEMPT"),
("voluntary", "PREEMPT_VOLUNTARY"), ("none", "PREEMPT_NONE"))
if cfg.get(sym) == "y"), "?")
gpus = [n for n, sym in (("amdgpu", "DRM_AMDGPU"), ("nouveau", "DRM_NOUVEAU"),
("i915", "DRM_I915"), ("xe", "DRM_XE")) if cfg.get(sym) in ("m", "y")]
return "%d modules, %d built-in | GPU: %s | CPU: %s | preempt %s%s, HZ %s | BTF %s | %s" % (
cfg.count("m"), cfg.count("y"), " ".join(gpus) or "none",
"native (-march=native)" if cfg.get("X86_NATIVE_CPU") == "y" or "-march=native" in kcflags
else "%s (%s)" % (kcflags, level or "?") if kcflags else "generic",
preempt, " (switchable at boot: preempt=)" if cfg.get("PREEMPT_DYNAMIC") == "y" else "",
cfg.get("HZ") or "?", "yes" if cfg.get("DEBUG_INFO_BTF") == "y" else "no",
hardening_summary(hardening_state(cfg)))
def hardening_install_text(cfg, tree, machine):
"""INSTALL.txt's HARDENING section: what the kernel builds in, whose DKMS key it trusts, and
what undoes each feature for one boot."""
st, arch = hardening_state(cfg), machine.get("arch") or os.uname().machine
lines = [" Built in: %s." % hardening_summary(st, arch)]
if st["lockdown"] in ("integrity", "confidentiality"):
lines.append(" Lockdown %s: %s. Nothing at the boot line lowers it: to get out of it, boot another "
"kernel." % (st["lockdown"], LOCKDOWN_COST[st["lockdown"]]))
dk = (machine.get("dkms_cert") or {}).get("der")
if dk and dk in trusted_certs(tree, st):
lines.append(" It trusts the DKMS key of the machine it was built on, and no other: on yours, DKMS "
"modules load only through shim + Secure Boot + MOK (mokutil --import your DKMS "
"certificate, then mokutil --trust-mok) -- or with a kernel built with --lockdown off.")
esc = state_escapes(st, arch)
if esc:
lines.append(" If one of these misbehaves, at the boot line -- %s." % esc)
return "\n".join(lines)
def packaged_image_signed(fmt, pkg, k):
"""Open the package and look at the vmlinuz INSIDE it. -> True / False, or
None when no image could be got out of it."""
d = tempfile.mkdtemp(prefix="elkt-pkg-")
try:
if fmt == "rpm":
cmd = "rpm2cpio %s | cpio -id --quiet './lib/modules/*/vmlinuz' './boot/vmlinuz*'" % shlex.quote(pkg)
elif fmt == "deb":
cmd = "dpkg-deb --fsys-tarfile %s | tar -x --wildcards './boot/vmlinuz*'" % shlex.quote(pkg)
else:
cmd = "tar -xf %s --wildcards '*/vmlinuz*' 'boot/vmlinuz*'" % shlex.quote(pkg)
run(["sh", "-c", cmd], cwd=d, check=False, env=scrubbed_env())
for dirpath, _, files in os.walk(d):
for fn in files:
if fn.startswith("vmlinuz"):
return image_signed_by(os.path.join(dirpath, fn), k)
return None
finally:
shutil.rmtree(d, ignore_errors=True)
def cmd_package(args):
opts = merge_options(args)
machine = detect_machine()
state = State(os.path.join(state_dir(opts), "state.ini"))
tree = built_tree(opts, state)
toolchain = tree_toolchain(opts, machine, tree, state.get(tree)) # packaging runs make again
cfg = DotConfig(os.path.join(tree, ".config"))
fmt = getattr(args, "format", None) or (
"rpm" if which("rpmbuild", path=":".join(SYSTEM_PATH)) else
"deb" if which("dpkg-deb", path=":".join(SYSTEM_PATH)) else "tar")
if fmt not in PKG_TARGET:
raise ToolError("--format must be rpm, deb or tar")
need = {"rpm": "rpmbuild", "deb": "dpkg-deb", "tar": "tar"}[fmt]
if not which(need, path=":".join(SYSTEM_PATH)):
raise ToolError("--format %s needs %s, which is not installed" % (fmt, need))
sb = opts["secure_boot"] == "on"
if not os.path.isfile(os.path.join(tree, "vmlinux")):
raise ToolError("%s has not been built (no vmlinux) -- build it first" % tree)
hardening_given_note(opts, state.get(tree), tree, "package")
if sb and fmt == "deb":
# Said BEFORE the long run, not after it. bindeb-pkg re-links the kernel
# under a new build number in `make ... headers all` -- a make WITH goals,
# which the tree's wrapper does not (and must not) hook -- so the image in
# the .deb would be unsigned, and the check below would throw it away.
raise ToolError("--format deb cannot carry a signed image yet: its build step re-links "
"the kernel where this tool cannot re-sign it.\n Use --format tar (the "
"signed image goes in as it is), or --secure-boot off for an unsigned .deb.")
lock_tree(tree)
k = signing_key() if sb else None
say("Packaging %s as %s" % (tree, fmt))
hst = hardening_state(cfg)
if hst["trusted"] == TRUSTED_PEM:
# The package's own build step links the kernel again, reading this file again.
if not os.path.isfile(os.path.join(tree, TRUSTED_PEM)):
if not write_trusted_pem(tree, machine, dict(opts, trust_dkms=state.get(tree).get("trust_dkms", "on"),
install=False)):
raise ToolError("this tree builds in %s, which is gone, and there is no certificate here to put "
"into it again -- build the tree again: %s %s"
% (TRUSTED_PEM, rebuild_command(tree), AS_BUILT))
note("%s was gone: written again, for the package's build step" % TRUSTED_PEM)
try:
with open(os.path.join(tree, "certs/x509_certificate_list"), "rb") as f:
listed = f.read()
except (OSError, IOError):
listed = None
if listed is not None and b"".join(trusted_certs(tree, hst)) != listed:
warn("%s holds other certificates now than the ones compiled into this kernel: the package's "
"kernel is linked again with these" % TRUSTED_PEM)
dk = (machine.get("dkms_cert") or {}).get("der")
if hst["lockdown"] in ("integrity", "confidentiality") and dk and dk in trusted_certs(tree, hst):
warn("this kernel trusts THIS machine's DKMS key: elsewhere DKMS modules load only through shim + Secure "
"Boot + MOK, or with --lockdown off. INSTALL.txt says so.")
if sb:
# A tree built with --secure-boot off, or by hand, is signed here; tarxz-pkg
# packs the image as it finds it (its `make -f Makefile` bypasses the wrapper).
sign_image(tree, k)
pin_toolchain(tree, toolchain, sb) # the wrapper is what keeps the image signed
log_path = os.path.join(state_dir(opts), "package-%s.log" % os.path.basename(tree))
info("full log : %s" % log_path)
mk = Make(tree, toolchain, opts["jobs"])
started = time.time()
# NOT ELKT_NO_SIGN here: the package's own build step relinks the kernel, and
# the wrapper re-signing at the end of it is the whole point.
# ELKT_TREE_LOCKED: the hook's `sign` runs UNDER this process's lock on the tree.
# As install_kernel() does: a --debug-info btf tree carries DWARF only so that
# BTF could be made from it. The rpm spec strips modules only when it builds a
# debuginfo package (never here), so without this every module ships ~10x its
# size. A command-line variable reaches the package's own `make
# modules_install` through MAKEFLAGS; kbuild strips BEFORE it signs.
strip = ["INSTALL_MOD_STRIP=1"] if cfg.get("DEBUG_INFO_BTF") == "y" \
and state.get(tree).get("debug_info") == "btf" else []
if strip:
info("modules : stripped of DWARF in the package (BTF stays), as `install` does")
rc, stats = run_build(mk, log_path, opts["verbose"], strip + [PKG_TARGET[fmt]],
{"ELKT_NO_SIGN": "" if sb else "1", "ELKT_NO_PIN": "",
"ELKT_TREE_LOCKED": "1"})
if rc != 0:
bad("packaging failed (make exit %d). First errors in the log:" % rc)
for h in error_summary(log_path):
out(" " + h)
raise ToolError("see %s" % log_path)
release = read_text(os.path.join(tree, "include/config/kernel.release")).strip()
where = {"rpm": os.path.join(tree, "rpmbuild", "RPMS", "**", "*.rpm"),
"deb": os.path.join(os.path.dirname(tree), "*.deb"),
"tar": os.path.join(tree, "*.tar.xz")}[fmt]
pkgs = sorted(f for f in glob.glob(where, recursive=True) if os.path.getmtime(f) >= started - 5)
if not pkgs:
raise ToolError("make succeeded but no %s package newer than this run was found (%s)" % (fmt, where))
ok("packaged in %dm%02ds" % (stats["seconds"] // 60, stats["seconds"] % 60))
main_pkg = next((f for f in pkgs if re.search(r"(kernel|linux-image)-?\d", os.path.basename(f))
and "devel" not in f and "headers" not in f and "dbg" not in f), pkgs[0])
if sb:
# BEFORE anything reaches the publish directory.
signed = packaged_image_signed(fmt, main_pkg, k)
if signed:
ok("the kernel image INSIDE %s is signed with '%s'" % (os.path.basename(main_pkg), key_cn(k)))
else:
raise ToolError("the kernel image inside %s %s -- not publishing it; nothing was "
"copied.\n (The tree's %s re-signs after the package's build step; "
"was it removed? The packages are still in %s)"
% (os.path.basename(main_pkg),
"is NOT signed with your key" if signed is False
else "could not be looked at", PIN_FILE, os.path.dirname(main_pkg)))
outdir = os.path.abspath(os.path.expanduser(getattr(args, "output", None) or
os.path.join("~", "kernel-packages", release)))
os.makedirs(outdir, exist_ok=True)
# One publish directory per RELEASE, and a release is rebuilt (another
# compiler, another CPU setting, a new build number): what an earlier run of
# THIS command put there must not sit beside the new set, checksummed as if
# it belonged to it. Only file kinds this command writes are removed.
for old in sorted(os.listdir(outdir)):
if old in ("INSTALL.txt", "SHA256SUMS", "secureboot.der") \
or old.endswith((".rpm", ".deb", ".tar.xz")):
os.unlink(os.path.join(outdir, old))
info("removed : %s (from an earlier run)" % old)
for f in pkgs:
shutil.copy2(f, outdir)
if sb:
shutil.copy2(k["der"], os.path.join(outdir, "secureboot.der"))
# kernel-headers (rpm) is the USERSPACE headers package and is always built;
# what builds a module is kernel-devel (rpm) / linux-headers (deb).
has_devel = any(re.match(r"(kernel-devel-|linux-headers-)", os.path.basename(f)) for f in pkgs)
names = [os.path.basename(f) for f in pkgs]
steps = {"rpm": " sudo dnf install ./%s\n reboot, and pick %s from the boot menu" % (
" ./".join(n for n in names if "headers" not in n), release),
"deb": " sudo apt install ./%s\n reboot, and pick %s from the boot menu" % (" ./".join(names), release),
# --keep-directory-symlink is NOT optional: the archive has a lib/
# directory, /lib is a symlink on every usr-merged distro, and GNU
# tar's default is to replace such a symlink with a real directory --
# on Debian that takes ld-linux away and nothing runs any more.
"tar": " sudo tar -C / --keep-directory-symlink --no-overwrite-dir -xf %s\n"
" (GNU tar. Do NOT leave --keep-directory-symlink out: /lib is a symlink,\n"
" and plain `tar -C / -xf` would replace it with a directory.)\n"
" sudo depmod %s && sudo dracut --kver %s (or update-initramfs -c -k %s)\n"
" add a boot entry (grubby --add-kernel / update-grub), reboot" % (names[0], release, release, release)}[fmt]
native = cfg.get("X86_NATIVE_CPU") == "y"
kcf, lvl = toolchain.get("kcflags", ""), opts.get("_cpu_level", "")
expects = [" CPU: %s" % ("built with -march=native on '%s' -- it needs a CPU with at least that "
"instruction set, and will not boot on an older one" % machine["cpu"]["model"]
if native or "-march=native" in kcf else
"built with KCFLAGS=%s -- it needs %s, and will not boot on an older "
"one" % (kcf, LEVEL_CPUS.get(lvl, "a CPU of that kind")) if kcf
else "any %s CPU" % machine["arch"]),
" Out-of-tree modules (DKMS, NVIDIA, VirtualBox): %s" % (
"install the kernel-devel / linux-headers package too; they are built with YOUR "
"system compiler" if has_devel else
"NOT possible -- a tar archive carries no build tree" if fmt == "tar" else
"NOT possible -- no kernel-devel package was produced (kbuild omits it when CC "
"and HOSTCC differ)")]
if not has_devel:
warn("no kernel-devel / linux-headers package in this set: whoever installs it cannot "
"build a module (DKMS, NVIDIA). INSTALL.txt says so.")
if not toolchain["system"]:
expects.append(" ! built with %s, which is not a distro compiler: module builds on another "
"machine need a compiler that accepts the same flags. For a kernel other "
"people install, build with the system compiler." % toolchain["version"][:60])
sbtxt = (" The image is signed with '%s'. With Secure Boot ENABLED, trust it once:\n"
" sudo mokutil --import secureboot.der (pick a one-time password)\n"
" reboot -> blue MOK Manager screen -> Enroll MOK -> that password\n"
" With Secure Boot disabled, skip this." % key_cn(k)) if sb else \
" NOT signed: boots only with Secure Boot disabled."
with open(os.path.join(outdir, "INSTALL.txt"), "w") as f:
f.write(INSTALL_TXT.format(release=release, date=time.strftime("%Y-%m-%d"),
compiler=toolchain["version"][:70],
summary=tree_summary(cfg, kcf, lvl), steps=steps, sb=sbtxt,
hardening=hardening_install_text(cfg, tree, machine),
expects="\n".join(expects)))
# Exactly this run's files -- not whatever else is in the directory.
with open(os.path.join(outdir, "SHA256SUMS"), "w") as f:
for n in sorted(names + ["INSTALL.txt"] + (["secureboot.der"] if sb else [])):
f.write("%s %s\n" % (sha256_file(os.path.join(outdir, n)), n))
say("Ready to publish: %s" % outdir)
for n in sorted(os.listdir(outdir)):
info("%10s %s" % (human(os.path.getsize(os.path.join(outdir, n))), n))
if not toolchain["system"]:
warn("built with a non-distro compiler -- see INSTALL.txt, 'WHAT THIS KERNEL EXPECTS'")
state.update(tree, packaged=outdir)
return 0
def cmd_pin(args):
opts = merge_options(args)
machine = detect_machine()
state = State(os.path.join(state_dir(opts), "state.ini"))
tree = built_tree(opts, state)
cfg = DotConfig(os.path.join(tree, ".config"))
built_by = cfg.get("CC_VERSION_TEXT") or ""
info("tree : %s" % tree)
info("built with : %s" % (built_by or "unknown -- not configured?"))
toolchain = tree_toolchain(opts, machine, tree, state.get(tree)) # a pin names what BUILT it
if pin_toolchain(tree, toolchain, opts["secure_boot"] == "on") and pin_text(toolchain):
ok("pinned: %s" % os.path.join(tree, PIN_FILE))
else:
info("nothing to pin: this is the system toolchain, which is what everything uses anyway")
release = read_text(os.path.join(tree, "include/config/kernel.release")).strip()
return 0 if dkms_check(tree, toolchain, machine, release) else 1
def cmd_dkms_check(args):
opts = merge_options(args)
machine = detect_machine()
state = State(os.path.join(state_dir(opts), "state.ini"))
tree = built_tree(opts, state)
toolchain = tree_toolchain(opts, machine, tree, state.get(tree), strict=False)
release = read_text(os.path.join(tree, "include/config/kernel.release")).strip()
info("tree : %s (%s)" % (tree, release or "release unknown -- not built?"))
return 0 if dkms_check(tree, toolchain, machine, release) else 1
def cmd_dkms_cap(args):
"""fix-amdgpu-dkms.sh as a command, for every amdgpu-dkms here (cap_amdgpu_dkms()):
`dkms status` before and after, sudo only for the writes, --dry-run prints them.
Nothing to do -- no dkms, no amdgpu-dkms -- is a success: exit 0."""
dry, force = bool(args.dry_run), bool(args.force)
series = (args.max or "").strip()
if series and not re.match(r"^\d+\.\d+(\.\d+)?$", series):
raise ToolError("--max takes a kernel series, e.g. 6.18 (the cap becomes 6.18.999), or a "
"full version -- got '%s'" % series)
report = {"tool": TOOL, "ok": True, "dry_run": dry, "packages": [], "dkms_status_before": [],
"dkms_status_after": [], "release": "", "skips_release": None, "error": ""}
def done(rc):
if args.json:
report["ok"] = rc == 0
emit_json(report)
return rc
say("amdgpu-dkms cap%s" % (" (dry run: nothing is written)" if dry else ""))
if not which("dkms", path=":".join(SYSTEM_PATH)):
info("no dkms on this machine: nothing to cap")
return done(0)
pkgs = amdgpu_dkms_packages()
if not pkgs:
info("no amdgpu-dkms here (no /usr/src/amdgpu-*/dkms.conf): nothing to cap")
return done(0)
if os.geteuid() == 0:
report["error"] = "do not run this as root; it calls sudo itself, for the writes only"
out("\n" + _c("31", "ERROR:") + " " + report["error"])
return done(1)
report["dkms_status_before"] = dkms_status_lines()
info("dkms status, before:")
for line in report["dkms_status_before"] or ["(nothing)"]:
info(" " + line)
keeper = SudoKeepalive()
try:
results = cap_amdgpu_dkms(pkgs, os.uname().release, series, force, dry, before_write=keeper.start)
finally:
keeper.stop()
report["packages"] = results
if not dry:
report["dkms_status_after"] = dkms_status_lines()
info("dkms status, after:")
for line in report["dkms_status_after"] or ["(nothing)"]:
info(" " + line)
# What it means for the newest tree this tool built.
state = State(os.path.join(state_dir({"state_dir": args.state_dir}), "state.ini"))
built = sorted((s.get("finished", ""), s["release"]) for s in (state.get(t) for t in state.trees())
if s.get("release") and s.get("status") in ("built", "installing", "installed"))
if built:
rel = report["release"] = built[-1][1]
skips = []
for p, r in zip(pkgs, results):
lim = dict(p["limits"], **({CAP_KEY: r["max_after"]} if r["max_after"] else {}))
will, why = dkms_will_build(lim, rel)
skips.append(not will)
(ok if not will else warn)("amdgpu/%s and %s: %s" % (
p["version"], rel, "DKMS %s it (%s)" % ("would skip" if dry else "skips", why) if not will
else "DKMS still tries to build it"))
report["skips_release"] = all(skips)
if not dry and any(r["action"] in ("capped", "replaced") for r in results):
info("check that a kernel past the cap is skipped cleanly: sudo dkms autoinstall -k %s"
% (report["release"] or "<release>"))
return done(1 if any(r["action"] in ("failed", "refused") for r in results) else 0)
def tree_lock_held(tree):
"""Whether a live instance holds the tree's .elkt-lock: a 'building' record nobody
holds belongs to a run that was killed."""
try:
f = open(os.path.join(tree, ".elkt-lock"), "r")
except (OSError, IOError):
return False
try:
fcntl.flock(f, fcntl.LOCK_SH | fcntl.LOCK_NB)
except (OSError, IOError):
return True
finally:
f.close()
return False
def status_data(opts, root="/"):
"""The toolkit's status contract for this tool (PLAN.md section 3), exactly at the top
level: {tool, installed: [{release, modules, build_link, boot_image, built_by_tool,
default}], trees: [{tree, status, stage, release, error}]} -- + what `status` shows
beyond it. A kernel counts as built by this tool when a tree on record has that release,
when its build link is a tree carrying this tool's GNUmakefile pin, or when the release
ends in the default LOCALVERSION (-elkt). `complete` = modules, build link and image all
there (build_everything.sh's rule: modules_install alone is not 'installed'). No sudo,
no network."""
state = State(os.path.join(state_dir(opts), "state.ini"))
ours, trees = set(), []
for tree in state.trees():
s = state.get(tree)
if s.get("release"):
ours.add(s["release"])
trees.append({"tree": tree, "status": s.get("status", ""), "stage": s.get("stage", ""),
"release": s.get("release", ""), "error": s.get("error") or s.get("last_error") or None,
"exists": os.path.isdir(tree), "started": s.get("started", ""),
"finished": s.get("finished", ""), "toolchain": s.get("toolchain", ""),
"cc": s.get("opt_cc", ""), "running": os.path.isdir(tree) and tree_lock_held(tree),
"in_service": in_service(tree, root)})
default = default_boot_release(root)
mods = rp(root, "lib/modules")
try:
rels = set(n for n in os.listdir(mods) if os.path.isdir(os.path.join(mods, n)))
except OSError:
rels = set()
rels.update(boot_images(root))
installed = []
for rel in sorted(rels, key=version_key):
mdir, build = os.path.join(mods, rel), os.path.join(mods, rel, "build")
tree = os.path.realpath(build) if os.path.lexists(build) else ""
image = next((p for p in (rp(root, "boot/%s-%s" % (n, rel)) for n in ("vmlinuz", "vmlinux", "kernel"))
if os.path.isfile(p)), "")
e = {"release": rel,
"modules": any(os.path.exists(os.path.join(mdir, x))
for x in ("modules.dep", "modules.order", "modules.builtin", "kernel")),
"build_link": os.path.isdir(build), "boot_image": bool(image),
"built_by_tool": rel in ours or rel.endswith(DEFAULTS["localversion"]) or
bool(tree and read_text(os.path.join(tree, PIN_FILE)).startswith(PIN_MARK)),
"default": bool(default) and rel == default, "image": image, "tree": tree,
"running": rel == os.uname().release}
e["complete"] = e["modules"] and e["build_link"] and e["boot_image"]
installed.append(e)
caps = [{"version": p["version"], "conf": p["conf"], "max": p["limits"].get(CAP_KEY)}
for p in amdgpu_dkms_packages(root, dkms=[])]
return {"tool": TOOL, "installed": installed, "trees": trees, "state_file": state.path,
"running": os.uname().release, "default": default or None, "default_known": bool(default),
"amdgpu_dkms": caps}
def cmd_status(args):
opts = merge_options(args)
if opts["json"]:
emit_json(status_data(opts))
return 0
state = State(os.path.join(state_dir(opts), "state.ini"))
say("Known source trees (%s)" % state.path)
if not state.trees():
info("none yet")
for tree in state.trees():
s = state.get(tree)
gone = "" if os.path.isdir(tree) else " [directory is gone]"
out("\n %s%s" % (tree, gone))
info(" %s%s release %s %s" % (
s.get("status", "?"), " at " + s["stage"] if s.get("stage") not in ("", "done", None) else "",
s.get("release", "?"), s.get("finished") or "started " + s.get("started", "?")))
serving = in_service(tree)
if serving:
info(" in service as the DKMS build dir of: %s" % ", ".join(serving))
if s.get("error") or s.get("last_error"):
info(" last error: %s" % (s.get("error") or s.get("last_error")))
data = status_data(opts)
say("Kernels installed here (/lib/modules, /boot)")
for k in data["installed"]:
info("%-40s %s%s%s%s%s" % (
k["release"], "modules" if k["modules"] else "NO modules",
", build link" if k["build_link"] else ", no build link",
", image" if k["boot_image"] else ", NO image in /boot",
" [built here]" if k["built_by_tool"] else "",
(" [default]" if k["default"] else "") + (" [running]" if k["running"] else "")))
if not data["installed"]:
info("none found")
if not data["default_known"]:
info("(which one is the default boot entry cannot be read without root here)")
for c in data["amdgpu_dkms"]:
info("amdgpu-dkms %s: %s" % (c["version"], "capped at %s" % c["max"] if c["max"] else
"NO kernel cap -- `dkms-cap` (install does it itself)"))
return 0
def build_parser():
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--state-dir", help="where state.ini and build logs live "
"(default: ~/.local/state/%s)" % TOOL)
common.add_argument("--json", action="store_true",
help="machine-readable output: stdout is the JSON alone, messages go to stderr")
common.add_argument("--opt-root", dest="opt_root",
help="where the satellite toolkit's compilers live, <dir>/<name>-<version>/bin "
"(default ~/opt): searched for toolchains; nothing is installed there")
common.add_argument("--yes", action="store_true",
help="do not stop for confirmations -- in THIS tool it also overrides the safety "
"refusals (installing over the running release, an unenrolled MOK key, a CPU "
"level this machine lacks, no initramfs generator, failed post-build checks -- "
"no boot console built in is one --, no display driver for the screen). Not: "
"an unsigned image under Secure Boot (--allow-unsigned), an out-of-tree module "
"the new kernel will refuse (--allow-untrusted-modules), a module that mounts / "
"or a kernel that cannot load its own modules (no override)")
b = argparse.ArgumentParser(add_help=False)
b.add_argument("--kernel", help="stable | longterm | mainline | 6.12 | 6.12.9 (default stable)")
b.add_argument("--source", help="an existing kernel tree; skips the download")
b.add_argument("--workdir", help="where trees are downloaded and built (default ~/src)")
b.add_argument("--gpu", help="auto | all | none | amd,nvidia,intel (default auto)")
b.add_argument("--toolchain", help="gcc | llvm (default gcc = the SYSTEM gcc)")
b.add_argument("--cc", help="the compiler: any gcc or clang, by path or name "
"(see the `toolchains` command)")
b.add_argument("--llvm-prefix", dest="llvm_prefix", help="directory holding clang, ld.lld ...")
b.add_argument("--cxx", help="the C++ compiler (kbuild's HOSTCXX: GCC plugins, xconfig); "
"default: the one installed beside --cc")
b.add_argument("--binutils", help="auto | gnu | llvm | <dir holding ld and as> (default auto: "
"what goes with the compiler)")
b.add_argument("--linker", help="default | bfd | lld")
b.add_argument("--python", help="the python3 kbuild's scripts run under (default /usr/bin/python3)")
b.add_argument("--ccache", help="off | on")
b.add_argument("--config-base", dest="config_base",
help="auto | running | defconfig | <file> (default auto)")
b.add_argument("--modules", help="full | trimmed (localmodconfig + GPU + rescue set)")
b.add_argument("--keep-symbol", action="append",
help="with --modules trimmed: also keep this CONFIG symbol (repeatable)")
b.add_argument("--cpu", help="generic | x86-64-v2 | x86-64-v3 | x86-64-v4 | native | a CPU name "
"the compiler knows (raptorlake, znver4 ...) (default generic)")
b.add_argument("--preempt", help="keep | none | voluntary | full | lazy")
b.add_argument("--hz", help="keep | 100 | 250 | 300 | 1000")
b.add_argument("--debug-info", dest="debug_info", help="off | btf | on")
b.add_argument("--secure-boot", dest="secure_boot",
help="on | off (default on: Secure-Boot-ready config, and the image signed "
"with your key -- see the `sign` command)")
b.add_argument("--lockdown",
help="integrity | confidentiality | off (default integrity, built in: modules the kernel "
"does not trust, /dev/mem, MSR writes, unsigned kexec and hibernation are refused, "
"root included; the boot line can raise it, never lower it)")
b.add_argument("--suspend",
help="s2idle | deep (default s2idle, x86: mem_sleep_default=s2idle built in; deep = S3; "
"mem_sleep_default=deep at the boot line overrides it)")
b.add_argument("--iommu",
help="on | strict | off (default on: DMA remapping on at boot, lazy flushing; "
"intel_iommu=off at the boot line)")
b.add_argument("--trust-dkms", dest="trust_dkms",
help="on | off (default on: DKMS's certificate is built into the kernel, so the modules "
"DKMS signs load under lockdown)")
b.add_argument("--localversion", help="release suffix (default -elkt)")
b.add_argument("--frame-warn", dest="frame_warn", help="-Wframe-larger-than (default 8192)")
b.add_argument("--jobs", help="parallel jobs (default: all CPUs)")
b.add_argument("--clean", help="auto | always | never | force")
b.add_argument("--profile", help="a saved profile, by name (see `profiles`) or by path")
b.add_argument("--save-profile", dest="save_profile", help="write the resolved answers to a profile")
b.add_argument("--require-pgp", action="store_true", help="refuse an unverified sha256sums.asc")
b.add_argument("--install-deps", action="store_true", help="run the package manager first (sudo)")
b.add_argument("--configure-only", action="store_true",
help="stop once the config is verified and written; build nothing")
b.add_argument("--install", action="store_true", help="install after a successful build")
b.add_argument("--set-default", action="store_true", help="make the new kernel the default boot entry")
b.add_argument("--keep-boot-default", dest="keep_boot_default", action="store_true",
help="Debian/Ubuntu (no grubby): keep the current default boot entry -- sets "
"GRUB_DEFAULT=saved (a dated backup of /etc/default/grub first), grub-set-default "
"to the entry booted now, update-grub. Without it /etc/default/grub is never touched")
b.add_argument("--no-dkms-cap", dest="no_dkms_cap", action="store_true",
help="install: leave amdgpu-dkms uncapped (by default it is capped first when it would "
"try -- and fail -- to build for the new kernel; see dkms-cap)")
b.add_argument("--allow-unsigned", action="store_true", help="install even though Secure Boot is on")
b.add_argument("--allow-untrusted-modules", dest="allow_untrusted_modules", action="store_true",
help="install even though a DKMS module will not load on the new kernel: signed with a key "
"it does not trust (--yes does not cover this; nothing does for a module that mounts /)")
b.add_argument("--dry-run", action="store_true", help="install: print the sudo commands, run none")
b.add_argument("--verbose", action="store_true", help="stream the whole build instead of a status line")
p = argparse.ArgumentParser(
prog=TOOL + ".py", formatter_class=argparse.RawDescriptionHelpFormatter,
description="Pick, configure, build and install a Linux kernel for this machine.\n"
"With no command on a terminal it asks; every question is also a flag.")
# --tool-version too, the name every toolkit tool shares: it is the one that does not
# stop to ask "run where?" first (--version picks a release in the other tools)
p.add_argument("--version", "--tool-version", action="version", version=VERSION)
sub = p.add_subparsers(dest="command")
k = sub.add_parser("kernels", parents=[common], help="what kernel.org offers right now")
k.add_argument("--series", help="list every point release of a series, e.g. 6.12")
sub.add_parser("detect", parents=[common], help="what this machine is")
sub.add_parser("profiles", parents=[common], help="the saved profiles")
sub.add_parser("toolchains", parents=[common, b],
help="every compiler, binutils set and python here; what would be used")
sub.add_parser("doctor", parents=[common, b], help="can this machine build a kernel?")
sub.add_parser("plan", parents=[common, b], help="resolve everything, touch nothing")
sub.add_parser("build", parents=[common, b], help="fetch, configure, verify, build, check")
sub.add_parser("wizard", parents=[common, b], help="ask, then build")
sub.add_parser("install", parents=[common, b], help="install a built tree")
sub.add_parser("dkms-check", parents=[common, b], help="will out-of-tree modules compile?")
dc = sub.add_parser("dkms-cap", parents=[common],
help="cap every amdgpu-dkms (BUILD_EXCLUSIVE_KERNEL_MAX) so DKMS skips newer kernels")
dc.add_argument("--max", metavar="SERIES",
help="the series to cap at, e.g. 6.18 -> 6.18.999 (default: the newest series each "
"package is built or installed for -- the rule dkms-check prints)")
dc.add_argument("--force", action="store_true",
help="replace a BUILD_EXCLUSIVE_KERNEL_MAX that is already there, and allow a cap "
"below a kernel the package is installed for")
dc.add_argument("--dry-run", action="store_true", help="print the sudo commands, run none")
pk = sub.add_parser("package", parents=[common, b],
help="turn a built tree into an RPM / deb / tarball other people can install")
pk.add_argument("--format", help="rpm | deb | tar (default: what this machine can build)")
pk.add_argument("--output", help="where the finished set goes (default ~/kernel-packages/<release>)")
sub.add_parser("pin", parents=[common, b],
help="make a built tree use ITS compiler for every out-of-tree build (DKMS, installers)")
sub.add_parser("sign", parents=[common, b],
help="sign a built tree's kernel image for Secure Boot (makes your key on first use)")
sub.add_parser("status", parents=[common, b], help="recorded state of every known tree")
# Every option spelled out: argparse's abbreviations are a guess, and `--local` was taken
# for --localversion (and ate the next word as its value).
for parser in [p] + list(sub.choices.values()):
parser.allow_abbrev = False
return p
def _as_interrupt(signum, frame):
raise KeyboardInterrupt()
COMMANDS = ("kernels", "detect", "toolchains", "profiles", "sign", "pin", "package", "doctor", "plan", "build", "wizard", "install",
"dkms-check", "dkms-cap", "status")
def normalize_argv(argv):
argv = list(argv)
# `tool.py --kernel 6.12` means "ask me the rest". argparse cannot express a
# default subcommand, so put it there before it looks. Only argv[0] can be
# the command: a VALUE may well be spelled 'build' (--workdir build).
if not argv or argv[0] not in COMMANDS + ("-h", "--help", "--version", "--tool-version"):
argv = ["wizard"] + argv
# Every LOCALVERSION starts with '-', which argparse reads as an option and
# answers with "expected one argument". Glue the pair into --opt=value.
for i in range(len(argv) - 1):
if argv[i] == "--localversion":
argv[i:i + 2] = ["--localversion=" + argv[i + 1]]
break
return argv
def main(argv=None):
# Here or on another machine (--local / --remote / --attach / --sessions): taken out first,
# before normalize_argv puts `wizard` in front -- and so `--local` is never read as an
# abbreviation of --localversion.
argv = where_to_run(list(sys.argv[1:] if argv is None else argv), __file__, TOOL)
parser = build_parser()
argv = normalize_argv(argv)
# A dropped ssh session is a SIGHUP, systemd stopping a unit a SIGTERM.
# Unhandled, neither runs a finally: state stays 'building' and the next run
# cleans a good tree. As KeyboardInterrupt they take the ^C path.
# But a SIGHUP somebody chose to IGNORE (`nohup tool build &`) stays ignored:
# replacing it aborted the build at logout and took make's immunity with it.
for sig in (signal.SIGTERM, signal.SIGHUP):
if signal.getsignal(sig) != signal.SIG_IGN:
signal.signal(sig, _as_interrupt)
args = parser.parse_args(argv)
saved = (UI.stream, UI.tty, UI.color, OPT_ROOT[0])
if getattr(args, "json", False):
json_mode()
OPT_ROOT[0] = getattr(args, "opt_root", None) or "~/opt"
try:
return run_command(args)
finally:
UI.stream, UI.tty, UI.color, OPT_ROOT[0] = saved
def run_command(args):
try:
if args.command == "wizard":
if not sys.stdin.isatty():
out("Not a terminal: nothing to ask. Use `build` with flags or "
"--profile, or see --help.")
return 2
return cmd_build(args, interactive=True)
return {"kernels": cmd_kernels, "detect": cmd_detect, "doctor": cmd_doctor,
"plan": cmd_plan, "build": cmd_build, "install": cmd_install,
"dkms-check": cmd_dkms_check, "dkms-cap": cmd_dkms_cap, "status": cmd_status,
"toolchains": cmd_toolchains, "profiles": cmd_profiles,
"sign": cmd_sign, "pin": cmd_pin, "package": cmd_package}[args.command](args)
except ToolError as e:
out("\n" + _c("31", "ERROR:") + " " + str(e))
return 1
except KeyboardInterrupt:
out("\ninterrupted -- the tree is consistent; re-running continues incrementally.")
return 130
def _ensure_platform_python():
"""Started as `python3 tool.py` where python3 is PyPy or a private build?
Re-exec under the platform interpreter, once."""
if sys.implementation.name == "cpython" or os.environ.get("ELKT_NO_REEXEC"):
return
if os.path.exists(PLATFORM_PYTHON) and \
os.path.realpath(sys.executable) != os.path.realpath(PLATFORM_PYTHON):
os.environ["ELKT_NO_REEXEC"] = "1"
os.execv(PLATFORM_PYTHON, [PLATFORM_PYTHON, "-I", os.path.abspath(__file__)] + sys.argv[1:])
if __name__ == "__main__":
_ensure_platform_python()
sys.exit(main())
