"""BTC.sh (Build Tool Chain) integration for Fester. BTC.sh produces a sovereign, forensically-stamped GCC toolchain with: - .note.BTC ELF note section (Silicon Birth Certificate) - xattr identity stamps (user.btc.identity, user.btc.hash) - Separated debug symbols with GNU debug links - Thermal + entropy sentinel during the forge process - Multi-architecture cross-compilation support (BTC-0.4.0+) Supported target families (BTC-0.4.0+): x86_64: haswell, haswell-ep, skylake, skylake-x, skylake-server, znver1, znver2, znver3, znver4, apu-zn1, apu-zn2, apu-zn3, apu-zn4, atom-silvermont, atom-goldmont, atom-tremont, atom-sierraforest mipsel: mipselr2 (MIPS32R2 LE, o32 ABI, musl) arm: armv7 (Cortex-A NEON hard-float, musl) tilegx: tilegx (Tilera TILE-Gx72, musl) This module allows Fester to: 1. Detect BTC golden images on nodes (via manifest JSON or filename) 2. Use BTC as a cross-compiler for the node's configured target 3. Verify BTC forensic stamps on build outputs 4. Pass BTC-aware build environments to executors 5. Track BTC SYS_LABEL for cross-node provenance """ import hashlib import json import logging import os import re import shutil import subprocess import tempfile from pathlib import Path from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # SYS_LABEL parsing # --------------------------------------------------------------------------- # BTC 0.3.x native format: DCOSNET-{MICROARCH}-{ISA}-{OPT} # Example: DCOSNET-HASWELL-AVX2-LTO # BTC 0.4.0 cross format: DCOSNET-{FAMILY}-{TARGET_ID}-{ISA}-CROSS # Example: DCOSNET-MIPS-MIPSELR2-MIPS32-CROSS # Unified regex: both formats have 3 or 4 uppercase segments separated by "-" _SYS_LABEL_RE = re.compile( r"^DCOSNET-([A-Z0-9]+)(?:-([A-Z0-9]+))?-([A-Z0-9]+)(?:-([A-Z0-9]+))?$" ) # Golden image filename pattern _GOLDEN_RE = re.compile(r"^(.+)-toolchain-golden\.tar\.xz$") # Manifest filename pattern: {SYS_LABEL}-manifest.json _MANIFEST_RE = re.compile(r"^(.+)-manifest\.json$") # .note.BTC stamp field parser _NOTE_FIELD_RE = re.compile(r"(\w+):\s*([^\|]+)") # Maps readelf note field keys (lowercase) to result dict keys. # Unified lookup avoids a long if/elif chain (PEP 868 / SEI CERT). _NOTE_KEY_MAP = { "org": "org", "k": "kernel", "kernel": "kernel", "arch": "arch", "label": "label", "forge": "forge", } # ISA flag table — maps ISA tag (uppercase) to compiler flags. # Table-driven lookup replaces if/elif chains (PEP 868). _ISA_FLAGS: Dict[str, str] = { "AVX2": " -mavx2", "AVX512": " -mavx512f -mavx512dq -mavx512vl -mavx512bw", "SSE4_2": " -msse4.2", "NEON": " -mfpu=neon -mfloat-abi=hard", "MIPS32": "", "TILE": "", } # Known x86_64 microarchitecture march values — these can be passed # directly to -march=. Non-x86 targets use their target_march from # the manifest instead. _KNOWN_MARCH = { "haswell", "broadwell", "sandybridge", "ivybridge", "skylake", "skylake-avx512", "skylake-server", "znver1", "znver2", "znver3", "znver4", "haswell-ep", } def _parse_sys_label(label: str) -> Dict[str, str]: """Parse a SYS_LABEL into its component parts. BTC 0.3.x: DCOSNET-HASWELL-AVX2-LTO -> family=HASWELL, target_id=AVX2, isa=LTO, opt="" BTC 0.4.0 cross: DCOSNET-MIPS-MIPSELR2-MIPS32-CROSS -> family=MIPS, target_id=MIPSELR2, isa=MIPS32, opt=CROSS BTC 0.4.0 native: DCOSNET-HASWELL-AVX2-LTO (unchanged) -> family=HASWELL, target_id="", isa=AVX2, opt=LTO Returns a dict with keys: full, family, target_id, isa, opt, cross. """ m = _SYS_LABEL_RE.match(label) if not m: return {"full": label} groups = m.groups() result: Dict[str, str] = {"full": label} if len(groups) == 4 and groups[3] == "CROSS": # 4-segment: DCOSNET-FAMILY-TARGET_ID-ISA-CROSS result["family"] = groups[0].lower() result["target_id"] = groups[1].lower() result["isa"] = groups[2].lower() result["opt"] = "" result["cross"] = "1" elif groups[1] in ("AVX2", "AVX512", "SSE4_2", "NEON", "MIPS32", "TILE", "LTO"): # 3-segment native: DCOSNET-MICROARCH-ISA-OPT # The first group IS the microarch/target_id result["family"] = groups[0].lower() result["target_id"] = groups[0].lower() result["isa"] = groups[1].lower() result["opt"] = groups[2].lower() if groups[2] else "" result["cross"] = "0" else: # Fallback: treat first two as family + target_id result["family"] = groups[0].lower() result["target_id"] = groups[1].lower() if groups[1] else groups[0].lower() result["isa"] = groups[2].lower() if groups[2] else "" result["opt"] = groups[3].lower() if groups[3] else "" result["cross"] = "0" return result def _run(cmd: list, timeout: int = 30) -> Tuple[int, str]: """Run a subprocess command, returning (rc, stdout+stderr). Any exception is caught and returned as rc=-1. """ try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, ) return proc.returncode, proc.stdout + proc.stderr except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: logger.debug("btc: command failed: %s -- %s", cmd, exc) return -1, "" def _load_manifest(btc_root: str, sys_label: str) -> Optional[Dict[str, Any]]: """Try to load the BTC manifest JSON for a given SYS_LABEL. BTC 0.4.0+ writes a {SYS_LABEL}-manifest.json sidecar alongside the golden image. This contains structured metadata (target_id, arch, clib, triple, cflags, etc.) that is more reliable than parsing filenames. Returns the parsed JSON dict, or None if not found. """ manifest_path = Path(btc_root) / f"{sys_label}-manifest.json" if not manifest_path.is_file(): return None try: with open(manifest_path, "r") as f: return json.load(f) except (OSError, json.JSONDecodeError) as exc: logger.warning("btc: failed to load manifest %s: %s", manifest_path, exc) return None # --------------------------------------------------------------------------- # 1. probe_btc -- detect BTC on a node # --------------------------------------------------------------------------- def probe_btc( btc_root: str = "/opt/BTC", target_filter: Optional[str] = None, ) -> Dict[str, Any]: """Check whether BTC is available on this node. Looks for ``*-toolchain-golden.tar.xz`` inside *btc_root*, parses the SYS_LABEL from the filename, and checks for the BTC.sh script. If a *target_filter* is given, only matches a golden image whose SYS_LABEL contains the filter string (useful for selecting a specific cross-compiler among multiple installed targets). If a manifest JSON is found, its structured fields are merged into the result (target_id, arch, clib, triple, cross_mode, etc.). Returns a dict: available (bool) - True if a golden image is found sys_label (str) - parsed SYS_LABEL, or empty string golden_image (str) - absolute path of the golden tar.xz, or None btc_path (str) - absolute path of BTC.sh, or None arch (str) - base architecture (x86_64, arm, mipsel, tilegx) family (str) - architecture family from SYS_LABEL target_id (str) - BTC target identifier (haswell, znver3, armv7, etc.) isa (str) - ISA tag (avx2, avx512, neon, mips32, tile) opt (str) - optimisation tag (lto) cross (str) - "1" for cross-compile, "0" for native clib (str) - C library (glibc or musl), from manifest triple (str) - GCC target triple, from manifest target_march (str) - -march value, from manifest manifest (dict) - full manifest JSON contents, or None """ root = Path(btc_root) result: Dict[str, Any] = { "available": False, "sys_label": "", "golden_image": None, "btc_path": None, "arch": "", "family": "", "target_id": "", "isa": "", "opt": "", "cross": "0", "clib": "", "triple": "", "target_march": "", "manifest": None, } # Locate golden image(s) — if multiple exist, prefer one matching target_filter golden = None if root.is_dir(): for entry in sorted(root.iterdir()): m = _GOLDEN_RE.match(entry.name) if not m: continue label = entry.name[: -len("-toolchain-golden.tar.xz")] if target_filter: # Case-insensitive substring match against the full label if target_filter.lower() not in label.lower(): continue golden = entry break if golden is None: logger.debug("btc: no golden image found in %s", btc_root) return result result["golden_image"] = str(golden.resolve()) # Parse SYS_LABEL from filename prefix label = golden.name[: -len("-toolchain-golden.tar.xz")] parsed = _parse_sys_label(label) result["sys_label"] = parsed["full"] result["family"] = parsed.get("family", "") result["target_id"] = parsed.get("target_id", "") result["isa"] = parsed.get("isa", "") result["opt"] = parsed.get("opt", "") result["cross"] = parsed.get("cross", "0") # For backward compat: "arch" used to hold the SYS_LABEL arch segment result["arch"] = parsed.get("family", "") # Try to load the manifest for richer metadata manifest = _load_manifest(btc_root, parsed["full"]) if manifest: result["manifest"] = manifest result["clib"] = manifest.get("clib", "") result["triple"] = manifest.get("target_triple", "") result["target_march"] = manifest.get("target_march", "") result["target_id"] = manifest.get("target_id", result["target_id"]) result["arch"] = manifest.get("target_arch", result["arch"]) result["cross"] = str(manifest.get("cross_mode", result["cross"])) # Check for BTC.sh in common locations for candidate in [root / "BTC.sh", Path("/usr/local/bin/BTC.sh")]: if candidate.is_file(): result["btc_path"] = str(candidate.resolve()) break if result["btc_path"]: result["available"] = True elif result.get("golden_image"): # Golden image exists but BTC.sh is not installed — BTC toolchain # is usable for builds but cannot be re-forged. result["available"] = True logger.warning("btc: golden image found but BTC.sh is missing; " "toolchain is usable but re-forge is not available") else: logger.debug("btc: no golden image or BTC.sh found") return result # --------------------------------------------------------------------------- # 2. btc_build_env -- produce a BTC-aware environment dict # --------------------------------------------------------------------------- def _btc_sysroot(btc_root: str, sys_label: str) -> str: """Return the expected extracted sysroot path for a given SYS_LABEL. BTC.sh extracts the toolchain into the cleanroom during the forge, then packages it. After extraction, the sysroot lives at ``{btc_root}/{SYS_LABEL}-cleanroom/`` or wherever the operator extracted the golden image. """ # Try the cleanroom path first (where BTC.sh builds it), then # a simple extraction path. cleanroom = os.path.join("/usr/src", f"{sys_label}-cleanroom") if os.path.isdir(cleanroom): return cleanroom return os.path.join(btc_root, sys_label) def btc_build_env( project: Dict[str, Any], node_config: Dict[str, Any], btc_root: str = "/opt/BTC", target: Optional[str] = None, ) -> Dict[str, str]: """Return a BTC-aware build environment dictionary. If BTC is available on the node, CC/CXX point at the BTC cross-compiler inside the extracted sysroot and optimisation flags are derived from the SYS_LABEL or manifest metadata. If BTC is not available, falls back to standard ``ccache gcc`` / ``ccache g++``. For cross-compiled toolchains (BTC 0.4.0+), the compiler is named ``{triple}-gcc`` and ``{triple}-g++`` in the sysroot bin/ directory. The *target* parameter allows selecting a specific cross-compiler by target ID (e.g., "armv7") when multiple golden images exist. The returned env is intended to be merged *on top of* the ccache/distcc env produced by :func:`build_ccache_env`. """ probe = probe_btc(btc_root, target_filter=target) env: Dict[str, str] = {} if probe["available"]: sysroot = _btc_sysroot(btc_root, probe["sys_label"]) target_id = probe.get("target_id", "") or "native" triple = probe.get("triple", "") target_march = probe.get("target_march", "") or target_id # Determine the compiler binary names. # Cross-toolchains (BTC 0.4.0+) use {triple}-gcc naming. # Native toolchains use plain gcc/g++. bin_dir = os.path.join(sysroot, "bin") if os.path.isdir(bin_dir) and triple: gcc_path = os.path.join(bin_dir, f"{triple}-gcc") gxx_path = os.path.join(bin_dir, f"{triple}-g++") if not os.path.isfile(gcc_path): gcc_path = os.path.join(bin_dir, "gcc") if not os.path.isfile(gxx_path): gxx_path = os.path.join(bin_dir, "g++") elif os.path.isdir(bin_dir): gcc_path = os.path.join(bin_dir, "gcc") gxx_path = os.path.join(bin_dir, "g++") if not os.path.isfile(gcc_path): gcc_path = "gcc" if not os.path.isfile(gxx_path): gxx_path = "g++" else: gcc_path = "gcc" gxx_path = "g++" env["CC"] = gcc_path env["CXX"] = gxx_path # Select the correct march value. # For x86_64 microarchs, use the target_id or target_march directly. # For non-x86 targets, the manifest provides the correct -march value. march = target_march if march in _KNOWN_MARCH: pass # Already a valid -march= value elif probe.get("target_march"): march = probe["target_march"] else: march = target_id # ISA-specific flags (table-driven lookup, PEP 868) isa_upper = probe.get("isa", "").upper() isa_flags = _ISA_FLAGS.get(isa_upper, "") # LTO flags opt = probe.get("opt", "").lower() lto_flags = " -flto -ffat-lto-objects" if opt == "lto" else "" # sysroot flag sysroot_flag = f" --sysroot={sysroot}" if os.path.isdir(sysroot) else "" env["CFLAGS"] = f"-O3 -march={march}{isa_flags}{lto_flags}{sysroot_flag} -pipe" env["CXXFLAGS"] = env["CFLAGS"] env["LDFLAGS"] = f"-Wl,-O1 -Wl,--as-needed{lto_flags}{sysroot_flag}" env["BTC_MODE"] = "1" env["BTC_SYS_LABEL"] = probe["sys_label"] env["BTC_TARGET_ID"] = target_id env["BTC_CROSS"] = probe.get("cross", "0") # Propagate C library info so executors can make informed decisions # (e.g., linking against -lcrypt for glibc vs. built-in for musl). clib = probe.get("clib", "") if clib: env["BTC_CLIB"] = clib # For cross-compiles, also set the target triple so executors # can use it for configure --host= and similar. if triple and probe.get("cross") == "1": env["BTC_TARGET_TRIPLE"] = triple else: # Fallback: standard ccache gcc env["CC"] = "ccache gcc" env["CXX"] = "ccache g++" env["BTC_MODE"] = "0" return env # --------------------------------------------------------------------------- # 3. verify_btc_stamp -- read forensic stamps from a binary # --------------------------------------------------------------------------- def verify_btc_stamp(binary_path: str) -> Dict[str, Any]: """Read BTC forensic stamps from a compiled binary. Uses ``readelf -n`` to extract the ``.note.BTC`` section and ``getfattr`` to read extended attributes. Computes a SHA-256 of the file and checks it against the ``user.btc.hash`` xattr. Returns a dict with: has_note (bool) - .note.BTC section was found has_xattr (bool) - xattr stamps were found org (str) - organisation field, or empty kernel (str) - kernel version field, or empty arch (str) - architecture field, or empty label (str) - SYS_LABEL field, or empty forge (str) - forge step field, or empty identity (str) - user.btc.identity xattr value, or empty hash (str) - user.btc.hash xattr value, or empty valid (bool) - True if the computed SHA-256 matches the stamp """ result: Dict[str, Any] = { "has_note": False, "has_xattr": False, "org": "", "kernel": "", "arch": "", "label": "", "forge": "", "identity": "", "hash": "", "valid": False, } if not os.path.isfile(binary_path): logger.debug("btc: verify: binary not found: %s", binary_path) return result # -- readelf -n: look for .note.BTC -- rc, out = _run(["readelf", "-n", binary_path]) if rc == 0: # readelf may emit multiple note sections; find the one that # contains BTC-specific fields. for line in out.splitlines(): for m in _NOTE_FIELD_RE.finditer(line): key, val = m.group(1).lower(), m.group(2).strip() field = _NOTE_KEY_MAP.get(key) if field is not None: result[field] = val result["has_note"] = True # -- getfattr: read xattr stamps -- rc, out = _run(["getfattr", "-n", "user.btc.identity", binary_path]) if rc == 0: # Output looks like: # file: /path/binary # user.btc.identity="..." for line in out.splitlines(): if line.strip().startswith("user.btc.identity="): result["identity"] = line.split("=", 1)[1].strip('"') result["has_xattr"] = True rc, out = _run(["getfattr", "-n", "user.btc.hash", binary_path]) if rc == 0: for line in out.splitlines(): if line.strip().startswith("user.btc.hash="): result["hash"] = line.split("=", 1)[1].strip('"') result["has_xattr"] = True # -- SHA-256 integrity check -- if result["hash"]: sha256 = hashlib.sha256() try: with open(binary_path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): sha256.update(chunk) computed = sha256.hexdigest() # Strip optional "sha256:" prefix for cross-project compatibility # (sorcery-go writes bare hex, but future consumers may prefix). stored_hash = result["hash"] if stored_hash.startswith("sha256:"): stored_hash = stored_hash[7:] result["valid"] = computed == stored_hash if not result["valid"]: logger.warning( "btc: hash mismatch for %s: computed=%s stamp=%s", binary_path, computed, result["hash"], ) except OSError as exc: logger.warning("btc: could not hash %s: %s", binary_path, exc) return result # --------------------------------------------------------------------------- # 4. apply_btc_stamp -- inject forensic stamps into a binary # --------------------------------------------------------------------------- def apply_btc_stamp( binary_path: str, forge_step: str, sys_label: str, kernel_ver: str = "7.1", arch: str = "haswell", ) -> Dict[str, Any]: """Apply BTC forensic stamps to a compiled binary. This creates a ``.note.BTC`` ELF note section via a temporary assembly stub, sets extended attributes, and optionally separates debug symbols (when ``BTC_STRIP_MODE=1``). Errors are logged as warnings but never cause the build to fail -- stamping is a best-effort post-build enhancement. Returns a dict: success (bool) - True if the note section was injected stripped (bool) - True if debug symbols were separated error (str) - error message, or empty on success """ result: Dict[str, Any] = { "success": False, "stripped": False, "error": "", } if not os.path.isfile(binary_path): result["error"] = f"binary not found: {binary_path}" logger.warning("btc: stamp: %s", result["error"]) return result # Build the note payload string stamp = ( f"Org: dcos.net|K:{kernel_ver}|" f"Arch:{arch}|Label:{sys_label}|Forge:{forge_step}" ) note_obj = None try: # -- Create a temporary .note.BTC object file via inline asm -- note_obj = _assemble_btc_note(stamp) if note_obj is None: result["error"] = "failed to assemble .note.BTC stub" logger.warning("btc: stamp: %s", result["error"]) return result # Inject the note section into the binary tmp_binary = binary_path + ".btc.tmp" rc, _ = _run([ "objcopy", "--add-section", ".note.BTC=" + note_obj, "--set-section-flags", ".note.BTC=alloc,readonly", binary_path, tmp_binary, ]) if rc != 0: result["error"] = "objcopy --add-section failed" logger.warning("btc: stamp: %s", result["error"]) return result # Replace the original binary shutil.move(tmp_binary, binary_path) os.chmod(binary_path, os.stat(binary_path).st_mode | 0o111) result["success"] = True except OSError as exc: result["error"] = str(exc) logger.warning("btc: stamp: %s", result["error"]) return result finally: if note_obj and os.path.isfile(note_obj): try: os.unlink(note_obj) except OSError: pass # -- Set extended attributes -- _set_btc_xattr(binary_path, sys_label, stamp) # -- Optional debug symbol separation -- if os.environ.get("BTC_STRIP_MODE", "0") == "1": result["stripped"] = _separate_debug_symbols(binary_path) return result def _assemble_btc_note(stamp: str) -> Optional[str]: """Assemble a small .note.BTC ELF note object and return its path. The note is built as a temporary .s file compiled with ``gcc -c``. Returns the path to the .o file, or None on failure. """ note_name = "BTC" # n_namesz includes the null terminator; n_descsz is len(stamp)+1 note_asm = ( '.section .note.BTC, "a", @note\n' '.balign 4\n' '.long 4 /* namesz */\n' f'.long {len(stamp) + 1} /* descsz */\n' '.long 1 /* type (NT_VERSION) */\n' '.asciz "BTC" /* name */\n' f'.asciz "{stamp}" /* desc */\n' '.balign 4\n' ) tmp_s = None tmp_o = None try: fd_s, tmp_s = tempfile.mkstemp(suffix=".s") os.write(fd_s, note_asm.encode()) os.close(fd_s) tmp_o = tmp_s.replace(".s", ".o") rc, _ = _run(["gcc", "-c", tmp_s, "-o", tmp_o]) if rc == 0 and os.path.isfile(tmp_o): return tmp_o return None except OSError: return None finally: if tmp_s and os.path.isfile(tmp_s): try: os.unlink(tmp_s) except OSError: pass def _set_btc_xattr(binary_path: str, sys_label: str, stamp: str) -> None: """Set BTC extended attributes on a binary. Sets user.btc.identity to the SYS_LABEL and user.btc.hash to the SHA-256 hex digest of the file contents. """ try: # Compute SHA-256 of the file *before* setting xattrs (so the # hash does not depend on the xattr data itself). sha256 = hashlib.sha256() with open(binary_path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): sha256.update(chunk) file_hash = sha256.hexdigest() _run(["setfattr", "-n", "user.btc.identity", "-v", sys_label, binary_path]) _run(["setfattr", "-n", "user.btc.hash", "-v", file_hash, binary_path]) except OSError as exc: logger.warning("btc: failed to set xattr on %s: %s", binary_path, exc) def _separate_debug_symbols(binary_path: str) -> bool: """Separate debug symbols from *binary_path*. Uses the standard objcopy/strip/add-gnu-debuglink sequence: 1. objcopy --only-keep-debug -> {path}.debug 2. strip --strip-unneeded on the original 3. objcopy --add-gnu-debuglink={path}.debug Returns True on success, False on any failure. """ debug_path = binary_path + ".debug" try: rc, _ = _run([ "objcopy", "--only-keep-debug", binary_path, debug_path, ]) if rc != 0: return False rc, _ = _run(["strip", "--strip-unneeded", binary_path]) if rc != 0: return False rc, _ = _run([ "objcopy", "--add-gnu-debuglink=" + debug_path, binary_path, ]) if rc != 0: return False return True except OSError: return False # --------------------------------------------------------------------------- # 5. list_btc_targets -- enumerate available golden images # --------------------------------------------------------------------------- def list_btc_targets(btc_root: str = "/opt/BTC") -> List[Dict[str, Any]]: """List all BTC golden images and their target metadata. Scans *btc_root* for ``*-toolchain-golden.tar.xz`` files and ``*-manifest.json`` sidecars. Returns a list of dicts, one per golden image, with parsed SYS_LABEL components and manifest data. This is useful for the Fester API to show operators which cross-compilers are available on each node. """ root = Path(btc_root) targets: List[Dict[str, Any]] = [] if not root.is_dir(): return targets for entry in sorted(root.iterdir()): m = _GOLDEN_RE.match(entry.name) if not m: continue label = entry.name[: -len("-toolchain-golden.tar.xz")] parsed = _parse_sys_label(label) entry_info: Dict[str, Any] = { "golden_image": str(entry.resolve()), "sys_label": parsed["full"], "family": parsed.get("family", ""), "target_id": parsed.get("target_id", ""), "isa": parsed.get("isa", ""), "opt": parsed.get("opt", ""), "cross": parsed.get("cross", "0"), } # Merge manifest if available manifest = _load_manifest(btc_root, parsed["full"]) if manifest: entry_info["manifest"] = manifest entry_info["clib"] = manifest.get("clib", "") entry_info["triple"] = manifest.get("target_triple", "") entry_info["description"] = manifest.get("description", "") targets.append(entry_info) return targets # --------------------------------------------------------------------------- # 6. merge_compiler_env -- main entry point for the executor # --------------------------------------------------------------------------- def merge_compiler_env( project: Dict[str, Any], node_config: Optional[Dict[str, Any]] = None, btc_root: str = "/opt/BTC", ) -> Dict[str, str]: """Build a merged compiler environment combining ccache/distcc + BTC. 1. Calls :func:`build_ccache_env` for the standard environment. 2. Probes for BTC on this node. 3. If BTC is available, overlays BTC-specific CC/CXX/CFLAGS/CXXFLAGS/ LDFLAGS on top of the ccache env (BTC takes precedence). 4. Adds a ``toolchain_source`` key: ``"btc"``, ``"btc-fallback-gcc"``, or ``"gcc"``. If the node_config contains a ``btc.target`` key, it is used to select a specific cross-compiler golden image when multiple are installed. Returns the fully merged environment dict. """ from backend.compiler_env import build_ccache_env node_config = node_config or {} env = build_ccache_env(project) # Allow per-node target selection via node config target = node_config.get("btc", {}).get("target") if isinstance(node_config.get("btc"), dict) else None probe = probe_btc(btc_root, target_filter=target) if not probe["available"]: env["toolchain_source"] = "gcc" return env btc_env = btc_build_env(project, node_config, btc_root, target=target) if btc_env.get("BTC_MODE") == "1": # BTC takes precedence for compiler and flags for key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "BTC_MODE", "BTC_SYS_LABEL", "BTC_TARGET_ID", "BTC_CROSS", "BTC_CLIB", "BTC_TARGET_TRIPLE"): if key in btc_env: env[key] = btc_env[key] env["toolchain_source"] = "btc" else: env["toolchain_source"] = "btc-fallback-gcc" return env