mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
refactor: refuse unsandboxed campaign runs
Without a working bubblewrap the pass is refused and nothing third-party runs, probes require the sandbox and builds are refused, unless --allow-unsandboxed is given. A job report holding an alert raises an autorun alert.
This commit is contained in:
1 parent
e440073562
commit
fd449fdae3
5 files changed
+93
-6
No files matched your search
@@ -13,8 +13,8 @@
|
|||||||
"started_at": { "type": "string", "format": "date-time" },
|
"started_at": { "type": "string", "format": "date-time" },
|
||||||
"ended_at": { "type": "string", "format": "date-time" },
|
"ended_at": { "type": "string", "format": "date-time" },
|
||||||
"result": {
|
"result": {
|
||||||
"enum": ["complete", "budget", "paused", "stopped"],
|
"enum": ["complete", "budget", "paused", "stopped", "refused"],
|
||||||
"description": "complete: nothing runnable was left. budget: the time budget ended the pass. paused: a busy host or a full disk ended it, the next pass resumes. stopped: interrupted."
|
"description": "complete: nothing runnable was left. budget: the time budget ended the pass. paused: a busy host or a full disk ended it, the next pass resumes. stopped: interrupted. refused: no sandbox for third-party code, nothing ran."
|
||||||
},
|
},
|
||||||
"detail": { "type": "string" },
|
"detail": { "type": "string" },
|
||||||
"counts": {
|
"counts": {
|
||||||
|
|||||||
@@ -1006,6 +1006,11 @@ def run_job(ctx: Context, name: str) -> tuple[str, str]:
|
|||||||
else previous.get("consecutive_failures", 0) + 1,
|
else previous.get("consecutive_failures", 0) + 1,
|
||||||
}
|
}
|
||||||
state["jobs"][name] = record
|
state["jobs"][name] = record
|
||||||
|
# A job's report can raise an alert of its own: campaign.py refusing to
|
||||||
|
# run third-party code outside its sandbox.
|
||||||
|
alert = runner.report.get("alert") if isinstance(runner.report, dict) else None
|
||||||
|
if result != DEFERRED:
|
||||||
|
set_alert(state, f"job:{name}", bool(alert), str(alert or ""), now_iso())
|
||||||
prune_logs(ctx.home / "logs")
|
prune_logs(ctx.home / "logs")
|
||||||
save_state(ctx.state_path, state)
|
save_state(ctx.state_path, state)
|
||||||
return result, detail
|
return result, detail
|
||||||
|
|||||||
+32
-4
@@ -91,6 +91,7 @@ PROBE_TIMEOUT = 900
|
|||||||
BUILD_TIMEOUT = 3600
|
BUILD_TIMEOUT = 3600
|
||||||
LOG_DAYS = 30
|
LOG_DAYS = 30
|
||||||
ROMFETCH_TRANSIENT = 3
|
ROMFETCH_TRANSIENT = 3
|
||||||
|
EXIT_REFUSED = 3
|
||||||
|
|
||||||
|
|
||||||
class Pause(RuntimeError):
|
class Pause(RuntimeError):
|
||||||
@@ -233,6 +234,9 @@ class Settings:
|
|||||||
network: bool = True
|
network: bool = True
|
||||||
delays: tuple[float, ...] = NETWORK_DELAYS
|
delays: tuple[float, ...] = NETWORK_DELAYS
|
||||||
echo: bool = True
|
echo: bool = True
|
||||||
|
# Third-party code (a nightly core, a project's Makefile) runs only under
|
||||||
|
# bubblewrap unless this is set.
|
||||||
|
allow_unsandboxed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -656,7 +660,10 @@ class Cores:
|
|||||||
makefile = "Makefile.libretro" if (src / "Makefile.libretro").is_file() else "Makefile"
|
makefile = "Makefile.libretro" if (src / "Makefile.libretro").is_file() else "Makefile"
|
||||||
jobs = str(max(1, (os.cpu_count() or 2) // 2))
|
jobs = str(max(1, (os.cpu_count() or 2) // 2))
|
||||||
command = ["make", "-f", makefile, f"-j{jobs}", "platform=unix"]
|
command = ["make", "-f", makefile, f"-j{jobs}", "platform=unix"]
|
||||||
prefix, isolation = core_probe.sandbox_argv([src], private_tmp=True)
|
prefix, isolation = core_probe.sandbox_argv([src])
|
||||||
|
if isolation != core_probe.SANDBOX and not self.c.settings.allow_unsandboxed:
|
||||||
|
raise Unavailable(f"refused: no sandbox to build {source} in ({isolation}), "
|
||||||
|
f"--allow-unsandboxed overrides")
|
||||||
step(prefix + command, cwd=src, timeout=BUILD_TIMEOUT)
|
step(prefix + command, cwd=src, timeout=BUILD_TIMEOUT)
|
||||||
built = sorted(src.rglob("*_libretro.so"), key=lambda p: p.stat().st_mtime)
|
built = sorted(src.rglob("*_libretro.so"), key=lambda p: p.stat().st_mtime)
|
||||||
if not built:
|
if not built:
|
||||||
@@ -842,6 +849,8 @@ class Campaign:
|
|||||||
"--emulator", emulator, "--subject", subject,
|
"--emulator", emulator, "--subject", subject,
|
||||||
"--max-load", str(self.host.max_load), "--timeout", str(self.settings.probe_timeout),
|
"--max-load", str(self.host.max_load), "--timeout", str(self.settings.probe_timeout),
|
||||||
"--work-dir", str(self.paths.root / "tmp" / "core_probe")]
|
"--work-dir", str(self.paths.root / "tmp" / "core_probe")]
|
||||||
|
if not self.settings.allow_unsandboxed:
|
||||||
|
argv.append("--require-sandbox")
|
||||||
if core.artifact:
|
if core.artifact:
|
||||||
argv += ["--core-artifact", core.artifact]
|
argv += ["--core-artifact", core.artifact]
|
||||||
if core.repo:
|
if core.repo:
|
||||||
@@ -1232,6 +1241,16 @@ class Campaign:
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
self.describe(units, system)
|
self.describe(units, system)
|
||||||
return current
|
return current
|
||||||
|
isolation = core_probe.sandbox_argv([])[1]
|
||||||
|
if isolation != core_probe.SANDBOX and not self.settings.allow_unsandboxed:
|
||||||
|
# Fail closed: no core and no build runs outside the sandbox.
|
||||||
|
current.result = "refused"
|
||||||
|
current.detail = (f"no sandbox for third-party code ({isolation}). Install bubblewrap, "
|
||||||
|
f"or pass --allow-unsandboxed")
|
||||||
|
self.log(f"pass refused: {current.detail}")
|
||||||
|
self.write_run_line(current)
|
||||||
|
save_state(self.paths.state, self.state)
|
||||||
|
return current
|
||||||
for unit in units:
|
for unit in units:
|
||||||
for content in self.unit_contents(unit, system):
|
for content in self.unit_contents(unit, system):
|
||||||
self.store.hold(content, unit.emulator)
|
self.store.hold(content, unit.emulator)
|
||||||
@@ -1500,6 +1519,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
run_p.add_argument("--romfetch", default="",
|
run_p.add_argument("--romfetch", default="",
|
||||||
help=f"romfetch command (default: ${ROMFETCH_ENV}, else romfetch on PATH)")
|
help=f"romfetch command (default: ${ROMFETCH_ENV}, else romfetch on PATH)")
|
||||||
run_p.add_argument("--offline", action="store_true", help="no buildbot, no romfetch, no build")
|
run_p.add_argument("--offline", action="store_true", help="no buildbot, no romfetch, no build")
|
||||||
|
run_p.add_argument("--allow-unsandboxed", action="store_true",
|
||||||
|
help="run cores and builds without bubblewrap when it is missing (refused by default)")
|
||||||
run_p.add_argument("--dry-run", action="store_true", help="print the plan, run nothing")
|
run_p.add_argument("--dry-run", action="store_true", help="print the plan, run nothing")
|
||||||
run_p.add_argument("--commit", action="store_true",
|
run_p.add_argument("--commit", action="store_true",
|
||||||
help="commit records, findings and each changed profile behind the gate")
|
help="commit records, findings and each changed profile behind the gate")
|
||||||
@@ -1535,7 +1556,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
settings = Settings(budget=args.budget, max_load_wait=args.max_load_wait,
|
settings = Settings(budget=args.budget, max_load_wait=args.max_load_wait,
|
||||||
min_free_gb=args.min_free_gb, cache_cap_gb=args.cache_cap_gb,
|
min_free_gb=args.min_free_gb, cache_cap_gb=args.cache_cap_gb,
|
||||||
romfetch=args.romfetch, network=not args.offline)
|
romfetch=args.romfetch, network=not args.offline,
|
||||||
|
allow_unsandboxed=args.allow_unsandboxed)
|
||||||
campaign = Campaign(paths, settings)
|
campaign = Campaign(paths, settings)
|
||||||
if args.command == "status":
|
if args.command == "status":
|
||||||
report = status_report(campaign)
|
report = status_report(campaign)
|
||||||
@@ -1559,8 +1581,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
print(f"campaign: deferred, {exc}", file=sys.stderr)
|
print(f"campaign: deferred, {exc}", file=sys.stderr)
|
||||||
return 75
|
return 75
|
||||||
counts, _, _ = campaign.counts()
|
counts, _, _ = campaign.counts()
|
||||||
print(json.dumps({"result": current.result, "detail": current.detail, "counts": counts,
|
report = {"result": current.result, "detail": current.detail, "counts": counts,
|
||||||
"records": len(current.records), "findings": current.findings}))
|
"records": len(current.records), "findings": current.findings}
|
||||||
|
if current.result == "refused":
|
||||||
|
# autorun raises an alert from this key of a job's report.
|
||||||
|
report["alert"] = current.detail
|
||||||
|
print(json.dumps(report))
|
||||||
|
if current.result == "refused":
|
||||||
|
return EXIT_REFUSED
|
||||||
return 75 if current.result == "paused" else 0
|
return 75 if current.result == "paused" else 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,19 @@ class StepEnvironment(Scratch):
|
|||||||
self.assertEqual(given["GITHUB_TOKEN"], "t1")
|
self.assertEqual(given["GITHUB_TOKEN"], "t1")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportAlert(Scratch):
|
||||||
|
def test_a_report_alert_is_raised_then_cleared(self):
|
||||||
|
write = "import json, sys; json.dump({'alert': sys.argv[1]} if sys.argv[1] else {}, open('r.json', 'w'))"
|
||||||
|
for message, expected in (("no sandbox", True), ("", False)):
|
||||||
|
ctx = self.context({"x": {"worktree": False, "min_free_gb": 0, "report": "r.json",
|
||||||
|
"steps": [[PY, "-c", write, message]]}})
|
||||||
|
ar.run_job(ctx, "x")
|
||||||
|
alerts = self.state(ctx).get("alerts", {})
|
||||||
|
self.assertEqual("job:x" in alerts, expected)
|
||||||
|
if expected:
|
||||||
|
self.assertEqual(alerts["job:x"]["detail"], "no sandbox")
|
||||||
|
|
||||||
|
|
||||||
class Retry(Scratch):
|
class Retry(Scratch):
|
||||||
def _runs(self, *outcomes: ar.Outcome):
|
def _runs(self, *outcomes: ar.Outcome):
|
||||||
queue = list(outcomes)
|
queue = list(outcomes)
|
||||||
|
|||||||
@@ -429,6 +429,45 @@ class ChildEnvironment(unittest.TestCase):
|
|||||||
self.assertNotIn("GITHUB_TOKEN", seen[0])
|
self.assertNotIn("GITHUB_TOKEN", seen[0])
|
||||||
|
|
||||||
|
|
||||||
|
class FailClosed(Workspace):
|
||||||
|
"""No core and no build runs outside the sandbox unless asked."""
|
||||||
|
|
||||||
|
def test_a_pass_without_a_sandbox_is_refused(self) -> None:
|
||||||
|
host = FakeHost()
|
||||||
|
host.results = [subprocess.CompletedProcess([], 0, "{}", "")]
|
||||||
|
c = self.make(host)
|
||||||
|
with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None):
|
||||||
|
current = c.run(set(), None)
|
||||||
|
self.assertEqual(current.result, "refused")
|
||||||
|
self.assertIn("bwrap not found", current.detail)
|
||||||
|
self.assertEqual(host.calls, [])
|
||||||
|
self.assertEqual(mr.read_campaign(self.root, mr.CAMPAIGN_RUNS)[-1]["result"], "refused")
|
||||||
|
|
||||||
|
def test_the_override_lets_it_run(self) -> None:
|
||||||
|
c = self.make(allow_unsandboxed=True)
|
||||||
|
c.unit_cores = lambda *a, **k: []
|
||||||
|
with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None):
|
||||||
|
self.assertEqual(c.run(set(), None).result, "complete")
|
||||||
|
|
||||||
|
def test_every_probe_requires_the_sandbox(self) -> None:
|
||||||
|
core = campaign.Core("fake", self.tmp / "x.so", "a" * 64)
|
||||||
|
self.assertIn("--require-sandbox", self.make().probe_args("run", core, "fake", "s", None, None))
|
||||||
|
self.assertNotIn("--require-sandbox", self.make(allow_unsandboxed=True).probe_args(
|
||||||
|
"run", core, "fake", "s", None, None))
|
||||||
|
|
||||||
|
def test_a_build_without_a_sandbox_is_refused(self) -> None:
|
||||||
|
c = self.make()
|
||||||
|
src = c.paths.home / "src" / campaign.slug("https://example.invalid/x")
|
||||||
|
(src / ".git").mkdir(parents=True)
|
||||||
|
(src / "Makefile").write_text("all:\n")
|
||||||
|
host = c.host
|
||||||
|
host.results = [subprocess.CompletedProcess([], 0, "", "")]
|
||||||
|
with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None), \
|
||||||
|
self.assertRaisesRegex(campaign.Unavailable, "refused: no sandbox"):
|
||||||
|
c.cores.compile("https://example.invalid/x", "a" * 40, self.tmp / "out")
|
||||||
|
self.assertFalse(any(call[0] == "make" for call in host.calls))
|
||||||
|
|
||||||
|
|
||||||
class ProbeRuns(Workspace):
|
class ProbeRuns(Workspace):
|
||||||
def completed(self, code: int, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
|
def completed(self, code: int, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
|
||||||
return subprocess.CompletedProcess([], code, stdout, stderr)
|
return subprocess.CompletedProcess([], code, stdout, stderr)
|
||||||
@@ -754,6 +793,8 @@ class FakeCorePasses(Workspace):
|
|||||||
current = c.run(set(), None)
|
current = c.run(set(), None)
|
||||||
self.assertEqual(current.result, "complete")
|
self.assertEqual(current.result, "complete")
|
||||||
records = [s.record for s in mr.read_records(self.root)]
|
records = [s.record for s in mr.read_records(self.root)]
|
||||||
|
isolations = {rep["isolation"] for r in records for v in r["variants"] for rep in v["repetitions"]}
|
||||||
|
self.assertEqual(isolations, {campaign.core_probe.SANDBOX})
|
||||||
kinds = sorted(r["subject"].split("-")[0] for r in records)
|
kinds = sorted(r["subject"].split("-")[0] for r in records)
|
||||||
self.assertEqual(kinds, ["alias", "bench", "boot", "files"])
|
self.assertEqual(kinds, ["alias", "bench", "boot", "files"])
|
||||||
boot = next(r for r in records if r["subject"].startswith("boot"))
|
boot = next(r for r in records if r["subject"].startswith("boot"))
|
||||||
|
|||||||
Reference in new issue
Block a user