From 52f5d145b385afb42616b1f5bb7ea1b7719390e0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 17 Aug 2026 13:42:45 -0700 Subject: [PATCH] [eric] publish: a webapp bundle now carries its own backend and a runspec so the hosted copy can run its real API (ENG-293) --- backend/apps/outputs/outputs.py | 2 + backend/apps/outputs/publish_build.py | 50 +++++++++++++-- backend/apps/outputs/publish_cloud.py | 7 +- backend/tests/test_publish_backend_bundle.py | 67 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_publish_backend_bundle.py diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index eda46d84..fa7c445d 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -938,6 +938,7 @@ async def publish_output(body: PublishRequest): dist = await build_static(output) bundle = collect_bundle(output, dist) slug_hint = slugify(body.slug or output.name) + from backend.apps.outputs.publish_build import workspace_backend_dir res = await upload_to_cloud( settings, output_id=output.id, @@ -945,6 +946,7 @@ async def publish_output(body: PublishRequest): slug_hint=slug_hint, bundle=bundle, override=body.force, + has_backend=bool(dist and workspace_backend_dir(output)), ) except PublishError as e: output.publish_status = "error" diff --git a/backend/apps/outputs/publish_build.py b/backend/apps/outputs/publish_build.py index bcae3348..a650755c 100644 --- a/backend/apps/outputs/publish_build.py +++ b/backend/apps/outputs/publish_build.py @@ -112,11 +112,46 @@ def p_is_secret_file(rel_path: str) -> bool: ) +P_BACKEND_SKIP_DIRS = ("__pycache__", ".venv", "node_modules", "openswarm_backend.egg-info", ".git") + + +def workspace_backend_dir(output: Output) -> Optional[str]: + """The webapp workspace's FastAPI backend dir, when the app has one (ENG-293).""" + if not is_webapp(output): + return None + b = os.path.join(workspace_dir(output), "backend") + return b if os.path.isfile(os.path.join(b, "main.py")) else None + + +def p_add_backend_tree(tar: "tarfile.TarFile", backend_dir: str) -> None: + """Pack the app's own backend under backend/ in the tar (source only, no caches, + no venv), so the hosted runner can boot the same API the preview ran locally.""" + for root, dirs, files in os.walk(backend_dir): + dirs[:] = [d for d in dirs if d not in P_BACKEND_SKIP_DIRS] + for fn in files: + full = os.path.join(root, fn) + if os.path.islink(full): + continue + rel = "backend/" + os.path.relpath(full, backend_dir).replace(os.sep, "/") + if p_is_secret_file(rel) or fn.endswith((".pyc", ".pyo")): + continue + try: + if os.path.getsize(full) > P_MAX_BUNDLE_FILE: + continue + except OSError: + continue + tar.add(full, arcname=rel) + + def collect_bundle(output: Output, dist_dir: Optional[str]) -> bytes: - """tar.gz of what the cloud should host. Webapp -> the built dist tree. - Flat -> the files dict, including backend.py (the edge runs it on the shared - sandbox; the edge refuses to serve .py as a static file). Secret-shaped files - (.env, private keys) are dropped: a published bundle is world-readable.""" + """tar.gz of what the cloud should host. Webapp -> the built dist tree, PLUS the + app's own backend/ tree and a runspec.json when one exists, so the hosted copy + can run its real API instead of serving frontend-only (ENG-293). Flat -> the + files dict, including backend.py (the edge runs it on the shared sandbox; the + edge refuses to serve .py as a static file). Secret-shaped files (.env, private + keys) are dropped: a published bundle is world-readable.""" + import json as p_json + buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: if dist_dir: @@ -134,6 +169,13 @@ def collect_bundle(output: Output, dist_dir: Optional[str]) -> bytes: except OSError: continue tar.add(full, arcname=rel) + backend_dir = workspace_backend_dir(output) + if backend_dir: + p_add_backend_tree(tar, backend_dir) + spec = p_json.dumps({"has_backend": True, "start": "uvicorn backend.main:app"}).encode("utf-8") + info = tarfile.TarInfo(name="runspec.json") + info.size = len(spec) + tar.addfile(info, io.BytesIO(spec)) else: for name, content in (output.files or {}).items(): rel = name.replace(os.sep, "/") diff --git a/backend/apps/outputs/publish_cloud.py b/backend/apps/outputs/publish_cloud.py index d35c4a0d..55a7823c 100644 --- a/backend/apps/outputs/publish_cloud.py +++ b/backend/apps/outputs/publish_cloud.py @@ -21,7 +21,8 @@ def p_safe_detail(resp: httpx.Response, fallback: str) -> str: async def upload_to_cloud( - settings, *, output_id: str, name: str, slug_hint: str, bundle: bytes, override: bool + settings, *, output_id: str, name: str, slug_hint: str, bundle: bytes, override: bool, + has_backend: bool = False, ) -> dict: token, base = account_auth(settings) if not token: @@ -32,7 +33,9 @@ async def upload_to_cloud( f"{base}/api/apps/publish", headers={"Authorization": f"Bearer {token}"}, # output_id lets the cloud reuse this app's slug on republish instead of minting a duplicate; override marks a publish past a non-clean scan. - data={"name": name, "slug": slug_hint, "output_id": output_id, "override": "1" if override else "0"}, + data={"name": name, "slug": slug_hint, "output_id": output_id, "override": "1" if override else "0", + # Tells the cloud to provision the per-app runner VM without unpacking the tar (ENG-293). + "has_backend": "1" if has_backend else "0"}, files={"bundle": ("app.tar.gz", bundle, "application/gzip")}, ) except httpx.HTTPError: diff --git a/backend/tests/test_publish_backend_bundle.py b/backend/tests/test_publish_backend_bundle.py new file mode 100644 index 00000000..407a3a77 --- /dev/null +++ b/backend/tests/test_publish_backend_bundle.py @@ -0,0 +1,67 @@ +"""ENG-293: a published webapp with its own FastAPI backend must carry backend/ + runspec.json in +the bundle (source only, no caches/venv/secrets), and a frontend-only app must carry neither, so +the cloud can trust has_backend without unpacking the tar.""" + +import io +import json +import os +import tarfile + +import pytest + +from backend.apps.outputs.models import Output +from backend.apps.outputs import publish_build + + +def p_make_workspace(tmp_path, with_backend: bool): + ws = tmp_path / "ws" + (ws / "frontend").mkdir(parents=True) + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("app") + if with_backend: + b = ws / "backend" + (b / "apps").mkdir(parents=True) + (b / "main.py").write_text("app = None") + (b / "apps" / "api.py").write_text("x = 1") + (b / "requirements.txt").write_text("fastapi==0.115.0") + (b / ".env").write_text("SECRET=1") + (b / "__pycache__").mkdir() + (b / "__pycache__" / "main.cpython-312.pyc").write_bytes(b"\x00") + (b / ".venv").mkdir() + (b / ".venv" / "big.py").write_text("nope") + return str(ws), str(dist) + + +def p_bundle_names(output, dist): + data = publish_build.collect_bundle(output, dist) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + return {m.name: tar.extractfile(m).read() if m.isfile() else b"" for m in tar.getmembers()} + + +def test_backend_workspace_bundles_source_and_runspec(tmp_path, monkeypatch): + ws, dist = p_make_workspace(tmp_path, with_backend=True) + out = Output(id="o1", name="T", output_type="webapp") + monkeypatch.setattr(publish_build, "workspace_dir", lambda o: ws) + monkeypatch.setattr(publish_build, "is_webapp", lambda o: True) + names = p_bundle_names(out, dist) + assert "index.html" in names + assert "backend/main.py" in names + assert "backend/apps/api.py" in names + assert "backend/requirements.txt" in names + spec = json.loads(names["runspec.json"]) + assert spec["has_backend"] is True and "uvicorn" in spec["start"] + # The world-readable bundle must never carry secrets or dead weight. + assert not any(".env" in n for n in names), "dotenv files are secrets" + assert not any("__pycache__" in n or ".venv" in n or n.endswith(".pyc") for n in names) + + +def test_frontend_only_workspace_carries_no_backend_or_runspec(tmp_path, monkeypatch): + ws, dist = p_make_workspace(tmp_path, with_backend=False) + out = Output(id="o2", name="T", output_type="webapp") + monkeypatch.setattr(publish_build, "workspace_dir", lambda o: ws) + monkeypatch.setattr(publish_build, "is_webapp", lambda o: True) + names = p_bundle_names(out, dist) + assert "index.html" in names + assert not any(n.startswith("backend/") for n in names) + assert "runspec.json" not in names