diff options
Diffstat (limited to 'src/sailfish_devel_mcp/vendor/build_sailfishos.py')
| -rwxr-xr-x | src/sailfish_devel_mcp/vendor/build_sailfishos.py | 1459 |
1 files changed, 1459 insertions, 0 deletions
diff --git a/src/sailfish_devel_mcp/vendor/build_sailfishos.py b/src/sailfish_devel_mcp/vendor/build_sailfishos.py new file mode 100755 index 0000000..98b52c0 --- /dev/null +++ b/src/sailfish_devel_mcp/vendor/build_sailfishos.py @@ -0,0 +1,1459 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import urlopen + + +CONTAINER_UID = 100000 +CONTAINER_IMAGE = "coderus/sailfishos-platform-sdk" +LIVE_RELEASE = "live" +DEFAULT_LOCAL_SDK = Path("/srv/mer/sdks/sfossdk/sdk-chroot") +LOCAL_SDK_BUILD_ENGINE_IMAGE_ENV = "SAILFISH_SDK_BUILD_ENGINE_IMAGE" +STATE_DIRNAME = "build-sailfishos-skill" +MANIFEST_NAME = "build-sailfishos-skill-manifest.txt" +BUILD_LOG_NAME = "build-sailfishos-skill-last.log" +BUILD_METADATA_NAME = "build-sailfishos-skill-last-build.json" +DEFAULT_PERMISSION_FALLBACK = "error" + +ROOT_PATTERNS = ( + "Makefile", + ".qmake.stash", + "*.o", + "*.a", + "*.so", + "*.prl", + "moc_*.cpp", + "moc_*.o", + "qrc_*.cpp", + "qrc_*.o", + "ui_*.h", + "CMakeCache.txt", + "cmake_install.cmake", + "compile_commands.json", + "build.ninja", + "rules.ninja", + "install_manifest.txt", +) + +RECURSIVE_PATTERNS = ( + "**/Makefile", + "**/.qmake.stash", + "**/moc_*.cpp", + "**/moc_*.o", + "**/qrc_*.cpp", + "**/qrc_*.o", + "**/*.o", + "**/*.a", + "**/*.so", + "**/*.prl", + "**/ui_*.h", + "**/CMakeCache.txt", + "**/cmake_install.cmake", + "**/compile_commands.json", + "**/build.ninja", + "**/rules.ninja", + "**/install_manifest.txt", + "**/CMakeFiles", +) + +ROOT_DIRS = ( + "installroot", +) + +LOCAL_TARGET_ARCHES = ("aarch64", "armv7hl", "i486") + + +@dataclass(frozen=True) +class LocalSdkTarget: + arch: str + target: str + release: str + version_id: str + flavour: str + + +@dataclass(frozen=True) +class LocalSdkBuild: + arch: str + target: str + + +def log(message: str) -> None: + print(message, file=sys.stderr) + + +def run(cmd: list[str], cwd: Path | None = None, capture_output: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + check=True, + text=True, + capture_output=capture_output, + ) + + +def require_tool(name: str) -> None: + if shutil.which(name): + return + raise SystemExit(f"Required tool not found: {name}") + + +def project_state_dir(project_dir: Path) -> Path: + return project_dir / ".mb2" / STATE_DIRNAME + + +def manifest_path(project_dir: Path) -> Path: + return project_dir / ".mb2" / MANIFEST_NAME + + +def build_log_path(project_dir: Path) -> Path: + return project_dir / ".mb2" / BUILD_LOG_NAME + + +def build_metadata_path(project_dir: Path) -> Path: + return project_dir / ".mb2" / BUILD_METADATA_NAME + + +def default_artifacts_dir(project_dir: Path) -> Path: + return project_dir / "RPMS" + + +def staging_rpms_dir(project_dir: Path) -> Path: + return project_state_dir(project_dir) / "rpms" + + +def has_spec_files(project_dir: Path) -> bool: + rpm_dir = project_dir / "rpm" + return rpm_dir.is_dir() and any(rpm_dir.glob("*.spec")) + + +def parse_version(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".")) + + +def is_version_release_tag(value: str) -> bool: + return bool(re.fullmatch(r"\d+(?:\.\d+){3}", value)) + + +def fetch_release_tags(prefix: str | None = None) -> list[str]: + name_filter = quote(prefix) if prefix else "" + + matches: list[str] = [] + next_url = ( + "https://registry.hub.docker.com/v2/repositories/" + f"{CONTAINER_IMAGE}/tags?page_size=100" + f"{f'&name={name_filter}' if name_filter else ''}" + ) + while next_url: + with urlopen(next_url, timeout=10) as response: + payload = json.load(response) + for result in payload.get("results", []): + name = result.get("name", "").strip() + if prefix: + if not (name == prefix or name.startswith(f"{prefix}.")): + continue + if is_version_release_tag(name): + matches.append(name) + next_url = payload.get("next") + return sorted(dict.fromkeys(matches), key=parse_version) + + +def latest_release_tag() -> str: + matches = fetch_release_tags() + if not matches: + raise SystemExit( + f"Could not determine the latest SailfishOS release from {CONTAINER_IMAGE} tags." + ) + return matches[-1] + + +def normalize_release_tag(release: str) -> str: + if release.lower() == LIVE_RELEASE: + return LIVE_RELEASE + + if release == "latest": + try: + resolved = latest_release_tag() + except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit("Could not resolve SailfishOS release tag 'latest'") from exc + log(f"Resolved SailfishOS release latest to {resolved}") + return resolved + + if not re.fullmatch(r"\d+(?:\.\d+){2,3}", release): + return release + if release.count(".") >= 3: + return release + + try: + matches = fetch_release_tags(release) + except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError): + return release + + if not matches: + return release + if release in matches: + return release + + resolved = max(matches, key=parse_version) + log(f"Resolved SailfishOS release {release} to {resolved}") + return resolved + + +def infer_release_from_workflows(project_dir: Path) -> str | None: + workflows_dir = project_dir / ".github" / "workflows" + if not workflows_dir.is_dir(): + return None + + regexes = ( + re.compile(r"^\s*RELEASE:\s*([^\s#]+)\s*$"), + re.compile(rf"{re.escape(CONTAINER_IMAGE)}:([^\s'\"#]+)"), + ) + + for workflow in sorted(workflows_dir.glob("*.y*ml")): + try: + text = workflow.read_text(encoding="utf-8") + except OSError: + continue + for line in text.splitlines(): + for regex in regexes: + match = regex.search(line) + if match: + return match.group(1).strip() + return None + + +def resolve_release(project_dirs: Iterable[Path], explicit_release: str | None) -> str: + if explicit_release: + return normalize_release_tag(explicit_release) + + env_release = os.environ.get("SAILFISHOS_RELEASE") + if env_release: + return normalize_release_tag(env_release) + + seen: set[Path] = set() + for project_dir in project_dirs: + if project_dir in seen: + continue + seen.add(project_dir) + inferred = infer_release_from_workflows(project_dir) + if inferred: + return normalize_release_tag(inferred) + + try: + resolved = latest_release_tag() + except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit( + "Could not determine SailfishOS release from arguments, environment, workflows, or Docker tags." + ) from exc + + log(f"No SailfishOS release specified; using latest available release {resolved}") + return resolved + + +def parse_last_arch(project_dir: Path) -> str | None: + target_file = project_dir / ".mb2" / "target" + if not target_file.is_file(): + return None + + target = target_file.read_text(encoding="utf-8").strip() + if not target: + return None + + if target.startswith("SailfishOS-"): + arch = target.rsplit("-", 1)[-1] + if arch.endswith(".default"): + arch = arch[: -len(".default")] + return arch + + prefix = target.split(".", 1)[0].strip() + return prefix or None + + +def pull_image(release: str) -> None: + image = f"{CONTAINER_IMAGE}:{release}" + log(f"Pulling {image}") + run(["docker", "pull", image]) + + +def list_supported_arches(release: str) -> list[str]: + image = f"{CONTAINER_IMAGE}:{release}" + result = run( + [ + "docker", + "run", + "--rm", + image, + "bash", + "-lc", + "sb2-config -l", + ], + capture_output=True, + ) + + arches: list[str] = [] + seen: set[str] = set() + prefix = f"SailfishOS-{release}-" + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith(prefix): + arch = line[len(prefix) :] + if arch.endswith(".default") or arch in seen: + continue + arches.append(arch) + seen.add(arch) + if not arches: + raise SystemExit(f"No supported architectures found in {image}") + return arches + + +def resolve_arches(requested_arches: list[str], build_all: bool, supported_arches: list[str], project_dir: Path) -> list[str]: + if build_all: + return supported_arches + + if requested_arches: + invalid = [arch for arch in requested_arches if arch not in supported_arches] + if invalid: + raise SystemExit( + f"Unsupported architectures: {', '.join(invalid)}. Supported: {', '.join(supported_arches)}" + ) + return requested_arches + + last_arch = parse_last_arch(project_dir) + if last_arch and last_arch in supported_arches: + return [last_arch] + + raise SystemExit( + "No architecture was specified and .mb2/target did not contain a supported one. " + f"Pass --arch or --all. Supported: {', '.join(supported_arches)}" + ) + + +def resolve_local_sdk_arches(requested_arches: list[str], build_all: bool, project_dir: Path) -> list[str]: + if build_all: + raise SystemExit("Local SDK builds use installed SDK targets; pass one or more explicit --arch values.") + + if requested_arches: + return requested_arches + + last_arch = parse_last_arch(project_dir) + if last_arch: + return [last_arch] + + raise SystemExit( + "No architecture was specified and .mb2/target did not contain a previous target. " + "Pass --arch for local SDK builds." + ) + + +def spec_names(project_dir: Path) -> set[str]: + names: set[str] = set() + for spec in sorted((project_dir / "rpm").glob("*.spec")): + try: + text = spec.read_text(encoding="utf-8") + except OSError: + continue + for line in text.splitlines(): + match = re.match(r"^\s*Name:\s*(\S+)\s*$", line) + if match: + names.add(match.group(1)) + break + return names + + +def pro_targets(project_dir: Path) -> set[str]: + targets: set[str] = set() + for pro in sorted(project_dir.glob("*.pro")): + try: + text = pro.read_text(encoding="utf-8") + except OSError: + continue + for line in text.splitlines(): + match = re.match(r"^\s*TARGET\s*=\s*([^\s#]+)\s*$", line) + if match and "$" not in match.group(1): + targets.add(match.group(1)) + break + return targets + + +def generated_candidate_paths(project_dir: Path) -> set[Path]: + tracked_paths = tracked_git_paths(project_dir) + paths: set[Path] = set() + + for dirname in ROOT_DIRS: + path = project_dir / dirname + if path.exists(): + paths.add(path) + + for pattern in ROOT_PATTERNS: + paths.update(path for path in project_dir.glob(pattern) if path.exists()) + + for pattern in RECURSIVE_PATTERNS: + paths.update( + path + for path in project_dir.glob(pattern) + if path.exists() and ".git" not in path.parts and ".mb2" not in path.parts + ) + + for qm in (project_dir / "translations").glob("*.qm") if (project_dir / "translations").is_dir() else []: + if qm.exists(): + paths.add(qm) + + for name in sorted(spec_names(project_dir) | pro_targets(project_dir)): + candidate = project_dir / name + if candidate.exists(): + paths.add(candidate) + + state_dir = project_state_dir(project_dir) + if state_dir.exists(): + paths.discard(state_dir) + + return { + path + for path in paths + if path.exists() and path.resolve() not in tracked_paths + } + + +def tracked_git_paths(project_dir: Path) -> set[Path]: + try: + repo_roots = git_worktree_roots(project_dir) + except OSError: + return set() + + tracked: set[Path] = set() + for root in repo_roots: + try: + result = run( + ["git", "-C", str(root), "ls-files", "-z"], + capture_output=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + continue + + for rel_path in result.stdout.split("\0"): + if not rel_path: + continue + tracked.add((root / rel_path).resolve()) + return tracked + + +def git_worktree_roots(project_dir: Path) -> list[Path]: + roots = {project_dir.resolve()} + for git_marker in project_dir.rglob(".git"): + if ".mb2" in git_marker.parts: + continue + repo_root = git_marker.parent.resolve() + roots.add(repo_root) + return sorted(roots) + + +def load_manifest(project_dir: Path) -> list[Path]: + path = manifest_path(project_dir) + if not path.is_file(): + return [] + + result: list[Path] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + candidate = (project_dir / line).resolve() + try: + candidate.relative_to(project_dir.resolve()) + except ValueError: + continue + if candidate.exists(): + result.append(candidate) + return result + + +def write_manifest(project_dir: Path, paths: Iterable[Path]) -> None: + manifest = manifest_path(project_dir) + manifest.parent.mkdir(parents=True, exist_ok=True) + + rel_paths = [] + root = project_dir.resolve() + for path in sorted({p.resolve() for p in paths if p.exists()}): + try: + rel_paths.append(str(path.relative_to(root))) + except ValueError: + continue + + manifest.write_text("\n".join(rel_paths) + ("\n" if rel_paths else ""), encoding="utf-8") + + +def write_build_metadata( + project_dir: Path, + *, + release: str, + arch: str, + debug_build: bool, + artifacts_dir: Path, + status: str, + rpms: Iterable[Path], +) -> None: + metadata_file = build_metadata_path(project_dir) + metadata_file.parent.mkdir(parents=True, exist_ok=True) + payload = { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "release": release, + "arch": arch, + "debug": debug_build, + "status": status, + "artifacts_dir": str(artifacts_dir), + "build_log": str(build_log_path(project_dir)), + "rpms": [str(path) for path in rpms], + } + metadata_file.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_target_marker(project_dir: Path, arch: str) -> None: + mb2_dir = project_dir / ".mb2" + mb2_dir.mkdir(parents=True, exist_ok=True) + (mb2_dir / "target").write_text(f"{arch}.{STATE_DIRNAME}\n", encoding="utf-8") + + +def remove_path(path: Path) -> None: + if not path.exists(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def cleanup_generated_artifacts(project_dir: Path, reason: str) -> list[Path]: + manifest_paths = load_manifest(project_dir) + cleanup_paths = set(manifest_paths) | generated_candidate_paths(project_dir) + + removed: list[Path] = [] + for path in sorted(cleanup_paths): + if project_state_dir(project_dir) in path.parents or path == project_state_dir(project_dir): + continue + if path == manifest_path(project_dir): + continue + if path.exists(): + remove_path(path) + removed.append(path) + + log(f"{reason}; removed {len(removed)} stale in-place artifacts") + return removed + + +def cleanup_in_place_artifacts(project_dir: Path, previous_arch: str, next_arch: str) -> list[Path]: + return cleanup_generated_artifacts( + project_dir, + f"Switched architecture from {previous_arch} to {next_arch}", + ) + + +def ensure_container_write_access(project_dir: Path, permission_fallback: str) -> None: + current_uid = os.getuid() + if shutil.which("setfacl"): + log( + f"Granting write ACLs to host uid {current_uid} and container uid {CONTAINER_UID} under {project_dir}" + ) + run( + [ + "find", + str(project_dir), + "(", + "-type", + "f", + "-o", + "-type", + "d", + ")", + "-uid", + str(current_uid), + "-exec", + "setfacl", + "-m", + f"u:{current_uid}:rwX,u:{CONTAINER_UID}:rwX", + "{}", + "+", + ] + ) + run( + [ + "find", + str(project_dir), + "-type", + "d", + "-uid", + str(current_uid), + "-exec", + "setfacl", + "-m", + f"d:u:{current_uid}:rwX,d:u:{CONTAINER_UID}:rwX", + "{}", + "+", + ] + ) + return + + if permission_fallback == "chmod": + log("setfacl unavailable; falling back to chmod -R a+rwX") + run(["chmod", "-R", "a+rwX", str(project_dir)]) + return + + raise SystemExit( + "setfacl is unavailable, so the Docker container may not be able to write in place. " + "Install acl utilities or rerun with --permission-fallback chmod." + ) + + +def parse_missing_build_requires(log_text: str) -> list[str]: + missing: list[str] = [] + capture = False + for line in log_text.splitlines(): + if line.strip() == "error: Failed build dependencies:": + capture = True + continue + if not capture: + continue + if line.startswith("\t") or line.startswith(" "): + requirement = line.strip() + if " is needed by " in requirement: + requirement = requirement.split(" is needed by ", 1)[0].strip() + if requirement: + missing.append(requirement) + continue + if missing and line.strip(): + break + return sorted(dict.fromkeys(missing)) + + +def extract_zypper_names(output: str) -> list[str]: + names: list[str] = [] + for line in output.splitlines(): + if "|" not in line or line.lstrip().startswith("--+"): + continue + parts = [part.strip() for part in line.split("|")] + if len(parts) < 3: + continue + name = parts[1] + if name and name not in {"Name", "S"}: + names.append(name) + return sorted(dict.fromkeys(names)) + + +def diagnose_missing_dependencies(project_dir: Path, release: str, arch: str) -> None: + log_file = build_log_path(project_dir) + if not log_file.is_file(): + return + + missing = parse_missing_build_requires(log_file.read_text(encoding="utf-8")) + if not missing: + return + + image = f"{CONTAINER_IMAGE}:{release}" + target = f"SailfishOS-{release}-{arch}" + log("Dependency diagnostics from the target SDK:") + for requirement in missing: + if requirement.startswith("pkgconfig("): + query = ( + f"sb2 -t {shlex.quote(target)} -m sdk-install -R " + f"zypper search --provides --match-exact {shlex.quote(requirement)}" + ) + else: + query = ( + f"sb2 -t {shlex.quote(target)} -m sdk-install -R " + f"zypper se -s {shlex.quote(requirement)}" + ) + + try: + result = run( + ["docker", "run", "--rm", image, "bash", "-lc", query], + capture_output=True, + ) + except subprocess.CalledProcessError: + log(f"- {requirement}: diagnostic lookup failed") + continue + + names = extract_zypper_names(result.stdout) + if names: + log(f"- {requirement}: available as {', '.join(names)}") + else: + log(f"- {requirement}: not available in {target}") + + +def verify_expected_rpms(rpms: list[Path], debug_build: bool) -> None: + if not rpms: + raise SystemExit("Build completed but no RPMs were captured") + if debug_build: + names = {rpm.name for rpm in rpms} + if not any("-debuginfo-" in name for name in names): + raise SystemExit("Debug build completed but no -debuginfo RPM was produced") + if not any("-debugsource-" in name for name in names): + raise SystemExit("Debug build completed but no -debugsource RPM was produced") + + +def variant_destination_dir(base_dir: Path, release: str, arch: str, debug_build: bool) -> Path: + return base_dir / release / arch / ("debug" if debug_build else "release") + + +def host_user() -> str: + return os.environ.get("USER") or os.environ.get("LOGNAME") or Path.home().name + + +def local_sdk_build_engine_image(user: str) -> str: + return os.environ.get(LOCAL_SDK_BUILD_ENGINE_IMAGE_ENV, f"sailfish-sdk-build-engine:{user}") + + +def local_sdk_project_mount_root(project_dir: Path) -> Path: + home = Path.home().resolve() + resolved = project_dir.resolve() + try: + resolved.relative_to(home) + except ValueError as exc: + raise SystemExit( + "Local SDK builds currently require the project to live under the current user's home " + "directory so the installed SDK chroot can see the same path." + ) from exc + return home + + +def local_sdk_mount_root(local_sdk: Path) -> Path: + resolved = local_sdk.resolve(strict=False) + srv_mer = Path("/srv/mer") + try: + resolved.relative_to(srv_mer) + except ValueError: + return resolved.parent + return srv_mer + + +def local_sdk_targets_dir(local_sdk: Path) -> Path: + return local_sdk_mount_root(local_sdk) / "targets" + + +def canonical_local_target_name(name: str) -> str: + while name.endswith(".default"): + name = name[: -len(".default")] + return name + + +def split_local_target_arch(target: str) -> tuple[str, str] | None: + for arch in LOCAL_TARGET_ARCHES: + if target == arch: + return arch, "" + if target.startswith(f"{arch}-"): + return arch, target[len(arch) + 1 :] + return None + + +def read_key_value_file(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + + metadata: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("[") or line.startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + metadata[key.strip()] = value.strip().strip('"') + return metadata + + +def target_metadata(target_dir: Path) -> dict[str, str]: + sailfish = read_key_value_file(target_dir / "etc" / "sailfish-release") + ssu = read_key_value_file(target_dir / "etc" / "ssu" / "ssu.ini") + return { + "release": ssu.get("release", ""), + "version_id": sailfish.get("VERSION_ID", ""), + "flavour": ssu.get("flavour") or sailfish.get("SAILFISH_FLAVOUR", ""), + } + + +def list_local_sdk_targets(local_sdk: Path) -> list[LocalSdkTarget]: + targets_dir = local_sdk_targets_dir(local_sdk) + if not targets_dir.is_dir(): + return [] + + targets: dict[str, LocalSdkTarget] = {} + for child in sorted(targets_dir.iterdir()): + if not child.is_dir() or ".pool." in child.name: + continue + + target = canonical_local_target_name(child.name) + arch_and_suffix = split_local_target_arch(target) + if arch_and_suffix is None: + continue + arch, suffix = arch_and_suffix + + if target in targets and child.name != target: + continue + + metadata = target_metadata(child) + release = metadata.get("release", "") + version_id = metadata.get("version_id", "") + flavour = metadata.get("flavour", "") + if not release and suffix: + release = suffix + targets[target] = LocalSdkTarget( + arch=arch, + target=target, + release=release, + version_id=version_id, + flavour=flavour, + ) + return sorted(targets.values(), key=lambda item: (item.arch, item.target)) + + +def release_component_count(release: str) -> int: + return len(release.split(".")) if re.fullmatch(r"\d+(?:\.\d+){2,3}", release) else 0 + + +def local_target_matches_release(target: LocalSdkTarget, release: str) -> bool: + release = normalize_local_release(release) + if not release or release == LIVE_RELEASE: + return target.release == LIVE_RELEASE + if release == "latest": + return False + + if target.release == release or target.version_id == release: + return True + + component_count = release_component_count(release) + if component_count == 3: + return target.version_id.startswith(f"{release}.") or target.target.endswith(f"-{release}") + + return target.target.endswith(f"-{release}") + + +def normalize_local_release(release: str | None) -> str: + if not release: + return "" + release = release.strip() + if release.lower() == LIVE_RELEASE: + return LIVE_RELEASE + return release + + +def requested_release(project_dirs: Iterable[Path], explicit_release: str | None) -> str | None: + if explicit_release: + return explicit_release + + env_release = os.environ.get("SAILFISHOS_RELEASE") + if env_release: + return env_release + + seen: set[Path] = set() + for project_dir in project_dirs: + if project_dir in seen: + continue + seen.add(project_dir) + inferred = infer_release_from_workflows(project_dir) + if inferred: + return inferred + + return None + + +def select_local_sdk_builds( + local_sdk: Path, + release: str, + requested_arches: list[str], + build_all: bool, + project_dir: Path, +) -> list[LocalSdkBuild] | None: + matching = [ + target + for target in list_local_sdk_targets(local_sdk) + if local_target_matches_release(target, release) + ] + if not matching: + return None + + by_arch = {target.arch: target for target in matching} + by_target = {target.target: target for target in matching} + + if build_all: + return [LocalSdkBuild(target.arch, target.target) for target in matching] + + requested = requested_arches[:] + if not requested: + last_arch = parse_last_arch(project_dir) + if last_arch: + requested = [last_arch] + + if not requested: + return None + + builds: list[LocalSdkBuild] = [] + for arch in requested: + target = by_target.get(arch) or by_arch.get(arch) + if target is None: + return None + builds.append(LocalSdkBuild(target.arch, target.target)) + return builds + + +def build_local_sdk_arch( + project_dir: Path, + local_sdk: Path, + release: str, + arch: str, + target: str, + debug_build: bool = False, + local_rpm_dirs: list[Path] | None = None, +) -> None: + user = host_user() + uid = os.getuid() + gid = os.getgid() + home = str(Path.home().resolve()) + image = local_sdk_build_engine_image(user) + project_mount_root = local_sdk_project_mount_root(project_dir) + sdk_mount_root = local_sdk_mount_root(local_sdk) + binary_names = ":".join(sorted(spec_names(project_dir) | pro_targets(project_dir))) + local_rpm_dirs = local_rpm_dirs or [] + + inner_command = r''' +set -euo pipefail +cd "$PROJECT_DIR" +mkdir -p .mb2 +mkdir -p .mb2/build-sailfishos-skill +logfile="$BUILD_LOG" +: > "$logfile" +{ + echo "# build-sailfishos-skill" + echo "release=${RELEASE:-}" + echo "arch=${ARCH:-}" + echo "debug=${DEBUG_BUILD:-0}" + echo "target=${TARGET:-}" + echo +} >> "$logfile" + +if [ -n "${LOCAL_RPM_DIRS:-}" ]; then + rpm_files=() + OLDIFS="$IFS" + IFS=':' + for dir in ${LOCAL_RPM_DIRS}; do + [ -d "$dir" ] || continue + for rpm in "$dir"/*.rpm; do + [ -e "$rpm" ] || continue + case "$(basename "$rpm")" in + *-debuginfo-*|*-debugsource-*|*-tests-*|*-examples-*|*-doc-*|*-ts-devel-*) + continue + ;; + esac + rpm_files+=("$rpm") + done + done + IFS="$OLDIFS" + if [ "${#rpm_files[@]}" -gt 0 ]; then + sb2 -t "$TARGET" -m sdk-install -R zypper --non-interactive install \ + --allow-unsigned-rpm --oldpackage --force-resolution "${rpm_files[@]}" 2>&1 | tee -a "$logfile" + fi +fi + +mb2_args=( -t "$TARGET" --no-vcs-apply build --prepare ) +if [ "${DEBUG_BUILD:-0}" = "1" ]; then + mb2_args+=( -d ) +fi +mb2 "${mb2_args[@]}" 2>&1 | tee -a "$logfile" + +rm -rf .mb2/build-sailfishos-skill/rpms +if [ -d RPMS ]; then + mkdir -p .mb2/build-sailfishos-skill/rpms + find RPMS -maxdepth 1 -type f -name '*.rpm' -exec cp -f {} .mb2/build-sailfishos-skill/rpms/ \; + chmod -R u+rwX .mb2/build-sailfishos-skill/rpms >/dev/null 2>&1 || true +fi + +OLDIFS="$IFS" +IFS=':' +for name in ${SYNC_BINARIES:-}; do + [ -n "$name" ] || continue + [ -e "$name" ] || continue + cp -f "$name" .mb2/build-sailfishos-skill/ >/dev/null 2>&1 || true +done +IFS="$OLDIFS" +''' + wrapper_command = rf''' +set -euo pipefail +if [ ! -x "$LOCAL_SDK" ]; then + echo "Installed Sailfish SDK chroot not found or not executable at $LOCAL_SDK" >&2 + exit 1 +fi +if getent passwd mersdk >/dev/null 2>&1; then + sed -i 's#^mersdk:[^:]*:[0-9]*:[0-9]*:[^:]*:[^:]*:#{user}:x:{uid}:{gid}::{home}:#' /etc/passwd +elif ! getent passwd {shlex.quote(user)} >/dev/null 2>&1; then + printf '%s:x:%s:%s::%s:/bin/bash\n' {shlex.quote(user)} {uid} {gid} {shlex.quote(home)} >> /etc/passwd +fi +"$LOCAL_SDK" -u {shlex.quote(user)} env \ + PROJECT_DIR="$PROJECT_DIR" \ + RELEASE="$RELEASE" \ + TARGET="$TARGET" \ + ARCH="$ARCH" \ + DEBUG_BUILD="$DEBUG_BUILD" \ + BUILD_LOG="$BUILD_LOG" \ + LOCAL_RPM_DIRS="$LOCAL_RPM_DIRS" \ + SYNC_BINARIES="$SYNC_BINARIES" \ + bash -lc {shlex.quote(inner_command)} +''' + log(f"Building local SDK target {target} for {release} via installed /srv/mer SDK") + run( + [ + "docker", + "run", + "--rm", + "--privileged", + "-v", + f"{sdk_mount_root}:{sdk_mount_root}", + "-v", + f"{project_mount_root}:{project_mount_root}", + "-w", + str(project_dir), + "-e", + f"PROJECT_DIR={project_dir}", + "-e", + f"LOCAL_SDK={local_sdk}", + "-e", + f"RELEASE={release}", + "-e", + f"TARGET={target}", + "-e", + f"ARCH={arch}", + "-e", + f"DEBUG_BUILD={'1' if debug_build else '0'}", + "-e", + f"BUILD_LOG={build_log_path(project_dir)}", + "-e", + f"LOCAL_RPM_DIRS={':'.join(str(path) for path in local_rpm_dirs)}", + "-e", + f"SYNC_BINARIES={binary_names}", + image, + "bash", + "-lc", + wrapper_command, + ] + ) + + +def build_arch( + project_dir: Path, + release: str, + arch: str, + debug_build: bool = False, + local_rpm_dirs: list[Path] | None = None, +) -> None: + image = f"{CONTAINER_IMAGE}:{release}" + target = f"SailfishOS-{release}-{arch}" + binary_names = ":".join(sorted(spec_names(project_dir) | pro_targets(project_dir))) + is_gecko_build = (project_dir / "gecko-dev").is_dir() and (project_dir / "rpm" / "xulrunner-qt5.spec").is_file() + local_rpm_dirs = local_rpm_dirs or [] + local_rpm_mounts = [f"/local-rpms/{index}" for index, _ in enumerate(local_rpm_dirs)] + build_command = r''' +set -euo pipefail +workroot="${HOME:-/tmp}" +if [ ! -d "$workroot" ] || [ ! -w "$workroot" ]; then + workroot=/tmp +fi +workdir="$workroot/build-sailfishos-skill" +mkdir -p /share/.mb2 +mkdir -p /share/.mb2/build-sailfishos-skill +logfile=/share/.mb2/build-sailfishos-skill-last.log +: > "$logfile" +{ + echo "# build-sailfishos-skill" + echo "release=${RELEASE:-}" + echo "arch=${ARCH:-}" + echo "debug=${DEBUG_BUILD:-0}" + echo "target=${TARGET:-}" + echo +} >> "$logfile" +rm -rf "$workdir" +mkdir -p "$workdir" +cp -a /share/. "$workdir/" +rm -rf "$workdir/RPMS" +cd "$workdir" +if [ "${IS_GECKO_BUILD:-0}" = "1" ]; then + # The local Sailfish gecko checkout already has the rpm/ patch stack applied, + # so keep %prep for its bootstrap side effects but disable patch re-apply in + # the copied spec. + sed -i \ + -e 's/^%autosetup -p1 -n /%autosetup -N -n /' \ + rpm/xulrunner-qt5.spec +fi + +if [ -n "${LOCAL_RPM_DIRS:-}" ]; then + rpm_files=() + OLDIFS="$IFS" + IFS=':' + for dir in ${LOCAL_RPM_DIRS}; do + [ -d "$dir" ] || continue + for rpm in "$dir"/*.rpm; do + [ -e "$rpm" ] || continue + case "$(basename "$rpm")" in + *-debuginfo-*|*-debugsource-*|*-tests-*|*-examples-*|*-doc-*|*-ts-devel-*) + continue + ;; + esac + rpm_files+=("$rpm") + done + done + IFS="$OLDIFS" + if [ "${#rpm_files[@]}" -gt 0 ]; then + zypper --non-interactive install --allow-unsigned-rpm --oldpackage --force-resolution \ + "${rpm_files[@]}" 2>&1 | tee -a "$logfile" + fi +fi + +mb2_args=( -t "$TARGET" ) +if [ "${IS_GECKO_BUILD:-0}" = "1" ]; then + mb2_args+=( --no-vcs-apply ) +fi +mb2_args+=( build ) +if [ "${IS_GECKO_BUILD:-0}" = "1" ]; then + mb2_args+=( --prepare ) +fi +if [ "${DEBUG_BUILD:-0}" = "1" ]; then + mb2_args+=( -d ) +fi +mb2 "${mb2_args[@]}" 2>&1 | tee -a "$logfile" + +for state_file in .mb2/target .mb2/spec .mb2/snapshot.lock; do + if [ -e "$state_file" ]; then + cp -f "$state_file" /share/.mb2/ + fi +done +chmod -R a+rwX /share/.mb2 >/dev/null 2>&1 || true + +rm -rf /share/.mb2/build-sailfishos-skill/rpms +if [ -d RPMS ]; then + mkdir -p /share/.mb2/build-sailfishos-skill/rpms + find RPMS -maxdepth 1 -type f -name '*.rpm' -exec cp -f {} /share/.mb2/build-sailfishos-skill/rpms/ \; + chmod -R a+rwX /share/.mb2/build-sailfishos-skill/rpms >/dev/null 2>&1 || true +fi + +for pattern in \ + Makefile .qmake.stash '*.o' '*.a' '*.so' '*.prl' '*.list' \ + 'moc_*.cpp' 'moc_*.o' 'qrc_*.cpp' 'qrc_*.o' 'ui_*.h' \ + CMakeCache.txt cmake_install.cmake compile_commands.json build.ninja rules.ninja install_manifest.txt +do + for f in $pattern; do + [ -e "$f" ] || continue + cp -f "$f" /share/ + done +done + +if [ -d translations ]; then + mkdir -p /share/translations + for f in translations/*.qm; do + [ -e "$f" ] || continue + cp -f "$f" /share/translations/ + done +fi + +OLDIFS="$IFS" +IFS=':' +for name in ${SYNC_BINARIES:-}; do + [ -n "$name" ] || continue + [ -e "$name" ] || continue + cp -f "$name" /share/ +done +IFS="$OLDIFS" +''' + gecko_wrapper_command = rf''' +set -euo pipefail +if [ ! -e /usr/lib/libclang.so.15 ]; then + zypper --non-interactive install clang-libs +fi +if ! rpm -q gcc-c++ >/dev/null 2>&1; then + zypper --non-interactive install gcc-c++ +fi +python3 - <<'PY' +import os +import pwd + +pw = pwd.getpwnam("mersdk") +os.environ["HOME"] = pw.pw_dir +os.setgroups([]) +os.setgid(pw.pw_gid) +os.setuid(pw.pw_uid) +os.execvp("bash", ["bash", "-lc", {build_command!r}]) +PY +''' + log(f"Building {target} via container shadow build and syncing artifacts back in place") + try: + docker_cmd = [ + "docker", + "run", + "--rm", + "--privileged", + "-v", + f"{project_dir}:/share", + ] + if is_gecko_build: + docker_cmd.extend(["-u", "0"]) + for mount_path, local_rpm_dir in zip(local_rpm_mounts, local_rpm_dirs): + docker_cmd.extend(["-v", f"{local_rpm_dir}:{mount_path}:ro"]) + docker_cmd.extend( + [ + "-e", + f"TARGET={target}", + "-e", + f"RELEASE={release}", + "-e", + f"ARCH={arch}", + "-e", + f"DEBUG_BUILD={'1' if debug_build else '0'}", + "-e", + f"BUILD_LOG={build_log_path(project_dir)}", + "-e", + f"SYNC_BINARIES={binary_names}", + "-e", + f"LOCAL_RPM_DIRS={':'.join(local_rpm_mounts)}", + "-e", + f"IS_GECKO_BUILD={'1' if is_gecko_build else '0'}", + image, + "bash", + "-lc", + gecko_wrapper_command if is_gecko_build else build_command, + ] + ) + run(docker_cmd) + except subprocess.CalledProcessError: + diagnose_missing_dependencies(project_dir, release, arch) + raise + + +def copy_rpms(project_dir: Path, release: str, arch: str, debug_build: bool, artifacts_dir: Path) -> list[Path]: + rpm_dir = staging_rpms_dir(project_dir) + if not rpm_dir.is_dir(): + raise SystemExit("Build completed but staged RPMs were not captured") + + rpms = sorted(rpm_dir.glob("*.rpm")) + if not rpms: + raise SystemExit("Build completed but no staged RPMs were found") + + destination_dir = variant_destination_dir(artifacts_dir, release, arch, debug_build) + if destination_dir.exists(): + shutil.rmtree(destination_dir) + destination_dir.mkdir(parents=True, exist_ok=True) + + copied: list[Path] = [] + for rpm in rpms: + destination = destination_dir / rpm.name + shutil.copy2(rpm, destination) + copied.append(destination) + + shutil.rmtree(rpm_dir) + return copied + + +def resolve_project_dir(project_dir: Path) -> Path: + if not project_dir.is_dir(): + raise SystemExit(f"Project directory not found: {project_dir}") + + if has_spec_files(project_dir): + return project_dir + + matches = [child for child in sorted(project_dir.iterdir()) if child.is_dir() and has_spec_files(child)] + if len(matches) == 1: + log(f"Using SailfishOS build root {matches[0]} discovered under {project_dir}") + return matches[0] + if len(matches) > 1: + options = ", ".join(str(path) for path in matches) + raise SystemExit( + f"{project_dir} contains multiple one-level-deep SailfishOS build roots: {options}. " + "Pass --project-dir pointing at the intended one." + ) + + raise SystemExit( + f"Could not find rpm/*.spec in {project_dir} or one level below it. " + "Pass --project-dir pointing at the SailfishOS build root." + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a SailfishOS project in place with Docker and mb2") + parser.add_argument("--project-dir", default=".", help="Project root containing rpm/*.spec") + parser.add_argument("--release", help="SailfishOS release, for example 3.4.0.24") + parser.add_argument("--arch", action="append", default=[], help="Architecture to build, may be repeated") + parser.add_argument("--all", action="store_true", help="Build every architecture supported by the chosen SDK image") + parser.add_argument("--list-arches", action="store_true", help="Print supported architectures and exit") + parser.add_argument( + "--permission-fallback", + choices=("error", "chmod"), + default=DEFAULT_PERMISSION_FALLBACK, + help="Fallback when setfacl is unavailable", + ) + parser.add_argument( + "--artifacts-dir", + help="Directory where built RPMs are copied. Defaults to RPMS/", + ) + parser.add_argument("--clean", action="store_true", help="Remove generated in-place build artifacts before building") + parser.add_argument( + "--debug", + action="store_true", + help="Pass -d to mb2 build so main binaries are stripped and debug packages are generated", + ) + parser.add_argument( + "--local-rpms-dir", + action="append", + default=[], + help="Directory of locally built RPMs to install into the SDK target before building; may be repeated", + ) + parser.add_argument("--no-pull", action="store_true", help="Skip docker pull before building") + parser.add_argument( + "--local-sdk", + nargs="?", + const=str(DEFAULT_LOCAL_SDK), + help=( + "Use the installed SDK chroot through a privileged Docker wrapper " + f"instead of a release Docker image. Defaults to {DEFAULT_LOCAL_SDK} " + "when no path is supplied." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + require_tool("docker") + + requested_project_dir = Path(args.project_dir).resolve() + project_dir = resolve_project_dir(requested_project_dir) + + local_sdk_path = Path(args.local_sdk).expanduser().resolve(strict=False) if args.local_sdk else None + raw_release = requested_release((requested_project_dir, project_dir), args.release) + local_release = normalize_local_release(raw_release) or LIVE_RELEASE + local_builds: list[LocalSdkBuild] | None = None + + if local_sdk_path: + local_builds = select_local_sdk_builds( + local_sdk_path, + local_release, + args.arch, + args.all or args.list_arches, + project_dir, + ) + if local_builds: + release = local_release + elif local_release == LIVE_RELEASE: + raise SystemExit("Release 'live' requires a matching installed local SDK target.") + else: + log( + f"No matching local SDK target for release {local_release}; " + f"falling back to {CONTAINER_IMAGE}" + ) + release = resolve_release((requested_project_dir, project_dir), args.release) + else: + release = resolve_release((requested_project_dir, project_dir), args.release) + if release == LIVE_RELEASE: + raise SystemExit("Release 'live' requires --local-sdk with a matching installed SDK target.") + + use_local_sdk = local_sdk_path is not None and local_builds is not None + + if not use_local_sdk and not args.no_pull: + pull_image(release) + + supported_arches = [] if use_local_sdk else list_supported_arches(release) + + if args.list_arches: + if use_local_sdk: + print("\n".join(build.arch for build in local_builds)) + return 0 + print("\n".join(supported_arches)) + return 0 + + if use_local_sdk: + builds: list[LocalSdkBuild | str] = local_builds + else: + builds = resolve_arches(args.arch, args.all, supported_arches, project_dir) + artifacts_dir = Path(args.artifacts_dir).resolve() if args.artifacts_dir else default_artifacts_dir(project_dir) + local_rpm_dirs = [Path(path).resolve() for path in args.local_rpms_dir] + + if not use_local_sdk: + ensure_container_write_access(project_dir, args.permission_fallback) + + all_copied_rpms: list[Path] = [] + for build in builds: + if isinstance(build, LocalSdkBuild): + arch = build.arch + else: + arch = build + previous_arch = parse_last_arch(project_dir) + if previous_arch and previous_arch != arch: + cleanup_in_place_artifacts(project_dir, previous_arch, arch) + elif args.clean: + cleanup_generated_artifacts(project_dir, "Explicit cleanup requested") + + try: + if isinstance(build, LocalSdkBuild): + build_local_sdk_arch( + project_dir, + local_sdk_path, + release, + arch, + build.target, + debug_build=args.debug, + local_rpm_dirs=local_rpm_dirs, + ) + else: + build_arch( + project_dir, + release, + arch, + debug_build=args.debug, + local_rpm_dirs=local_rpm_dirs, + ) + write_target_marker(project_dir, arch) + + manifest_paths = generated_candidate_paths(project_dir) + write_manifest(project_dir, manifest_paths) + + copied = copy_rpms(project_dir, release, arch, args.debug, artifacts_dir) + verify_expected_rpms(copied, args.debug) + write_build_metadata( + project_dir, + release=release, + arch=arch, + debug_build=args.debug, + artifacts_dir=artifacts_dir, + status="success", + rpms=copied, + ) + all_copied_rpms.extend(copied) + log( + f"Copied {len(copied)} RPM(s) for {arch} to " + f"{variant_destination_dir(artifacts_dir, release, arch, args.debug)}" + ) + except Exception: + write_build_metadata( + project_dir, + release=release, + arch=arch, + debug_build=args.debug, + artifacts_dir=artifacts_dir, + status="failed", + rpms=[], + ) + raise + + print("Built RPMs:") + for rpm in all_copied_rpms: + print(rpm) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
