Files
distro/src/builder.py
T
2026-05-26 03:06:26 +02:00

284 lines
7.8 KiB
Python

import os
import re
import shutil
from pathlib import Path
from src import apk, fetch, log
from src.container import Container, Mount
from src.context import RecipeContext
from src.layout import Layout
from src.plan import (
PHASE_STAMPS,
Plan,
stamp_dir,
stamp_token,
stamp_valid,
transitive_host_deps,
)
from src.recipe import Recipe, RecipeSet
def _container_name(*parts: str) -> str:
name = "-".join(parts)
name = re.sub(
r"[^a-zA-Z0-9_.-]",
lambda m: f"_{ord(m.group(0)):02x}",
name,
)
if not name or not name[0].isalnum():
name = f"orchid-{name}"
return name
def _source_tree(layout: Layout, r: Recipe, key: str | None) -> Path:
base = layout.source_tree(r.name, r.version)
if len(r.sources) == 1:
return base
if key is None:
raise ValueError(f"{r.name}: multi-source entries must be named")
return base / key
def _prepare_all_sources(layout: Layout, r: Recipe) -> None:
for key, src in r.sources.items():
tree = _source_tree(layout, r, key)
tree.parent.mkdir(parents=True, exist_ok=True)
fetch.prepare_source(layout, r.dir, src, tree)
def _build_path_env(host_deps_order: list[str], layout: Layout) -> str:
parts: list[str] = []
for h in reversed(host_deps_order):
parts += [
f"/tools/{h}/sbin",
f"/tools/{h}/bin",
]
parts += [
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
]
return ":".join(parts)
def _container_for(
rs: RecipeSet, layout: Layout, profile: dict, r: Recipe
) -> tuple[Container, list[str]]:
host_order = transitive_host_deps(rs, r)
name = _container_name("orchid", layout.build.name, r.kind, r.name)
mounts: list[Mount] = []
tmpfs: list[str] = ["/sysroot"]
# sources
if len(r.sources) == 1:
key = next(iter(r.sources.keys()))
mounts.append(Mount(_source_tree(layout, r, key), "/sources", readonly=True))
else:
for k in r.sources.keys():
mounts.append(
Mount(_source_tree(layout, r, k), f"/sources/{k}", readonly=True)
)
# build dir
wd = layout.build_workdir(r.name if r.kind == "target" else f"host-{r.name}")
wd.mkdir(parents=True, exist_ok=True)
mounts.append(Mount(wd, "/build", readonly=False))
# files dir
if r.files_dir is not None:
mounts.append(Mount(r.files_dir, "/files", readonly=True))
# tools
for h in host_order:
mounts.append(Mount(layout.host_pkg_dir(h), f"/tools/{h}", readonly=True))
# pkgs (produced packages persist on disk); sysroot lives in tmpfs
mounts.append(Mount(layout.pkgs_dir, "/pkgs", readonly=False))
env = {
"PATH": _build_path_env(host_order, layout),
"ORCHID_ARCH": profile["arch"],
"ORCHID_TRIPLE": profile["triple"],
"ORCHID_JOBS": str(os.cpu_count() or 1),
}
c = Container(
name=name,
image=profile["container_image"],
mounts=mounts,
tmpfs=tmpfs,
network=False,
extra_env=env,
)
return c, host_order
def _recipe_ref(r: Recipe) -> str:
return f"{r.key} ({r.version}-r{r.revision})"
def _mark_stamp(layout: Layout, r: Recipe, phase: str) -> None:
name = PHASE_STAMPS.get(phase)
if name is None:
return
d = stamp_dir(layout, r)
d.mkdir(parents=True, exist_ok=True)
(d / name).write_text(stamp_token(r))
def _clear_stamps(layout: Layout, r: Recipe) -> None:
d = stamp_dir(layout, r)
if d.is_dir():
shutil.rmtree(d)
def _wipe_workdir(layout: Layout, r: Recipe) -> None:
wd = layout.build_workdir(r.name if r.kind == "target" else f"host-{r.name}")
if wd.is_dir():
shutil.rmtree(wd)
def _run_phases(ctx: RecipeContext, r: Recipe, layout: Layout) -> None:
for phase in ("prepare", "configure", "build"):
fn = r.phases.get(phase)
if fn is None:
continue
if stamp_valid(layout, r, phase):
log.info_field(phase, f"{_recipe_ref(r)} (cached)")
continue
log.info_field(phase, _recipe_ref(r))
fn(ctx)
_mark_stamp(layout, r, phase)
def _do_install(ctx: RecipeContext, r: Recipe) -> None:
fn = r.phases.get("install")
if fn is None:
return
log.info_field("install", _recipe_ref(r))
ctx._dest_output = r.name
try:
fn(ctx)
finally:
ctx._dest_output = None
def _split_subpackages(c: Container, r: Recipe) -> None:
for sub in r.subpackages:
c.exec(["mkdir", "-p", f"/dest/{sub.name}"])
for pat in sub.files:
c.exec_shell(apk.split_subpackage_script(r.name, sub.name, pat))
def _package_target(c: Container, r: Recipe, arch: str) -> None:
apk.mkpkg_base(c, r, arch)
for sub in r.subpackages:
apk.mkpkg_subpackage(c, r, sub, arch)
apk.index(c)
def _finalize_host(c: Container, layout: Layout, r: Recipe) -> None:
import subprocess
out = layout.host_pkg_dir(r.name)
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
p1 = subprocess.Popen(
[
"podman",
"exec",
c.name,
"sh",
"-c",
f"cd /dest/{r.name}/tools/{r.name} && tar -cf - .",
],
stdout=subprocess.PIPE,
)
p2 = subprocess.Popen(["tar", "-xf", "-", "-C", str(out)], stdin=p1.stdout)
if p1.stdout is not None:
p1.stdout.close()
rc = p2.wait()
p1.wait()
if rc != 0 or p1.returncode != 0:
raise RuntimeError(f"host {r.name}: copy-out failed")
layout.host_pkg_marker(r.name, r.version, r.revision).write_text("ok\n")
def _sysroot_sync(c: Container, r: Recipe) -> None:
direct_deps = list(r.deps)
if not direct_deps:
return
initdb = not apk.sysroot_initialized(c)
apk.sysroot_install(c, direct_deps, initdb=initdb)
def build_one(
rs: RecipeSet, layout: Layout, profile: dict, r: Recipe, *, forced: bool = False
) -> None:
log.info_field("recipe", _recipe_ref(r))
if forced:
_clear_stamps(layout, r)
_wipe_workdir(layout, r)
_prepare_all_sources(layout, r)
c, _host_order = _container_for(rs, layout, profile, r)
c.start()
try:
# Ensure base output dest dir exists.
c.exec(["mkdir", "-p", f"/dest/{r.name}"])
_sysroot_sync(c, r)
ctx = RecipeContext(
recipe=r, profile=profile, container=c, jobs=os.cpu_count() or 1
)
_run_phases(ctx, r, layout)
_do_install(ctx, r)
if r.kind == "target":
log.info_field("package", _recipe_ref(r))
_split_subpackages(c, r)
_package_target(c, r, profile["arch"])
else:
log.info_field("finalize", _recipe_ref(r))
_finalize_host(c, layout, r)
finally:
c.stop()
log.ok_field("done", _recipe_ref(r))
def execute(plan: Plan, rs: RecipeSet, layout: Layout, profile: dict) -> None:
if not plan.order:
log.info_field("plan", "nothing to do")
return
for k in plan.order:
r = plan.recipes[k]
build_one(rs, layout, profile, r, forced=k in plan.forced)
def install_to(
layout: Layout,
profile: dict,
dest: Path,
pkgs: list[str],
*,
initdb: bool = True,
) -> None:
if not pkgs:
log.info_field("install", "nothing to install")
return
c = Container(
name=_container_name("orchid", layout.build.name, "install"),
image=profile["container_image"],
mounts=[
Mount(layout.pkgs_dir, "/pkgs", readonly=True),
Mount(dest, "/sysroot", readonly=False),
],
network=False,
)
c.start()
try:
log.info_field("install", f"{dest}: {', '.join(pkgs)}")
apk.sysroot_install(c, pkgs, initdb=initdb)
finally:
c.stop()