mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
[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:
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user