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 /src/sailfish_devel_mcp/tools.py | |
| 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.
Diffstat (limited to 'src/sailfish_devel_mcp/tools.py')
| -rw-r--r-- | src/sailfish_devel_mcp/tools.py | 805 |
1 files changed, 705 insertions, 100 deletions
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), }, |
