mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] publish: a webapp bundle now carries its own backend and a runspec so the hosted copy can run its real API (ENG-293)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("<html>app</html>")
|
||||
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
|
||||
Reference in New Issue
Block a user