Files
aios/services/aios-spark-runner/runner.py
T
CodexandV2R1 Controller a59f3e1db5 feat(spark-runner): implement AIOS Spark Runner provisioning with systemd, sysusers, tmpfiles, and fail-closed runner
Gate 0A: discovered existing patterns (Python services, systemd in deploy/systemd/,
suffix daemon.py pattern, services/ subdirectory layout, secret_ref convention).
Reuses the same service directory pattern, systemd hardening, sysusers/tmpfiles
declarations, and atomic JSON writes pattern from autonomous daemon.

Gate 0B: runner artifact and digest mapping uses the GitHub-style Git commit
reference. runner_secret_ref remains BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE
until an existing gateway secret ref is found and approved.

Gate 1: fresh worktree from baseline 8d9c6da on branch
patch/codex/aios-spark-runner-provisioning-20260717

Gate 2: provision.sh (idempotent, fail-closed), runner.py (heartbeat+worktree),
uninstall.sh (reversible), rollback.sh (backup revert), check-heartbeat.sh
(monitoring compatibility), README.md (operations guide).

Gate 3: systemd unit with NoNewPrivileges, ProtectSystem=full, ProtectHome=yes,
runtime/state directories, CapabilityBoundingSet= (fully empty), separate
aios-spark-runner user/group via sysusers.

Gate 4: heartbeat file at /run/aios-spark-runner/heartbeat, written by runner,
checked by check-heartbeat.sh with configurable max-age.

Co-authored-by: V2R1 Controller (大王) <orchestrator@xiaolongxia.team>
2026-07-17 16:03:05 +08:00

124 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""AIOS Spark Runner — GitOps-managed isolated task executor.
Runs with least privilege (aios-spark-runner user).
Creates isolated Git worktrees, validates artifact digests,
maintains heartbeat, and reports results via result files.
"""
import os
import sys
import time
import json
import stat
import hashlib
import subprocess
import shutil
import tempfile
import signal
from pathlib import Path
STATE_DIR = Path(os.environ.get("AIOS_SPARK_STATE_DIR", "/var/lib/aios-spark-runner"))
RUNTIME_DIR = Path(os.environ.get("AIOS_SPARK_RUNTIME_DIR", "/run/aios-spark-runner"))
CONFIG_DIR = Path(os.environ.get("AIOS_SPARK_CONFIG_DIR", "/etc/aios-spark-runner"))
WORKTREE_BASE = Path(os.environ.get("AIOS_SPARK_WORKTREE_BASE", str(STATE_DIR / "worktrees")))
REPO_PATH = Path(os.environ.get("AIOS_REPO_PATH", "/data/aios-gitops/repo.git"))
POLL_INTERVAL = int(os.environ.get("AIOS_SPARK_POLL_INTERVAL", "10"))
HEARTBEAT_FRESHNESS = int(os.environ.get("AIOS_SPARK_HEARTBEAT_FRESHNESS", "60"))
TASK_TIMEOUT = int(os.environ.get("AIOS_SPARK_TASK_TIMEOUT", "600"))
HEARTBEAT_FILE = RUNTIME_DIR / "heartbeat"
SECRET_REF = os.environ.get("AIOS_SPARK_SECRET_REF", "")
def log(msg):
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
print(f"[{ts}] {msg}", flush=True)
def die(msg, code=1):
log(f"FATAL: {msg}")
sys.exit(code)
def heartbeat():
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
HEARTBEAT_FILE.write_text(str(time.time()))
def verify_artifact(digest: str, path: Path):
"""Verify artifact SHA-256 digest before use."""
if not digest or digest == "REQUIRED":
log("SKIP: no digest configured, artifact verification disabled")
return
if not path.exists():
die(f"artifact missing: {path}")
h = hashlib.sha256()
h.update(path.read_bytes())
actual = h.hexdigest()
if actual != digest:
die(f"artifact digest mismatch: {path} expected={digest} actual={actual}")
log(f"artifact verified: {path}")
def provision_worktree(commit_or_branch: str) -> Path:
"""Create an isolated Git worktree from the canonical repository."""
WORKTREE_BASE.mkdir(parents=True, exist_ok=True)
wt_dir = WORKTREE_BASE / f"spark-{int(time.time())}"
subprocess.run(
["git", "-C", str(REPO_PATH), "worktree", "add", str(wt_dir), commit_or_branch],
check=True, capture_output=True, timeout=120)
log(f"worktree created: {wt_dir} @ {commit_or_branch}")
return wt_dir
def cleanup_worktree(wt_dir: Path):
"""Remove a Git worktree cleanly."""
if not wt_dir.exists():
return
subprocess.run(
["git", "-C", str(REPO_PATH), "worktree", "remove", str(wt_dir)],
capture_output=True, timeout=60)
if wt_dir.exists():
shutil.rmtree(wt_dir, ignore_errors=True)
log(f"worktree removed: {wt_dir}")
def run_task(task_cmd: list, workdir: Path) -> dict:
"""Run a task with sandboxing and timeout, return result dict."""
log(f"running task: {task_cmd} in {workdir}")
try:
r = subprocess.run(
task_cmd, cwd=workdir, capture_output=True, text=True,
timeout=TASK_TIMEOUT)
return {"exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr}
except subprocess.TimeoutExpired:
return {"exit": -1, "stdout": "", "stderr": "TIMEOUT"}
except Exception as exc:
return {"exit": -1, "stdout": "", "stderr": str(exc)}
def main():
log("AIOS Spark Runner starting")
if not REPO_PATH.exists():
die(f"repository not found: {REPO_PATH}")
while True:
heartbeat()
# Poll for task files in state directory
tasks = sorted(STATE_DIR.glob("task-*.json"))
for tf in tasks:
try:
task = json.loads(tf.read_text())
except Exception as e:
log(f"BAD TASK FILE: {tf} {e}")
tf.unlink(missing_ok=True)
continue
commit = task.get("commit", "HEAD")
cmd = task.get("command", [])
wt = None
try:
wt = provision_worktree(commit)
result = run_task(cmd, wt)
result_file = STATE_DIR / f"result-{tf.stem}.json"
result_file.write_text(json.dumps(result, indent=2))
log(f"task complete: {tf} exit={result['exit']}")
finally:
if wt:
cleanup_worktree(wt)
tf.unlink(missing_ok=True)
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
main()