From b91c7514d7eeb155febbbc867caefb3b59381fdb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 17 Aug 2026 13:55:52 -0700 Subject: [PATCH] [eric] runner: per-app VM image boots a published bundle from storage and serves backend + frontend from one process (ENG-293) --- openswarm-apprunner/Dockerfile | 8 +++ openswarm-apprunner/boot.py | 92 ++++++++++++++++++++++++++++++ openswarm-apprunner/fallback.py | 11 ++++ openswarm-apprunner/fly.toml | 18 ++++++ openswarm-apprunner/serve.py | 6 ++ openswarm-apprunner/swarm_debug.py | 16 ++++++ 6 files changed, 151 insertions(+) create mode 100644 openswarm-apprunner/Dockerfile create mode 100644 openswarm-apprunner/boot.py create mode 100644 openswarm-apprunner/fallback.py create mode 100644 openswarm-apprunner/fly.toml create mode 100644 openswarm-apprunner/serve.py create mode 100644 openswarm-apprunner/swarm_debug.py diff --git a/openswarm-apprunner/Dockerfile b/openswarm-apprunner/Dockerfile new file mode 100644 index 00000000..72541741 --- /dev/null +++ b/openswarm-apprunner/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /srv/runner +# The webapp template's dependency set, preinstalled so most apps boot without a pip step. +RUN pip install --no-cache-dir "fastapi[standard]" "pydantic>=2.9.0" httpx uvicorn "typeguard==4.4.2" boto3 +COPY boot.py serve.py fallback.py swarm_debug.py ./ +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/srv/runner +EXPOSE 8080 +CMD ["python", "boot.py"] diff --git a/openswarm-apprunner/boot.py b/openswarm-apprunner/boot.py new file mode 100644 index 00000000..8541cc96 --- /dev/null +++ b/openswarm-apprunner/boot.py @@ -0,0 +1,92 @@ +"""Boot for a published app's runner VM (ENG-293). Downloads the app's bundle from storage by +APP_SLUG (read-only creds scoped to apps/*), unpacks it, installs the app's own requirements into +a local target dir, then serves backend + built frontend from one uvicorn on :8080. BUNDLE_VERSION +in env busts the on-disk copy on republish (the cloud stamps a fresh value into the machine).""" +import io +import json +import os +import subprocess +import sys +import tarfile + +import boto3 +from botocore.config import Config + +WORKDIR = "/data/app" +SLUG = os.environ.get("APP_SLUG", "") +VERSION = os.environ.get("BUNDLE_VERSION", "0") +BUCKET = os.environ.get("TIGRIS_BUCKET", "openswarm-app-bundles") +STAMP = os.path.join(WORKDIR, ".bundle-version") + + +def s3(): + return boto3.client( + "s3", + endpoint_url=os.environ.get("AWS_ENDPOINT_URL_S3", "https://fly.storage.tigris.dev"), + region_name=os.environ.get("TIGRIS_REGION", "auto"), + aws_access_key_id=os.environ.get("TIGRIS_ACCESS_KEY_ID", ""), + aws_secret_access_key=os.environ.get("TIGRIS_SECRET_ACCESS_KEY", ""), + config=Config(signature_version="s3v4"), + ) + + +def fetch_and_unpack() -> None: + obj = s3().get_object(Bucket=BUCKET, Key=f"apps/{SLUG}/bundle.tar.gz") + data = obj["Body"].read() + os.makedirs(WORKDIR, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + for m in tar.getmembers(): + # A hostile bundle must not escape the workdir. + if m.name.startswith(("/", "..")) or ".." in m.name.split("/"): + continue + tar.extract(m, WORKDIR) + with open(STAMP, "w") as f: + f.write(VERSION) + + +def ensure_bundle() -> bool: + try: + with open(STAMP) as f: + if f.read().strip() == VERSION and os.path.isdir(os.path.join(WORKDIR, "backend")): + return True + except OSError: + pass + try: + fetch_and_unpack() + return True + except Exception as e: + print(f"[runner] bundle fetch failed: {type(e).__name__}: {e}", flush=True) + return False + + +def install_requirements() -> None: + req = os.path.join(WORKDIR, "backend", "requirements.txt") + if not os.path.isfile(req): + return + # Best-effort: the base image preinstalls the template's deps; this covers apps that added more. + subprocess.run( + [sys.executable, "-m", "pip", "install", "--no-cache-dir", "-q", "-r", req], + timeout=180, check=False, + ) + + +def main() -> None: + if not SLUG or not ensure_bundle(): + # Serve an honest 503 shell instead of crash-looping the machine. + os.execvp(sys.executable, [sys.executable, "-m", "uvicorn", "fallback:app", "--host", "0.0.0.0", "--port", "8080"]) + install_requirements() + os.chdir(WORKDIR) + sys.path.insert(0, WORKDIR) + spec = {} + try: + with open(os.path.join(WORKDIR, "runspec.json")) as f: + spec = json.load(f) + except Exception: + pass + entry = "serve:app" if spec.get("has_backend") else "fallback:app" + os.environ["PYTHONPATH"] = WORKDIR + ":" + os.environ.get("PYTHONPATH", "") + ":/srv/runner" + os.execvp(sys.executable, [sys.executable, "-m", "uvicorn", entry, "--host", "0.0.0.0", "--port", "8080", "--app-dir", "/srv/runner"]) + + +if __name__ == "__main__": + main() diff --git a/openswarm-apprunner/fallback.py b/openswarm-apprunner/fallback.py new file mode 100644 index 00000000..643a1461 --- /dev/null +++ b/openswarm-apprunner/fallback.py @@ -0,0 +1,11 @@ +"""Honest degraded mode: the bundle could not be fetched (or has no backend); every request says +so instead of crash-looping the VM.""" +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +app = FastAPI() + + +@app.get("/{path:path}") +async def unavailable(path: str) -> JSONResponse: + return JSONResponse({"error": "this app's backend is not available right now"}, status_code=503) diff --git a/openswarm-apprunner/fly.toml b/openswarm-apprunner/fly.toml new file mode 100644 index 00000000..7f62fab4 --- /dev/null +++ b/openswarm-apprunner/fly.toml @@ -0,0 +1,18 @@ +# Runner pool app: one MACHINE per published backend app, created by openswarm-cloud via the +# Machines API; this toml exists for the initial `fly deploy` that builds/pushes the image and +# for staging smoke. Machines are addressed by fly-force-instance-id from the edge. +app = "openswarm-apprunner" +primary_region = "sjc" + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/openswarm-apprunner/serve.py b/openswarm-apprunner/serve.py new file mode 100644 index 00000000..124df2be --- /dev/null +++ b/openswarm-apprunner/serve.py @@ -0,0 +1,6 @@ +"""One process serves the published app: its own FastAPI backend at /api/* plus the built +frontend at /. Imported by uvicorn AFTER boot.py unpacked the bundle into /data/app.""" +from backend.main import app +from fastapi.staticfiles import StaticFiles + +app.mount("/", StaticFiles(directory="/data/app", html=True), name="frontend") diff --git a/openswarm-apprunner/swarm_debug.py b/openswarm-apprunner/swarm_debug.py new file mode 100644 index 00000000..c7bc9127 --- /dev/null +++ b/openswarm-apprunner/swarm_debug.py @@ -0,0 +1,16 @@ +"""No-op shim: generated workspaces import the desktop's injected debugger; hosted runs have no +debugger to talk to, so every hook is a silent pass-through (call-style and decorator-style).""" + + +def debug(*args, **kwargs): + if len(args) == 1 and callable(args[0]) and not kwargs: + return args[0] + return None + + +def log(*args, **kwargs): + return None + + +def snapshot(*args, **kwargs): + return None