生产部署:prod.sh 直接运行,package-docker.sh 打 Docker 离线包(镜像 + 一键部署脚本 + 配置 + 说明)

- 服务端可以托管前端构建产物(AGENTLAB_WEB_DIST):页面路由回退到 index.html,/api 下不存在的路径照旧是 JSON 404,
  挡住路径穿越;index.html 不缓存,assets/ 带哈希的文件长缓存;没设时行为完全不变
- scripts/prod.sh:不用 Docker 的生产运行。按需构建前端,单进程、不热重载,默认只听本机,--host 开放到局域网并提示没有登录
- Dockerfile(多阶段构建,Linux 镜像):前端在构建机架构上构建,运行阶段用 python:3.12-slim,依赖装进独立的 venv,
  以非 root 用户(uid 10001)运行,数据在 /data,带健康检查;.dockerignore 排除 .env、data/、各种库文件,镜像里实测没有密钥和数据库
- scripts/package-docker.sh:构建指定平台的镜像(默认按本机架构,可选 linux/amd64),打成
  release/agentlab-<版本>-linux-<架构>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS
- deploy/deploy.sh:目标机上只依赖 Docker,离线可用。install / start / stop / restart / status / logs / upgrade / uninstall;
  检查架构和配置,自动导入镜像,替换容器时保留旧容器以便回退,数据永远保留,日志轮转
- 修正 bubblewrap 的可用性判断:以前只看有没有装 bwrap,容器里和 Ubuntu 24.04 默认配置下装了却建不了命名空间,
  每次执行代码都失败;现在真起一次试,不能用就退回下一个后端,并写明原因
