Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59f3e1db5 |
@@ -0,0 +1,33 @@
|
||||
[Unit]
|
||||
Description=AIOS Spark Isolated Runner
|
||||
Documentation=https://docs.openclaw.ai
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-spark-runner
|
||||
Group=aios-spark-runner
|
||||
ExecStart=/opt/aios-spark-runner/runner.py
|
||||
EnvironmentFile=/etc/aios-spark-runner/env.conf
|
||||
WatchdogSec=90
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
TimeoutStopSec=30
|
||||
KillMode=control-group
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
LimitNOFILE=4096
|
||||
WorkingDirectory=/opt/aios-spark-runner
|
||||
RuntimeDirectory=aios-spark-runner
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-spark-runner
|
||||
StateDirectoryMode=0750
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by AIOS GitOps. Do not edit manually.
|
||||
u aios-spark-runner - "AIOS Spark Runner" /var/lib/aios-spark-runner
|
||||
g aios-spark-runner - -
|
||||
@@ -0,0 +1,3 @@
|
||||
# Managed by AIOS GitOps. Do not edit manually.
|
||||
d /run/aios-spark-runner 0750 aios-spark-runner aios-spark-runner -
|
||||
d /var/lib/aios-spark-runner/worktrees 0750 aios-spark-runner aios-spark-runner -
|
||||
@@ -0,0 +1,51 @@
|
||||
# AIOS Spark Runner — GitOps-Managed Isolated Task Executor
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `provision.sh` | Idempotent provisioning script (run as root). Creates user, group, directories, installs runner.py, configures systemd. |
|
||||
| `uninstall.sh` | Reverses provisioning: stops service, removes user/group/files. |
|
||||
| `rollback.sh` | Restores previous runner.py from backup; limited safety net. |
|
||||
| `runner.py` | The runner daemon — polls for task JSON files, creates isolated Git worktrees, runs commands, reports results. |
|
||||
| `check-heartbeat.sh` | Nagios/Icinga-compatible heartbeat freshness check. |
|
||||
| `README.md` | This file. |
|
||||
|
||||
## Systemd Integration
|
||||
|
||||
`../deploy/systemd/aios-spark-runner.service` — systemd unit with sandboxing:
|
||||
- NoNewPrivileges, ProtectSystem=full, ProtectHome=yes
|
||||
- RuntimeDirectory, StateDirectory for heartbeat and task state
|
||||
- Restart on failure, WatchdogSec=90
|
||||
|
||||
`../deploy/systemd/aios-spark-runner.sysusers` — declarative user/group creation
|
||||
`../deploy/systemd/aios-spark-runner.tmpfiles` — runtime and worktree directories
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Provision (root)
|
||||
sudo ./services/aios-spark-runner/provision.sh
|
||||
|
||||
# Submit a task
|
||||
cat > /var/lib/aios-spark-runner/task-example.json <<'EOF'
|
||||
{
|
||||
"commit": "main",
|
||||
"command": ["echo", "hello world"]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Check heartbeat
|
||||
./services/aios-spark-runner/check-heartbeat.sh 60
|
||||
|
||||
# Uninstall
|
||||
sudo ./services/aios-spark-runner/uninstall.sh
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- The runner runs as `aios-spark-runner`, a non-privileged system user.
|
||||
- Secrets must be supplied via `AIOS_SPARK_SECRET_REF` in `env.conf` (never committed).
|
||||
- No plaintext secrets in Git, logs, task files, environment dumps, or RESULT files.
|
||||
- Artifact digest verification prevents execution of unapproved runner binaries.
|
||||
- All Git worktrees created in isolated state directory, cleaned up after task completion.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Check that the aios-spark-runner service is alive via heartbeat file.
|
||||
set -euo pipefail
|
||||
|
||||
HEARTBEAT_FILE="/run/aios-spark-runner/heartbeat"
|
||||
MAX_AGE_SEC="${1:-60}"
|
||||
|
||||
if [ ! -f "$HEARTBEAT_FILE" ]; then
|
||||
echo "CRITICAL: no heartbeat file at $HEARTBEAT_FILE"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NOW=$(date +%s)
|
||||
MTIME=$(stat -c %Y "$HEARTBEAT_FILE")
|
||||
AGE=$((NOW - MTIME))
|
||||
|
||||
if [ "$AGE" -gt "$MAX_AGE_SEC" ]; then
|
||||
echo "CRITICAL: heartbeat stale (age=${AGE}s, max=${MAX_AGE_SEC}s)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "OK: heartbeat fresh (age=${AGE}s)"
|
||||
exit 0
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# AIOS Spark Runner Provisioning Script
|
||||
# Must run as root.
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/aios-spark-runner
|
||||
CONFIG_DIR=/etc/aios-spark-runner
|
||||
STATE_DIR=/var/lib/aios-spark-runner
|
||||
SERVICE_NAME=aios-spark-runner.service
|
||||
SYSTEMD_UNIT_DIR=/etc/systemd/system
|
||||
SYSUSERS_DIR=/etc/sysusers.d
|
||||
TMPFILES_DIR=/etc/tmpfiles.d
|
||||
REPO_PATH=/data/aios-gitops/repo.git
|
||||
RUN_USER=aios-spark-runner
|
||||
RUN_GROUP=aios-spark-runner
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "FATAL: must be root"; exit 1; }
|
||||
which systemctl >/dev/null || { echo "FATAL: no systemd"; exit 1; }
|
||||
|
||||
# user/group
|
||||
if ! getent group "$RUN_GROUP" >/dev/null 2>&1; then groupadd --system "$RUN_GROUP"; fi
|
||||
if ! getent passwd "$RUN_USER" >/dev/null 2>&1; then useradd --system --gid "$RUN_GROUP" --no-create-home "$RUN_USER"; fi
|
||||
|
||||
# dirs
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR"
|
||||
chown "$RUN_USER":"$RUN_GROUP" "$STATE_DIR"; chmod 0750 "$STATE_DIR"
|
||||
|
||||
# install runner
|
||||
cp "$SRC_DIR/runner.py" "$INSTALL_DIR/runner.py"
|
||||
chown root:root "$INSTALL_DIR/runner.py"; chmod 0755 "$INSTALL_DIR/runner.py"
|
||||
|
||||
# env conf
|
||||
tee "$CONFIG_DIR/env.conf" > /dev/null <<CONFEOF
|
||||
AIOS_SPARK_STATE_DIR=$STATE_DIR
|
||||
AIOS_SPARK_RUNTIME_DIR=/run/aios-spark-runner
|
||||
AIOS_SPARK_CONFIG_DIR=$CONFIG_DIR
|
||||
AIOS_SPARK_WORKTREE_BASE=$STATE_DIR/worktrees
|
||||
AIOS_REPO_PATH=$REPO_PATH
|
||||
AIOS_SPARK_ARTIFACT_DIGEST=${AIOS_SPARK_ARTIFACT_DIGEST:-}
|
||||
AIOS_SPARK_SOURCE_COMMIT=${AIOS_SPARK_SOURCE_COMMIT:-}
|
||||
AIOS_SPARK_RUN_USER=$RUN_USER
|
||||
AIOS_SPARK_HEARTBEAT_FRESHNESS=60
|
||||
AIOS_SPARK_TASK_TIMEOUT=600
|
||||
AIOS_SPARK_POLL_INTERVAL=10
|
||||
CONFEOF
|
||||
chown root:"$RUN_GROUP" "$CONFIG_DIR/env.conf"; chmod 0640 "$CONFIG_DIR/env.conf"
|
||||
|
||||
# sysusers
|
||||
if [ -f "$SRC_DIR/../../deploy/systemd/aios-spark-runner.sysusers" ]; then
|
||||
cp "$SRC_DIR/../../deploy/systemd/aios-spark-runner.sysusers" "$SYSUSERS_DIR/aios-spark-runner.conf"
|
||||
chmod 0644 "$SYSUSERS_DIR/aios-spark-runner.conf"
|
||||
systemd-sysusers 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# tmpfiles
|
||||
if [ -f "$SRC_DIR/../../deploy/systemd/aios-spark-runner.tmpfiles" ]; then
|
||||
cp "$SRC_DIR/../../deploy/systemd/aios-spark-runner.tmpfiles" "$TMPFILES_DIR/aios-spark-runner.conf"
|
||||
chmod 0644 "$TMPFILES_DIR/aios-spark-runner.conf"
|
||||
systemd-tmpfiles --create "$TMPFILES_DIR/aios-spark-runner.conf" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# systemd unit
|
||||
cp "$SRC_DIR/../../deploy/systemd/aios-spark-runner.service" "$SYSTEMD_UNIT_DIR/$SERVICE_NAME"
|
||||
chmod 0644 "$SYSTEMD_UNIT_DIR/$SERVICE_NAME"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl start "$SERVICE_NAME" || echo "WARN: service start failed, check journalctl"
|
||||
|
||||
echo "OK: provision complete"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# AIOS Spark Runner Rollback Script
|
||||
# Reverts to the previous installed version using backups.
|
||||
set -euo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "FATAL: must be root"; exit 1; }
|
||||
|
||||
BACKUP_DIR=/var/lib/aios-spark-runner/backups
|
||||
INSTALL_DIR=/opt/aios-spark-runner
|
||||
|
||||
LATEST=$(ls -dt "$BACKUP_DIR"/* 2>/dev/null | head -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo "FATAL: no backup found at $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rolling back to: $LATEST"
|
||||
systemctl stop aios-spark-runner.service 2>/dev/null || true
|
||||
cp "$LATEST/runner.py" "$INSTALL_DIR/runner.py" 2>/dev/null || echo "WARN: no runner.py backup"
|
||||
systemctl start aios-spark-runner.service || echo "WARN: restart failed"
|
||||
echo "OK: rollback to $LATEST complete"
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/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()
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# AIOS Spark Runner Uninstall Script
|
||||
set -euo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "FATAL: must be root"; exit 1; }
|
||||
|
||||
SERVICE_NAME=aios-spark-runner.service
|
||||
SYSUSERS_DIR=/etc/sysusers.d
|
||||
TMPFILES_DIR=/etc/tmpfiles.d
|
||||
STATE_DIR=/var/lib/aios-spark-runner
|
||||
RUN_USER=aios-spark-runner
|
||||
RUN_GROUP=aios-spark-runner
|
||||
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/$SERVICE_NAME"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf /opt/aios-spark-runner /etc/aios-spark-runner "$STATE_DIR" /run/aios-spark-runner
|
||||
rm -f "$SYSUSERS_DIR/aios-spark-runner.conf" "$TMPFILES_DIR/aios-spark-runner.conf"
|
||||
|
||||
userdel "$RUN_USER" 2>/dev/null || true
|
||||
groupdel "$RUN_GROUP" 2>/dev/null || true
|
||||
|
||||
echo "OK: uninstall complete"
|
||||
Reference in New Issue
Block a user