summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)