- README 增加「生产部署」一节,部署细节见 deploy/README.md
This commit is contained in:
ThearchyHelios
2026-09-30 09:48:16 +08:00
parent 03c18c4d78
commit 6af4e396b9
16 changed files with 1742 additions and 7 deletions
+42
View File
@@ -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/
+3
View File
@@ -27,3 +27,6 @@ dist/
# 已迁到 conda,不再用 uv # 已迁到 conda,不再用 uv
uv.lock uv.lock
# 离线打包产物(scripts/package-docker.sh)
/release/
+75
View File
@@ -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"]
+19 -3
View File
@@ -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-<arch>.tar`。拷到只装了 Docker Engine 的离线机器上,解包、按需修改
`agentlab.env`、运行 `./deploy.sh` 即可,不需要 docker compose。安装、升级、备份、沙箱权限与常见问题见
[deploy/README.md](deploy/README.md)。容器默认权限下代码沙箱退回本地子进程,隔离边界只有容器本身。
---
## 核心能力 ## 核心能力
| 领域 | 能力 | | 领域 | 能力 |
@@ -483,8 +498,9 @@ Excel / CSV 若传入知识库,只能被切块检索,数字即成为模型
设置页「运行环境」列出全部候选及其可用性,并标注当前选中项。安装 microVM 依赖且镜像已缓存时 设置页「运行环境」列出全部候选及其可用性,并标注当前选中项。安装 microVM 依赖且镜像已缓存时
auto 选择 `microvm`;否则 macOS 使用 `seatbelt`、Linux 上安装 `bwrap` auto 选择 `microvm`;否则 macOS 使用 `seatbelt`、Linux 上安装 `bwrap`
(`apt install bubblewrap`)后使用 `bubblewrap`;均不可用时降级至 `local`——**该路径不含任何 (`apt install bubblewrap`)后使用 `bubblewrap`。装了 `bwrap` 还要能真正创建命名空间才算可用:
访问控制**,界面明确标红。可通过 `AGENTLAB_SANDBOX_BACKEND` 强制指定, 启动后首次选择时实际起一次 `bwrap` 探测,Docker 默认权限、Ubuntu 24.04 的 AppArmor 限制下会失败并跳过,
「选择原因」写明。均不可用时降级至 `local`——**该路径不含任何访问控制**,界面明确标红。可通过 `AGENTLAB_SANDBOX_BACKEND` 强制指定,
亦可在代码节点上单独选择隔离档位(strict = microVM,fast = 系统沙箱)。 亦可在代码节点上单独选择隔离档位(strict = microVM,fast = 系统沙箱)。
### 启用 microVM ### 启用 microVM
+20
View File
@@ -44,6 +44,11 @@ class Settings(BaseSettings):
# --- 存储 --- # --- 存储 ---
data_dir: Path = PROJECT_DIR / "data" data_dir: Path = PROJECT_DIR / "data"
# --- 前端构建产物(生产部署用)---
# 设了就由服务端一并托管页面(见 app/web.py),页面和接口同源、只起一个进程。
# 不设(开发模式)什么都不变:页面由 Vite 开发服务器提供。相对路径按仓库根目录算
web_dist: Path | None = None
# --- 密钥加密 --- # --- 密钥加密 ---
# 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。 # 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。
secret_key: str = "" secret_key: str = ""
@@ -112,6 +117,21 @@ class Settings(BaseSettings):
return [part.strip() for part in text.split(",") if part.strip()] return [part.strip() for part in text.split(",") if part.strip()]
return value 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") @field_validator("http_tool_fake_ip_ranges")
@classmethod @classmethod
def _valid_ranges(cls, value: list[str]) -> list[str]: def _valid_ranges(cls, value: list[str]) -> list[str]:
+8
View File
@@ -120,3 +120,11 @@ async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse:
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok", "service": "agentlab"} 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)
+52 -3
View File
@@ -5,8 +5,10 @@ import logging
import os import os
import resource import resource
import shutil import shutil
import subprocess
import sys import sys
import time import time
from functools import lru_cache
from pathlib import Path from pathlib import Path
from app.core.config import settings, sanitize_session from app.core.config import settings, sanitize_session
@@ -30,6 +32,37 @@ _LANG = {
_RO_BINDS = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives", "/etc/ssl"] _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): class BubblewrapSandbox(Sandbox):
"""Linux 上的对等方案:bubblewrap(Flatpak 的沙箱内核)。 """Linux 上的对等方案:bubblewrap(Flatpak 的沙箱内核)。
@@ -46,7 +79,23 @@ class BubblewrapSandbox(Sandbox):
@staticmethod @staticmethod
def available() -> bool: 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]: async def health(self) -> dict[str, object]:
ok = self.available() ok = self.available()
@@ -59,7 +108,7 @@ class BubblewrapSandbox(Sandbox):
"enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True}, "enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True},
"root": str(self._root), "root": str(self._root),
"interpreter": interpreter.describe(), "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: def _session_dir(self, session_id: str) -> Path:
@@ -119,7 +168,7 @@ class BubblewrapSandbox(Sandbox):
if language not in _LANG: if language not in _LANG:
return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}")
if not self.available(): 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] ext, cmd_prefix = _LANG[language]
# python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器 # python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器
+6 -1
View File
@@ -127,7 +127,12 @@ class SandboxManager:
if warm is not None and not warm(): if warm is not None and not warm():
self._resolved_from = f"{name} 可用但镜像未缓存,自动选择时暂不使用" self._resolved_from = f"{name} 可用但镜像未缓存,自动选择时暂不使用"
continue 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() return impl()
self._resolved_from = "没有可用的隔离方式,已降级为本地子进程" self._resolved_from = "没有可用的隔离方式,已降级为本地子进程"
+96
View File
@@ -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,
)
+105
View File
@@ -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
+221
View File
@@ -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 = "<!doctype html><title>fake-agentlab</title><div id=root></div>"
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("<svg xmlns='http://www.w3.org/2000/svg'/>")
(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
+167
View File
@@ -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。** 包在拷贝中损坏了,重新拷一份。
+69
View File
@@ -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 和额外下载的运行时与镜像,离线包默认不支持。
+543
View File
@@ -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 <<USAGE
用法:./deploy.sh [子命令] [选项]
install 部署(默认):导入镜像、创建数据目录、启动容器,等健康检查通过后打印访问地址。
已经有同名容器的,先停掉再换成新的,数据保留。改了 agentlab.env 也用它让配置生效。
upgrade 用这个包里的镜像替换正在用的容器,数据保留。旧镜像留着,方便回退。
start | stop | restart
启动 / 停止 / 重启容器。
status 查看容器状态、健康检查、数据目录和访问地址。
logs [-f] [--tail N]
查看日志(默认最后 200 行),-f 持续跟随。
uninstall [--purge-image]
删除容器。默认保留数据和镜像,--purge-image 连镜像一起删。数据永远不删。
-h, --help 显示这段说明。
这个包:AgentLab ${VERSION}(linux/${IMAGE_ARCH})
USAGE
}
say() { printf '==> %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
+161
View File
@@ -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/<arch>。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 显示这段说明。
产物:<out>/agentlab-<版本>-linux-<arch>.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)"
+155
View File
@@ -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"