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()