[eric] runner: per-app VM image boots a published bundle from storage and serves backend + frontend from one process (ENG-293)

This commit is contained in:
ciregenz
2026-08-17 13:55:52 -07:00
parent 95f99d8b30
commit b91c7514d7
6 changed files with 151 additions and 0 deletions
+8
View File
@@ -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"]
+92
View File
@@ -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()
+11
View File
@@ -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)
+18
View File
@@ -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"
+6
View File
@@ -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")
+16
View File
@@ -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