diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b3114e2d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# 构建上下文里只该有源码。这个文件的头等任务:绝不能把 .env(密钥)和 data/(开发库)打进镜像。 +# Dockerfile 也只按需 COPY 具体目录,两道都守着。 + +# 密钥与运行期数据 +.env +.env.* +**/.env +**/.env.* +data/ +**/data/*.db +**/*.db +**/*.db-* +**/*.sqlite +**/*.sqlite3 +**/.secret_key + +# 版本库、依赖、构建产物、缓存 +.git +.gitignore +**/node_modules +frontend/dist +**/__pycache__ +**/*.py[cod] +backend/.venv +**/.venv +**/.pytest_cache +**/.ruff_cache +**/*.tsbuildinfo +**/.vite +**/.vite-* + +# 打包产物和不进镜像的东西 +release/ +docs/ +scripts/ +deploy/ +backend/tests/ +.claude/ +.DS_Store +**/.DS_Store +.idea/ +.vscode/ diff --git a/.gitignore b/.gitignore index 7c426ea1..840c4dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dist/ # 已迁到 conda,不再用 uv uv.lock + +# 离线打包产物(scripts/package-docker.sh) +/release/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6dd2dba8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# syntax=docker/dockerfile:1 +# +# AgentLab 生产镜像:后端一并托管前端构建产物,单进程。 +# +# docker buildx build --platform linux/arm64 --load -t agentlab:dev . +# +# 打离线包(镜像 + 一键部署脚本)用 scripts/package-docker.sh。 +# 密钥和开发库进不来:.dockerignore 排除了 .env、data/、*.db,下面也只 COPY 用得到的目录。 + +# ---- 前端:只产出 JS / CSS,和目标架构无关,所以固定在构建机的架构上跑,跨架构构建时不用模拟 ---- +FROM --platform=$BUILDPLATFORM node:22-slim AS web +# 和仓库开发时用的 pnpm 同一个大版本,锁文件格式对得上 +ARG PNPM_VERSION=12.3.4 +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + CI=true +RUN corepack enable pnpm && corepack prepare "pnpm@${PNPM_VERSION}" --activate +WORKDIR /src/frontend +# 先只拷依赖清单:源码改了,依赖这一层还能用缓存 +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +# ---- 运行 ---- +FROM python:3.12-slim + +# bubblewrap:代码沙箱。默认容器权限下它建不了命名空间,服务端会自动退回本地子进程, +# 给了额外权限才用得上(见 deploy/agentlab.env 的沙箱一节)。健康检查用 python 自己探,不装 curl +RUN apt-get update \ + && apt-get install -y --no-install-recommends bubblewrap \ + && rm -rf /var/lib/apt/lists/* + +# 依赖装进独立的 venv:系统自带的 /usr/local/bin/python3 因此保持干净, +# 沙箱跑用户代码时用的就是它,看不见 fastapi、cryptography 这些服务端的包 +ENV VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 +COPY backend/requirements.lock /tmp/requirements.lock +RUN python -m venv /opt/venv \ + && pip install --no-cache-dir -r /tmp/requirements.lock \ + && rm /tmp/requirements.lock + +# 固定的 uid / gid:部署脚本按它把宿主机上的数据目录改成容器能写 +RUN groupadd --gid 10001 agentlab \ + && useradd --uid 10001 --gid 10001 --create-home --home-dir /home/agentlab --shell /usr/sbin/nologin agentlab \ + && mkdir -p /data \ + && chown 10001:10001 /data + +WORKDIR /app +COPY LICENSE COMMERCIAL-LICENSE.md /app/ +COPY backend/app /app/backend/app +COPY --from=web /src/frontend/dist /app/web + +ENV AGENTLAB_WEB_DIST=/app/web \ + AGENTLAB_DATA_DIR=/data +VOLUME /data +EXPOSE 8000 +USER 10001:10001 +WORKDIR /app/backend + +# 探 /api/health,并确认答话的是 agentlab。绕开代理:agentlab.env 里配了 HTTP(S)_PROXY 时, +# urllib 默认会连 127.0.0.1 也往代理上送 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --start-interval=2s --retries=3 \ + CMD ["python", "-c", "import sys, urllib.request as u; r = u.build_opener(u.ProxyHandler({})).open('http://127.0.0.1:8000/api/health', timeout=4); sys.exit(0 if b'agentlab' in r.read() else 1)"] + +# 版本号放在最后:它每次打包都不同,放前面会让装依赖那几层的缓存全部失效 +ARG VERSION=dev +LABEL org.opencontainers.image.title="AgentLab" \ + org.opencontainers.image.description="可视化 Agent 编排实验台" \ + org.opencontainers.image.version="${VERSION}" + +# 单进程:运行管理器在进程内、数据库是 SQLite,不能开多个 worker +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 3a979d1b..3d467b3f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ## 目录 -- [快速开始](#快速开始) · [核心能力](#核心能力) · [受限动态编排](#受限动态编排模板定骨架agent-填关节) +- [快速开始](#快速开始) · [生产部署](#生产部署) · [核心能力](#核心能力) · [受限动态编排](#受限动态编排模板定骨架agent-填关节) - [可点击证据](#可点击证据报告里的每个数都点得开出处) · [工作流形态](#工作流形态) · [节点类型](#节点类型) · [架构](#架构) - [关键设计决策](#关键设计决策) · [运行环境](#运行环境) · [常见问题](#常见问题) · [开发与验证](#开发与验证) @@ -56,6 +56,21 @@ Mock 不会写引用标记:探索运行里报告撰写节点照样跑完,但 --- +## 生产部署 + +**不用 Docker:** `./scripts/prod.sh` 构建前端并由后端一并托管页面,单进程、不热重载,启动于 +http://127.0.0.1:8000 。前端产物不存在或比源码旧时才重新构建,`--build` / `--no-build` 强制构建或跳过。 +`--port`(或 `AGENTLAB_PORT`)改端口;`--host` 监听所有网卡,同样**没有登录**,只在信得过的网络里开。 +数据目录默认为仓库下的 `data/`,`AGENTLAB_DATA_DIR` 可改。只能单进程运行:运行管理器在进程内、数据库是 SQLite。 + +**Docker 离线包:** `./scripts/package-docker.sh` 构建 Linux 镜像(默认取本机架构,目标机不同时用 +`--platform linux/arm64` 或 `linux/amd64`),连同一键部署脚本、配置模板和部署说明打成 +`release/agentlab-<版本>-linux-.tar`。拷到只装了 Docker Engine 的离线机器上,解包、按需修改 +`agentlab.env`、运行 `./deploy.sh` 即可,不需要 docker compose。安装、升级、备份、沙箱权限与常见问题见 +[deploy/README.md](deploy/README.md)。容器默认权限下代码沙箱退回本地子进程,隔离边界只有容器本身。 + +--- + ## 核心能力 | 领域 | 能力 | @@ -483,8 +498,9 @@ Excel / CSV 若传入知识库,只能被切块检索,数字即成为模型 设置页「运行环境」列出全部候选及其可用性,并标注当前选中项。安装 microVM 依赖且镜像已缓存时 auto 选择 `microvm`;否则 macOS 使用 `seatbelt`、Linux 上安装 `bwrap` -(`apt install bubblewrap`)后使用 `bubblewrap`;均不可用时降级至 `local`——**该路径不含任何 -访问控制**,界面明确标红。可通过 `AGENTLAB_SANDBOX_BACKEND` 强制指定, +(`apt install bubblewrap`)后使用 `bubblewrap`。装了 `bwrap` 还要能真正创建命名空间才算可用: +启动后首次选择时实际起一次 `bwrap` 探测,Docker 默认权限、Ubuntu 24.04 的 AppArmor 限制下会失败并跳过, +「选择原因」写明。均不可用时降级至 `local`——**该路径不含任何访问控制**,界面明确标红。可通过 `AGENTLAB_SANDBOX_BACKEND` 强制指定, 亦可在代码节点上单独选择隔离档位(strict = microVM,fast = 系统沙箱)。 ### 启用 microVM diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 49c9e953..7055d754 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -44,6 +44,11 @@ class Settings(BaseSettings): # --- 存储 --- data_dir: Path = PROJECT_DIR / "data" + # --- 前端构建产物(生产部署用)--- + # 设了就由服务端一并托管页面(见 app/web.py),页面和接口同源、只起一个进程。 + # 不设(开发模式)什么都不变:页面由 Vite 开发服务器提供。相对路径按仓库根目录算 + web_dist: Path | None = None + # --- 密钥加密 --- # 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。 secret_key: str = "" @@ -112,6 +117,21 @@ class Settings(BaseSettings): return [part.strip() for part in text.split(",") if part.strip()] return value + @field_validator("web_dist", mode="before") + @classmethod + def _blank_is_unset(cls, value: object) -> object: + # AGENTLAB_WEB_DIST= 写成空值等于没设,别变成 Path("") 也就是当前目录 + if isinstance(value, str) and not value.strip(): + return None + return value + + @field_validator("web_dist") + @classmethod + def _relative_to_project(cls, value: Path | None) -> Path | None: + if value is None or value.is_absolute(): + return value + return PROJECT_DIR / value + @field_validator("http_tool_fake_ip_ranges") @classmethod def _valid_ranges(cls, value: list[str]) -> list[str]: diff --git a/backend/app/main.py b/backend/app/main.py index 29def10c..c2f9f8cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -120,3 +120,11 @@ async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse: @app.get("/api/health") async def health() -> dict[str, str]: return {"status": "ok", "service": "agentlab"} + + +# 生产部署:前端构建产物由服务端一并托管(见 app/web.py)。页面回退是通配路由, +# 必须在所有 /api 路由之后注册。没设 AGENTLAB_WEB_DIST 时什么都不挂,开发模式照旧 +if settings.web_dist is not None: + from app.web import mount_web + + mount_web(app, settings.web_dist) diff --git a/backend/app/sandbox/bubblewrap_sandbox.py b/backend/app/sandbox/bubblewrap_sandbox.py index ad64e574..941cecc8 100644 --- a/backend/app/sandbox/bubblewrap_sandbox.py +++ b/backend/app/sandbox/bubblewrap_sandbox.py @@ -5,8 +5,10 @@ import logging import os import resource import shutil +import subprocess import sys import time +from functools import lru_cache from pathlib import Path from app.core.config import settings, sanitize_session @@ -30,6 +32,37 @@ _LANG = { _RO_BINDS = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives", "/etc/ssl"] +def _on_linux() -> bool: + return sys.platform.startswith("linux") + + +@lru_cache(maxsize=4) +def _probe(bwrap: str) -> str: + """真起一次 bwrap,返回失败原因;空串表示能用。 + + 装了 bwrap 不等于建得了隔离环境:Docker 默认的 seccomp 配置不许非特权进程建 + user namespace,Ubuntu 24.04 的 AppArmor 默认也限制它。以前 available() 只看 + 二进制在不在,于是容器里 auto 选中 bubblewrap,每次执行都报 "No permissions to + create a new namespace"——而本地子进程明明能用。 + + 探测带上执行时同样要用的命名空间、/proc 和 /dev,任何一样建不起来都算不可用。 + 结果缓存:内核和容器权限在进程生命周期里不会变。 + """ + argv = [ + bwrap, "--unshare-all", "--die-with-parent", "--new-session", + "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", + "--", shutil.which("true") or "/bin/true", + ] + try: + proc = subprocess.run(argv, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError) as e: + return raw(e) + if proc.returncode == 0: + return "" + lines = [line.strip() for line in (proc.stderr or "").splitlines() if line.strip()] + return lines[-1] if lines else f"退出码 {proc.returncode}" + + class BubblewrapSandbox(Sandbox): """Linux 上的对等方案:bubblewrap(Flatpak 的沙箱内核)。 @@ -46,7 +79,23 @@ class BubblewrapSandbox(Sandbox): @staticmethod def available() -> bool: - return sys.platform.startswith("linux") and shutil.which("bwrap") is not None + if not _on_linux(): + return False + bwrap = shutil.which("bwrap") + return bwrap is not None and not _probe(bwrap) + + @staticmethod + def probe_failure() -> str: + """装了 bwrap 却建不起隔离环境时的原因;没装或者能用都返回空串。""" + bwrap = shutil.which("bwrap") if _on_linux() else None + return _probe(bwrap) if bwrap else "" + + @classmethod + def unavailable_reason(cls) -> str: + failure = cls.probe_failure() + if failure: + return f"已安装 bwrap,但无法创建隔离环境(容器默认权限或系统策略不允许):{failure}" + return "未找到 bwrap,可执行 apt install bubblewrap 安装" async def health(self) -> dict[str, object]: ok = self.available() @@ -59,7 +108,7 @@ class BubblewrapSandbox(Sandbox): "enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True}, "root": str(self._root), "interpreter": interpreter.describe(), - **({} if ok else {"error": "未找到 bwrap,可执行 apt install bubblewrap 安装"}), + **({} if ok else {"error": self.unavailable_reason()}), } def _session_dir(self, session_id: str) -> Path: @@ -119,7 +168,7 @@ class BubblewrapSandbox(Sandbox): if language not in _LANG: return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") if not self.available(): - return ExecResult(ok=False, backend=self.name, error="bwrap 不可用") + return ExecResult(ok=False, backend=self.name, error=self.unavailable_reason()) ext, cmd_prefix = _LANG[language] # python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器 diff --git a/backend/app/sandbox/manager.py b/backend/app/sandbox/manager.py index 4daf3c05..5a74b66c 100644 --- a/backend/app/sandbox/manager.py +++ b/backend/app/sandbox/manager.py @@ -127,7 +127,12 @@ class SandboxManager: if warm is not None and not warm(): self._resolved_from = f"{name} 可用但镜像未缓存,自动选择时暂不使用" continue - self._resolved_from = f"自动选择 {name}({_why(name)})" + why = _why(name) + if name == "local" and BubblewrapSandbox.probe_failure(): + # 装了 bwrap 却建不起隔离环境(容器默认权限下就是这样)要说清楚, + # 不然看着装了 bwrap 却退到本地子进程,没人知道为什么 + why = "已安装 bwrap,但无法创建隔离环境,已降级为本地子进程" + self._resolved_from = f"自动选择 {name}({why})" return impl() self._resolved_from = "没有可用的隔离方式,已降级为本地子进程" diff --git a/backend/app/web.py b/backend/app/web.py new file mode 100644 index 00000000..d2c36605 --- /dev/null +++ b/backend/app/web.py @@ -0,0 +1,96 @@ +"""生产部署:由服务端直接托管前端构建产物(AGENTLAB_WEB_DIST)。 + +开发时页面由 Vite 开发服务器提供、/api 由它转发,用不到这里。生产部署只起一个进程, +页面和接口同源,省掉 CORS,也不用另配静态服务器。 + +规则: +- 构建产物里有这个文件就返回它; +- 其余非 /api 的 GET 回退到 index.html:前端是单页应用,/studio 这类地址由前端路由处理; +- /api 下不存在的路径照旧是 JSON 404。排除 /api 写在路由的路径规则里,不在处理函数里判断—— + 否则 POST /api/不存在 会因为「路径对上了、方法不对」变成 405; +- 只注册 GET / HEAD 的 HTTP 路由,WebSocket 不经过这里; +- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存。 +""" +from __future__ import annotations + +import mimetypes +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from starlette.convertors import Convertor, register_url_convertor + +_NO_CACHE = "no-cache" +_IMMUTABLE = "public, max-age=31536000, immutable" + +# 精简的 Linux 镜像里没有 /etc/mime.types,mimetypes 的内置表又缺几种(woff2 之类)。 +# 构建产物里出现的类型写死在这里,别的再交给 mimetypes 猜 +_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".wasm": "application/wasm", + ".txt": "text/plain; charset=utf-8", +} + + +class _WebPath(Convertor[str]): + """任意路径,但不以 api 这一段开头:/api、/api/… 留给接口路由和它们自己的 404。""" + + regex = r"(?!api(?:/|$)).*" + + def convert(self, value: str) -> str: + return value + + def to_string(self, value: str) -> str: + return value + + +register_url_convertor("agentlab_web", _WebPath()) + + +def _media_type(path: Path) -> str | None: + return _TYPES.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] + + +def mount_web(app: FastAPI, dist: Path) -> None: + """在 app 上挂页面托管。必须在所有 /api 路由注册之后调用。""" + root = dist.resolve() + index = root / "index.html" + if not index.is_file(): + # 配了却没有产物:启动时就报出来,别等打开页面才看到一个 404 + raise RuntimeError( + f"AGENTLAB_WEB_DIST 指向的 {root} 下没有 index.html。" + "先构建前端(cd frontend && pnpm build),或者去掉这个设置" + ) + + def respond(target: Path, *, immutable: bool = False) -> FileResponse: + return FileResponse( + target, + media_type=_media_type(target), + headers={"Cache-Control": _IMMUTABLE if immutable else _NO_CACHE}, + ) + + async def serve(path: str) -> FileResponse: + if path: + target = (root / path).resolve() + # resolve 之后还得在 dist 里面:挡住 %2e%2e 这类穿越和指向外面的软链 + if target.is_file() and target.is_relative_to(root): + return respond(target, immutable=path.startswith("assets/")) + if path.startswith("assets/"): + # 发版后旧页面还在请求旧哈希的文件。回退成 index.html 的话浏览器会把它当脚本 + # 解析、报一个看不懂的 MIME 错误,所以明确给 404 + raise HTTPException(status_code=404) + return respond(index) + + app.add_api_route( + "/{path:agentlab_web}", serve, methods=["GET", "HEAD"], include_in_schema=False, + ) diff --git a/backend/tests/test_bubblewrap_probe.py b/backend/tests/test_bubblewrap_probe.py new file mode 100644 index 00000000..81b1aaf8 --- /dev/null +++ b/backend/tests/test_bubblewrap_probe.py @@ -0,0 +1,105 @@ +"""装了 bwrap 不等于建得了隔离环境。 + +Docker 默认的 seccomp 配置不许非特权进程建 user namespace,Ubuntu 24.04 的 AppArmor 默认也限制它。 +以前 available() 只看 bwrap 在不在:容器里 auto 选中 bubblewrap,每次执行都报 +「No permissions to create a new namespace」,而本地子进程明明能用。现在真起一次 bwrap 试, +起不来就算不可用,auto 往下退。 +""" +from __future__ import annotations + +import subprocess +from typing import Any + +import pytest + +from app.sandbox import bubblewrap_sandbox as bw +from app.sandbox import manager as mgr +from app.sandbox.local_sandbox import LocalSandbox + +DENIED = ( + "bwrap: No permissions to create a new namespace, likely because the kernel does not " + "allow non-privileged user namespaces.\n" +) + + +@pytest.fixture(autouse=True) +def _fresh_probe(monkeypatch: pytest.MonkeyPatch): + bw._probe.cache_clear() + monkeypatch.setattr(bw, "_on_linux", lambda: True) + yield + bw._probe.cache_clear() + + +def _fake_run(returncode: int, stderr: str = "", calls: list[Any] | None = None): + def run(argv, **kwargs): # noqa: ANN001, ANN003, ANN202 + if calls is not None: + calls.append(argv) + return subprocess.CompletedProcess(argv, returncode, stdout="", stderr=stderr) + + return run + + +def test_binary_present_but_namespaces_denied(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[Any] = [] + monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED, calls)) + + assert bw.BubblewrapSandbox.available() is False + assert "No permissions" in bw.BubblewrapSandbox.probe_failure() + # 探测真的在起 bwrap,并且带上执行时同样要用的命名空间和 /proc + argv = calls[0] + assert argv[0] == "/usr/bin/bwrap" + assert "--unshare-all" in argv and "--proc" in argv + # 结果缓存:内核和容器权限在进程生命周期里不会变 + bw.BubblewrapSandbox.available() + assert len(calls) == 1 + + +async def test_health_explains_why(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED)) + info = await bw.BubblewrapSandbox().health() + assert info["available"] is False + assert "No permissions" in info["error"] + + +def test_binary_present_and_working(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(bw.subprocess, "run", _fake_run(0)) + assert bw.BubblewrapSandbox.available() is True + assert bw.BubblewrapSandbox.probe_failure() == "" + + +def test_probe_that_cannot_start_counts_as_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(argv, **kwargs): # noqa: ANN001, ANN003, ANN202 + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(bw.subprocess, "run", boom) + assert bw.BubblewrapSandbox.available() is False + + +def test_no_binary_skips_probe(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[Any] = [] + monkeypatch.setattr(bw.shutil, "which", lambda name: None) + monkeypatch.setattr(bw.subprocess, "run", _fake_run(0, calls=calls)) + assert bw.BubblewrapSandbox.available() is False + assert bw.BubblewrapSandbox.probe_failure() == "" + assert calls == [] + + +def test_auto_falls_back_to_local_and_says_why(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bw.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(bw.subprocess, "run", _fake_run(1, DENIED)) + monkeypatch.setattr(mgr.MicroVMSandbox, "available", staticmethod(lambda: False)) + monkeypatch.setattr(mgr.SeatbeltSandbox, "available", staticmethod(lambda: False)) + monkeypatch.setattr(mgr.settings, "sandbox_backend", "auto") + + manager = mgr.SandboxManager() + backend = manager._resolve() + assert isinstance(backend, LocalSandbox) + assert "bwrap" in manager._resolved_from + assert "本地子进程" in manager._resolved_from + # 节点上选 fast 档也不能点到一个起不来的 bubblewrap + monkeypatch.setattr(mgr.sys, "platform", "linux") + assert manager._resolve_named("fast") is None diff --git a/backend/tests/test_web_dist.py b/backend/tests/test_web_dist.py new file mode 100644 index 00000000..6e13b432 --- /dev/null +++ b/backend/tests/test_web_dist.py @@ -0,0 +1,221 @@ +"""生产模式下服务端托管前端构建产物(AGENTLAB_WEB_DIST)。 + +用临时目录伪造一个 dist,覆盖这几条: +- 文件存在就返回文件;其余非 /api 的 GET 回退到 index.html(单页应用); +- /api 下不存在的路径照旧是 JSON 404,不能被回退吞掉——POST 也一样,不能变成 405; +- WebSocket 不受影响; +- index.html 不缓存,assets/ 下带哈希的文件长缓存; +- 没设 AGENTLAB_WEB_DIST 时行为完全不变。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import httpx +import pytest +from fastapi import APIRouter, FastAPI, WebSocket +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings + +INDEX = "fake-agentlab
" +JS = "console.log('fake bundle')" + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + root = tmp_path / "dist" + (root / "assets").mkdir(parents=True) + (root / "index.html").write_text(INDEX) + (root / "favicon.svg").write_text("") + (root / "assets" / "index-AbC123.js").write_text(JS) + (root / "assets" / "index-AbC123.css").write_text("body{}") + # dist 外面的文件:路径穿越不能读到它 + (tmp_path / "secret.txt").write_text("TOP-SECRET") + return root + + +def _app(dist: Path) -> FastAPI: + from app.web import mount_web + + app = FastAPI() + api = APIRouter(prefix="/api") + + @api.get("/ping") + async def ping() -> dict[str, str]: + return {"pong": "1"} + + @api.websocket("/echo") + async def echo(ws: WebSocket) -> None: + await ws.accept() + await ws.send_text(await ws.receive_text()) + await ws.close() + + app.include_router(api) + mount_web(app, dist) + return app + + +async def _get(app: FastAPI, path: str, method: str = "GET") -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: + return await client.request(method, path) + + +async def test_index_and_spa_fallback(dist: Path) -> None: + app = _app(dist) + for path in ("/", "/index.html", "/studio", "/runs/abc/evidence", "/studio/"): + r = await _get(app, path) + assert r.status_code == 200, path + assert r.text == INDEX, path + assert r.headers["content-type"].startswith("text/html"), path + assert "no-cache" in r.headers["cache-control"], path + + +async def test_existing_files_are_served(dist: Path) -> None: + app = _app(dist) + r = await _get(app, "/assets/index-AbC123.js") + assert r.status_code == 200 + assert r.text == JS + assert "javascript" in r.headers["content-type"] + assert "immutable" in r.headers["cache-control"] + assert "max-age=31536000" in r.headers["cache-control"] + + r = await _get(app, "/assets/index-AbC123.css") + assert r.status_code == 200 and r.headers["content-type"].startswith("text/css") + + r = await _get(app, "/favicon.svg") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("image/svg+xml") + assert "immutable" not in r.headers.get("cache-control", "") + + +async def test_missing_asset_is_404_not_index(dist: Path) -> None: + # 发版后旧页面请求旧哈希的文件:回退成 index.html 会被当脚本解析,还会被长缓存 + r = await _get(_app(dist), "/assets/index-old999.js") + assert r.status_code == 404 + assert r.text != INDEX + + +async def test_api_routes_win_and_unknown_api_is_json_404(dist: Path) -> None: + app = _app(dist) + r = await _get(app, "/api/ping") + assert r.status_code == 200 and r.json() == {"pong": "1"} + + for path in ("/api/nope", "/api", "/api/", "/api/runs/xyz/nope"): + r = await _get(app, path) + assert r.status_code == 404, path + assert r.headers["content-type"].startswith("application/json"), path + assert r.json() == {"detail": "Not Found"}, path + + # 路径规则里就排除了 /api:否则「路径对上、方法不对」会让 POST 变成 405 + r = await _get(app, "/api/nope", method="POST") + assert r.status_code == 404 + assert r.json() == {"detail": "Not Found"} + + # 只保留 api 这一段,别的前缀照常回退 + r = await _get(app, "/apiary") + assert r.status_code == 200 and r.text == INDEX + + +async def test_head_and_non_get(dist: Path) -> None: + app = _app(dist) + r = await _get(app, "/studio", method="HEAD") + assert r.status_code == 200 + r = await _get(app, "/studio", method="POST") + assert r.status_code == 405 + + +async def test_path_traversal_stays_inside_dist(dist: Path) -> None: + app = _app(dist) + for path in ("/%2e%2e/secret.txt", "/..%2fsecret.txt", "/assets/%2e%2e/%2e%2e/secret.txt"): + r = await _get(app, path) + assert "TOP-SECRET" not in r.text, path + + +def test_websocket_unaffected(dist: Path) -> None: + client = TestClient(_app(dist)) + with client.websocket_connect("/api/echo") as ws: + ws.send_text("hi") + assert ws.receive_text() == "hi" + # 没有这条 WebSocket 路由的地址不会被页面回退接走 + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/studio") as ws: + ws.receive_text() + + +def test_missing_index_fails_fast(tmp_path: Path) -> None: + from app.web import mount_web + + with pytest.raises(RuntimeError, match="index.html"): + mount_web(FastAPI(), tmp_path) + + +def test_setting_parsing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AGENTLAB_WEB_DIST", raising=False) + assert Settings().web_dist is None + monkeypatch.setenv("AGENTLAB_WEB_DIST", "") + assert Settings().web_dist is None + # 相对路径按仓库根目录算,和从哪个目录启动无关 + monkeypatch.setenv("AGENTLAB_WEB_DIST", "frontend/dist") + assert Settings().web_dist == PROJECT_DIR / "frontend" / "dist" + monkeypatch.setenv("AGENTLAB_WEB_DIST", "/srv/web") + assert Settings().web_dist == Path("/srv/web") + + +async def test_default_app_unchanged_without_setting() -> None: + from app.core.config import settings + from app.main import app + + assert settings.web_dist is None + r = await _get(app, "/studio") + assert r.status_code == 404 + assert r.json() == {"detail": "Not Found"} + + +_PROBE = r""" +import asyncio, json, httpx +from app.main import app + +async def main(): + t = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=t, base_url="http://t") as c: + out = {} + for m, p in (("GET", "/api/health"), ("GET", "/"), ("GET", "/studio"), + ("GET", "/assets/index-AbC123.js"), ("GET", "/api/nope"), ("POST", "/api/nope")): + r = await c.request(m, p) + out[f"{m} {p}"] = [r.status_code, r.text[:200]] + print(json.dumps(out)) + +asyncio.run(main()) +""" + + +def test_main_app_mounts_when_setting_present(dist: Path, tmp_path: Path) -> None: + """真实的 app.main 在设了 AGENTLAB_WEB_DIST 时托管页面、接口照旧。 + + settings 和 app 都是模块级单例,同一进程里改不了,所以起一个子进程导入。 + """ + env = { + **os.environ, + "AGENTLAB_DATA_DIR": str(tmp_path / "data"), + "AGENTLAB_WEB_DIST": str(dist), + "AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", + } + proc = subprocess.run( + [sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, + capture_output=True, text=True, timeout=120, + ) + assert proc.returncode == 0, proc.stderr[-2000:] + out = json.loads(proc.stdout.strip().splitlines()[-1]) + assert out["GET /api/health"][0] == 200 and '"agentlab"' in out["GET /api/health"][1] + assert out["GET /"] == [200, INDEX] + assert out["GET /studio"] == [200, INDEX] + assert out["GET /assets/index-AbC123.js"] == [200, JS] + assert out["GET /api/nope"][0] == 404 and json.loads(out["GET /api/nope"][1]) == {"detail": "Not Found"} + assert out["POST /api/nope"][0] == 404 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..acfe7eb2 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,167 @@ +# AgentLab 离线部署说明 + +版本 **__AGENTLAB_VERSION__** · 架构 **linux/__AGENTLAB_ARCH__** + +这个包在一台只装了 Docker Engine、不能联网的 Linux 机器上部署 AgentLab。 +不需要 docker compose,不需要 Python、Node,也不需要联网。 + +## 包里有什么 + +| 文件 | 用途 | +| --- | --- | +| `image.tar` | Docker 镜像(`docker save` 导出),镜像名 `agentlab:__AGENTLAB_VERSION__` | +| `deploy.sh` | 一键部署脚本:安装、启动、停止、升级、卸载 | +| `agentlab.env` | 配置:端口、监听地址、数据目录、模型 Key、代理、沙箱 | +| `README.md` | 本说明 | +| `SHA256SUMS` | 校验和。拷完先校验:`sha256sum -c SHA256SUMS`(改过 `agentlab.env` 之后它那一行会对不上,正常) | + +## 前提条件 + +- Ubuntu 24.04(别的 64 位 Linux 发行版一般也行),已装好 Docker Engine 并在运行; +- **架构要一致**:这个包是 `linux/__AGENTLAB_ARCH__`。在目标机上运行 `uname -m`: + `aarch64` 对应 arm64(ARM 单板机要装 64 位系统),`x86_64` 对应 amd64。不一致的镜像跑不起来, + deploy.sh 会在导入前就报出来; +- 磁盘:镜像导入后约 0.6~0.8 GB(看 Docker 用的存储方式),另留数据目录的空间; +- 当前用户能用 docker(在 `docker` 组里),或者用 `sudo` 运行 deploy.sh。 + +## 三步部署 + +```bash +# 1. 解包 +tar -xf agentlab-__AGENTLAB_VERSION__-linux-__AGENTLAB_ARCH__.tar +cd agentlab-__AGENTLAB_VERSION__-linux-__AGENTLAB_ARCH__ + +# 2. 按需修改配置(端口、监听地址、数据目录、模型 Key……每一项都有注释) +nano agentlab.env + +# 3. 部署 +./deploy.sh +``` + +deploy.sh 依次:检查 docker 和架构 → 导入镜像(`docker load -i image.tar`,本机已有就跳过)→ +创建数据目录 → 启动容器(`--restart unless-stopped`,开机自动起来)→ 等健康检查通过 → 打印访问地址。 + +## 访问 + +浏览器打开 deploy.sh 最后打印的地址,默认 `http://<这台机器的 IP>:8000`。 +端口改 `HOST_PORT`;只给本机用的话把 `BIND_ADDR` 改成 `127.0.0.1`。 + +第一次打开就能用:内置的演示模型不需要 API Key,编排链路能完整走一遍。接入真实模型有两种方式: + +- 在 `agentlab.env` 里填 `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`,第一次启动时自动导入成模型接入; +- 或者启动后在界面「设置 → 模型接入」里配。 + +注意:环境变量只在第一次启动(库里还没有任何模型接入)时导入,之后要改 Key 请到界面里改。 + +## 数据和备份 + +所有数据都在 `DATA_DIR`(默认是包目录下的 `data/`):数据库 `agentlab.db`、执行断点 `checkpoints.db`、 +工件、上传文件、沙箱工作区,以及加密密钥 `.secret_key`。**要备份的就是这个目录**, +丢了 `.secret_key`,存进去的 API Key 就解不开了。 + +deploy.sh 会把数据目录的属主改成容器里的用户(uid 10001),所以备份、删除要用 sudo。 +停下来备份最稳妥(以默认的 `./data` 为例,在包目录里执行): + +```bash +./deploy.sh stop +sudo tar -czf ../agentlab-data-$(date +%Y%m%d).tar.gz data +./deploy.sh start +``` + +恢复:停掉容器,把备份解回 `DATA_DIR`,再 `./deploy.sh start`。 + +## 升级 + +把新版本的离线包拷到机器上,解到一个新目录,然后: + +```bash +cd agentlab-<新版本>-linux-__AGENTLAB_ARCH__ +cp ../agentlab-<旧版本>-linux-__AGENTLAB_ARCH__/agentlab.env . # 沿用原来的配置 +# 数据目录是相对路径(./data)的话,把 agentlab.env 里的 DATA_DIR 改成原来那个目录的绝对路径 +./deploy.sh upgrade +``` + +`upgrade` 导入新镜像、用新镜像替换容器,数据目录不动。deploy.sh 会检查新配置指向的数据目录和 +正在用的是不是同一个,不是就停下来提醒,不会让新版本悄悄从一个空库开始。 + +新容器没通过健康检查时,原来的容器会保留为 `agentlab-previous`,屏幕上会打印回退命令。 +旧镜像也留着,确认新版本没问题后可以 `docker rmi agentlab:<旧版本>` 删掉。 + +## 查看状态和日志 + +```bash +./deploy.sh status # 容器状态、健康检查、数据目录、访问地址 +./deploy.sh logs # 最后 200 行 +./deploy.sh logs -f # 持续跟随,Ctrl-C 退出 +./deploy.sh logs --tail 1000 +``` + +日志由 docker 自动轮转(单个 10 MB、保留 3 个),不会塞满存储卡。 + +## 停止和卸载 + +```bash +./deploy.sh stop # 停止(开机也不会自动起来) +./deploy.sh start # 再启动 +./deploy.sh restart # 重启 +./deploy.sh uninstall # 删除容器,保留数据和镜像 +./deploy.sh uninstall --purge-image # 连镜像一起删 +``` + +卸载**永远不删数据**。确定不要了再手动删除数据目录(不可恢复):`sudo rm -rf <数据目录>`。 + +改了 `agentlab.env` 之后运行 `./deploy.sh install` 让新配置生效(`restart` 不会重读配置)。 + +## 安全提示 + +- **AgentLab 没有登录。** 同一网络里打得开页面的人都能用它:跑工作流、花你配的模型额度、 + 读你接入的数据源、在沙箱里执行代码。只在信得过的网络里开。 +- 用防火墙限制能访问端口的来源。注意 Docker 发布的端口会绕过 ufw 的默认规则,要么把 `BIND_ADDR` + 设成具体网卡的地址或 `127.0.0.1`,要么在 `DOCKER-USER` 链里加规则。 +- 需要多人使用时,把 `BIND_ADDR` 改成 `127.0.0.1`,前面另配一个带登录的反向代理。 +- API Key 加密存在数据目录里,密钥也在数据目录里:数据目录的备份要和 Key 一样保管好。 + +## 代码沙箱 + +工作流里的代码节点、界面上的沙箱都在服务端执行代码。容器里能用哪种隔离,实测结果如下: + +| 容器权限 | 实际使用的沙箱 | 隔离效果 | +| --- | --- | --- | +| 默认(`ENABLE_BWRAP=0`) | local:容器里的普通子进程 | 隔离边界只有容器本身。代码伤不到宿主机,但和 AgentLab 同一个容器、同一个用户,读得到数据目录里的数据库和加密密钥、读得到传进容器的 API Key、能联网 | +| `ENABLE_BWRAP=1` | bubblewrap | 代码看不到数据目录、看不到别的进程,默认断网 | + +默认权限下 bubblewrap 无法创建命名空间(Docker 默认的系统调用过滤不允许),AgentLab 会自动退回 local, +「设置 → 运行环境」里的「选择原因」会写明。只运行你信得过的工作流;完全不需要执行代码的话, +在 `agentlab.env` 里设 `AGENTLAB_SANDBOX_BACKEND=off`。 + +要用 bubblewrap:把 `agentlab.env` 里的 `ENABLE_BWRAP` 改成 `1`,运行 `./deploy.sh install`。 +deploy.sh 会给容器加 `--security-opt seccomp=unconfined`、`--security-opt systempaths=unconfined`、 +`--security-opt apparmor=unconfined`。代价是整个容器少了系统调用过滤和对 `/proc`、`/sys` 部分路径的遮蔽, +内核暴露给容器的面变大(AgentLab 自己仍以非 root 运行)。这组权限在 Docker Desktop(arm64)上实测生效。 +Ubuntu 24.04 默认还限制非特权 user namespace:开了之后「设置 → 运行环境」里仍显示 local 的话, +要在宿主机上执行 `sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0`,这会放宽整台机器的限制,自行权衡。 + +microVM(独立内核)需要 `/dev/kvm` 和额外下载的运行时与镜像,离线包默认不支持。 + +## 常见问题 + +**端口被占用。** deploy.sh 报「端口 8000 已被别的程序占用」,或 docker 报 `port is already allocated` / +`address already in use`:改 `agentlab.env` 里的 `HOST_PORT`,再 `./deploy.sh install`。 +看是谁占着:`sudo ss -ltnp 'sport = :8000'`。 + +**没有权限。** 报 `permission denied while trying to connect to the Docker daemon socket`: +用 `sudo ./deploy.sh`,或者 `sudo usermod -aG docker $USER` 后重新登录。 +数据目录建不了:用 sudo,或者把 `DATA_DIR` 改到有写权限的位置。 + +**架构不对。** 报「架构不一致」或容器日志里有 `exec format error`:这个包是 `linux/__AGENTLAB_ARCH__`, +和 `uname -m` 对不上。在打包的机器上按目标机的架构重新打一份 +(`./scripts/package-docker.sh --platform linux/arm64` 或 `linux/amd64`)。ARM 单板机要装 64 位系统。 + +**健康检查不过。** deploy.sh 会打出容器最后 60 行日志,先看那里。常见原因: + +- 数据目录写不进去(日志里有 `Permission denied` / `unable to open database file`):确认 `DATA_DIR` + 没指到只读的位置,重新运行 `./deploy.sh install`,它会修正属主; +- 机器太慢,启动超过 3 分钟:`./deploy.sh status` 看看,变成 `healthy` 就是好了; +- `agentlab.env` 里的值带了引号、或者注释写在了值后面:去掉。 + +**`sha256sum -c` 报 image.tar FAILED。** 包在拷贝中损坏了,重新拷一份。 diff --git a/deploy/agentlab.env b/deploy/agentlab.env new file mode 100644 index 00000000..7c146678 --- /dev/null +++ b/deploy/agentlab.env @@ -0,0 +1,69 @@ +# AgentLab 一键部署的配置。改完运行 ./deploy.sh(已经在跑的,改完运行 ./deploy.sh install 让它生效)。 +# +# 写法(这个文件直接交给 docker run --env-file,规矩和 shell 不一样): +# - 一行一个 KEY=VALUE,等号两边不要空格; +# - 值不要加引号,引号会原样变成值的一部分; +# - 注释只能单独成行,不能写在值后面。 +# +# 前四项(HOST_PORT、BIND_ADDR、DATA_DIR、CONTAINER_NAME)和 ENABLE_BWRAP 是给 deploy.sh 用的; +# 其余的变量原样传进容器,给 AgentLab 自己用。 + +# ---- 访问 ---- +# 宿主机上对外的端口。浏览器打开 http://<这台机器的地址>:HOST_PORT +HOST_PORT=8000 +# 监听哪个地址。0.0.0.0 表示所有网卡,同一网络里的电脑、手机都能打开。 +# +# 注意:AgentLab 没有登录。同一网络里打得开页面的人都能用它——跑工作流、花你配的模型额度、 +# 读你接入的数据源、在沙箱里执行代码。只在信得过的网络里开,并用防火墙限制来源。 +# 只给本机用(比如另配反向代理做登录),改成 127.0.0.1。 +BIND_ADDR=0.0.0.0 + +# ---- 数据 ---- +# 数据目录:数据库、执行断点、上传的文件、沙箱工作区,还有加密密钥 .secret_key 都在这里。 +# 要备份的就是这个目录(丢了 .secret_key,存进去的 API Key 就解不开了)。 +# 相对路径以 deploy.sh 所在的目录为准。升级时解到新目录的,记得指回原来的数据目录(写绝对路径最稳)。 +# deploy.sh 会把它的属主改成容器里的用户(uid 10001),备份和删除要用 sudo。 +DATA_DIR=./data + +# ---- 容器 ---- +CONTAINER_NAME=agentlab + +# ---- 模型接入(可选)---- +# 填了的话,第一次启动时(库里还没有任何模型接入)自动导入成模型接入。之后再改这里不会更新已导入的, +# 要到界面「设置 → 模型接入」里改。也可以都留空,启动后在「设置 → 模型接入」里配,Key 加密存进数据目录。 +# 不配也能用:内置的演示模型不需要 Key,编排链路能完整走一遍。 +# ANTHROPIC_API_KEY= +# 用中转网关时填这两个(以 Authorization: Bearer 发送),这时不填 ANTHROPIC_API_KEY +# ANTHROPIC_BASE_URL= +# ANTHROPIC_AUTH_TOKEN= +# OPENAI_API_KEY= +# 配了就用 Tavily 做网页搜索,否则退回 DuckDuckGo +# TAVILY_API_KEY= + +# ---- 代理(可选)---- +# 机器要经代理才能访问模型接口时填。NO_PROXY 里的地址不走代理。 +# HTTPS_PROXY=http://proxy.example.internal:3128 +# NO_PROXY=localhost,127.0.0.1 + +# ---- 代码沙箱 ---- +# auto:按 microvm → bubblewrap → local 挑第一个能用的。可选值 auto | bubblewrap | local | off +# +# 容器里实测(Docker 默认权限):bubblewrap 无法创建命名空间,auto 自动退回 local,也就是在容器里 +# 起一个普通子进程执行代码。这时隔离的边界只有容器本身:代码伤不到宿主机,但和 AgentLab 在同一个容器、 +# 同一个用户下,读得到数据目录里的数据库和加密密钥、读得到传进容器的 API Key、能联网。 +# 只运行你信得过的工作流;不需要执行代码就设成 off。 +AGENTLAB_SANDBOX_BACKEND=auto +# +# 想让代码在 bubblewrap 里执行(看不到数据目录、看不到别的进程、默认断网),把下面改成 1, +# 然后运行 ./deploy.sh install。deploy.sh 会给容器加三项权限: +# --security-opt seccomp=unconfined 放开 Docker 默认的系统调用过滤,才能创建命名空间 +# --security-opt systempaths=unconfined 取消对 /proc、/sys 部分路径的遮蔽,才能在沙箱里挂载 /proc +# --security-opt apparmor=unconfined Ubuntu 上 Docker 默认的 AppArmor 配置不允许沙箱里的挂载 +# 代价:整个容器少了系统调用过滤和这几处遮蔽,内核暴露给容器的面变大。AgentLab 自己仍以非 root 运行。 +# 实测在 Docker Desktop(arm64)上生效,沙箱类型显示为 bubblewrap。Ubuntu 24.04 默认还限制非特权 +# user namespace,开了之后「设置 → 运行环境」里仍显示 local 的话,要在宿主机上执行 +# sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 +# 这一步会放宽整台机器的限制,自行权衡。 +ENABLE_BWRAP=0 +# +# microVM(独立内核)需要 /dev/kvm 和额外下载的运行时与镜像,离线包默认不支持。 diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 00000000..753a7523 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,543 @@ +#!/usr/bin/env bash +# AgentLab 一键部署(离线包自带)。只依赖 docker,不需要 docker compose、不需要联网。 +# 配置在同目录的 agentlab.env 里,用法见 ./deploy.sh --help。 +set -euo pipefail + +# 下面两个值由 scripts/package-docker.sh 打包时填进来 +VERSION="__AGENTLAB_VERSION__" +IMAGE_ARCH="__AGENTLAB_ARCH__" +IMAGE="agentlab:$VERSION" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$HERE/agentlab.env" +IMAGE_TAR="$HERE/image.tar" + +# 给沙箱用 bubblewrap 时容器要加的权限,说明见 agentlab.env 的沙箱一节 +BWRAP_OPTS=(--security-opt seccomp=unconfined --security-opt systempaths=unconfined --security-opt apparmor=unconfined) + +usage() { + cat < %s\n' "$*"; } +warn() { printf '警告:%s\n' "$*" >&2; } +die() { printf '错误:%s\n' "$*" >&2; exit 1; } +bad_arg() { printf '认不出的参数:%s\n\n' "$1" >&2; usage >&2; exit 2; } + +# --------------------------------------------------------------------------- +# 参数:先认完再干活,认不出的直接报错 +# --------------------------------------------------------------------------- +CMD="install" +FOLLOW="" +TAIL="200" +PURGE_IMAGE="" +if [ $# -gt 0 ]; then + case "$1" in + -h|--help) usage; exit 0 ;; + -*) ;; + *) CMD="$1"; shift ;; + esac +fi +case "$CMD" in + install|upgrade|start|stop|restart|status|logs|uninstall) ;; + *) printf '认不出的子命令:%s\n\n' "$CMD" >&2; usage >&2; exit 2 ;; +esac +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -f|--follow) [ "$CMD" = logs ] || bad_arg "$1"; FOLLOW=1 ;; + --tail) + [ "$CMD" = logs ] && [ $# -ge 2 ] || bad_arg "$1" + TAIL="$2"; shift ;; + --tail=*) [ "$CMD" = logs ] || bad_arg "$1"; TAIL="${1#--tail=}" ;; + --purge-image) [ "$CMD" = uninstall ] || bad_arg "$1"; PURGE_IMAGE=1 ;; + *) bad_arg "$1" ;; + esac + shift +done +case "$TAIL" in + all) ;; + ''|*[!0-9]*) die "--tail 要跟行数或 all:$TAIL" ;; +esac + +case "$VERSION$IMAGE_ARCH" in + *"__AGENTLAB"_*) die "这是仓库里的模板,要用 scripts/package-docker.sh 打出来的离线包里的 deploy.sh" ;; +esac + +# --------------------------------------------------------------------------- +# 读 agentlab.env。不 source 它:docker --env-file 不处理引号和变量,这里按同样的规矩逐行读, +# 两边看到的值才一致;source 还会把值当命令执行 +# --------------------------------------------------------------------------- +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +unquote() { + local s="$1" + case "$s" in + \"*\"|\'*\') s="${s:1:${#s}-2}" ;; + esac + printf '%s' "$s" +} + +HOST_PORT="8000" +BIND_ADDR="0.0.0.0" +DATA_DIR="./data" +CONTAINER_NAME="agentlab" +ENABLE_BWRAP="0" + +read_env() { + [ -f "$ENV_FILE" ] || die "找不到 ${ENV_FILE},它应该和 deploy.sh 在同一个目录" + if grep -q "$(printf '\r')" "$ENV_FILE"; then + die "agentlab.env 是 Windows 换行(CRLF),docker 会把行尾的 \\r 当成值的一部分。先转换:sed -i 's/\\r\$//' agentlab.env" + fi + local line key val n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + case "$(trim "$line")" in ''|'#'*) continue ;; esac + case "$line" in *=*) ;; *) continue ;; esac + key="$(trim "${line%%=*}")" + val="${line#*=}" + case "$key" in + HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP) + printf -v "$key" '%s' "$(unquote "$(trim "$val")")" ;; + AGENTLAB_DATA_DIR|AGENTLAB_WEB_DIST|AGENTLAB_PORT|AGENTLAB_HOST) + die "agentlab.env 第 $n 行:不要设 ${key}。容器里的路径和端口是固定的,改宿主机这边用 DATA_DIR、HOST_PORT、BIND_ADDR" ;; + *) + case "$val" in + \"*\"|\'*\') warn "agentlab.env 第 $n 行:$key 的值带了引号。docker 不去引号,引号会原样传进容器,一般要去掉" ;; + esac ;; + esac + done < "$ENV_FILE" + + case "$HOST_PORT" in ''|*[!0-9]*) die "HOST_PORT 必须是数字:$HOST_PORT" ;; esac + if [ "$HOST_PORT" -lt 1 ] || [ "$HOST_PORT" -gt 65535 ]; then die "HOST_PORT 超出范围:$HOST_PORT"; fi + [ -n "$BIND_ADDR" ] || die "BIND_ADDR 不能为空(所有网卡写 0.0.0.0,只给本机用写 127.0.0.1)" + [ -n "$DATA_DIR" ] || die "DATA_DIR 不能为空" + case "$CONTAINER_NAME" in + ''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9_.-]*) die "CONTAINER_NAME 只能用字母、数字和 _ . -,并以字母或数字开头:$CONTAINER_NAME" ;; + esac + case "$ENABLE_BWRAP" in 0|1) ;; *) die "ENABLE_BWRAP 只能是 0 或 1:$ENABLE_BWRAP" ;; esac + + # 相对路径以 deploy.sh 所在的目录为准,和从哪里运行无关 + # 下面比较的就是字面上的 ~,不需要展开 + # shellcheck disable=SC2088 + case "$DATA_DIR" in + /*) DATA_PATH="$DATA_DIR" ;; + "~"|"~/"*) DATA_PATH="$HOME${DATA_DIR#\~}" ;; + *) DATA_PATH="$HERE/${DATA_DIR#./}" ;; + esac +} + +# --------------------------------------------------------------------------- +# docker 与镜像 +# --------------------------------------------------------------------------- +check_docker() { + command -v docker >/dev/null 2>&1 || die "没找到 docker。先装好 Docker Engine(离线机器要提前下载好安装包),再运行这个脚本" + local out + if ! out="$(docker version --format '{{.Server.Version}}' 2>&1)"; then + case "$out" in + *"ermission denied"*) + die "当前用户没有权限访问 docker。二选一: + - 用 sudo 运行:sudo ./deploy.sh $CMD + - 把当前用户加进 docker 组(之后重新登录才生效):sudo usermod -aG docker \$USER" ;; + *) die "连不上 docker 服务,确认它在运行:sudo systemctl start docker +docker 的原话:$out" ;; + esac + fi +} + +host_arch() { + case "$(uname -m)" in + x86_64|amd64) echo amd64 ;; + aarch64|arm64) echo arm64 ;; + armv7l|armv6l|armhf) echo arm ;; + *) uname -m ;; + esac +} + +check_arch() { + local host + host="$(host_arch)" + [ "$host" = "$IMAGE_ARCH" ] && return 0 + local hint="在打包的机器上用 ./scripts/package-docker.sh --platform linux/$host 重新打一份。" + [ "$host" = arm ] && hint="这台机器跑的是 32 位系统,AgentLab 只提供 64 位镜像。ARM 单板机请装 64 位系统(arm64)。" + die "架构不一致:这个包里的镜像是 linux/${IMAGE_ARCH},本机是 $(uname -m)。 +不同架构的镜像跑不起来(没有模拟层时直接报 exec format error)。$hint" +} + +image_present() { docker image inspect "$IMAGE" >/dev/null 2>&1; } + +verify_tar() { + # 拷贝中途断了、U 盘出错,docker load 的报错不好懂:先对一下校验和 + [ -f "$HERE/SHA256SUMS" ] || return 0 + command -v sha256sum >/dev/null 2>&1 || return 0 + local line + line="$(grep ' image.tar$' "$HERE/SHA256SUMS" || true)" + [ -n "$line" ] || return 0 + say "校验 image.tar" + (cd "$HERE" && printf '%s\n' "$line" | sha256sum -c --quiet -) \ + || die "image.tar 校验和对不上,文件不完整或损坏。重新拷一份离线包再试" +} + +load_image() { + [ -f "$IMAGE_TAR" ] || die "本地没有镜像 ${IMAGE},包里也找不到 image.tar" + verify_tar + say "导入镜像:docker load -i image.tar(性能较弱的 ARM 单板机上可能要一两分钟)" + docker load -i "$IMAGE_TAR" + image_present || die "导入后仍找不到 ${IMAGE},image.tar 可能不是这个版本的" +} + +ensure_image() { + if image_present; then + say "镜像 $IMAGE 已在本地,跳过导入" + else + load_image + fi + local arch + arch="$(docker image inspect -f '{{.Architecture}}' "$IMAGE")" + [ "$arch" = "$(host_arch)" ] || die "镜像 $IMAGE 的架构是 ${arch},本机是 $(uname -m),跑不起来" +} + +# --------------------------------------------------------------------------- +# 数据目录 +# --------------------------------------------------------------------------- +prepare_data() { + DATA_CREATED="" + [ -d "$DATA_PATH" ] || DATA_CREATED=1 + mkdir -p "$DATA_PATH" 2>/dev/null \ + || die "无法创建数据目录 ${DATA_PATH}。权限不够的话用 sudo 运行,或者把 DATA_DIR 改到有写权限的位置" + DATA_ABS="$(cd "$DATA_PATH" && pwd -P)" + case "$DATA_ABS" in + /|/bin|/boot|/dev|/etc|/home|/lib|/opt|/proc|/root|/sbin|/srv|/sys|/usr|/var) + die "DATA_DIR 不能是系统目录:$DATA_ABS" ;; + esac + [ "$DATA_ABS" != "$HOME" ] || die "DATA_DIR 不能是家目录本身:$DATA_ABS" +} + +# 已经有同名容器时:它得是 AgentLab 的,数据目录也得和配置的是同一个。 +# 升级解到新目录、DATA_DIR 还是相对路径,是最容易踩的坑:新容器会从一个空库开始,看着像数据丢了 +check_existing() { + container_exists "$CONTAINER_NAME" || return 0 + check_is_agentlab + local old_src + old_src="$(data_source_of "$CONTAINER_NAME")" + [ -n "$old_src" ] && [ "$old_src" != "$DATA_ABS" ] || return 0 + # 刚才为这次部署新建的空目录收回去,别留一个误导人的空 data + [ -z "$DATA_CREATED" ] || rmdir "$DATA_ABS" 2>/dev/null || true + die "现有容器 ${CONTAINER_NAME} 用的数据目录是 ${old_src},而 agentlab.env 配置的是 ${DATA_ABS}。 +直接换过去,新容器会从一个空库开始(旧数据还在原处,不会丢)。 + - 要沿用原来的数据:把 agentlab.env 里的 DATA_DIR 改成 ${old_src} 再运行; + - 确实要换新目录:先 ./deploy.sh uninstall,再 ./deploy.sh install。" +} + +fix_data_owner() { + # 容器里的服务以固定的非 root 用户运行,数据目录得归它,否则启动时写不进数据库 + local user uid gid owner + user="$(docker image inspect -f '{{.Config.User}}' "$IMAGE")" + uid="${user%%:*}" + gid="${user#*:}" + [ "$gid" != "$user" ] || gid="$uid" + case "$uid$gid" in ''|*[!0-9]*) return 0 ;; esac + # 只看一个目录自己的属主;stat 的参数 GNU 和 BSD 不一样,ls -nd 两边通用 + # shellcheck disable=SC2012 + owner="$(ls -nd "$DATA_ABS" | awk '{print $3}')" + [ "$owner" = "$uid" ] && return 0 + # 改属主前确认这确实是一个新目录或者 AgentLab 的数据目录,免得 DATA_DIR 写错改了别的东西 + if [ -n "$(ls -A "$DATA_ABS" 2>/dev/null)" ] && [ ! -e "$DATA_ABS/agentlab.db" ]; then + die "DATA_DIR 指向的 $DATA_ABS 不是空目录,也不像 AgentLab 的数据目录(里面没有 agentlab.db)。 +为免改错别的文件的属主,请指向一个新目录,或者原来的 AgentLab 数据目录" + fi + say "把数据目录的属主改成容器里的用户(uid ${uid}),容器里的服务才写得进去" + docker run --rm --user 0:0 --network none --entrypoint chown \ + -v "$DATA_ABS:/data" "$IMAGE" -R "$uid:$gid" /data \ + || die "无法修改 $DATA_ABS 的属主" +} + +# --------------------------------------------------------------------------- +# 容器 +# --------------------------------------------------------------------------- +container_exists() { docker container inspect "$1" >/dev/null 2>&1; } + +container_state() { docker inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo missing; } + +data_source_of() { + docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$1" 2>/dev/null || true +} + +require_container() { + container_exists "$CONTAINER_NAME" || die "没有名为 $CONTAINER_NAME 的容器。还没部署的话运行 ./deploy.sh install" +} + +check_is_agentlab() { + local image + image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")" + case "$image" in + agentlab:*) ;; + *) die "已经有一个叫 $CONTAINER_NAME 的容器,但它用的镜像是 ${image},不是 AgentLab。改一下 agentlab.env 里的 CONTAINER_NAME" ;; + esac +} + +port_in_use() { + # 查不了(没有 ss)就当没占,交给 docker run 报错 + command -v ss >/dev/null 2>&1 || return 1 + ss -ltnH "sport = :$1" 2>/dev/null | grep -q . +} + +container_publishes() { + docker port "$1" 8000/tcp 2>/dev/null | grep -q ":$2\$" +} + +run_container() { + local extra=() + [ "$ENABLE_BWRAP" = 1 ] && extra=("${BWRAP_OPTS[@]}") + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + --env-file "$ENV_FILE" \ + -p "$BIND_ADDR:$HOST_PORT:8000" \ + -v "$DATA_ABS:/data" \ + --log-opt max-size=10m --log-opt max-file=3 \ + ${extra[@]+"${extra[@]}"} \ + "$IMAGE" >/dev/null +} + +wait_healthy() { + local name="$1" state health i + say "等健康检查通过" + for i in $(seq 1 90); do + state="$(container_state "$name")" + health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$name" 2>/dev/null || true)" + case "$state" in + exited|dead|missing) return 1 ;; + restarting) [ "$i" -gt 3 ] && return 1 ;; + esac + case "$health" in + healthy) return 0 ;; + unhealthy) return 1 ;; + esac + sleep 2 + done + return 1 +} + +print_access() { + echo + case "$BIND_ADDR" in + 0.0.0.0) + local ip + ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + echo " 本机 http://127.0.0.1:$HOST_PORT" + echo " 局域网 http://${ip:-<本机 IP>}:$HOST_PORT" + echo + echo " 注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它(跑工作流、花模型额度、" + echo " 读数据源、执行代码)。只在信得过的网络里开,并用防火墙限制来源。" + ;; + 127.0.0.1|localhost) echo " http://127.0.0.1:$HOST_PORT (只有本机能打开)" ;; + *) echo " http://$BIND_ADDR:$HOST_PORT" ;; + esac + echo +} + +show_failure() { + echo >&2 + echo "---- 容器最后 60 行日志 ----" >&2 + docker logs --tail 60 "$CONTAINER_NAME" >&2 2>&1 || true + echo "----" >&2 +} + +# 起一个新容器替换掉同名的旧容器(没有旧的就直接起)。数据目录不动。 +# 旧容器先改名留着:新容器起不来就换回去;起来了但健康检查没过,告诉用户怎么回退 +deploy_container() { + local prev="$CONTAINER_NAME-previous" had_old="" + container_exists "$CONTAINER_NAME" && had_old=1 + + if port_in_use "$HOST_PORT"; then + if [ -z "$had_old" ] || ! container_publishes "$CONTAINER_NAME" "$HOST_PORT"; then + die "端口 $HOST_PORT 已被别的程序占用。改 agentlab.env 里的 HOST_PORT,或停掉占用方(sudo ss -ltnp 'sport = :$HOST_PORT' 可以看是谁)" + fi + fi + + if [ -n "$had_old" ]; then + docker rm -f "$prev" >/dev/null 2>&1 || true + say "停止现有容器 ${CONTAINER_NAME}(数据保留)" + docker stop "$CONTAINER_NAME" >/dev/null + docker rename "$CONTAINER_NAME" "$prev" + fi + + say "启动容器 ${CONTAINER_NAME}(${IMAGE})" + if ! run_container; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [ -n "$had_old" ]; then + docker rename "$prev" "$CONTAINER_NAME" && docker start "$CONTAINER_NAME" >/dev/null \ + && warn "新容器没能启动,已换回原来的容器" + fi + die "容器启动失败,见上面 docker 的报错(端口被占、数据目录权限不对最常见)" + fi + + if wait_healthy "$CONTAINER_NAME"; then + [ -z "$had_old" ] || docker rm "$prev" >/dev/null 2>&1 || true + say "AgentLab $VERSION 已就绪,数据目录 $DATA_ABS" + print_access + return 0 + fi + + show_failure + if [ -n "$had_old" ]; then + echo "原来的容器还留着,名字改成了 ${prev}。要回退:" >&2 + echo " docker rm -f $CONTAINER_NAME && docker rename $prev $CONTAINER_NAME && docker start $CONTAINER_NAME" >&2 + fi + die "容器没通过健康检查。看上面的日志;常见原因见 README 的常见问题" +} + +# --------------------------------------------------------------------------- +# 子命令 +# --------------------------------------------------------------------------- +cmd_install() { + check_docker + check_arch + read_env + ensure_image + prepare_data + check_existing + fix_data_owner + deploy_container +} + +cmd_upgrade() { + check_docker + check_arch + read_env + container_exists "$CONTAINER_NAME" || die "没有名为 $CONTAINER_NAME 的容器,这台机器还没部署过。运行 ./deploy.sh install" + check_is_agentlab + local old_image + old_image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")" + say "当前版本 ${old_image#agentlab:},升级到 $VERSION" + # 总是从包里导入:同名标签在本机可能指向别的构建 + load_image + local arch + arch="$(docker image inspect -f '{{.Architecture}}' "$IMAGE")" + [ "$arch" = "$(host_arch)" ] || die "镜像 $IMAGE 的架构是 ${arch},本机是 $(uname -m),跑不起来" + prepare_data + check_existing + fix_data_owner + deploy_container + if [ "$old_image" != "$IMAGE" ] && docker image inspect "$old_image" >/dev/null 2>&1; then + echo "旧镜像 $old_image 还留着,方便回退。确认新版本没问题后可以删:docker rmi $old_image" + fi +} + +cmd_start() { + check_docker; read_env; require_container + docker start "$CONTAINER_NAME" >/dev/null + if wait_healthy "$CONTAINER_NAME"; then + say "已启动"; print_access + else + show_failure; die "容器没通过健康检查" + fi +} + +cmd_stop() { + check_docker; read_env; require_container + say "停止 ${CONTAINER_NAME}(数据保留)" + docker stop "$CONTAINER_NAME" >/dev/null + say "已停止。开机也不会自动起来,要用时运行 ./deploy.sh start" +} + +cmd_restart() { + check_docker; read_env; require_container + say "重启 $CONTAINER_NAME" + docker restart "$CONTAINER_NAME" >/dev/null + if wait_healthy "$CONTAINER_NAME"; then + say "已重启"; print_access + else + show_failure; die "容器没通过健康检查" + fi +} + +cmd_status() { + check_docker; read_env + if ! container_exists "$CONTAINER_NAME"; then + echo "没有名为 $CONTAINER_NAME 的容器(还没部署,或已卸载)。部署:./deploy.sh install" + exit 1 + fi + local state health image src + state="$(container_state "$CONTAINER_NAME")" + health="" + # 停着的容器,健康检查结果是停之前的,不显示免得误导 + [ "$state" != running ] || health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$CONTAINER_NAME")" + image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")" + src="$(data_source_of "$CONTAINER_NAME")" + echo "容器 $CONTAINER_NAME" + echo "状态 $state${health:+(健康检查:${health})}" + echo "镜像 $image" + echo "端口 $(docker port "$CONTAINER_NAME" 8000/tcp 2>/dev/null | tr '\n' ' ')" + echo "数据目录 ${src:-(未挂载)}" + echo "这个包 $IMAGE" + if [ "$state" = running ]; then print_access; fi +} + +cmd_logs() { + check_docker; read_env; require_container + local args=(--tail "$TAIL") + [ -z "$FOLLOW" ] || args+=(--follow) + # exec:Ctrl-C 直接交给 docker logs,不留一个还在跟随的子进程 + exec docker logs "${args[@]}" "$CONTAINER_NAME" +} + +cmd_uninstall() { + check_docker; read_env + local src="" name + if container_exists "$CONTAINER_NAME"; then + check_is_agentlab + src="$(data_source_of "$CONTAINER_NAME")" + fi + for name in "$CONTAINER_NAME" "$CONTAINER_NAME-previous"; do + if container_exists "$name"; then + say "删除容器 $name" + docker rm -f "$name" >/dev/null + fi + done + if [ -n "$PURGE_IMAGE" ]; then + if image_present; then + say "删除镜像 $IMAGE" + docker rmi "$IMAGE" >/dev/null || warn "镜像 $IMAGE 没删掉(可能还有别的容器在用)" + fi + else + echo "镜像 $IMAGE 保留着,重新部署不用再导入。要删:./deploy.sh uninstall --purge-image" + fi + [ -n "$src" ] || src="$DATA_PATH" + echo + echo "数据没有删,还在:$src" + echo "确定不要了再手动删除(不可恢复):sudo rm -rf '$src'" +} + +case "$CMD" in + install) cmd_install ;; + upgrade) cmd_upgrade ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs ;; + uninstall) cmd_uninstall ;; +esac diff --git a/scripts/package-docker.sh b/scripts/package-docker.sh new file mode 100755 index 00000000..290e285f --- /dev/null +++ b/scripts/package-docker.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# 打 Docker 离线包:构建 Linux 镜像,连同一键部署脚本、配置模板和部署说明打成一个 tar, +# 拷到只装了 Docker Engine、不能联网的机器上就能部署。部署说明见 deploy/README.md。 +set -euo pipefail + +usage() { + cat <<'USAGE' +用法:./scripts/package-docker.sh [--platform linux/arm64|linux/amd64] [--version <版本>] [--out <目录>] [--gzip] + + --platform <平台> 目标机的平台。默认按本机架构取 linux/。64 位系统的 ARM 单板机是 linux/arm64, + 普通 PC 和服务器是 linux/amd64。和本机架构不同时 docker 用模拟构建,会慢很多。 + --version <版本> 镜像标签和包名里的版本。默认 git describe --tags --always --dirty,拿不到就用短哈希加日期。 + --out <目录> 产物放哪里,默认仓库下的 release/。 + --gzip 另外再产出一份 .tar.gz。docker 用 containerd 镜像存储时镜像层本身已经压缩过,.tar.gz 小不了多少。 + -h, --help 显示这段说明。 + +产物:/agentlab-<版本>-linux-.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS。 +构建需要联网(拉基础镜像、装依赖);部署不需要。镜像只留在本机,不会推到任何仓库。 +USAGE +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PLATFORM="" +VERSION="" +OUT="$ROOT/release" +GZIP="" +need_value() { [ $# -ge 2 ] && [ -n "$2" ] || { echo "$1 后面要跟一个值"; echo; usage; exit 2; }; } +while [ $# -gt 0 ]; do + case "$1" in + --platform) need_value "$@"; PLATFORM="$2"; shift ;; + --platform=*) PLATFORM="${1#--platform=}" ;; + --version) need_value "$@"; VERSION="$2"; shift ;; + --version=*) VERSION="${1#--version=}" ;; + --out) need_value "$@"; OUT="$2"; shift ;; + --out=*) OUT="${1#--out=}" ;; + --gzip) GZIP=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "认不出的参数:$1"; echo; usage; exit 2 ;; + esac + shift +done + +# ---- 平台 ---- +if [ -z "$PLATFORM" ]; then + case "$(uname -m)" in + arm64|aarch64) PLATFORM="linux/arm64" ;; + x86_64|amd64) PLATFORM="linux/amd64" ;; + *) echo "认不出本机架构 $(uname -m),用 --platform 指定 linux/arm64 或 linux/amd64"; exit 2 ;; + esac +fi +case "$PLATFORM" in + linux/arm64|linux/amd64) ARCH="${PLATFORM#linux/}" ;; + *) echo "只支持 linux/arm64 和 linux/amd64:$PLATFORM"; exit 2 ;; +esac + +# ---- 版本 ---- +# GIT_OPTIONAL_LOCKS=0:只读版本库,不顺手刷新索引文件 +if [ -z "$VERSION" ]; then + VERSION="$(GIT_OPTIONAL_LOCKS=0 git -C "$ROOT" describe --tags --always --dirty 2>/dev/null || true)" +fi +if [ -z "$VERSION" ]; then + HASH="$(GIT_OPTIONAL_LOCKS=0 git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo nogit)" + VERSION="$HASH-$(date +%Y%m%d)" +fi +# docker 标签只认字母、数字和 _ . -,最长 128,不能以 . 或 - 开头 +VERSION="$(printf '%s' "$VERSION" | tr -c 'A-Za-z0-9_.-' '-')" +VERSION="${VERSION#[.-]}" +VERSION="${VERSION:0:120}" +[ -n "$VERSION" ] || { echo "版本号是空的,用 --version 指定"; exit 2; } + +IMAGE="agentlab:$VERSION" +NAME="agentlab-$VERSION-linux-$ARCH" + +# ---- 工具 ---- +command -v docker >/dev/null || { echo "缺少 docker"; exit 1; } +docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "连不上 docker 服务,先把 Docker 启动起来"; exit 1; } +docker buildx version >/dev/null 2>&1 || { echo "缺少 docker buildx(Docker Desktop 自带;Linux 上装 docker-buildx-plugin)"; exit 1; } +for f in deploy/deploy.sh deploy/agentlab.env deploy/README.md Dockerfile .dockerignore; do + [ -f "$ROOT/$f" ] || { echo "缺少 $f"; exit 1; } +done +if command -v sha256sum >/dev/null 2>&1; then SHA=(sha256sum); else SHA=(shasum -a 256); fi + +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +STAGE="$OUT/$NAME" +TAR="$OUT/$NAME.tar" + +echo "==> 版本 $VERSION · 平台 $PLATFORM · 产物 $TAR" + +# ---- 1. 构建 ---- +# 不带 provenance / SBOM:它们会让导出的镜像多出几份附加清单,老版本的 docker load 认不全 +echo "==> 构建镜像 $IMAGE" +docker buildx build \ + --platform "$PLATFORM" \ + --provenance=false --sbom=false \ + --build-arg VERSION="$VERSION" \ + --load \ + -t "$IMAGE" \ + "$ROOT" + +GOT="$(docker image inspect -f '{{.Os}}/{{.Architecture}}' "$IMAGE")" +[ "$GOT" = "$PLATFORM" ] || { echo "构建出来的镜像是 ${GOT},不是 $PLATFORM"; exit 1; } + +# ---- 2. 暂存目录 ---- +rm -rf "$STAGE" +mkdir -p "$STAGE" +trap 'rm -rf "$STAGE"' EXIT + +echo "==> 导出镜像(docker save)" +if docker save --help 2>/dev/null | grep -q -- '--platform'; then + docker save --platform "$PLATFORM" -o "$STAGE/image.tar" "$IMAGE" +else + docker save -o "$STAGE/image.tar" "$IMAGE" +fi + +fill() { + # 模板里的占位符换成这一次的版本和架构 + sed -e "s|__AGENTLAB_VERSION__|$VERSION|g" -e "s|__AGENTLAB_ARCH__|$ARCH|g" "$1" > "$2" +} +fill "$ROOT/deploy/deploy.sh" "$STAGE/deploy.sh" +fill "$ROOT/deploy/README.md" "$STAGE/README.md" +cp "$ROOT/deploy/agentlab.env" "$STAGE/agentlab.env" +chmod 755 "$STAGE/deploy.sh" +chmod 644 "$STAGE/README.md" "$STAGE/agentlab.env" "$STAGE/image.tar" +if grep -q '__AGENTLAB_' "$STAGE/deploy.sh" "$STAGE/README.md"; then + echo "模板里还有没填上的占位符"; exit 1 +fi + +(cd "$STAGE" && "${SHA[@]}" image.tar deploy.sh agentlab.env README.md > SHA256SUMS) +chmod 644 "$STAGE/SHA256SUMS" + +# ---- 3. 打包 ---- +# macOS 的 tar(bsdtar)默认会塞进 ._ 开头的资源文件和扩展属性,Linux 上解包会报一堆警告,关掉; +# 属主统一写成 0,免得目标机上用 sudo 解包时冒出一个不存在的 uid +echo "==> 打包 $TAR" +rm -f "$TAR" "$TAR.gz" +if tar --version 2>/dev/null | grep -q 'GNU tar'; then + tar --owner=0 --group=0 --numeric-owner -cf "$TAR" -C "$OUT" "$NAME" +else + COPYFILE_DISABLE=1 tar --uid 0 --gid 0 --uname root --gname root --no-mac-metadata --no-xattrs -cf "$TAR" -C "$OUT" "$NAME" +fi +if [ -n "$GZIP" ]; then + echo "==> 压缩 $TAR.gz" + if command -v pigz >/dev/null 2>&1; then pigz -c "$TAR" > "$TAR.gz"; else gzip -c "$TAR" > "$TAR.gz"; fi +fi + +size() { du -h "$1" | awk '{print $1}'; } +echo +echo "完成:" +echo " $TAR ($(size "$TAR"))" +[ -z "$GZIP" ] || echo " $TAR.gz ($(size "$TAR.gz"))" +echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库)" +echo +echo "下一步:" +echo " 1. 把 $NAME.tar 拷到目标机(scp、U 盘都行)" +echo " 2. 在目标机上:" +echo " tar -xf $NAME.tar && cd $NAME" +echo " nano agentlab.env # 端口、监听地址、数据目录、模型 Key" +echo " ./deploy.sh" +echo " 详细说明见包里的 README.md(仓库里是 deploy/README.md)" diff --git a/scripts/prod.sh b/scripts/prod.sh new file mode 100755 index 00000000..a00ce384 --- /dev/null +++ b/scripts/prod.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# 不用 Docker 的生产运行:构建前端,由后端一并托管页面,单进程、不热重载。Ctrl-C 退出。 +set -euo pipefail + +usage() { + cat <<'USAGE' +用法:./scripts/prod.sh [--host] [--port <端口>] [--build | --no-build] + + --host 监听 0.0.0.0(同一网络里的设备都能打开)。默认只听 127.0.0.1,只有本机能打开。 + 注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它——跑工作流、花你配的 + 模型额度、读数据源、执行沙箱代码。只在信得过的网络里开,并配好防火墙。 + --port <端口> 监听端口。也可以用环境变量 AGENTLAB_PORT,默认 8000。 + --build 不管前端产物新旧,强制重新构建。 + --no-build 跳过构建,直接用现有的 frontend/dist。 + -h, --help 显示这段说明。 + +默认只在 frontend/dist 不存在、或者比前端源码旧的时候才构建。 +数据目录照后端配置:默认是仓库下的 data/,用环境变量 AGENTLAB_DATA_DIR 改。 +只能单进程运行:运行管理器在进程内、数据库是 SQLite,多个 worker 会互相踩。 +USAGE +} + +# 参数先认完再干活,认不出的直接报错,免得写错的参数被悄悄忽略 +HOST="127.0.0.1" +PORT="${AGENTLAB_PORT:-8000}" +BUILD="auto" +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST="0.0.0.0" ;; + --port) + [ $# -ge 2 ] || { echo "--port 后面要跟端口号"; echo; usage; exit 2; } + PORT="$2"; shift ;; + --port=*) PORT="${1#--port=}" ;; + --build) BUILD="always" ;; + --no-build) BUILD="never" ;; + -h|--help) usage; exit 0 ;; + *) echo "认不出的参数:$1"; echo; usage; exit 2 ;; + esac + shift +done +case "$PORT" in + ''|*[!0-9]*) echo "端口必须是数字:$PORT"; exit 2 ;; +esac +if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then + echo "端口超出范围:$PORT"; exit 2 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST="$ROOT/frontend/dist" +CONDA_ENV="${AGENTLAB_CONDA_ENV:-agentlab}" + +# ---- 后端解释器:和 dev.sh 一样找 conda 环境,没有就按 environment.yml 建 ---- +command -v conda >/dev/null || { echo "缺少 conda:https://conda-forge.org/miniforge/"; exit 1; } +if ! conda env list | awk '{print $1}' | grep -qx "$CONDA_ENV"; then + echo "==> 创建 conda 环境 $CONDA_ENV" + conda env create -f "$ROOT/environment.yml" +fi +PY_BIN="$(conda run -n "$CONDA_ENV" python -c 'import sys; print(sys.executable)' 2>/dev/null)" +[ -x "$PY_BIN" ] || { echo "conda 环境 $CONDA_ENV 不可用,试试:conda env create -f environment.yml"; exit 1; } +echo "==> 后端解释器 $PY_BIN" + +# ---- 前端构建 ---- +# dist 比源码旧:源码、入口页、依赖清单、构建配置里有任何一个比 dist/index.html 新 +dist_stale() { + [ -f "$DIST/index.html" ] || return 0 + local newer + newer="$(find "$ROOT/frontend/src" "$ROOT/frontend/public" \ + "$ROOT/frontend/index.html" "$ROOT/frontend/package.json" "$ROOT/frontend/pnpm-lock.yaml" \ + "$ROOT/frontend/vite.config.ts" "$ROOT/frontend/tsconfig.json" \ + -newer "$DIST/index.html" -print 2>/dev/null | head -n 1)" + [ -n "$newer" ] +} + +need_build="" +case "$BUILD" in + always) need_build=1 ;; + never) + [ -f "$DIST/index.html" ] || { echo "用了 --no-build,但 $DIST 下没有 index.html。先去掉 --no-build 构建一次"; exit 1; } + ;; + auto) + if [ ! -f "$DIST/index.html" ]; then + echo "==> 前端还没构建过"; need_build=1 + elif dist_stale; then + echo "==> 前端源码比构建产物新"; need_build=1 + else + echo "==> 前端构建产物是最新的,跳过构建(--build 可强制重建)" + fi + ;; +esac + +if [ -n "$need_build" ]; then + command -v pnpm >/dev/null || { echo "缺少 pnpm:npm i -g pnpm"; exit 1; } + [ -d "$ROOT/frontend/node_modules" ] || (cd "$ROOT/frontend" && pnpm install --frozen-lockfile) + echo "==> 构建前端(pnpm build)" + (cd "$ROOT/frontend" && pnpm build) + [ -f "$DIST/index.html" ] || { echo "构建完了却没有 $DIST/index.html,看上面的构建输出"; exit 1; } +fi + +# ---- 端口 ---- +# 生产服务要固定地址,端口被占就报错,不像 dev.sh 那样自动往后挪 +port_taken() { + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 + else + # 没有 lsof(精简的 Linux)就试着绑一下:绑得上说明没人占 + if "$PY_BIN" -c 'import socket,sys; s=socket.socket(); s.bind((sys.argv[1], int(sys.argv[2])))' "$HOST" "$1" 2>/dev/null; then + return 1 + fi + return 0 + fi +} +if port_taken "$PORT"; then + echo "端口 $PORT 已被占用,换一个(--port 或 AGENTLAB_PORT)或停掉占用方" + command -v lsof >/dev/null 2>&1 && { lsof -nP -iTCP:"$PORT" -sTCP:LISTEN | tail -n +2 | head -3; } + exit 1 +fi + +export AGENTLAB_WEB_DIST="$DIST" +DATA_SHOW="${AGENTLAB_DATA_DIR:-$ROOT/data}" + +echo "==> 数据目录 $DATA_SHOW" +if [ "$HOST" = "0.0.0.0" ]; then + # 本机在局域网里的地址:macOS 取 en0 / en1,Linux 取 hostname -I 的第一个 + LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || true)" + URLS="http://127.0.0.1:$PORT · http://${LAN_IP:-<本机 IP>}:$PORT" + echo "==> 监听所有网卡。注意:AgentLab 没有登录,同一网络里打得开页面的人都能用它——" + echo " 跑工作流、花你配的模型额度、读数据源、执行沙箱代码。只在信得过的网络里开。" +else + URLS="http://127.0.0.1:$PORT" +fi + +# 就绪后再报一次地址:启动日志一长,前面打的那行容易被冲掉。 +# 确认答话的是 agentlab 自己(/api/health 里带 "agentlab"),不是碰巧占着端口的别的服务 +if command -v curl >/dev/null 2>&1; then + ( + # $$ 在子 shell 里仍是主脚本的进程号,exec 之后就是 uvicorn:它退了就别再等 + for _ in $(seq 1 120); do + kill -0 $$ 2>/dev/null || exit 0 + if curl -sf "http://127.0.0.1:$PORT/api/health" 2>/dev/null | grep -q '"agentlab"'; then + echo + echo " AgentLab 已就绪:$URLS" + echo " 按 Ctrl-C 停止" + echo + exit 0 + fi + sleep 0.5 + done + ) & +fi + +echo "==> 启动 $URLS" +cd "$ROOT/backend" +# exec:让 uvicorn 直接收 Ctrl-C / SIGTERM,一个信号就走完整的优雅退出(收尾运行、关沙箱)。 +# 要是包一层 shell 再转发,终端的 Ctrl-C 会让 uvicorn 收到两次信号,第二次直接强退、跳过收尾 +exec "$PY_BIN" -m uvicorn app.main:app --host "$HOST" --port "$PORT"