mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
生产部署: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:
@@ -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/
|
||||
@@ -27,3 +27,6 @@ dist/
|
||||
|
||||
# 已迁到 conda,不再用 uv
|
||||
uv.lock
|
||||
|
||||
# 离线打包产物(scripts/package-docker.sh)
|
||||
/release/
|
||||
|
||||
+75
@@ -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"]
|
||||
@@ -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 依赖且镜像已缓存时
|
||||
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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器
|
||||
|
||||
@@ -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 = "没有可用的隔离方式,已降级为本地子进程"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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。** 包在拷贝中损坏了,重新拷一份。
|
||||
@@ -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 和额外下载的运行时与镜像,离线包默认不支持。
|
||||
Executable
+543
@@ -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
|
||||
Executable
+161
@@ -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)"
|
||||
Executable
+155
@@ -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"
|
||||
Reference in New Issue
Block a user