summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-08-09 15:03:34 +0200
committerAndrew Branson <andrew.branson@jolla.com>2026-08-09 15:03:34 +0200
commitb9746bfa320cdd1464eb6c91fbadad35c84d2459 (patch)
tree6528b43f66e0bc6b451666c3fb607a927110d304
parenteb92561bd685961889cc553cd96db4a569eefec5 (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.md69
-rw-r--r--pyproject.toml3
-rw-r--r--scripts/update_build_helper.py66
-rw-r--r--src/sailfish_devel_mcp/tools.py805
-rwxr-xr-xsrc/sailfish_devel_mcp/vendor/build_sailfishos.py711
-rw-r--r--tests/test_build_jobs.py318
-rw-r--r--tests/test_server.py83
7 files changed, 1819 insertions, 236 deletions
diff --git a/README.md b/README.md
index a2e01f7..ce5d1a3 100644
--- a/README.md
+++ b/README.md
@@ -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)