diff options
| author | Andrew Branson <andrew.branson@jolla.com> | 2026-08-09 15:03:34 +0200 |
|---|---|---|
| committer | Andrew Branson <andrew.branson@jolla.com> | 2026-08-09 15:03:34 +0200 |
| commit | b9746bfa320cdd1464eb6c91fbadad35c84d2459 (patch) | |
| tree | 6528b43f66e0bc6b451666c3fb607a927110d304 | |
| parent | eb92561bd685961889cc553cd96db4a569eefec5 (diff) | |
Harden build workflows and OBS selection
Add preflight, cancellation, confined status, and timeout handling for
local Sailfish and remote Android build jobs.
Vendor helper 2.0.0 with explicit backend/pull controls, build locking,
local RPM validation, metadata, and structured failure reporting.
Expose named internal, partner, and community OBS servers while retaining
raw osc API aliases.
| -rw-r--r-- | README.md | 69 | ||||
| -rw-r--r-- | pyproject.toml | 3 | ||||
| -rw-r--r-- | scripts/update_build_helper.py | 66 | ||||
| -rw-r--r-- | src/sailfish_devel_mcp/tools.py | 805 | ||||
| -rwxr-xr-x | src/sailfish_devel_mcp/vendor/build_sailfishos.py | 711 | ||||
| -rw-r--r-- | tests/test_build_jobs.py | 318 | ||||
| -rw-r--r-- | tests/test_server.py | 83 |
7 files changed, 1819 insertions, 236 deletions
@@ -22,8 +22,8 @@ The server currently exposes tools for: - system and user service management - user-session command execution with the configured D-Bus environment - Sailfish Browser launch/debug helpers -- Docker/mb2 RPM builds through the local `build-sailfishos` helper -- remote Android/AppSupport builds on configured build hosts +- preflighted Docker/mb2 RPM builds through the vendored `build-sailfishos` helper +- cancellable local and remote Android/AppSupport build jobs - installed SDK repository metadata refresh - Jolla OBS result and build-log lookup through `osc` - repository status and search under the configured git root @@ -36,11 +36,13 @@ placeholder SSH target `root@device`, the Sailfish user-session bus at `~/OBS` as the OBS checkout root, and the vendored build helper at `src/sailfish_devel_mcp/vendor/build_sailfishos.py`. Put a config file at `~/.config/sailfish-devel-mcp/config.json` or pass `--config` to provide your -real device and OBS settings. Device entries can also carry the preferred -user, architecture, and current release label. If an installed SDK is -available, set `paths.local_sdk` to its `sdk-chroot` path; builds will use it -when it has a target matching the requested release and architecture, otherwise -they fall back to the coderus SDK image. +real device and OBS settings. Device entries can also carry the preferred user, +architecture, and configured release label. If an installed SDK is available, +set `paths.local_sdk` to its `sdk-chroot` path; builds will use it when it has a +target matching the requested release and architecture, otherwise they fall +back to a matching tag in the third-party coderus Docker mirror. Neither a +configured device label nor mirror tag availability independently establishes +the current official SailfishOS release or SDK target. ## Running @@ -127,7 +129,9 @@ Mutating tools are annotated as non-read-only: - `sailfish_device_restart_service` - `sailfish_device_browser_launch` - `sailfish_build_rpm` +- `sailfish_build_cancel` - `sailfish_android_build` +- `sailfish_android_build_cancel` - `sailfish_sdk_refresh_metadata` `sailfish_device_lipstick_screenshot` defaults to @@ -169,34 +173,54 @@ for the topmost PID and checks whether that process has `libxul.so` mapped. as defaults when the call includes `device`. If `paths.local_sdk` is set, the build defaults to `live` and first checks the installed SDK targets. `live` uses the unversioned local target for the requested architecture, for example -`aarch64`. A named production release such as `5.0.0` is used only when passed -explicitly through the tool arguments, environment, or device config; it uses a -matching versioned local target such as `aarch64-5.0.0` when available, and -otherwise falls back to the release-specific coderus Docker SDK image. The -wrapper image defaults to `sailfish-sdk-build-engine:$USER` and can be -overridden with +`aarch64`. A named production release is used only when passed explicitly +through the tool arguments, environment, or device config; it uses a matching +versioned local target when available, and otherwise falls back to a matching +tag in the third-party `coderus/sailfishos-platform-sdk` Docker mirror. Tags in +that mirror indicate image availability; they do not identify the current +official SailfishOS release or SDK target. The wrapper image defaults to +`sailfish-sdk-build-engine:$USER` and can be overridden with `SAILFISH_SDK_BUILD_ENGINE_IMAGE`. +Use `sailfish_build_preflight` to validate backend, image/target selection, +architectures, local RPM inputs, pull policy, VCS behavior, and artifact paths +without pulling an image or changing the project. `sailfish_build_rpm` accepts +the same `backend`, `local_sdk`, `target`, `pull_policy`, `no_vcs_apply`, and +`allow_untrusted_rpms` controls. An explicitly selected `local` backend does +not silently fall back to Docker. Asynchronous jobs use confined UUID job +directories and expose helper metadata and RPM paths through +`sailfish_build_status`; use `sailfish_build_cancel` to terminate the tracked +process group. + `sailfish_android_build` starts a remote Android/AppSupport build on the configured build host. Configure `android_build_hosts.<host>.project_dir` or pass `project_dir` to point at the remote Android tree, for example an `alien-aliendalvik-system` checkout. The tool writes job state under the remote -`state_dir`, creates a per-job `run.sh`, and starts it with `nohup`, so the SSH -session used to launch the job can disconnect without killing the build. Poll -with `sailfish_android_build_status`; omit `job_id` to list recent jobs, or pass -a job id to read state and tail `build.log`. +`state_dir`, atomically creates a per-job directory, and starts its own process +group with `nohup` and `setsid`, so the SSH session used to launch the job can +disconnect without killing the build. Set `build_timeout` for a remote build +lifetime limit. Poll with `sailfish_android_build_status`; omit `job_id` to list +recent jobs, pass a job id to read state and tail `build.log`, or use +`sailfish_android_build_cancel` to terminate the identity-checked process +group. `sailfish_sdk_refresh_metadata` refreshes zypper metadata in the installed SDK main target, for example `aarch64.default`, using the same privileged Docker wrapper style as local SDK builds. Use it when local SDK builds fail because a package listed in repository metadata cannot be downloaded. +`sailfish_obs_results` and `sailfish_obs_buildlog` accept `server` as +`internal`, `partner`, or `community`. `internal` maps to the `.oscrc` alias +`jolla`; the other names map to matching aliases. Omit `server` to use +`paths.osc_api_alias`. The advanced `api_alias` argument accepts any raw +`osc -A` alias or API URL and cannot be combined with `server`. + `sailfish_obs_buildlog` defaults to `osc api` with `nostream=1` so a build log request does not become a long-running live stream. Set `nostream` to `false` to use `osc remotebuildlog`. -Read-only tools include the journal, topmost PID, process maps, OBS lookup, -repo search, spec summary, and QML checks. +Read-only tools include build preflight/status, the journal, topmost PID, +process maps, OBS lookup, repo search, spec summary, and QML checks. ## Smoke Test @@ -214,3 +238,10 @@ Run tests without installing the package: ```sh PYTHONPATH=src python3 -m unittest discover -s tests ``` + +The canonical helper lives in the `build-sailfishos` skill. Update the exact +vendored copy with: + +```sh +python3 scripts/update_build_helper.py /path/to/build-sailfishos/scripts/build_sailfishos.py +``` diff --git a/pyproject.toml b/pyproject.toml index 0a4851f..dac1f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "sailfish-devel-mcp" version = "0.1.0" description = "Host-side MCP server for Sailfish OS development workflows" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "0BSD" } authors = [{ name = "Andrew Branson" }] dependencies = [] @@ -17,4 +17,3 @@ sailfish-devel-mcp = "sailfish_devel_mcp.server:main" [tool.setuptools.packages.find] where = ["src"] - diff --git a/scripts/update_build_helper.py b/scripts/update_build_helper.py new file mode 100644 index 0000000..c9436a1 --- /dev/null +++ b/scripts/update_build_helper.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import argparse +import importlib.util +import os +from pathlib import Path +import py_compile +import shutil +import tempfile + + +DESTINATION = ( + Path(__file__).resolve().parents[1] + / "src" + / "sailfish_devel_mcp" + / "vendor" + / "build_sailfishos.py" +) + + +def helper_version(path: Path) -> str: + spec = importlib.util.spec_from_file_location("candidate_build_sailfishos", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load helper: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + version = getattr(module, "HELPER_VERSION", None) + if not isinstance(version, str) or not version: + raise RuntimeError(f"helper has no HELPER_VERSION: {path}") + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description="Update the vendored build-sailfishos helper") + parser.add_argument("source", type=Path, help="Canonical build_sailfishos.py") + args = parser.parse_args() + + source = args.source.expanduser().resolve() + if not source.is_file(): + parser.error(f"source helper not found: {source}") + version = helper_version(source) + py_compile.compile(str(source), doraise=True) + + DESTINATION.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{DESTINATION.name}.", + suffix=".tmp", + dir=DESTINATION.parent, + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + shutil.copyfile(source, temporary) + temporary.chmod(0o755 if source.stat().st_mode & 0o111 else 0o644) + os.replace(temporary, DESTINATION) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + print(f"vendored build helper {version}: {DESTINATION}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py index 979e699..0820ce6 100644 --- a/src/sailfish_devel_mcp/tools.py +++ b/src/sailfish_devel_mcp/tools.py @@ -14,6 +14,7 @@ import sys import time from typing import Any, Callable from urllib.parse import quote +import uuid from .config import AndroidBuildHostConfig, Config, DeviceConfig from .runner import ( @@ -30,6 +31,18 @@ from .vendor import build_sailfishos ToolHandler = Callable[[dict[str, Any]], dict[str, Any]] +_BACKGROUND_SUPERVISORS: dict[int, subprocess.Popen[Any]] = {} +OBS_SERVER_API_ALIASES = { + "internal": "jolla", + "partner": "partner", + "community": "community", +} + + +def _reap_background_supervisors() -> None: + for pid, process in list(_BACKGROUND_SUPERVISORS.items()): + if process.poll() is not None: + _BACKGROUND_SUPERVISORS.pop(pid, None) @dataclass(frozen=True) @@ -74,7 +87,9 @@ def build_registry(config: Config) -> dict[str, Tool]: lambda args: handle_device_browser_launch(config, args), ), Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)), + Tool(_spec_build_preflight(), lambda args: handle_build_preflight(config, args)), Tool(_spec_build_status(), lambda args: handle_build_status(config, args)), + Tool(_spec_build_cancel(), lambda args: handle_build_cancel(config, args)), Tool( _spec_android_build_hosts(), lambda args: handle_android_build_hosts(config, args), @@ -88,6 +103,10 @@ def build_registry(config: Config) -> dict[str, Tool]: lambda args: handle_android_build_status(config, args), ), Tool( + _spec_android_build_cancel(), + lambda args: handle_android_build_cancel(config, args), + ), + Tool( _spec_sdk_refresh_metadata(), lambda args: handle_sdk_refresh_metadata(config, args), ), @@ -484,19 +503,34 @@ def handle_device_browser_launch(config: Config, args: dict[str, Any]) -> dict[s ) -def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: +def _sailfish_build_command( + config: Config, + args: dict[str, Any], + *, + dry_run: bool = False, +) -> tuple[list[str], Path]: project_path = _safe_input_path(config, _str_arg(args, "project_path"), allow_tmp=False) device = config.device(_optional_str(args, "device")) if args.get("device") else None script = config.paths.build_sailfishos if not script.exists(): - return tool_error(f"build helper not found: {script}") + raise ValueError(f"build helper not found: {script}") command = ["python3", str(script), "--project-dir", str(project_path)] + backend = _enum_arg(args, "backend", ["auto", "docker", "local"], default="auto") + command += ["--backend", backend] release = _optional_str(args, "release") or (device.release if device else None) arches = args.get("arch") artifacts_dir = _optional_str(args, "artifacts_dir") - if config.paths.local_sdk: - command += ["--local-sdk", str(config.paths.local_sdk)] + local_sdk_arg = _optional_str(args, "local_sdk") + if local_sdk_arg and backend == "docker": + raise ValueError("local_sdk cannot be combined with the Docker backend") + local_sdk = ( + _safe_local_sdk_path(config, local_sdk_arg) + if local_sdk_arg + else config.paths.local_sdk + ) + if local_sdk and backend != "docker": + command += ["--local-sdk", str(local_sdk)] if not release: release = "live" if release: @@ -506,12 +540,22 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: elif isinstance(arches, list): for arch in arches: if not isinstance(arch, str): - return tool_error("arch must be a string or list of strings") + raise ValueError("arch must be a string or list of strings") command += ["--arch", arch] elif arches is not None: - return tool_error("arch must be a string or list of strings") + raise ValueError("arch must be a string or list of strings") elif device and device.architecture: command += ["--arch", device.architecture] + targets = args.get("target") + if isinstance(targets, str): + command += ["--target", targets] + elif isinstance(targets, list): + for target in targets: + if not isinstance(target, str): + raise ValueError("target must be a string or list of strings") + command += ["--target", target] + elif targets is not None: + raise ValueError("target must be a string or list of strings") if artifacts_dir: output = _safe_output_path(config, artifacts_dir) command += ["--artifacts-dir", str(output)] @@ -521,14 +565,61 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: command.append("--clean") if _bool_arg(args, "debug", default=False): command.append("--debug") + permission_fallback = _enum_arg( + args, + "permission_fallback", + ["error", "chmod"], + default="error", + ) + command += ["--permission-fallback", permission_fallback] + pull_policy = _enum_arg(args, "pull_policy", ["always", "missing", "never"], default="always") if _bool_arg(args, "no_pull", default=False): - command.append("--no-pull") + pull_policy = "never" + command += ["--pull-policy", pull_policy] + if _bool_arg(args, "no_vcs_apply", default=False): + command.append("--no-vcs-apply") + if _bool_arg(args, "allow_untrusted_rpms", default=False): + command.append("--allow-untrusted-rpms") for local_dir in _string_list_arg(args, "local_rpms_dir"): command += ["--local-rpms-dir", str(_safe_input_path(config, local_dir, allow_tmp=True))] + if dry_run: + command += ["--dry-run", "--json"] + return command, project_path + + +def handle_build_preflight(config: Config, args: dict[str, Any]) -> dict[str, Any]: + command, project_path = _sailfish_build_command(config, args, dry_run=True) + timeout = _int_arg(args, "timeout", default=120, minimum=1, maximum=600) + result = run(command, timeout=timeout) + structured: dict[str, Any] = { + "project_path": str(project_path), + "command": command, + **result.public_dict(), + } + if result.ok: + try: + plan = json.loads(result.stdout) + except json.JSONDecodeError: + return tool_error("build preflight returned invalid JSON", structured) + if not isinstance(plan, dict): + return tool_error("build preflight did not return a JSON object", structured) + structured["plan"] = plan + return ok_text(result.stdout.strip(), structured) + return command_result("Sailfish build preflight", result, structured) + + +def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: + command, project_path = _sailfish_build_command(config, args) timeout = _int_arg(args, "timeout", default=3600, minimum=1, maximum=21600) if _bool_arg(args, "wait", default=False): return command_result("build Sailfish RPM", run(command, timeout=timeout)) - job = _start_background_command("build Sailfish RPM", command, timeout=timeout) + metadata_path = project_path / ".mb2" / build_sailfishos.BUILD_METADATA_NAME + job = _start_background_command( + "build Sailfish RPM", + command, + timeout=timeout, + metadata_path=metadata_path, + ) text = "\n".join( [ f"started build job {job['job_id']}", @@ -543,13 +634,17 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]: def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: job_id = _optional_str(args, "job_id") lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000) + wait_seconds = _int_arg(args, "wait_seconds", default=0, minimum=0, maximum=60) jobs_dir = _build_jobs_dir() if not job_id: jobs = [] if jobs_dir.exists(): for status_path in sorted(jobs_dir.glob("*/status.json"), key=lambda p: p.stat().st_mtime): + if not _valid_local_job_id(status_path.parent.name): + continue status = _read_job_status(status_path) if status: + status = _with_supervisor_health(status, status_path.parent) jobs.append(status) jobs = jobs[-20:] text = "\n".join( @@ -558,14 +653,25 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: ) return ok_text(text or "no build jobs found", {"jobs_dir": str(jobs_dir), "jobs": jobs}) - job_dir = jobs_dir / job_id + _validate_local_job_id(job_id) + job_dir = _local_job_dir(jobs_dir, job_id) status_path = job_dir / "status.json" if not status_path.exists(): return tool_error(f"unknown build job: {job_id}", {"jobs_dir": str(jobs_dir)}) + deadline = time.monotonic() + wait_seconds status = _read_job_status(status_path) + if status: + status = _with_supervisor_health(status, job_dir) + while status and wait_seconds and not _job_is_terminal(status): + if time.monotonic() >= deadline: + break + time.sleep(0.25) + status = _read_job_status(status_path) + if status: + status = _with_supervisor_health(status, job_dir) if not status: return tool_error(f"could not read build job status: {job_id}") - log_path = Path(str(status.get("log_path") or job_dir / "build.log")) + log_path = job_dir / "build.log" log_tail = _tail_file(log_path, lines) state = str(status.get("state") or "unknown") returncode = status.get("returncode") @@ -575,6 +681,12 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: f"returncode: {returncode}", f"log: {log_path}", ] + if status.get("failure_class"): + text_lines.append(f"failure: {status.get('failure_class')}: {status.get('failure_message', '')}") + artifacts = status.get("artifacts") + if isinstance(artifacts, list) and artifacts: + text_lines.append(f"artifacts: {len(artifacts)}") + text_lines.extend(f" {artifact}" for artifact in artifacts) if log_tail: text_lines += ["", log_tail] structured = dict(status) @@ -582,10 +694,29 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: return { "content": [{"type": "text", "text": "\n".join(text_lines)}], "structuredContent": structured, - "isError": state == "finished" and returncode not in (0, None), + "isError": state in {"failed", "timed_out"} or ( + state == "finished" and returncode not in (0, None) + ), } +def handle_build_cancel(config: Config, args: dict[str, Any]) -> dict[str, Any]: + job_id = _str_arg(args, "job_id") + _validate_local_job_id(job_id) + job_dir = _local_job_dir(_build_jobs_dir(), job_id) + status_path = job_dir / "status.json" + status = _read_job_status(status_path) + if status is None: + return tool_error(f"unknown build job: {job_id}") + if _job_is_terminal(status): + return ok_text(f"build job {job_id} is already {status.get('state')}", status) + marker = job_dir / "cancel.requested" + marker.write_text(datetime.now(timezone.utc).isoformat() + "\n", encoding="utf-8") + structured = dict(status) + structured["cancel_requested"] = True + return ok_text(f"cancellation requested for build job {job_id}", structured) + + def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]: text = "\n".join( ( @@ -623,6 +754,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any] shell_command = _str_arg(args, "shell_command") shell = _enum_arg(args, "shell", ["bash", "sh"], default="bash") timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) + build_timeout = _int_arg(args, "build_timeout", default=0, minimum=0, maximum=86400) job_id = _optional_str(args, "job_id") or _new_android_build_job_id() _validate_remote_job_id(job_id) @@ -635,6 +767,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any] job_id=job_id, shell=shell, shell_command=shell_command, + build_timeout=build_timeout, ) result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) structured = { @@ -645,6 +778,7 @@ def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any] "job_dir": job_dir, "log_path": log_path, "shell": shell, + "build_timeout": build_timeout, } if not result.ok: return command_result("start Android build", result, structured) @@ -698,11 +832,31 @@ def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[st response = command_result("Android build status", result, structured) returncode = response["structuredContent"].get("returncode") state = response["structuredContent"].get("state") - if result.ok and state == "finished" and returncode not in (0, None): + if result.ok and ( + state == "timed_out" or (state == "finished" and returncode not in (0, None)) + ): response["isError"] = True return response +def handle_android_build_cancel(config: Config, args: dict[str, Any]) -> dict[str, Any]: + host = _android_build_host(config, args) + state_dir = _remote_absolute_path( + _optional_str(args, "state_dir") or host.state_dir, + "state_dir", + ) + job_id = _str_arg(args, "job_id") + _validate_remote_job_id(job_id) + timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600) + remote = _android_build_cancel_command(state_dir, job_id) + result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout) + return command_result( + "cancel Android build", + result, + {"host": host.public_dict(), "state_dir": state_dir, "job_id": job_id}, + ) + + def _mcp_state_dir() -> Path: value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR") if value: @@ -725,17 +879,109 @@ def _build_jobs_dir() -> Path: def _write_json_atomic(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp.replace(path) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + finally: + try: + tmp.unlink() + except FileNotFoundError: + pass + +def _valid_local_job_id(job_id: str) -> bool: + return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id)) + + +def _validate_local_job_id(job_id: str) -> None: + if not _valid_local_job_id(job_id): + raise ValueError("job_id contains unsupported characters") -def _start_background_command(label: str, command: list[str], *, timeout: int) -> dict[str, Any]: + +def _local_job_dir(jobs_dir: Path, job_id: str) -> Path: + _validate_local_job_id(job_id) + root = jobs_dir.resolve(strict=False) + job_dir = (root / job_id).resolve(strict=False) + try: + job_dir.relative_to(root) + except ValueError as exc: # Defensive in case validation changes later. + raise ValueError("job_id escapes the build jobs directory") from exc + return job_dir + + +def _new_local_build_job(jobs_dir: Path) -> tuple[str, Path]: + jobs_dir.mkdir(parents=True, exist_ok=True) + for _ in range(3): + now = datetime.now(timezone.utc) + job_id = f"build-{now.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:16]}" + job_dir = _local_job_dir(jobs_dir, job_id) + try: + job_dir.mkdir(mode=0o700) + except FileExistsError: + continue + return job_id, job_dir + raise RuntimeError("could not allocate a unique build job directory") + + +def _process_start_time(pid: int) -> str | None: + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + except OSError: + return None + marker = stat.rfind(") ") + if marker < 0: + return None + fields = stat[marker + 2 :].split() + return fields[19] if len(fields) > 19 else None + + +def _job_is_terminal(status: dict[str, Any]) -> bool: + return status.get("state") in {"finished", "failed", "timed_out", "cancelled"} + + +def _with_supervisor_health(status: dict[str, Any], job_dir: Path | None = None) -> dict[str, Any]: + if _job_is_terminal(status): + return status + pid = status.get("supervisor_pid") + expected_start = status.get("supervisor_start_time") + if not isinstance(pid, int) and job_dir is not None: + sidecar = _read_job_status(job_dir / "supervisor.json") + if sidecar: + pid = sidecar.get("pid") + expected_start = sidecar.get("start_time") + if not isinstance(pid, int): + return status + actual_start = _process_start_time(pid) + if actual_start is None or (expected_start and actual_start != expected_start): + result = dict(status) + result.update( + state="failed", + failure_class="supervisor-lost", + failure_message="build supervisor is no longer running", + ) + return result + return status + + +def _start_background_command( + label: str, + command: list[str], + *, + timeout: int, + metadata_path: Path | None = None, +) -> dict[str, Any]: + _reap_background_supervisors() now = datetime.now(timezone.utc) - job_id = f"build-{now.strftime('%Y%m%dT%H%M%S')}-{os.getpid()}-{int(time.time() * 1000) % 100000}" - job_dir = _build_jobs_dir() / job_id + job_id, job_dir = _new_local_build_job(_build_jobs_dir()) log_path = job_dir / "build.log" status_path = job_dir / "status.json" + supervisor_status_path = job_dir / "supervisor.json" + cancel_path = job_dir / "cancel.requested" + try: + metadata_mtime_ns = metadata_path.stat().st_mtime_ns if metadata_path else None + except OSError: + metadata_mtime_ns = None status = { "job_id": job_id, "label": label, @@ -745,6 +991,8 @@ def _start_background_command(label: str, command: list[str], *, timeout: int) - "created_at": now.isoformat(), "status_path": str(status_path), "log_path": str(log_path), + "metadata_path": str(metadata_path) if metadata_path else None, + "metadata_mtime_ns": metadata_mtime_ns, } _write_json_atomic(status_path, status) @@ -766,83 +1014,183 @@ def now() -> str: def write_status(path: Path, data: dict[str, object]) -> None: - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp.replace(path) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + try: + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + finally: + try: + tmp.unlink() + except FileNotFoundError: + pass + + +def process_start_time(pid: int) -> str | None: + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + except OSError: + return None + marker = stat.rfind(") ") + fields = stat[marker + 2:].split() if marker >= 0 else [] + return fields[19] if len(fields) > 19 else None + + +def terminate(process: subprocess.Popen[str], log, reason: str) -> int: + log.write(f"[{now()}] {reason}; terminating process group {process.pid}\n") + log.flush() + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + return process.wait(timeout=30) + except subprocess.TimeoutExpired: + log.write(f"[{now()}] process group did not exit; killing {process.pid}\n") + log.flush() + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + return process.wait() status_path = Path(sys.argv[1]) log_path = Path(sys.argv[2]) -timeout = int(sys.argv[3]) -command = sys.argv[4:] +cancel_path = Path(sys.argv[3]) +timeout = int(sys.argv[4]) +metadata_arg = sys.argv[5] +metadata_path = Path(metadata_arg) if metadata_arg else None +command = sys.argv[6:] status = json.loads(status_path.read_text(encoding="utf-8")) status["supervisor_pid"] = os.getpid() +status["supervisor_start_time"] = process_start_time(os.getpid()) status["started_at"] = now() log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8", errors="replace") as log: log.write(f"[{now()}] starting {' '.join(command)}\n") log.flush() - process = subprocess.Popen( - command, - stdin=subprocess.DEVNULL, - stdout=log, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - close_fds=True, - ) - status["pid"] = process.pid - status["state"] = "running" - write_status(status_path, status) - - deadline = time.monotonic() + timeout - timed_out = False - returncode = None - while True: - returncode = process.poll() - if returncode is not None: - break - if time.monotonic() >= deadline: - timed_out = True - log.write(f"[{now()}] timeout after {timeout}s; terminating process group {process.pid}\n") - log.flush() + process = None + try: + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + close_fds=True, + ) + status["pid"] = process.pid + status["state"] = "running" + write_status(status_path, status) + + deadline = time.monotonic() + timeout + timed_out = False + cancelled = False + returncode = None + while True: + returncode = process.poll() + if returncode is not None: + break + if cancel_path.exists(): + cancelled = True + returncode = terminate(process, log, "cancellation requested") + break + if time.monotonic() >= deadline: + timed_out = True + returncode = terminate(process, log, f"timeout after {timeout}s") + break + time.sleep(0.25) + + status["state"] = ( + "cancelled" if cancelled else "timed_out" if timed_out else "finished" if returncode == 0 else "failed" + ) + status["returncode"] = returncode + status["timed_out"] = timed_out + status["cancelled"] = cancelled + status["finished_at"] = now() + baseline_mtime = status.get("metadata_mtime_ns") + try: + metadata_stat = metadata_path.stat() if metadata_path else None + except OSError: + metadata_stat = None + if ( + metadata_path + and metadata_stat + and metadata_stat.st_size <= 1024 * 1024 + and (baseline_mtime is None or metadata_stat.st_mtime_ns > baseline_mtime) + ): try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + metadata = None + if isinstance(metadata, dict): + status["build_metadata"] = metadata + status["artifacts"] = metadata.get("rpms", []) + if metadata.get("failure_class"): + status["failure_class"] = metadata["failure_class"] + if metadata.get("failure_message"): + status["failure_message"] = metadata["failure_message"] + write_status(status_path, status) + log.write( + f"[{now()}] finished state={status['state']} returncode={returncode} " + f"timed_out={timed_out} cancelled={cancelled}\n" + ) + except BaseException as error: + if process is not None and process.poll() is None: try: - returncode = process.wait(timeout=30) - except subprocess.TimeoutExpired: - log.write(f"[{now()}] process group did not exit; killing {process.pid}\n") - log.flush() - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - returncode = process.wait() - break - time.sleep(1) - - status["state"] = "finished" - status["returncode"] = returncode - status["timed_out"] = timed_out - status["finished_at"] = now() - write_status(status_path, status) - log.write(f"[{now()}] finished returncode={returncode} timed_out={timed_out}\n") + terminate(process, log, "supervisor failure") + except BaseException as terminate_error: + log.write( + f"[{now()}] failed to terminate process group: " + f"{type(terminate_error).__name__}: {terminate_error}\n" + ) + status["state"] = "failed" + status["failure_class"] = "supervisor" + status["failure_message"] = f"{type(error).__name__}: {error}" + status["finished_at"] = now() + write_status(status_path, status) + log.write(f"[{now()}] supervisor failed: {type(error).__name__}: {error}\n") + raise """ - process = subprocess.Popen( - [sys.executable, "-c", supervisor, str(status_path), str(log_path), str(timeout), *command], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - close_fds=True, + try: + process = subprocess.Popen( + [ + sys.executable, + "-c", + supervisor, + str(status_path), + str(log_path), + str(cancel_path), + str(timeout), + str(metadata_path) if metadata_path else "", + *command, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + except BaseException as error: + status.update( + state="failed", + failure_class="supervisor-start", + failure_message=f"{type(error).__name__}: {error}", + finished_at=datetime.now(timezone.utc).isoformat(), + ) + _write_json_atomic(status_path, status) + raise + _write_json_atomic( + supervisor_status_path, + {"pid": process.pid, "start_time": _process_start_time(process.pid)}, ) - status["supervisor_pid"] = process.pid - _write_json_atomic(status_path, status) - return status + _BACKGROUND_SUPERVISORS[process.pid] = process + response = dict(status) + response["supervisor_pid"] = process.pid + return response def _read_job_status(path: Path) -> dict[str, Any] | None: @@ -850,7 +1198,19 @@ def _read_job_status(path: Path) -> dict[str, Any] | None: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None - return data if isinstance(data, dict) else None + if not isinstance(data, dict): + return None + if _job_is_terminal(data): + pid = data.get("supervisor_pid") + supervisor = _BACKGROUND_SUPERVISORS.get(pid) if isinstance(pid, int) else None + if supervisor is not None: + try: + supervisor.wait(timeout=1) + except subprocess.TimeoutExpired: + pass + else: + _BACKGROUND_SUPERVISORS.pop(pid, None) + return data def _tail_file(path: Path, lines: int) -> str: @@ -890,7 +1250,7 @@ def handle_sdk_refresh_metadata(config: Config, args: dict[str, Any]) -> dict[st def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]: project = _str_arg(args, "project") package = _optional_str(args, "package") - api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias + server, api_alias = _obs_server_selection(config, args) command = ["osc"] if api_alias: command += ["-A", api_alias] @@ -898,7 +1258,11 @@ def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]: if package: command.append(package) timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600) - return command_result("OBS results", run(command, timeout=timeout)) + return command_result( + "OBS results", + run(command, timeout=timeout), + {"server": server, "api_alias": api_alias}, + ) def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]: @@ -906,7 +1270,7 @@ def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]: package = _str_arg(args, "package") repository = _str_arg(args, "repository") arch = _str_arg(args, "arch") - api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias + server, api_alias = _obs_server_selection(config, args) timeout = _int_arg(args, "timeout", default=90, minimum=1, maximum=1800) nostream = _bool_arg(args, "nostream", default=True) command = ["osc"] @@ -922,7 +1286,11 @@ def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]: command += ["api", path] else: command += ["remotebuildlog", project, package, repository, arch] - return command_result("OBS build log", run(command, timeout=timeout)) + return command_result( + "OBS build log", + run(command, timeout=timeout), + {"server": server, "api_alias": api_alias}, + ) def handle_repo_status(config: Config, args: dict[str, Any]) -> dict[str, Any]: @@ -1057,7 +1425,7 @@ def _android_build_ssh_argv( def _new_android_build_job_id() -> str: now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") - return f"android-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + return f"android-{now}-{uuid.uuid4().hex[:16]}" def _validate_remote_job_id(job_id: str) -> None: @@ -1075,7 +1443,7 @@ def _remote_absolute_path(value: str, name: str) -> str: def _new_remote_rpm_dir() -> str: now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") - return f"/tmp/sailfish-devel-mcp-rpms-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}" + return f"/tmp/sailfish-devel-mcp-rpms-{now}-{uuid.uuid4().hex[:16]}" def _android_build_start_command( @@ -1086,16 +1454,21 @@ def _android_build_start_command( job_id: str, shell: str, shell_command: str, + build_timeout: int, ) -> str: job_dir = str(PurePosixPath(state_dir) / job_id) log_path = str(PurePosixPath(job_dir) / "build.log") run_path = str(PurePosixPath(job_dir) / "run.sh") command_path = str(PurePosixPath(job_dir) / "command") pid_path = str(PurePosixPath(job_dir) / "pid") + process_start_path = str(PurePosixPath(job_dir) / "process_start") created_at_path = str(PurePosixPath(job_dir) / "created_at") started_at_path = str(PurePosixPath(job_dir) / "started_at") finished_at_path = str(PurePosixPath(job_dir) / "finished_at") returncode_path = str(PurePosixPath(job_dir) / "returncode") + timed_out_path = str(PurePosixPath(job_dir) / "timed_out") + watchdog_pid_path = str(PurePosixPath(job_dir) / "watchdog_pid") + watchdog_start_path = str(PurePosixPath(job_dir) / "watchdog_start") run_script = f"""#!/bin/sh set +e project_dir={shlex.quote(project_dir)} @@ -1106,6 +1479,10 @@ returncode_path={shlex.quote(returncode_path)} host_name={shlex.quote(host.name)} shell_bin={shlex.quote(shell)} shell_command={shlex.quote(shell_command)} +build_timeout={build_timeout} +timed_out_path={shlex.quote(timed_out_path)} +watchdog_pid_path={shlex.quote(watchdog_pid_path)} +watchdog_start_path={shlex.quote(watchdog_start_path)} date -Is > "$started_at_path" printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path" @@ -1121,8 +1498,41 @@ if [ "$cd_rc" -ne 0 ]; then exit "$cd_rc" fi +if [ "$build_timeout" -gt 0 ]; then + setsid sh -c ' + sleep "$1" + if kill -0 "$2" 2>/dev/null; then + date -Is > "$3" + kill -TERM -"$2" 2>/dev/null || true + attempts=0 + while [ "$attempts" -lt 30 ]; do + sleep 1 + if ! kill -0 -"$2" 2>/dev/null; then + exit 0 + fi + attempts=$((attempts + 1)) + done + kill -KILL -"$2" 2>/dev/null || true + fi + ' android-build-watchdog "$build_timeout" "$$" "$timed_out_path" \ + >/dev/null 2>&1 </dev/null & + watchdog_pid=$! + printf '%s\n' "$watchdog_pid" > "$watchdog_pid_path" + sed 's/.*) //' "/proc/$watchdog_pid/stat" 2>/dev/null | cut -d' ' -f20 > "$watchdog_start_path" || true +else + watchdog_pid= +fi + "$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1 rc=$? +if [ -n "$watchdog_pid" ]; then + if [ -f "$timed_out_path" ]; then + rc=124 + else + kill "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + fi +fi printf '%s\\n' "$rc" > "$returncode_path" date -Is > "$finished_at_path" printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path" @@ -1138,20 +1548,22 @@ run_path={shlex.quote(run_path)} log_path={shlex.quote(log_path)} command_path={shlex.quote(command_path)} pid_path={shlex.quote(pid_path)} +process_start_path={shlex.quote(process_start_path)} created_at_path={shlex.quote(created_at_path)} -mkdir -p "$state_dir" "$job_dir" -if [ -e "$pid_path" ] || [ -e {shlex.quote(returncode_path)} ]; then +umask 077 +mkdir -p "$state_dir" +if ! mkdir "$job_dir"; then echo "job already exists: $job_id" >&2 exit 2 fi -umask 077 printf '%s' {shlex.quote(encoded_run_script)} | base64 -d > "$run_path" chmod +x "$run_path" : > "$log_path" printf '%s\\n' {shlex.quote(shell_command)} > "$command_path" date -Is > "$created_at_path" -nohup "$run_path" >/dev/null 2>&1 </dev/null & +nohup setsid "$run_path" >/dev/null 2>&1 </dev/null & pid=$! +sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 > "$process_start_path" || true printf '%s\\n' "$pid" > "$pid_path" printf 'job_id: %s\\n' "$job_id" printf 'pid: %s\\n' "$pid" @@ -1176,14 +1588,22 @@ output=$( [ -d "$job_dir" ] || continue job_id=${{job_dir##*/}} pid=$(cat "$job_dir/pid" 2>/dev/null || true) + expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true) + actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true) returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) + cancel_requested=$(cat "$job_dir/cancel_requested" 2>/dev/null || true) + timed_out=$(cat "$job_dir/timed_out" 2>/dev/null || true) created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) timestamp=${{started_at:-$created_at}} - if [ -n "$returncode" ]; then + if [ -n "$timed_out" ]; then + state=timed_out + elif [ -n "$returncode" ]; then state=finished - elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - state=running + elif [ -n "$cancel_requested" ] && [ "$actual_start" != "$expected_start" ]; then + state=cancelled + elif [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then + if [ -n "$cancel_requested" ]; then state=cancelling; else state=running; fi elif [ -n "$pid" ]; then state=unknown else @@ -1215,15 +1635,26 @@ if [ ! -d "$job_dir" ]; then exit 2 fi pid=$(cat "$job_dir/pid" 2>/dev/null || true) +expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true) +actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true) +watchdog_pid=$(cat "$job_dir/watchdog_pid" 2>/dev/null || true) +watchdog_expected_start=$(cat "$job_dir/watchdog_start" 2>/dev/null || true) +watchdog_actual_start=$(sed 's/.*) //' "/proc/$watchdog_pid/stat" 2>/dev/null | cut -d' ' -f20 || true) returncode=$(cat "$job_dir/returncode" 2>/dev/null || true) +cancel_requested=$(cat "$job_dir/cancel_requested" 2>/dev/null || true) +timed_out=$(cat "$job_dir/timed_out" 2>/dev/null || true) created_at=$(cat "$job_dir/created_at" 2>/dev/null || true) started_at=$(cat "$job_dir/started_at" 2>/dev/null || true) finished_at=$(cat "$job_dir/finished_at" 2>/dev/null || true) command=$(cat "$job_dir/command" 2>/dev/null || true) -if [ -n "$returncode" ]; then +if [ -n "$timed_out" ]; then + state=timed_out +elif [ -n "$returncode" ]; then state=finished -elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - state=running +elif [ -n "$cancel_requested" ] && [ "$actual_start" != "$expected_start" ]; then + state=cancelled +elif [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then + if [ -n "$cancel_requested" ]; then state=cancelling; else state=running; fi elif [ -n "$pid" ]; then state=unknown else @@ -1276,6 +1707,37 @@ def _parse_android_build_status(stdout: str) -> dict[str, Any]: return fields +def _android_build_cancel_command(state_dir: str, job_id: str) -> str: + job_dir = str(PurePosixPath(state_dir) / job_id) + script = f""" +set -eu +job_id={shlex.quote(job_id)} +job_dir={shlex.quote(job_dir)} +if [ ! -d "$job_dir" ]; then + echo "unknown android build job: $job_id" >&2 + exit 2 +fi +pid=$(cat "$job_dir/pid" 2>/dev/null || true) +expected_start=$(cat "$job_dir/process_start" 2>/dev/null || true) +actual_start=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | cut -d' ' -f20 || true) +if [ -f "$job_dir/returncode" ] || [ -f "$job_dir/timed_out" ]; then + echo "job already finished: $job_id" + exit 0 +fi +date -Is > "$job_dir/cancel_requested" +if [ -n "$watchdog_pid" ] && [ -n "$watchdog_expected_start" ] && [ "$watchdog_actual_start" = "$watchdog_expected_start" ]; then + kill "$watchdog_pid" 2>/dev/null || true +fi +if [ -n "$pid" ] && [ -n "$expected_start" ] && [ "$actual_start" = "$expected_start" ] && kill -0 "$pid" 2>/dev/null; then + kill -TERM -"$pid" 2>/dev/null || true + printf 'cancellation requested for %s (process group %s)\n' "$job_id" "$pid" +else + printf 'job %s is no longer running; marked cancelled\n' "$job_id" +fi +""" + return remote_command(["sh", "-lc", script]) + + def _default_screenshot_path(device: DeviceConfig, label: str) -> str: timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") return f"{_device_home_path(device)}/Pictures/Screenshots/{label}-{timestamp}.png" @@ -1756,6 +2218,32 @@ def _device(config: Config, args: dict[str, Any]) -> DeviceConfig: return config.device(_optional_str(args, "device")) +def _obs_server_selection(config: Config, args: dict[str, Any]) -> tuple[str | None, str]: + server = _optional_str(args, "server") + api_alias = _optional_str(args, "api_alias") + if server and api_alias: + raise ValueError("server and api_alias cannot be combined") + if server: + try: + return server, OBS_SERVER_API_ALIASES[server] + except KeyError as exc: + choices = ", ".join(OBS_SERVER_API_ALIASES) + raise ValueError(f"server must be one of: {choices}") from exc + + selected_alias = api_alias or config.paths.osc_api_alias + if selected_alias in OBS_SERVER_API_ALIASES: + return selected_alias, OBS_SERVER_API_ALIASES[selected_alias] + selected_server = next( + ( + name + for name, alias in OBS_SERVER_API_ALIASES.items() + if alias == selected_alias + ), + None, + ) + return selected_server, selected_alias + + def _str_arg(args: dict[str, Any], name: str) -> str: value = args.get(name) if not isinstance(value, str) or not value: @@ -1859,6 +2347,22 @@ def _safe_output_path(config: Config, value: str) -> Path: return path +def _safe_local_sdk_path(config: Config, value: str) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + raise ValueError("local_sdk must be an absolute path") + path = path.resolve(strict=False) + srv_mer = Path("/srv/mer").resolve(strict=False) + configured = ( + config.paths.local_sdk.expanduser().resolve(strict=False) + if config.paths.local_sdk + else None + ) + if not _is_relative_to_any(path, [srv_mer]) and path != configured: + raise ValueError("local_sdk must be under /srv/mer or match the configured SDK path") + return path + + def _host_path_roots(config: Config) -> list[Path]: roots: list[Path] = [] for root in (config.paths.git_root, config.paths.obs_root): @@ -2244,7 +2748,7 @@ def _spec_build_rpm() -> dict[str, Any]: return { "name": "sailfish_build_rpm", "title": "Build Sailfish RPM", - "description": "Start an asynchronous RPM build; paths.local_sdk defaults to the live installed SDK, with public SDK fallback only for explicit named releases.", + "description": "Start an asynchronous RPM build; paths.local_sdk defaults to the live installed SDK, with third-party coderus Docker-image fallback only for explicit named releases.", "inputSchema": _object_schema( { "project_path": {"type": "string"}, @@ -2253,17 +2757,44 @@ def _spec_build_rpm() -> dict[str, Any]: "description": "Optional configured device to supply default release and architecture.", }, "release": {"type": "string"}, + "backend": { + "type": "string", + "enum": ["auto", "docker", "local"], + "default": "auto", + }, + "local_sdk": { + "type": "string", + "description": "Installed sdk-chroot path under /srv/mer; defaults to paths.local_sdk.", + }, "arch": { "oneOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, + "target": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ] + }, "artifacts_dir": {"type": "string"}, "all_arches": {"type": "boolean", "default": False}, "clean": {"type": "boolean", "default": False}, "debug": {"type": "boolean", "default": False}, + "permission_fallback": { + "type": "string", + "enum": ["error", "chmod"], + "default": "error", + }, "no_pull": {"type": "boolean", "default": False}, + "pull_policy": { + "type": "string", + "enum": ["always", "missing", "never"], + "default": "always", + }, + "no_vcs_apply": {"type": "boolean", "default": False}, + "allow_untrusted_rpms": {"type": "boolean", "default": False}, "local_rpms_dir": {"type": "array", "items": {"type": "string"}}, "wait": { "type": "boolean", @@ -2278,6 +2809,19 @@ def _spec_build_rpm() -> dict[str, Any]: } +def _spec_build_preflight() -> dict[str, Any]: + properties = dict(_spec_build_rpm()["inputSchema"]["properties"]) + properties.pop("wait", None) + properties["timeout"] = _timeout_prop(120) + return { + "name": "sailfish_build_preflight", + "title": "Preflight Sailfish Build", + "description": "Validate and return a structured Sailfish RPM build plan without mutating the project.", + "inputSchema": _object_schema(properties, ["project_path"]), + "annotations": _read_only_annotations("Preflight Sailfish Build"), + } + + def _spec_build_status() -> dict[str, Any]: return { "name": "sailfish_build_status", @@ -2296,12 +2840,29 @@ def _spec_build_status() -> dict[str, Any]: "default": 80, "description": "Number of trailing log lines to include.", }, + "wait_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 60, + "default": 0, + "description": "Wait up to this many seconds for job completion.", + }, } ), "annotations": _read_only_annotations("Build Job Status"), } +def _spec_build_cancel() -> dict[str, Any]: + return { + "name": "sailfish_build_cancel", + "title": "Cancel Sailfish Build", + "description": "Request cancellation of a local asynchronous Sailfish RPM build job.", + "inputSchema": _object_schema({"job_id": {"type": "string"}}, ["job_id"]), + "annotations": _mutating_annotations("Cancel Sailfish Build"), + } + + def _spec_android_build_hosts() -> dict[str, Any]: return { "name": "sailfish_android_build_hosts", @@ -2350,6 +2911,13 @@ def _spec_android_build() -> dict[str, Any]: "enum": ["bash", "sh"], "default": "bash", }, + "build_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 86400, + "default": 0, + "description": "Remote build lifetime in seconds; zero disables the build timeout.", + }, "timeout": _timeout_prop(60), }, ["shell_command"], @@ -2394,6 +2962,27 @@ def _spec_android_build_status() -> dict[str, Any]: } +def _spec_android_build_cancel() -> dict[str, Any]: + return { + "name": "sailfish_android_build_cancel", + "title": "Cancel Android Build", + "description": "Cancel a remote Android/AppSupport build after verifying its process identity.", + "inputSchema": _object_schema( + { + "host": { + "type": "string", + "description": "Configured build host alias or ssh target.", + }, + "job_id": {"type": "string"}, + "state_dir": {"type": "string"}, + "timeout": _timeout_prop(30), + }, + ["job_id"], + ), + "annotations": _mutating_annotations("Cancel Android Build"), + } + + def _spec_sdk_refresh_metadata() -> dict[str, Any]: return { "name": "sailfish_sdk_refresh_metadata", @@ -2431,12 +3020,20 @@ def _spec_obs_results() -> dict[str, Any]: return { "name": "sailfish_obs_results", "title": "OBS Results", - "description": "Run osc results using the configured OBS API alias when set.", + "description": "Run osc results against internal, partner, community, or an explicit OBS API alias.", "inputSchema": _object_schema( { "project": {"type": "string"}, "package": {"type": "string"}, - "api_alias": {"type": "string"}, + "server": { + "type": "string", + "enum": list(OBS_SERVER_API_ALIASES), + "description": "Named OBS server; internal maps to the .oscrc alias jolla.", + }, + "api_alias": { + "type": "string", + "description": "Advanced raw osc -A alias or API URL; overrides paths.osc_api_alias and cannot be combined with server.", + }, "timeout": _timeout_prop(60), }, ["project"], @@ -2449,14 +3046,22 @@ def _spec_obs_buildlog() -> dict[str, Any]: return { "name": "sailfish_obs_buildlog", "title": "OBS Build Log", - "description": "Fetch an OBS build log with osc; defaults to the API nostream form to avoid live streams.", + "description": "Fetch a build log from internal, partner, community, or an explicit OBS API alias; defaults to the API nostream form.", "inputSchema": _object_schema( { "project": {"type": "string"}, "package": {"type": "string"}, "repository": {"type": "string"}, "arch": {"type": "string"}, - "api_alias": {"type": "string"}, + "server": { + "type": "string", + "enum": list(OBS_SERVER_API_ALIASES), + "description": "Named OBS server; internal maps to the .oscrc alias jolla.", + }, + "api_alias": { + "type": "string", + "description": "Advanced raw osc -A alias or API URL; overrides paths.osc_api_alias and cannot be combined with server.", + }, "nostream": {"type": "boolean", "default": True}, "timeout": _timeout_prop(90), }, diff --git a/src/sailfish_devel_mcp/vendor/build_sailfishos.py b/src/sailfish_devel_mcp/vendor/build_sailfishos.py index c9ad480..67cb26e 100755 --- a/src/sailfish_devel_mcp/vendor/build_sailfishos.py +++ b/src/sailfish_devel_mcp/vendor/build_sailfishos.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import argparse +from contextlib import contextmanager, nullcontext +import fcntl import json import os import re @@ -8,6 +10,7 @@ import shlex import shutil import subprocess import sys +import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -18,7 +21,10 @@ from urllib.request import urlopen CONTAINER_UID = 100000 +# Third-party mirror. Its tags describe available build images, not the current +# official SailfishOS release or installed SDK target. CONTAINER_IMAGE = "coderus/sailfishos-platform-sdk" +HELPER_VERSION = "2.0.0" LIVE_RELEASE = "live" DEFAULT_LOCAL_SDK = Path("/srv/mer/sdks/sfossdk/sdk-chroot") LOCAL_SDK_BUILD_ENGINE_IMAGE_ENV = "SAILFISH_SDK_BUILD_ENGINE_IMAGE" @@ -26,8 +32,19 @@ 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" +BUILD_LOCK_NAME = "build-sailfishos-skill.lock" +LOCAL_RPMS_STAGING_NAME = "local-rpms" DEFAULT_PERMISSION_FALLBACK = "error" +LOCAL_RPM_EXCLUDED_MARKERS = ( + "-debuginfo-", + "-debugsource-", + "-tests-", + "-examples-", + "-doc-", + "-ts-devel-", +) + ROOT_PATTERNS = ( "Makefile", ".qmake.stash", @@ -91,6 +108,15 @@ class LocalSdkBuild: target: str +@dataclass(frozen=True) +class BuildContext: + backend: str + release: str + image: str | None + image_id: str | None + local_sdk: str | None + + def log(message: str) -> None: print(message, file=sys.stderr) @@ -127,6 +153,10 @@ def build_metadata_path(project_dir: Path) -> Path: return project_dir / ".mb2" / BUILD_METADATA_NAME +def build_lock_path(project_dir: Path) -> Path: + return project_dir / ".mb2" / BUILD_LOCK_NAME + + def default_artifacts_dir(project_dir: Path) -> Path: return project_dir / "RPMS" @@ -135,6 +165,45 @@ def staging_rpms_dir(project_dir: Path) -> Path: return project_state_dir(project_dir) / "rpms" +def local_rpms_staging_dir(project_dir: Path) -> Path: + return project_state_dir(project_dir) / LOCAL_RPMS_STAGING_NAME + + +def write_json_atomic(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + try: + temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +@contextmanager +def project_build_lock(project_dir: Path): + path = build_lock_path(project_dir) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+", encoding="utf-8") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + lock_file.seek(0) + owner = lock_file.read().strip() + detail = f" (owner {owner})" if owner else "" + raise SystemExit(f"Another SailfishOS build is already active for {project_dir}{detail}.") from exc + lock_file.seek(0) + lock_file.truncate() + lock_file.write(f"pid={os.getpid()} started_utc={datetime.now(timezone.utc).isoformat()}\n") + lock_file.flush() + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def has_spec_files(project_dir: Path) -> bool: rpm_dir = project_dir / "rpm" return rpm_dir.is_dir() and any(rpm_dir.glob("*.spec")) @@ -148,7 +217,7 @@ 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]: +def fetch_coderus_mirror_tags(prefix: str | None = None) -> list[str]: name_filter = quote(prefix) if prefix else "" matches: list[str] = [] @@ -171,11 +240,11 @@ def fetch_release_tags(prefix: str | None = None) -> list[str]: return sorted(dict.fromkeys(matches), key=parse_version) -def latest_release_tag() -> str: - matches = fetch_release_tags() +def latest_coderus_mirror_tag() -> str: + matches = fetch_coderus_mirror_tags() if not matches: raise SystemExit( - f"Could not determine the latest SailfishOS release from {CONTAINER_IMAGE} tags." + f"Could not determine the newest available Docker image tag from {CONTAINER_IMAGE}." ) return matches[-1] @@ -186,10 +255,10 @@ def normalize_release_tag(release: str) -> str: if release == "latest": try: - resolved = latest_release_tag() + resolved = latest_coderus_mirror_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}") + raise SystemExit(f"Could not resolve Docker image tag 'latest' from {CONTAINER_IMAGE}") from exc + log(f"Resolved newest available {CONTAINER_IMAGE} image tag to {resolved}") return resolved if not re.fullmatch(r"\d+(?:\.\d+){2,3}", release): @@ -198,7 +267,7 @@ def normalize_release_tag(release: str) -> str: return release try: - matches = fetch_release_tags(release) + matches = fetch_coderus_mirror_tags(release) except (HTTPError, URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError): return release @@ -208,7 +277,7 @@ def normalize_release_tag(release: str) -> str: return release resolved = max(matches, key=parse_version) - log(f"Resolved SailfishOS release {release} to {resolved}") + log(f"Resolved release shorthand {release} to {CONTAINER_IMAGE} image tag {resolved}") return resolved @@ -253,13 +322,17 @@ def resolve_release(project_dirs: Iterable[Path], explicit_release: str | None) return normalize_release_tag(inferred) try: - resolved = latest_release_tag() + resolved = latest_coderus_mirror_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." + "Could not determine a build release from arguments, environment, workflows, " + f"or available {CONTAINER_IMAGE} image tags." ) from exc - log(f"No SailfishOS release specified; using latest available release {resolved}") + log( + f"No build release specified; using newest available {CONTAINER_IMAGE} " + f"image tag {resolved}. This does not identify the current SailfishOS release." + ) return resolved @@ -288,6 +361,42 @@ def pull_image(release: str) -> None: run(["docker", "pull", image]) +def docker_image_exists(image: str) -> bool: + result = subprocess.run( + ["docker", "image", "inspect", image], + check=False, + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def docker_image_id(image: str) -> str | None: + try: + result = run( + ["docker", "image", "inspect", "--format", "{{.Id}}", image], + capture_output=True, + ) + except subprocess.CalledProcessError: + return None + return result.stdout.strip() or None + + +def ensure_image(release: str, pull_policy: str) -> tuple[str, bool]: + image = f"{CONTAINER_IMAGE}:{release}" + exists = docker_image_exists(image) + should_pull = pull_policy == "always" or (pull_policy == "missing" and not exists) + if should_pull: + pull_image(release) + return image, True + if not exists: + raise SystemExit( + f"Docker image {image} is not available locally and pull policy is '{pull_policy}'." + ) + return image, False + + def list_supported_arches(release: str) -> list[str]: image = f"{CONTAINER_IMAGE}:{release}" result = run( @@ -498,26 +607,46 @@ def write_manifest(project_dir: Path, paths: Iterable[Path]) -> None: def write_build_metadata( project_dir: Path, *, - release: str, - arch: str, + context: BuildContext, + builds: list[dict[str, object]], debug_build: bool, artifacts_dir: Path, status: str, - rpms: Iterable[Path], + started_at: datetime, + failure_class: str | None = None, + failure_message: str | None = None, ) -> None: metadata_file = build_metadata_path(project_dir) - metadata_file.parent.mkdir(parents=True, exist_ok=True) + finished_at = datetime.now(timezone.utc) + serialized_builds: list[dict[str, object]] = [] + for build in builds: + serialized = dict(build) + serialized["rpms"] = [str(path) for path in build.get("rpms", [])] + serialized_builds.append(serialized) + rpms = [path for build in serialized_builds for path in build.get("rpms", [])] payload = { - "timestamp_utc": datetime.now(timezone.utc).isoformat(), - "release": release, - "arch": arch, + "schema_version": 2, + "helper_version": HELPER_VERSION, + "started_utc": started_at.isoformat(), + "updated_utc": finished_at.isoformat(), + "finished_utc": None if status == "running" else finished_at.isoformat(), + "duration_seconds": round((finished_at - started_at).total_seconds(), 3), + "backend": context.backend, + "release": context.release, + "image": context.image, + "image_id": context.image_id, + "local_sdk": context.local_sdk, "debug": debug_build, "status": status, + "failure_class": failure_class, + "failure_message": failure_message, "artifacts_dir": str(artifacts_dir), "build_log": str(build_log_path(project_dir)), - "rpms": [str(path) for path in rpms], + "builds": serialized_builds, + "rpms": rpms, + "rpmlint": rpmlint_summary(build_log_path(project_dir)), } - metadata_file.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + write_json_atomic(metadata_file, payload) def write_target_marker(project_dir: Path, arch: str) -> None: @@ -564,25 +693,21 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) - 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}" + f"Granting read ACLs under {project_dir} and scoped output write ACLs to container uid {CONTAINER_UID}" ) + (project_dir / ".mb2").mkdir(parents=True, exist_ok=True) 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", + f"u:{CONTAINER_UID}:rX", "{}", "+", ] @@ -592,17 +717,28 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) - "find", str(project_dir), "-type", - "d", + "f", "-uid", str(current_uid), "-exec", "setfacl", "-m", - f"d:u:{current_uid}:rwX,d:u:{CONTAINER_UID}:rwX", + f"u:{CONTAINER_UID}:rX", "{}", "+", ] ) + writable_paths = {project_dir, project_dir / ".mb2"} + translations = project_dir / "translations" + if translations.is_dir(): + writable_paths.add(translations) + writable_paths.update(generated_candidate_paths(project_dir)) + for path in sorted(writable_paths): + if not path.exists(): + continue + run(["setfacl", "-m", f"u:{CONTAINER_UID}:rwX", str(path)]) + if path.is_dir(): + run(["setfacl", "-m", f"d:u:{CONTAINER_UID}:rwX", str(path)]) return if permission_fallback == "chmod": @@ -616,6 +752,85 @@ def ensure_container_write_access(project_dir: Path, permission_fallback: str) - ) +def usable_local_rpms(directory: Path) -> list[Path]: + if not directory.is_dir(): + raise SystemExit(f"Local RPM directory not found: {directory}") + return [ + rpm + for rpm in sorted(directory.glob("*.rpm")) + if not any(marker in rpm.name for marker in LOCAL_RPM_EXCLUDED_MARKERS) + ] + + +def validate_local_rpm_dirs(directories: list[Path]) -> dict[Path, list[Path]]: + selected: dict[Path, list[Path]] = {} + for directory in directories: + rpms = usable_local_rpms(directory) + if not rpms: + raise SystemExit(f"No installable RPMs found in local RPM directory: {directory}") + selected[directory] = rpms + return selected + + +@contextmanager +def stage_local_sdk_rpms(project_dir: Path, selected: dict[Path, list[Path]]): + staging_root = local_rpms_staging_dir(project_dir) + if staging_root.exists(): + shutil.rmtree(staging_root) + staged_dirs: list[Path] = [] + try: + for index, rpms in enumerate(selected.values()): + destination = staging_root / str(index) + destination.mkdir(parents=True, exist_ok=True) + for rpm in rpms: + shutil.copy2(rpm, destination / rpm.name) + staged_dirs.append(destination) + yield staged_dirs + finally: + if staging_root.exists(): + shutil.rmtree(staging_root) + + +def rpmlint_summary(log_file: Path) -> dict[str, int]: + summary = {"errors": 0, "warnings": 0} + if not log_file.is_file(): + return summary + try: + text = log_file.read_text(encoding="utf-8", errors="replace") + except OSError: + return summary + final = re.findall(r"(?im);\s*(\d+)\s+errors?,\s*(\d+)\s+warnings?\.?$", text) + if final: + summary["errors"], summary["warnings"] = map(int, final[-1]) + return summary + summary["errors"] = len(re.findall(r"(?m)^\S.*:\s+E:\s+", text)) + summary["warnings"] = len(re.findall(r"(?m)^\S.*:\s+W:\s+", text)) + return summary + + +def classify_failure(error: BaseException, log_file: Path | None = None) -> str: + text = str(error) + if log_file and log_file.is_file(): + try: + text += "\n" + log_file.read_text(encoding="utf-8", errors="replace")[-20000:] + except OSError: + pass + lowered = text.lower() + if "failed build dependencies" in lowered or "is needed by" in lowered: + return "missing-build-requires" + if "no basic authentication credentials" in lowered or "repository" in lowered and "not found" in lowered: + return "repository" + if "signature" in lowered or "gpg" in lowered or "unsigned rpm" in lowered: + return "package-trust" + if "permission denied" in lowered or "operation not permitted" in lowered: + return "permission" + if "docker image" in lowered or "manifest unknown" in lowered or "pull access denied" in lowered: + return "image" + if isinstance(error, subprocess.TimeoutExpired): + return "timeout" + return "build" + + def parse_missing_build_requires(log_text: str) -> list[str]: missing: list[str] = [] capture = False @@ -885,12 +1100,26 @@ def select_local_sdk_builds( requested_arches: list[str], build_all: bool, project_dir: Path, + requested_targets: list[str] | None = None, ) -> list[LocalSdkBuild] | None: + requested_targets = requested_targets or [] + installed = list_local_sdk_targets(local_sdk) matching = [ target - for target in list_local_sdk_targets(local_sdk) + for target in installed if local_target_matches_release(target, release) ] + if requested_targets: + by_name = {target.target: target for target in installed} + builds: list[LocalSdkBuild] = [] + for requested in requested_targets: + target = by_name.get(canonical_local_target_name(requested)) + if target is None: + return None + if release != LIVE_RELEASE and not local_target_matches_release(target, release): + return None + builds.append(LocalSdkBuild(target.arch, target.target)) + return builds if not matching: return None @@ -926,6 +1155,8 @@ def build_local_sdk_arch( target: str, debug_build: bool = False, local_rpm_dirs: list[Path] | None = None, + no_vcs_apply: bool = True, + allow_untrusted_rpms: bool = False, ) -> None: user = host_user() uid = os.getuid() @@ -971,12 +1202,20 @@ if [ -n "${LOCAL_RPM_DIRS:-}" ]; then 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" + zypper_args=( --non-interactive install --oldpackage --force-resolution ) + if [ "${ALLOW_UNTRUSTED_RPMS:-0}" = "1" ]; then + zypper_args+=( --allow-unsigned-rpm ) + fi + sb2 -t "$TARGET" -m sdk-install -R zypper "${zypper_args[@]}" \ + "${rpm_files[@]}" 2>&1 | tee -a "$logfile" fi fi -mb2_args=( -t "$TARGET" --no-vcs-apply build --prepare ) +mb2_args=( -t "$TARGET" ) +if [ "${NO_VCS_APPLY:-0}" = "1" ]; then + mb2_args+=( --no-vcs-apply ) +fi +mb2_args+=( build --prepare ) if [ "${DEBUG_BUILD:-0}" = "1" ]; then mb2_args+=( -d ) fi @@ -1017,6 +1256,8 @@ fi DEBUG_BUILD="$DEBUG_BUILD" \ BUILD_LOG="$BUILD_LOG" \ LOCAL_RPM_DIRS="$LOCAL_RPM_DIRS" \ + NO_VCS_APPLY="$NO_VCS_APPLY" \ + ALLOW_UNTRUSTED_RPMS="$ALLOW_UNTRUSTED_RPMS" \ SYNC_BINARIES="$SYNC_BINARIES" \ bash -lc {shlex.quote(inner_command)} ''' @@ -1050,6 +1291,10 @@ fi "-e", f"LOCAL_RPM_DIRS={':'.join(str(path) for path in local_rpm_dirs)}", "-e", + f"NO_VCS_APPLY={'1' if no_vcs_apply else '0'}", + "-e", + f"ALLOW_UNTRUSTED_RPMS={'1' if allow_untrusted_rpms else '0'}", + "-e", f"SYNC_BINARIES={binary_names}", image, "bash", @@ -1065,6 +1310,8 @@ def build_arch( arch: str, debug_build: bool = False, local_rpm_dirs: list[Path] | None = None, + no_vcs_apply: bool = False, + allow_untrusted_rpms: bool = False, ) -> None: image = f"{CONTAINER_IMAGE}:{release}" target = f"SailfishOS-{release}-{arch}" @@ -1123,13 +1370,16 @@ if [ -n "${LOCAL_RPM_DIRS:-}" ]; then 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" + zypper_args=( --non-interactive install --oldpackage --force-resolution ) + if [ "${ALLOW_UNTRUSTED_RPMS:-0}" = "1" ]; then + zypper_args+=( --allow-unsigned-rpm ) + fi + zypper "${zypper_args[@]}" "${rpm_files[@]}" 2>&1 | tee -a "$logfile" fi fi mb2_args=( -t "$TARGET" ) -if [ "${IS_GECKO_BUILD:-0}" = "1" ]; then +if [ "${IS_GECKO_BUILD:-0}" = "1" ] || [ "${NO_VCS_APPLY:-0}" = "1" ]; then mb2_args+=( --no-vcs-apply ) fi mb2_args+=( build ) @@ -1234,6 +1484,10 @@ PY "-e", f"LOCAL_RPM_DIRS={':'.join(local_rpm_mounts)}", "-e", + f"NO_VCS_APPLY={'1' if no_vcs_apply else '0'}", + "-e", + f"ALLOW_UNTRUSTED_RPMS={'1' if allow_untrusted_rpms else '0'}", + "-e", f"IS_GECKO_BUILD={'1' if is_gecko_build else '0'}", image, "bash", @@ -1295,14 +1549,81 @@ def resolve_project_dir(project_dir: Path) -> Path: ) -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Build a SailfishOS project in place with Docker and mb2") +def build_preflight_payload( + *, + project_dir: Path, + context: BuildContext, + builds: list[LocalSdkBuild | str], + artifacts_dir: Path, + local_rpms: dict[Path, list[Path]], + debug_build: bool, + clean: bool, + no_vcs_apply: bool, + allow_untrusted_rpms: bool, + pull_policy: str, + image_available: bool | None, +) -> dict[str, object]: + planned_builds = [ + { + "arch": build.arch if isinstance(build, LocalSdkBuild) else build, + "target": build.target if isinstance(build, LocalSdkBuild) else f"SailfishOS-{context.release}-{build}", + } + for build in builds + ] + permission_strategy = "local-sdk-user" + if context.backend == "docker": + permission_strategy = "scoped-acl" if shutil.which("setfacl") else "configured-fallback" + would_pull = bool( + context.backend == "docker" + and (pull_policy == "always" or (pull_policy == "missing" and image_available is False)) + ) + return { + "schema_version": 1, + "helper_version": HELPER_VERSION, + "project_dir": str(project_dir), + "backend": context.backend, + "release": context.release, + "image": context.image, + "image_available": image_available, + "local_sdk": context.local_sdk, + "builds": planned_builds, + "debug": debug_build, + "clean": clean, + "no_vcs_apply": no_vcs_apply, + "artifacts_dir": str(artifacts_dir), + "local_rpms": { + str(directory): [str(rpm) for rpm in rpms] + for directory, rpms in local_rpms.items() + }, + "allow_untrusted_rpms": allow_untrusted_rpms, + "pull_policy": pull_policy, + "would_pull": would_pull, + "permission_strategy": permission_strategy, + "mutates_project": False, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a SailfishOS project with Docker or an installed SDK") + parser.add_argument("--version", action="version", version=f"%(prog)s {HELPER_VERSION}") 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( + "--target", + action="append", + default=[], + help="Exact installed local SDK target 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( + "--backend", + choices=("auto", "docker", "local"), + default="auto", + help="Build backend. Auto uses --local-sdk/--target when supplied, otherwise Docker", + ) + parser.add_argument( "--permission-fallback", choices=("error", "chmod"), default=DEFAULT_PERMISSION_FALLBACK, @@ -1324,7 +1645,13 @@ def parse_args() -> argparse.Namespace: 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( + "--pull-policy", + choices=("always", "missing", "never"), + default="always", + help="When to pull the release Docker image", + ) + parser.add_argument("--no-pull", action="store_true", help=argparse.SUPPRESS) parser.add_argument( "--local-sdk", nargs="?", @@ -1335,20 +1662,65 @@ def parse_args() -> argparse.Namespace: "when no path is supplied." ), ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() + vcs_group = parser.add_mutually_exclusive_group() + vcs_group.add_argument( + "--no-vcs-apply", + dest="no_vcs_apply", + action="store_true", + default=None, + help="Tell mb2 not to apply VCS changes before building", + ) + vcs_group.add_argument( + "--vcs-apply", + dest="no_vcs_apply", + action="store_false", + help="Allow mb2 to apply VCS changes (local SDK builds default to no VCS apply)", + ) + parser.add_argument( + "--allow-untrusted-rpms", + action="store_true", + help="Allow unsigned RPMs supplied with --local-rpms-dir", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate inputs and print the build plan without pulling, cleaning, changing ACLs, or building", + ) + parser.add_argument("--json", action="store_true", help="Print --dry-run output as JSON") + args = parser.parse_args(argv) + if args.json and not args.dry_run: + parser.error("--json requires --dry-run") + if args.target and args.backend == "docker": + parser.error("--target requires --backend local or auto") + if args.local_sdk and args.backend == "docker": + parser.error("--local-sdk cannot be combined with --backend docker") + return args + + +def concise_error(error: BaseException) -> str: + if isinstance(error, subprocess.CalledProcessError): + command = error.cmd if isinstance(error.cmd, list) else [str(error.cmd)] + return f"Command exited with status {error.returncode}: {shlex.join(command)[:500]}" + return str(error) or error.__class__.__name__ + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) 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 + if args.no_pull: + args.pull_policy = "never" + + local_requested = args.backend == "local" or args.local_sdk is not None or bool(args.target) + local_sdk_value = args.local_sdk or (str(DEFAULT_LOCAL_SDK) if local_requested else None) + local_sdk_path = Path(local_sdk_value).expanduser().resolve(strict=False) if local_sdk_value else None local_builds: list[LocalSdkBuild] | None = None - if local_sdk_path: + if local_requested and args.backend != "docker": + assert local_sdk_path is not None local_release = local_sdk_requested_release(args.release) local_builds = select_local_sdk_builds( local_sdk_path, @@ -1356,11 +1728,21 @@ def main() -> int: args.arch, args.all or args.list_arches, project_dir, + args.target, ) if local_builds: - release = local_release - elif local_release == LIVE_RELEASE: - raise SystemExit("Release 'live' requires a matching installed local SDK target.") + if local_release == LIVE_RELEASE: + installed_by_name = {target.target: target for target in list_local_sdk_targets(local_sdk_path)} + selected = installed_by_name.get(local_builds[0].target) + release = (selected.release or selected.version_id) if selected else LIVE_RELEASE + release = release or LIVE_RELEASE + else: + release = local_release + elif args.backend == "local" or args.target or local_release == LIVE_RELEASE: + available = ", ".join(target.target for target in list_local_sdk_targets(local_sdk_path)) or "none" + raise SystemExit( + f"No matching installed local SDK target for release {local_release}. Available targets: {available}" + ) else: log( f"No matching local SDK target for release {local_release}; " @@ -1374,91 +1756,185 @@ def main() -> int: 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) + image: str | None = None + image_available: bool | None = None + if use_local_sdk: + image = local_sdk_build_engine_image(host_user()) + image_available = docker_image_exists(image) + if not image_available and not args.dry_run: + raise SystemExit(f"Local SDK wrapper image is not available: {image}") + else: + image = f"{CONTAINER_IMAGE}:{release}" + image_available = docker_image_exists(image) - supported_arches = [] if use_local_sdk else list_supported_arches(release) + supported_arches: list[str] = [] + if not use_local_sdk and not args.dry_run: + image, _ = ensure_image(release, args.pull_policy) + image_available = True + supported_arches = list_supported_arches(release) + elif not use_local_sdk and image_available: + supported_arches = list_supported_arches(release) if args.list_arches: if use_local_sdk: print("\n".join(build.arch for build in local_builds)) return 0 + if args.dry_run and not supported_arches: + raise SystemExit(f"Cannot list architectures because Docker image {image} is not available locally.") print("\n".join(supported_arches)) return 0 if use_local_sdk: builds: list[LocalSdkBuild | str] = local_builds + elif args.dry_run and not supported_arches: + if args.all: + builds = ["<all-supported-architectures>"] + else: + requested = args.arch or ([parse_last_arch(project_dir)] if parse_last_arch(project_dir) else []) + if not requested: + raise SystemExit("Pass --arch or make the Docker image available so targets can be discovered.") + builds = [arch for arch in requested if arch] 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 + selected_local_rpms = validate_local_rpm_dirs(local_rpm_dirs) + no_vcs_apply = args.no_vcs_apply if args.no_vcs_apply is not None else use_local_sdk + + context = BuildContext( + backend="local" if use_local_sdk else "docker", + release=release, + image=image, + image_id=docker_image_id(image) if image_available and image else None, + local_sdk=str(local_sdk_path) if use_local_sdk else None, + ) + if args.dry_run: + payload = build_preflight_payload( + project_dir=project_dir, + context=context, + builds=builds, + artifacts_dir=artifacts_dir, + local_rpms=selected_local_rpms, + debug_build=args.debug, + clean=args.clean, + no_vcs_apply=no_vcs_apply, + allow_untrusted_rpms=args.allow_untrusted_rpms, + pull_policy=args.pull_policy, + image_available=image_available, + ) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) 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) + print(f"Backend: {payload['backend']}") + print(f"Release: {payload['release']}") + print(f"Builds: {', '.join(item['target'] for item in payload['builds'])}") + print(f"Artifacts: {payload['artifacts_dir']}") + print(f"Would pull image: {'yes' if payload['would_pull'] else 'no'}") + return 0 - 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: + started_at = datetime.now(timezone.utc) + all_copied_rpms: list[Path] = [] + build_records: list[dict[str, object]] = [] + rpm_context = stage_local_sdk_rpms(project_dir, selected_local_rpms) if use_local_sdk else nullcontext(local_rpm_dirs) + with project_build_lock(project_dir), rpm_context as effective_local_rpm_dirs: + if not use_local_sdk: + ensure_container_write_access(project_dir, args.permission_fallback) + + for build in builds: + arch = build.arch if isinstance(build, LocalSdkBuild) else build + target = build.target if isinstance(build, LocalSdkBuild) else f"SailfishOS-{release}-{arch}" + record: dict[str, object] = { + "arch": arch, + "target": target, + "status": "running", + "rpms": [], + } + build_records.append(record) write_build_metadata( project_dir, - release=release, - arch=arch, + context=context, + builds=build_records, debug_build=args.debug, artifacts_dir=artifacts_dir, - status="failed", - rpms=[], + status="running", + started_at=started_at, ) - raise + build_started = time.monotonic() + try: + 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") + + if isinstance(build, LocalSdkBuild): + assert local_sdk_path is not None + build_local_sdk_arch( + project_dir, + local_sdk_path, + release, + arch, + build.target, + debug_build=args.debug, + local_rpm_dirs=effective_local_rpm_dirs, + no_vcs_apply=no_vcs_apply, + allow_untrusted_rpms=args.allow_untrusted_rpms, + ) + else: + build_arch( + project_dir, + release, + arch, + debug_build=args.debug, + local_rpm_dirs=effective_local_rpm_dirs, + no_vcs_apply=no_vcs_apply, + allow_untrusted_rpms=args.allow_untrusted_rpms, + ) + write_target_marker(project_dir, arch) + write_manifest(project_dir, generated_candidate_paths(project_dir)) + copied = copy_rpms(project_dir, release, arch, args.debug, artifacts_dir) + verify_expected_rpms(copied, args.debug) + record.update( + status="success", + duration_seconds=round(time.monotonic() - build_started, 3), + 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 BaseException as error: + failure_class = classify_failure(error, build_log_path(project_dir)) + message = concise_error(error) + record.update( + status="failed", + duration_seconds=round(time.monotonic() - build_started, 3), + failure_class=failure_class, + failure_message=message, + ) + write_build_metadata( + project_dir, + context=context, + builds=build_records, + debug_build=args.debug, + artifacts_dir=artifacts_dir, + status="failed", + started_at=started_at, + failure_class=failure_class, + failure_message=message, + ) + raise + + write_build_metadata( + project_dir, + context=context, + builds=build_records, + debug_build=args.debug, + artifacts_dir=artifacts_dir, + status="success", + started_at=started_at, + ) print("Built RPMs:") for rpm in all_copied_rpms: @@ -1467,4 +1943,11 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except subprocess.CalledProcessError as error: + log(f"Build failed: {concise_error(error)}") + sys.exit(error.returncode or 1) + except OSError as error: + log(f"Build failed: {concise_error(error)}") + sys.exit(1) diff --git a/tests/test_build_jobs.py b/tests/test_build_jobs.py new file mode 100644 index 0000000..bec80b2 --- /dev/null +++ b/tests/test_build_jobs.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +import base64 +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +from sailfish_devel_mcp.config import AndroidBuildHostConfig, Config, DeviceConfig, PathConfig +from sailfish_devel_mcp.runner import CommandResult +from sailfish_devel_mcp import tools +from sailfish_devel_mcp.vendor import build_sailfishos + + +class BuildJobTests(unittest.TestCase): + def make_config(self, root: Path) -> Config: + return Config( + path=None, + default_device="test", + devices={"test": DeviceConfig(name="test", ssh_target="root@test")}, + paths=PathConfig( + git_root=root, + obs_root=root / "OBS", + ssh_config=root / "ssh_config", + build_sailfishos=root / "build_sailfishos.py", + ), + ) + + def wait_for_terminal(self, status_path: Path, timeout: float = 5.0) -> dict[str, object]: + deadline = time.monotonic() + timeout + status: dict[str, object] = {} + while time.monotonic() < deadline: + status = tools._read_job_status(status_path) or {} + if tools._job_is_terminal(status): + return status + time.sleep(0.05) + self.fail(f"job did not finish: {status}") + + def test_status_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config = self.make_config(root) + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(root / "state")}): + with self.assertRaisesRegex(ValueError, "unsupported characters"): + tools.handle_build_status(config, {"job_id": "../outside"}) + + def test_status_uses_confined_log_path(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + state = root / "state" + job_dir = state / "builds" / "build-safe" + job_dir.mkdir(parents=True) + external = root / "secret.log" + external.write_text("secret\n", encoding="utf-8") + (job_dir / "build.log").write_text("safe\n", encoding="utf-8") + tools._write_json_atomic( + job_dir / "status.json", + { + "job_id": "build-safe", + "state": "finished", + "returncode": 0, + "log_path": str(external), + }, + ) + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(state)}): + response = tools.handle_build_status(self.make_config(root), {"job_id": "build-safe"}) + + self.assertIn("safe", response["structuredContent"]["log_tail"]) + self.assertNotIn("secret", response["structuredContent"]["log_tail"]) + + def test_atomic_status_writes_have_unique_temporaries(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "status.json" + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(lambda value: tools._write_json_atomic(path, {"value": value}), range(80))) + payload = json.loads(path.read_text(encoding="utf-8")) + leftovers = list(path.parent.glob(".status.json.*.tmp")) + + self.assertIn(payload["value"], range(80)) + self.assertEqual(leftovers, []) + + def test_background_job_records_metadata_and_finishes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + metadata = root / "last-build.json" + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(root / "state")}): + job = tools._start_background_command( + "test build", + [ + sys.executable, + "-c", + "from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])", + str(metadata), + json.dumps({"rpms": ["/tmp/example.rpm"]}), + ], + timeout=5, + metadata_path=metadata, + ) + status = self.wait_for_terminal(Path(job["status_path"])) + + self.assertEqual(status["state"], "finished") + self.assertEqual(status["returncode"], 0) + self.assertEqual(status["artifacts"], ["/tmp/example.rpm"]) + + def test_background_job_start_failure_becomes_terminal(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + metadata = root / "last-build.json" + metadata.write_text(json.dumps({"rpms": ["/tmp/stale.rpm"]}), encoding="utf-8") + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(root / "state")}): + job = tools._start_background_command( + "broken build", + [str(root / "does-not-exist")], + timeout=5, + metadata_path=metadata, + ) + status = self.wait_for_terminal(Path(job["status_path"])) + + self.assertEqual(status["state"], "failed") + self.assertEqual(status["failure_class"], "supervisor") + self.assertNotIn("artifacts", status) + + def test_job_list_reconciles_lost_supervisor(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + state = root / "state" + job_dir = state / "builds" / "build-lost" + job_dir.mkdir(parents=True) + tools._write_json_atomic( + job_dir / "status.json", + {"job_id": "build-lost", "state": "running", "supervisor_pid": 99999999}, + ) + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(state)}): + response = tools.handle_build_status(self.make_config(root), {}) + + self.assertEqual(response["structuredContent"]["jobs"][0]["state"], "failed") + self.assertEqual( + response["structuredContent"]["jobs"][0]["failure_class"], + "supervisor-lost", + ) + + def test_background_job_can_be_cancelled(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + state = root / "state" + config = self.make_config(root) + with patch.dict(os.environ, {"SAILFISH_DEVEL_MCP_STATE_DIR": str(state)}): + job = tools._start_background_command("slow build", ["/bin/sleep", "30"], timeout=60) + status_path = Path(job["status_path"]) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + status = tools._read_job_status(status_path) or {} + if status.get("state") == "running": + break + time.sleep(0.05) + response = tools.handle_build_cancel(config, {"job_id": job["job_id"]}) + status = self.wait_for_terminal(status_path) + + self.assertFalse(response["isError"]) + self.assertEqual(status["state"], "cancelled") + self.assertTrue(status["cancelled"]) + + def test_build_command_exposes_helper_controls(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "rpm").mkdir() + (root / "rpm" / "sample.spec").write_text("Name: sample\n", encoding="utf-8") + (root / "build_sailfishos.py").write_text("# helper\n", encoding="utf-8") + command, project = tools._sailfish_build_command( + self.make_config(root), + { + "project_path": str(root), + "backend": "local", + "local_sdk": "/srv/mer/sdks/sfossdk/sdk-chroot", + "target": "aarch64-devel", + "pull_policy": "missing", + "no_vcs_apply": True, + "allow_untrusted_rpms": True, + }, + dry_run=True, + ) + + self.assertEqual(project, root) + self.assertIn("--backend", command) + self.assertIn("/srv/mer/sdks/sfossdk/sdk-chroot", command) + self.assertIn("aarch64-devel", command) + self.assertIn("missing", command) + self.assertIn("--no-vcs-apply", command) + self.assertIn("--allow-untrusted-rpms", command) + self.assertEqual(command[-2:], ["--dry-run", "--json"]) + + def test_build_command_rejects_invalid_arch_shape(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "build_sailfishos.py").write_text("# helper\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "arch must be"): + tools._sailfish_build_command( + self.make_config(root), + {"project_path": str(root), "arch": ["aarch64", 7]}, + ) + + def test_build_command_rejects_untrusted_local_sdk_mount(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "build_sailfishos.py").write_text("# helper\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "under /srv/mer"): + tools._sailfish_build_command( + self.make_config(root), + { + "project_path": str(root), + "backend": "local", + "local_sdk": "/etc/passwd", + }, + ) + + def test_preflight_returns_parsed_plan(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "rpm").mkdir() + (root / "rpm" / "sample.spec").write_text("Name: sample\n", encoding="utf-8") + (root / "build_sailfishos.py").write_text("# helper\n", encoding="utf-8") + plan = {"backend": "docker", "mutates_project": False} + result = CommandResult(("python3",), 0, json.dumps(plan), "") + with patch("sailfish_devel_mcp.tools.run", return_value=result): + response = tools.handle_build_preflight( + self.make_config(root), + {"project_path": str(root), "release": "5.0.0.43", "arch": "aarch64"}, + ) + + self.assertFalse(response["isError"]) + self.assertEqual(response["structuredContent"]["plan"], plan) + + def test_android_job_scripts_are_atomic_and_identity_checked(self): + host = AndroidBuildHostConfig( + name="builder", + ssh_target="builder@example", + project_dir="/src/android", + state_dir="/tmp/builds", + ) + start = tools._android_build_start_command( + host=host, + project_dir=host.project_dir, + state_dir=host.state_dir, + job_id="android-test", + shell="bash", + shell_command="m services", + build_timeout=3600, + ) + cancel = tools._android_build_cancel_command(host.state_dir, "android-test") + encoded = re.search(r" ([A-Za-z0-9+/=]+) \| base64 -d", start) + self.assertIsNotNone(encoded) + run_script = base64.b64decode(encoded.group(1)).decode("utf-8") + + self.assertIn('if ! mkdir "$job_dir"', start) + self.assertNotIn('mkdir -p "$state_dir" "$job_dir"', start) + self.assertIn("nohup setsid", start) + self.assertIn("process_start", start) + self.assertIn("build_timeout=3600", run_script) + self.assertIn("android-build-watchdog", run_script) + self.assertIn('kill -KILL -"$2"', run_script) + self.assertIn("expected_start", cancel) + self.assertIn('kill -TERM -"$pid"', cancel) + + def test_vendored_helper_has_versioned_interface(self): + self.assertEqual(build_sailfishos.HELPER_VERSION, "2.0.0") + args = build_sailfishos.parse_args( + ["--backend", "docker", "--pull-policy", "never", "--dry-run", "--json"] + ) + self.assertEqual(args.backend, "docker") + self.assertEqual(args.pull_policy, "never") + + def test_android_timeout_terminates_remote_process_group(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "project" + state = root / "state" + project.mkdir() + host = AndroidBuildHostConfig( + name="local-test", + ssh_target="unused", + project_dir=str(project), + state_dir=str(state), + ) + start = tools._android_build_start_command( + host=host, + project_dir=str(project), + state_dir=str(state), + job_id="android-timeout", + shell="sh", + shell_command="sleep 30", + build_timeout=1, + ) + started = subprocess.run(["sh", "-c", start], check=False, capture_output=True, text=True) + self.assertEqual(started.returncode, 0, started.stderr) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not (state / "android-timeout" / "timed_out").exists(): + time.sleep(0.05) + timed_out = (state / "android-timeout" / "timed_out").exists() + pid = int((state / "android-timeout" / "pid").read_text(encoding="utf-8")) + expected_start = (state / "android-timeout" / "process_start").read_text(encoding="utf-8").strip() + actual_start = tools._process_start_time(pid) + while time.monotonic() < deadline and actual_start == expected_start: + time.sleep(0.05) + actual_start = tools._process_start_time(pid) + + self.assertTrue(timed_out) + self.assertNotEqual(actual_start, expected_start) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 3a80e80..232d411 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -65,17 +65,26 @@ class McpServerTests(unittest.TestCase): response = server.handle( {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} ) - names = {tool["name"] for tool in response["result"]["tools"]} + tools = response["result"]["tools"] + names = {tool["name"] for tool in tools} self.assertIn("sailfish_device_topmost_pid", names) self.assertIn("sailfish_device_touch", names) self.assertIn("sailfish_device_touch_workflow", names) self.assertIn("sailfish_device_user_session_command", names) self.assertIn("sailfish_device_browser_launch", names) self.assertIn("sailfish_sdk_refresh_metadata", names) + self.assertIn("sailfish_build_preflight", names) + self.assertIn("sailfish_build_cancel", names) self.assertIn("sailfish_android_build_hosts", names) self.assertIn("sailfish_android_build", names) self.assertIn("sailfish_android_build_status", names) + self.assertIn("sailfish_android_build_cancel", names) self.assertIn("sailfish_qml_check_translator_ternaries", names) + obs_results = next(tool for tool in tools if tool["name"] == "sailfish_obs_results") + self.assertEqual( + obs_results["inputSchema"]["properties"]["server"]["enum"], + ["internal", "partner", "community"], + ) def test_qml_ternary_checker_reports_inline_ternary_qstrid(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -608,6 +617,78 @@ class McpServerTests(unittest.TestCase): "/build/home%3Aexample/5.0.0/aarch64/browser/_log?nostream=1", ) + def test_obs_results_selects_named_servers(self) -> None: + aliases = { + "internal": "jolla", + "partner": "partner", + "community": "community", + } + with tempfile.TemporaryDirectory() as tmp: + mcp_server = self.make_server(Path(tmp)) + for named_server, api_alias in aliases.items(): + with self.subTest(server=named_server): + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + mocked_run.return_value = CommandResult(("osc",), 0, "", "") + response = mcp_server.handle( + { + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "sailfish_obs_results", + "arguments": { + "project": "home:example", + "package": "browser", + "server": named_server, + }, + }, + } + ) + + self.assertFalse(response["result"].get("isError", False)) + argv = list(mocked_run.call_args.args[0]) + self.assertEqual( + argv, + [ + "osc", + "-A", + api_alias, + "results", + "home:example", + "browser", + ], + ) + structured = response["result"]["structuredContent"] + self.assertEqual(structured["server"], named_server) + self.assertEqual(structured["api_alias"], api_alias) + + def test_obs_results_rejects_server_with_api_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + server = self.make_server(Path(tmp)) + with patch("sailfish_devel_mcp.tools.run") as mocked_run: + response = server.handle( + { + "jsonrpc": "2.0", + "id": 14, + "method": "tools/call", + "params": { + "name": "sailfish_obs_results", + "arguments": { + "project": "home:example", + "server": "partner", + "api_alias": "community", + }, + }, + } + ) + + self.assertTrue(response["result"].get("isError", False)) + self.assertIn( + "server and api_alias cannot be combined", + response["result"]["content"][0]["text"], + ) + mocked_run.assert_not_called() + def test_sdk_refresh_metadata_uses_local_sdk_main_target(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) |
