1
0
Fork 1
mirror of https://gitlab.postmarketos.org/postmarketOS/pmbootstrap.git synced 2025-07-13 03:19:47 +03:00
pmbootstrap/pmb/build/kconfig.py
Casey Connolly 18cf14bcca use kconfiglib to start fidning missing dependencies
We can go up one layer, now we need to figure out how to recurse
dependencies and enable them.

Signed-off-by: Casey Connolly <kcxt@postmarketos.org>
2025-06-14 19:23:01 +02:00

411 lines
14 KiB
Python

# Copyright 2023 Oliver Smith
# SPDX-License-Identifier: GPL-3.0-or-later
import enum
import os
import tempfile
from pathlib import Path
from pmb.core.arch import Arch
from pmb.core.context import get_context
from pmb.helpers import logging
from typing import Any
import pmb.build
import pmb.build.autodetect
import pmb.build.checksum
import pmb.chroot
import pmb.chroot.apk
import pmb.chroot.other
import pmb.helpers.pmaports
import pmb.helpers.run
import pmb.parse
import pmb.parse.kconfig
from pmb.core import Chroot
from pmb.types import Apkbuild, CrossCompile, Env
class KConfigUI(enum.Enum):
MENUCONFIG = "menuconfig"
XCONFIG = "xconfig"
NCONFIG = "nconfig"
def is_graphical(self) -> bool:
match self:
case KConfigUI.MENUCONFIG | KConfigUI.NCONFIG:
return False
case KConfigUI.XCONFIG:
return True
def depends(self) -> list[str]:
match self:
case KConfigUI.MENUCONFIG:
return ["ncurses-dev"]
case KConfigUI.NCONFIG:
return ["ncurses-dev"]
case KConfigUI.XCONFIG:
return ["qt5-qtbase-dev", "font-noto"]
def __str__(self) -> str:
return self.value
def get_arch(apkbuild: Apkbuild) -> Arch:
"""Take the architecture from the APKBUILD or complain if it's ambiguous.
This function only gets called if --arch is not set.
:param apkbuild: looks like: {"pkgname": "linux-...",
"arch": ["x86_64", "armhf", "aarch64"]}
or: {"pkgname": "linux-...", "arch": ["armhf"]}
"""
pkgname = apkbuild["pkgname"]
# Disabled package (arch="")
if not apkbuild["arch"]:
raise RuntimeError(
f"'{pkgname}' is disabled (arch=\"\"). Please use"
" '--arch' to specify the desired architecture."
)
# Multiple architectures
if len(apkbuild["arch"]) > 1:
raise RuntimeError(
f"'{pkgname}' supports multiple architectures"
f" ({', '.join(apkbuild['arch'])}). Please use"
" '--arch' to specify the desired architecture."
)
return Arch.from_str(apkbuild["arch"][0])
def get_outputdir(pkgname: str, apkbuild: Apkbuild, must_exist: bool = True) -> Path:
"""Get the folder for the kernel compilation output.
For most APKBUILDs, this is $builddir. But some older ones still use
$srcdir/build (see the discussion in #1551).
:param must_exist: if True, check that .config exists; if False, just return the directory
"""
chroot = Chroot.native()
# Old style ($srcdir/build)
old_ret = Path(f"{pmb.config.abuild_basedir}/src/build")
if must_exist and os.path.exists(chroot / old_ret / ".config"):
logging.warning("*****")
logging.warning(
"NOTE: The code in this linux APKBUILD is pretty old."
" Consider making a backup and migrating to a modern"
" version with: pmbootstrap aportgen " + pkgname
)
logging.warning("*****")
return old_ret
# New style ($builddir)
ret = ""
if "builddir" in apkbuild:
ret = Path(apkbuild["builddir"])
if not must_exist:
# For fragment-based configs, check if old style exists first
if (chroot / old_ret).exists():
return old_ret
# Otherwise return the most likely directory
# TODO: test this.. the condition is probably not correct?
if (chroot / ret / "kernel/kernel").exists():
return ret / "kernel" # Mediatek style
elif "_outdir" in apkbuild:
return ret / apkbuild["_outdir"] # Out-of-tree
else:
return ret # Standard
# Check all possible locations when must_exist=True
if (chroot / ret / ".config").exists():
return ret
# Some Mediatek kernels use a 'kernel' subdirectory
if (chroot / ret / "kernel/.config").exists():
return ret / "kernel"
# Out-of-tree builds ($_outdir)
if (chroot / ret / apkbuild["_outdir"] / ".config").exists():
return ret / apkbuild["_outdir"]
# out-of-tree ($builddir)
guess = pmb.chroot.root(
["find", "-maxdepth", "3", "-name", ".config"], chroot, Path(pmb.config.abuild_basedir), output_return=True
).rstrip()
if guess:
return (Path(pmb.config.abuild_basedir) / guess).parent
# Not found
raise RuntimeError(
"Could not find the kernel config. Consider making a"
" backup of your APKBUILD and recreating it from the"
" template with: pmbootstrap aportgen " + pkgname
)
def extract_and_patch_sources(pkgname: str, arch: Arch) -> None:
pmb.build.copy_to_buildpath(pkgname)
logging.info("(native) extract kernel source")
pmb.chroot.user(["abuild", "unpack"], working_dir=Path(pmb.config.abuild_basedir))
logging.info("(native) apply patches")
pmb.chroot.user(
["abuild", "prepare"],
working_dir=Path(pmb.config.abuild_basedir),
output="interactive",
env={"CARCH": str(arch)},
)
def _make(
chroot: pmb.core.Chroot,
make_command: list[str],
env: Env,
pkgname: str,
arch: Arch,
apkbuild: Apkbuild,
outputdir: Path | None = None,
) -> None:
aport = pmb.helpers.pmaports.find(pkgname)
if not outputdir:
outputdir = get_outputdir(pkgname, apkbuild)
logging.info("(native) make " + " ".join(make_command))
pmb.chroot.user(["make", *make_command], chroot, outputdir, output="tui", env=env)
# Find the updated config
source = Chroot.native() / outputdir / ".config"
if not source.exists():
raise RuntimeError(f"No kernel config generated: {source}")
# Update the aport (config and checksum)
logging.info("Copy kernel config back to pmaports dir")
config = f"config-{apkbuild['_flavor']}.{arch}"
target = aport / config
pmb.helpers.run.user(["cp", source, target])
pmb.build.checksum.update(pkgname, skip_init=True)
def _init(pkgname: str, arch: Arch | None) -> tuple[str, Arch, Any, Chroot, Env]:
"""
:returns: pkgname, arch, apkbuild, chroot, env
"""
# Pkgname: allow omitting "linux-" prefix
if not pkgname.startswith("linux-"):
pkgname = "linux-" + pkgname
aport = pmb.helpers.pmaports.find(pkgname)
apkbuild = pmb.parse.apkbuild(aport / "APKBUILD")
if arch is None:
arch = get_arch(apkbuild)
cross = pmb.build.autodetect.crosscompile(apkbuild, arch)
logging.debug(f"Using cross: {cross.name}")
chroot = cross.build_chroot(arch)
hostspec = arch.alpine_triple()
# Set up build tools and makedepends
pmb.chroot.init(chroot)
pmb.build.init(chroot)
if cross.enabled():
pmb.build.init_compiler(get_context(), [], cross, arch)
depends = apkbuild["makedepends"] + ["gcc", "make"]
pmb.chroot.apk.install(depends, chroot)
extract_and_patch_sources(pkgname, arch)
env: Env = {
"ARCH": arch.kernel(),
}
if cross.enabled():
env["CROSS_COMPILE"] = f"{hostspec}-"
env["CC"] = f"{hostspec}-gcc"
return pkgname, arch, apkbuild, chroot, env
def migrate_config(pkgname: str, arch: Arch | None) -> None:
pkgname, arch, apkbuild, chroot, env = _init(pkgname, arch)
_make(chroot, ["oldconfig"], env, pkgname, arch, apkbuild)
def edit_config(pkgname: str, arch: Arch | None, config_ui: KConfigUI) -> None:
pkgname, arch, apkbuild, chroot, env = _init(pkgname, arch)
pmb.chroot.apk.install(config_ui.depends(), chroot)
# Copy host's .xauthority into native
if config_ui.is_graphical():
pmb.chroot.other.copy_xauthority(chroot)
env["DISPLAY"] = os.environ.get("DISPLAY") or ":0"
env["XAUTHORITY"] = "/home/pmos/.Xauthority"
# Check for background color variable
color = os.environ.get("MENUCONFIG_COLOR")
if color:
env["MENUCONFIG_COLOR"] = color
mode = os.environ.get("MENUCONFIG_MODE")
if mode:
env["MENUCONFIG_MODE"] = mode
_make(chroot, [str(config_ui)], env, pkgname, arch, apkbuild)
def generate_config(pkgname: str, arch: Arch | None) -> None:
pkgname, arch, apkbuild, chroot, env = _init(pkgname, arch)
fragments: list[str] = []
if defconfig := apkbuild.get("_defconfig"):
fragments += defconfig
# Generate fragment based on categories for kernel, using kconfigcheck.toml
pmos_frag, syms_dict = pmb.parse.kconfig.create_fragment(apkbuild, arch)
# Write the pmos fragment to the kernel source tree
outputdir = get_outputdir(pkgname, apkbuild, must_exist=False)
arch_configs_dir = outputdir / "arch" / arch.kernel() / "configs"
# Create the configs directory if it doesn't exist
pmb.chroot.user(
["mkdir", "-p", str(arch_configs_dir)], chroot, working_dir=Path(pmb.config.abuild_basedir)
)
# Write the pmos fragment to a temp file and copy it in
with tempfile.NamedTemporaryFile(mode="w", delete=False) as pmos_frag_file:
pmos_frag_file.write(pmos_frag)
try:
# Copy the temp file to the configs directory
pmb.helpers.run.root(
[
"cp",
pmos_frag_file.name,
f"{Chroot.native() / arch_configs_dir}/pmos_generated.config",
]
)
finally:
os.unlink(pmos_frag_file.name)
fragments.append("pmos_generated.config")
# Parse fragments before copying to track expected options
aport = pmb.helpers.pmaports.find(pkgname)
fragment_options: dict[str, dict[str, bool | str | list[str]]] = {}
# Collect and parse other fragments from the kernel package directory
for config_file in aport.glob("*.config"):
# Parse the fragment
with open(config_file) as f:
fragment_options[config_file.name] = parse_fragment(f.read())
# Copy fragment to arch/$arch/configs in kernel source
pmb.helpers.run.root(
["cp", str(config_file), f"{Chroot.native() / arch_configs_dir}/{config_file.name}"]
)
if config_file.name not in fragments:
fragments.append(config_file.name)
# Fixup fragments' permissions
pmb.chroot.root(["chown", "-R", "pmos:pmos", str(arch_configs_dir)])
# Generate the config using all fragments
_make(chroot, fragments, env, pkgname, arch, apkbuild, outputdir)
print("Parsing kconfig!")
pmb.parse.kconfig.add_missing_dependencies(apkbuild, syms_dict, outputdir / ".config")
# Validate the generated config
if not pmb.parse.kconfig.check(pkgname, details=True):
raise RuntimeError("Generated kernel config does not pass all checks")
# Validate that all fragment options made it to the final config
final_config_path = aport / f"config-{apkbuild['_flavor']}.{arch}"
with open(final_config_path) as f:
final_config = f.read()
validation_failed = False
for fragment_name, options in fragment_options.items():
for option, expected_value in options.items():
if isinstance(expected_value, bool):
if expected_value:
# Option should be set (=y or =m)
if not pmb.parse.kconfig.is_set(final_config, option):
logging.error(
f"Fragment {fragment_name}: CONFIG_{option} was not enabled in final config (missing dependencies?)"
)
validation_failed = True
else:
# Option should not be set
if pmb.parse.kconfig.is_set(final_config, option):
logging.error(
f"Fragment {fragment_name}: CONFIG_{option} should not be set but is enabled in final config"
)
validation_failed = True
elif isinstance(expected_value, str):
if not pmb.parse.kconfig.is_set_str(final_config, option, expected_value):
logging.error(
f"Fragment {fragment_name}: CONFIG_{option} expected to be '{expected_value}' but has different value in final config"
)
validation_failed = True
elif isinstance(expected_value, list):
for value in expected_value:
if not pmb.parse.kconfig.is_in_array(final_config, option, value):
logging.error(
f"Fragment {fragment_name}: CONFIG_{option} expected to contain '{value}' but doesn't in final config"
)
validation_failed = True
if validation_failed:
raise RuntimeError(
"Fragment validation failed: Some options from fragments did not make it to the final kernel config. This usually means missing dependencies."
)
def parse_fragment(content: str) -> dict[str, bool | str | list[str]]:
"""Parse a kconfig fragment and return a dict of options and their values."""
options: dict[str, bool | str | list[str]] = {}
for line in content.splitlines():
line = line.strip()
# Skip empty lines and comments (except "is not set" lines)
if not line or (line.startswith("#") and "is not set" not in line):
continue
# Handle "is not set" format
if "# CONFIG_" in line and "is not set" in line:
# Extract option name from "# CONFIG_OPTION is not set"
option = line.split("CONFIG_")[1].split(" ")[0]
options[option] = False
continue
# Handle regular CONFIG_OPTION=value format
if line.startswith("CONFIG_"):
parts = line.split("=", 1)
if len(parts) == 2:
option = parts[0].removeprefix("CONFIG_")
value = parts[1]
# Boolean options (y/m)
if value in ["y", "m"]:
options[option] = True
# String options
elif value.startswith('"') and value.endswith('"'):
# Remove quotes and check for comma-separated list
value_unquoted = value[1:-1]
if "," in value_unquoted:
options[option] = value_unquoted.split(",")
else:
options[option] = value_unquoted
# Numeric or other options (treat as string)
else:
options[option] = value
return options