deploy.sh 带内置 PostgreSQL:install --postgres 在旁边再起一个 PostgreSQL 容器(镜像小版本固定 postgres:16.15、可用 POSTGRES_IMAGE 改;同一个 docker 网络、端口不对外;数据在 docker 数据卷;密码第一次自动生成在数据目录的 .postgres/、0600,按 POSTGRES_PASSWORD_FILE 和 PGPASSFILE 传,不进镜像、日志和 docker inspect;agentlab.env 什么都不用填)。用过一次的数据目录记在 .postgres/state,之后 install / upgrade / start 不用再加。start、restart 先起库等 pg_isready 再起服务,stop 先停服务再停库,status 显示两个容器,logs --postgres 看库的,uninstall 连库的容器和网络一起删、数据卷和数据目录留着。和外接 PostgreSQL(agentlab.env 里的 AGENTLAB_DATABASE_URL)互斥,照实报错;已有 SQLite 库的数据目录不许 install --postgres(指向 migrate-to-postgres),别的数据目录留下的数据卷不拿来用。upgrade 先停旧服务、自动备份(SQLite 用备份接口拷到 backups/sqlite/,内置库 pg_dump 到 backups/postgres/,各留最近 BACKUP_KEEP 份),备份没做成就把旧服务起回来不升级;POSTGRES_IMAGE 换了时库的容器按新镜像重建、起不来就换回原来的。新增 migrate-to-postgres:停服务 → 备份 → 起库 → copy-to-postgres 先 --dry-run 再正式导入、核对 → 记下已切换 → 按内置库起服务;记下之前哪一步出错都退回 SQLite(服务原样恢复、库的容器停掉、状态记成 migrating),重跑先清空上次导了一半的再导,已切过的什么都不做,不是这个数据目录留下的数据卷不清空。容器、网络、数据卷、端口等可用 DEPLOY_<名字> 环境变量盖过(测试、并排部署)。package-docker.sh 把同一个标签的 PostgreSQL 镜像导出成 postgres-image.tar 一起打包(--no-postgres 不带),标签只在 deploy.sh 里定一处。单测用假的 docker(tests/fake_docker.py)核对先后顺序、生成的参数、互斥和各条回退

This commit is contained in:
ThearchyHelios
2026-10-19 13:43:52 +08:00
parent bf74423651
commit 9e6a6db406
4 changed files with 1460 additions and 44 deletions
+363
View File
@@ -0,0 +1,363 @@
"""给 deploy/deploy.sh 的测试用的假 docker(tests/test_deploy_script.py):不碰本机的 docker,状态放一个 JSON 文件里。
只认 deploy.sh 用到的那些子命令和 inspect 格式,认不出的直接报错退出(测试就会失败),免得假的悄悄放过脚本的新用法。
每次调用记一行 JSON(argv)到 FAKE_DOCKER_LOG,测试按它核对先后顺序和参数。用完就删的容器(run --rm)里要做的事照做:
python -m app.ops 真跑(/data 换成挂进来的目录),chown 不做,mkdir、pg_dump 在目录里建文件,copy-to-postgres 按
FAKE_COPY_FAIL 成功或失败。
几个开关(环境变量):
FAKE_COPY_FAIL=dry|real copy-to-postgres 的核对(--dry-run)或正式导入失败
FAKE_PGDUMP_FAIL=1 pg_dump 失败
FAKE_PSQL_FAIL=1 docker exec … psql 失败
FAKE_UNHEALTHY_ENV=<变量名> 带这个环境变量(-e)起的容器健康检查不过
FAKE_UNHEALTHY_IMAGE=<镜像> 用这个镜像起的容器健康检查不过
"""
from __future__ import annotations
import json
import os
import platform
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
# 测试里 import 这个模块只为 host_arch,不设这两个变量
STATE = Path(os.environ.get("FAKE_DOCKER_STATE", "fake-docker-state.json"))
LOG = Path(os.environ.get("FAKE_DOCKER_LOG", "fake-docker-calls.jsonl"))
VALUE_OPTS = {
"--name", "--label", "--restart", "--network", "-e", "--env", "-v", "--volume", "-p", "--publish", "--env-file",
"--entrypoint", "--user", "-u", "--health-cmd", "--health-interval", "--health-timeout", "--health-retries",
"--health-start-period", "--stop-timeout", "--log-opt", "--security-opt", "--device", "--group-add", "--tmpfs",
}
FLAG_OPTS = {"-d", "--detach", "--rm", "-i", "-t", "-it"}
def host_arch() -> str:
machine = platform.machine().lower()
return {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(machine, machine)
def load() -> dict:
if STATE.exists():
return json.loads(STATE.read_text())
return {"images": {}, "containers": {}, "networks": [], "volumes": []}
def save(state: dict) -> None:
STATE.write_text(json.dumps(state, ensure_ascii=False, indent=1))
def fail(message: str, code: int = 1) -> None:
print(message, file=sys.stderr)
sys.exit(code)
def unknown(argv: list[str]) -> None:
fail(f"fake docker: 认不出 {argv!r}", 99)
def image_info(state: dict, ref: str) -> dict | None:
return state["images"].get(ref)
def container_health(c: dict) -> str:
if c["state"] != "running":
return c.get("health", "")
bad_env = os.environ.get("FAKE_UNHEALTHY_ENV")
if bad_env and bad_env in c["env"]:
return "unhealthy"
if os.environ.get("FAKE_UNHEALTHY_IMAGE") and c["image"] == os.environ["FAKE_UNHEALTHY_IMAGE"]:
return "unhealthy"
return "healthy"
def fmt_container(c: dict, fmt: str) -> str:
if fmt == "{{.State.Status}}":
return c["state"]
if fmt == "{{.State.Running}}":
return "true" if c["state"] == "running" else "false"
if fmt == "{{.Config.Image}}":
return c["image"]
if ".Mounts" in fmt and '"/data"' in fmt:
return c["mounts"].get("/data", "")
if ".State.Health" in fmt:
return container_health(c)
if '"agentlab.role"' in fmt:
return c["labels"].get("agentlab.role", "")
if ".HostConfig.PortBindings" in fmt:
return "".join(p.rsplit(":", 2)[-2] for p in c["ports"])
raise KeyError(fmt)
def fmt_image(img: dict, fmt: str) -> str:
if fmt == "{{.Architecture}}":
return img["arch"]
if fmt == "{{.Os}}/{{.Architecture}}":
return f"linux/{img['arch']}"
if fmt == "{{.Config.User}}":
return img.get("user", "")
if '"agentlab.microvm"' in fmt:
return img.get("microvm", "0")
raise KeyError(fmt)
def parse_run(args: list[str]) -> tuple[dict, list[str]]:
opts: dict = {"env": {}, "labels": {}, "mounts": {}, "ports": [], "flags": set(), "volumes": []}
i = 0
while i < len(args):
a = args[i]
if a in FLAG_OPTS:
opts["flags"].add(a)
i += 1
continue
if a.startswith("-") and "=" in a and a.split("=", 1)[0] in VALUE_OPTS:
a, value = a.split("=", 1)
i += 1
elif a in VALUE_OPTS:
value = args[i + 1]
i += 2
elif a.startswith("-"):
unknown(["run", *args])
else:
break
if a in ("-e", "--env"):
key, _, val = value.partition("=")
opts["env"][key] = val
elif a == "--label":
key, _, val = value.partition("=")
opts["labels"][key] = val
elif a in ("-v", "--volume"):
src, dst = value.split(":")[:2]
opts["mounts"][dst] = src
if not src.startswith("/"):
opts["volumes"].append(src)
elif a in ("-p", "--publish"):
opts["ports"].append(value)
else:
opts[a] = value
return opts, args[i:]
def to_host(arg: str, data: str | None) -> str:
if data and (arg == "/data" or arg.startswith("/data/")):
return data + arg[len("/data"):]
return arg
def run_oneoff(state: dict, opts: dict, image: str, command: list[str]) -> int:
data = opts["mounts"].get("/data")
entry = opts.get("--entrypoint")
if entry == "python" and command[:2] == ["-m", "app.ops"]:
sys.path.insert(0, str(BACKEND))
from app import ops
rest = [to_host(a, data) for a in command[2:]]
if rest and rest[0] in ("postgres-secrets", "state", "backup-sqlite") and "--data" not in rest:
rest[1:1] = ["--data", data or "/data"]
return ops.main(rest)
if entry == "python" and command[:3] == ["-m", "app.db", "copy-to-postgres"]:
which = "dry" if "--dry-run" in command else "real"
if os.environ.get("FAKE_COPY_FAIL") == which:
print("没有导入:fake 的失败", file=sys.stderr)
return 1
print("核对通过")
return 0
if entry == "chown":
return 0
if entry == "mkdir":
for a in command:
if not a.startswith("-"):
Path(to_host(a, data)).mkdir(parents=True, exist_ok=True)
return 0
if entry == "pg_dump":
if os.environ.get("FAKE_PGDUMP_FAIL"):
print("pg_dump: fake 的失败", file=sys.stderr)
return 1
out = command[command.index("-f") + 1]
Path(to_host(out, data)).write_bytes(b"PGDMP fake")
return 0
unknown(["run", "--rm", image, *command])
return 99
def main(argv: list[str]) -> int:
with LOG.open("a") as fh:
fh.write(json.dumps(argv, ensure_ascii=False) + "\n")
state = load()
if not argv:
unknown(argv)
cmd, args = argv[0], argv[1:]
if cmd == "version":
print("27.0.0-fake")
return 0
if cmd == "image" and args[:1] == ["inspect"]:
fmt = None
rest = args[1:]
if rest[:1] == ["-f"]:
fmt, rest = rest[1], rest[2:]
img = image_info(state, rest[0])
if img is None:
fail(f"Error: No such image: {rest[0]}")
if fmt:
try:
print(fmt_image(img, fmt))
except KeyError:
unknown(argv)
else:
print("[{}]")
return 0
if cmd == "load":
tar = Path(args[args.index("-i") + 1])
for line in tar.read_text().splitlines():
ref, _, user = line.partition(" ")
if ref:
state["images"][ref] = {"arch": host_arch(), "user": user, "microvm": "0"}
print(f"Loaded image: {ref}")
save(state)
return 0
if cmd == "rmi":
for ref in args:
state["images"].pop(ref, None)
save(state)
return 0
if cmd == "container" and args[:1] == ["inspect"]:
return 0 if args[1] in state["containers"] else 1
if cmd == "inspect":
if args[:1] != ["-f"]:
unknown(argv)
fmt, name = args[1], args[2]
c = state["containers"].get(name)
if c is None:
fail(f"Error: No such object: {name}")
try:
print(fmt_container(c, fmt))
except KeyError:
unknown(argv)
return 0
if cmd == "run":
opts, rest = parse_run(args)
image, command = rest[0], rest[1:]
if image_info(state, image) is None:
fail(f"Unable to find image '{image}' locally", 125)
network = opts.get("--network")
if network and network not in ("none", "host", "bridge") and network not in state["networks"]:
fail(f"docker: Error response from daemon: network {network} not found.", 125)
if "--rm" in opts["flags"]:
return run_oneoff(state, opts, image, command)
name = opts.get("--name")
if not name or "-d" not in opts["flags"]:
unknown(argv)
if name in state["containers"]:
fail(f'docker: Error response from daemon: Conflict. The container name "/{name}" is already in use.', 125)
for vol in opts["volumes"]:
if vol not in state["volumes"]:
state["volumes"].append(vol)
state["containers"][name] = {
"image": image, "state": "running", "env": opts["env"], "labels": opts["labels"],
"mounts": opts["mounts"], "ports": opts["ports"], "network": network or "bridge",
}
save(state)
print("f" * 64)
return 0
if cmd in ("start", "stop", "restart"):
names = [a for a in args if not a.startswith("-") and not a.isdigit()]
for name in names:
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
c["state"] = "exited" if cmd == "stop" else "running"
save(state)
return 0
if cmd == "rm":
force = "-f" in args
for name in (a for a in args if not a.startswith("-")):
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
if c["state"] == "running" and not force:
fail(f"Error response from daemon: cannot remove container {name}: container is running")
del state["containers"][name]
save(state)
return 0
if cmd == "rename":
old, new = args
if old not in state["containers"] or new in state["containers"]:
fail(f"Error response from daemon: cannot rename {old} to {new}")
state["containers"][new] = state["containers"].pop(old)
save(state)
return 0
if cmd == "exec":
name, command = args[0], args[1:]
c = state["containers"].get(name)
if c is None or c["state"] != "running":
fail(f"Error response from daemon: container {name} is not running")
if command[:1] == ["psql"]:
return 1 if os.environ.get("FAKE_PSQL_FAIL") else 0
if command[:1] == ["python"]:
print("子进程(fake)")
return 0
if command[:1] == ["pg_isready"]:
return 0
unknown(argv)
if cmd == "logs":
print("fake 的日志")
return 0
if cmd == "port":
c = state["containers"].get(args[0])
if c is None:
return 1
for p in c["ports"]:
host_part = p.rsplit(":", 1)[0]
print(host_part)
return 0
if cmd == "network":
sub, name = args[0], args[-1]
if sub == "inspect":
return 0 if name in state["networks"] else 1
if sub == "create":
if name in state["networks"]:
fail(f"network with name {name} already exists")
state["networks"].append(name)
save(state)
return 0
if sub == "rm":
if any(c["network"] == name for c in state["containers"].values()):
fail(f"error while removing network: network {name} has active endpoints")
state["networks"].remove(name)
save(state)
return 0
unknown(argv)
if cmd == "volume":
sub, name = args[0], args[-1]
if sub == "inspect":
return 0 if name in state["volumes"] else 1
if sub == "create":
if name not in state["volumes"]:
state["volumes"].append(name)
save(state)
return 0
unknown(argv)
unknown(argv)
return 99
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+523
View File
@@ -0,0 +1,523 @@
"""deploy/deploy.sh:内置 PostgreSQL 的起停顺序、生成的 docker 参数、模式互斥、升级前备份、migrate-to-postgres 的回退。
docker 换成 tests/fake_docker.py(PATH 最前面),不碰本机的 docker;脚本照打包时那样填好版本和架构,放进临时目录跑。
真起容器的端到端见 deploy/README.md「用 PostgreSQL」末尾记的做法(不在单测里跑)。
"""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import stat
import subprocess
import sys
from pathlib import Path
import pytest
from tests.fake_docker import host_arch
REPO = Path(__file__).resolve().parents[2]
FAKE = Path(__file__).resolve().parent / "fake_docker.py"
VERSION = "t1"
APP = "agl-t"
PG = f"{APP}-postgres"
NET = f"{APP}-net"
VOL = f"{APP}-pgdata"
PG_IMAGE = "postgres:16.15"
pytestmark = pytest.mark.skipif(shutil.which("bash") is None, reason="要 bash")
class Deploy:
def __init__(self, root: Path, *, env_lines: list[str] | None = None) -> None:
self.root = root
self.pkg = root / "pkg"
self.pkg.mkdir()
text = (REPO / "deploy" / "deploy.sh").read_text(encoding="utf-8")
text = text.replace("__AGENTLAB_VERSION__", VERSION).replace("__AGENTLAB_ARCH__", host_arch())
(self.pkg / "deploy.sh").write_text(text, encoding="utf-8")
self.write_env(env_lines or [])
(self.pkg / "image.tar").write_text(f"agentlab:{VERSION} 10001:10001\n")
(self.pkg / "postgres-image.tar").write_text(f"{PG_IMAGE} \n")
bin_dir = root / "bin"
bin_dir.mkdir()
docker = bin_dir / "docker"
docker.write_text(f'#!/bin/sh\nexec "{sys.executable}" -S "{FAKE}" "$@"\n')
docker.chmod(0o755)
self.state_file = root / "docker-state.json"
self.log_file = root / "docker-calls.jsonl"
self.env = {
"PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(root), "LANG": "C.UTF-8",
"FAKE_DOCKER_STATE": str(self.state_file), "FAKE_DOCKER_LOG": str(self.log_file),
}
@property
def data(self) -> Path:
return (self.pkg / "data").resolve()
def write_env(self, extra: list[str]) -> None:
lines = ["HOST_PORT=18765", "BIND_ADDR=127.0.0.1", "DATA_DIR=./data", f"CONTAINER_NAME={APP}",
"ENABLE_MICROVM=0", "ENABLE_BWRAP=0", "AGENTLAB_SECRET_KEY=", *extra]
(self.pkg / "agentlab.env").write_text("\n".join(lines) + "\n")
def __call__(self, *args: str, ok: bool = True, **env: str) -> subprocess.CompletedProcess:
self.log_file.write_text("")
out = subprocess.run(["bash", str(self.pkg / "deploy.sh"), *args], capture_output=True, text=True,
env={**self.env, **env}, timeout=120)
if ok:
assert out.returncode == 0, out.stdout + out.stderr
return out
def calls(self) -> list[list[str]]:
return [json.loads(line) for line in self.log_file.read_text().splitlines() if line.strip()]
def state(self) -> dict:
return json.loads(self.state_file.read_text()) if self.state_file.exists() else {"containers": {}}
def containers(self) -> dict:
return self.state()["containers"]
def mode(self) -> str:
path = self.data / ".postgres" / "state"
if not path.exists():
return "sqlite"
return next(line.split("=", 1)[1] for line in path.read_text().splitlines() if line.startswith("mode="))
def index_of(calls: list[list[str]], pred) -> int:
for i, argv in enumerate(calls):
if pred(argv):
return i
raise AssertionError("没找到这次调用:\n" + "\n".join(" ".join(c) for c in calls))
def runs_named(name: str):
return lambda argv: argv[:1] == ["run"] and "--name" in argv and argv[argv.index("--name") + 1] == name
def is_cmd(cmd: str, name: str):
return lambda argv: argv[:1] == [cmd] and argv[-1] == name
def oneoff(entry: str, *words: str):
return lambda argv: (argv[:1] == ["run"] and "--rm" in argv and "--entrypoint" in argv
and argv[argv.index("--entrypoint") + 1] == entry and all(w in argv for w in words))
def make_sqlite_data(data: Path) -> None:
data.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(data / "agentlab.db")
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
db.execute("INSERT INTO t (v) VALUES ('一')")
db.commit()
db.close()
(data / ".secret_key").write_text("x")
@pytest.fixture
def deploy(tmp_path):
return Deploy(tmp_path)
# --------------------------------------------------------------------------
# 参数
# --------------------------------------------------------------------------
@pytest.mark.parametrize("args", [["bogus"], ["start", "--postgres"], ["upgrade", "--postgres"],
["install", "--purge-image"], ["migrate-to-postgres", "-f"], ["status", "--tail", "3"]])
def test_unknown_arguments_are_refused_before_doing_anything(deploy, args):
out = deploy(*args, ok=False)
assert out.returncode == 2
assert "认不出" in out.stderr
assert deploy.calls() == [], "认参数时一次 docker 都没调"
def test_help_lists_the_postgres_commands(deploy):
out = deploy("--help")
for word in ("install [--postgres]", "migrate-to-postgres", "logs [-f] [--tail N] [--postgres]", "DEPLOY_<名字>"):
assert word in out.stdout
# --------------------------------------------------------------------------
# install --postgres
# --------------------------------------------------------------------------
def test_install_postgres_starts_the_database_first_and_wires_the_app_without_the_password(deploy):
out = deploy("install", "--postgres")
calls = deploy.calls()
net = index_of(calls, lambda a: a[:2] == ["network", "create"] and a[-1] == NET)
pg = index_of(calls, runs_named(PG))
pg_ready = index_of(calls, lambda a: a[:1] == ["inspect"] and a[-1] == PG and ".State.Health" in a[2])
app = index_of(calls, runs_named(APP))
assert net < pg < pg_ready < app, "先建网络、起库、等库就绪,再起服务"
pg_args = calls[pg]
assert "-p" not in pg_args and not any(a.startswith("--publish") for a in pg_args), "库的端口不对外"
assert pg_args[pg_args.index("--network") + 1] == NET
assert f"{VOL}:/var/lib/postgresql/data" in pg_args
assert "POSTGRES_PASSWORD_FILE=/run/agentlab/password" in pg_args
assert any(a.endswith("/.postgres/password:/run/agentlab/password:ro") for a in pg_args)
assert "pg_isready -h 127.0.0.1 -U agentlab -d agentlab" in pg_args
assert pg_args[-1] == PG_IMAGE
app_args = calls[app]
assert app_args[app_args.index("--network") + 1] == NET
assert f"AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab@{PG}:5432/agentlab" in app_args
assert "PGPASSFILE=/data/.postgres/pgpass" in app_args
assert "127.0.0.1:18765:8000" in app_args
secret = (deploy.data / ".postgres" / "password").read_text().strip()
assert len(secret) >= 32
everything = json.dumps(calls, ensure_ascii=False) + out.stdout + out.stderr
assert secret not in everything, "密码不进任何 docker 参数和输出"
for name, mode in (("password", 0o600), ("pgpass", 0o600), ("state", 0o644)):
assert stat.S_IMODE((deploy.data / ".postgres" / name).stat().st_mode) == mode, name
assert stat.S_IMODE((deploy.data / ".postgres").stat().st_mode) == 0o711
pgpass = (deploy.data / ".postgres" / "pgpass").read_text().strip()
assert pgpass.startswith(f"{PG}:5432:agentlab:agentlab:")
assert deploy.mode() == "builtin"
assert "内置 PostgreSQL" in out.stdout
assert deploy.containers()[PG]["labels"]["agentlab.role"] == "postgres"
def test_once_builtin_later_installs_keep_using_it_without_the_flag(deploy):
deploy("install", "--postgres")
secret = (deploy.data / ".postgres" / "password").read_text()
deploy("install")
calls = deploy.calls()
assert not any(runs_named(PG)(a) for a in calls), "库的容器已经在跑,不重建"
app_args = calls[index_of(calls, runs_named(APP))]
assert f"AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab@{PG}:5432/agentlab" in app_args
assert (deploy.data / ".postgres" / "password").read_text() == secret, "密码沿用"
def test_default_install_is_sqlite_and_touches_no_postgres(deploy):
deploy("install")
calls = deploy.calls()
app_args = calls[index_of(calls, runs_named(APP))]
assert not any("AGENTLAB_DATABASE_URL" in a for a in app_args) and "--network" not in app_args
assert not any(a[:2] == ["network", "create"] for a in calls)
assert PG not in deploy.containers()
assert not (deploy.data / ".postgres").exists()
def test_builtin_and_external_are_mutually_exclusive(tmp_path):
deploy = Deploy(tmp_path, env_lines=["AGENTLAB_DATABASE_URL=postgresql+asyncpg://u:p@db.example:5432/agentlab"])
out = deploy("install", "--postgres", ok=False)
assert out.returncode == 1 and "两种库只能选一种" in out.stderr and "AGENTLAB_DATABASE_URL" in out.stderr
assert APP not in deploy.containers() and PG not in deploy.containers()
out = deploy("migrate-to-postgres", ok=False)
assert "外接 PostgreSQL" in out.stderr
# 不加 --postgres:外接的照常用,不起内置的
deploy("install")
assert PG not in deploy.containers()
assert "外接 PostgreSQL" in deploy("status").stdout
def test_external_url_on_a_builtin_data_dir_is_refused(deploy):
deploy("install", "--postgres")
deploy.write_env(["AGENTLAB_DATABASE_URL=postgresql+asyncpg://u:p@db.example:5432/agentlab"])
out = deploy("install", ok=False)
assert "两种库只能选一种" in out.stderr
# 停服务这类不改配置的照样能做,按数据目录里记的管两个容器
deploy("stop")
assert deploy.containers()[PG]["state"] == "exited"
def test_install_postgres_on_a_sqlite_data_dir_points_to_migrate(deploy):
make_sqlite_data(deploy.pkg / "data")
out = deploy("install", "--postgres", ok=False)
assert "migrate-to-postgres" in out.stderr
assert deploy.containers() == {} and deploy.mode() == "sqlite"
def test_install_postgres_refuses_a_leftover_volume(deploy):
deploy.state_file.write_text(json.dumps({"images": {}, "containers": {}, "networks": [], "volumes": [VOL]}))
out = deploy("install", "--postgres", ok=False)
assert VOL in out.stderr and "docker volume rm" in out.stderr
assert PG not in deploy.containers()
def test_deploy_overrides_take_names_from_the_environment(deploy):
env = {"DEPLOY_CONTAINER_NAME": "agl-x", "DEPLOY_POSTGRES_CONTAINER": "agl-x-db", "DEPLOY_DOCKER_NETWORK": "agl-x-n",
"DEPLOY_POSTGRES_VOLUME": "agl-x-v", "DEPLOY_HOST_PORT": "18766", "DEPLOY_POSTGRES_IMAGE": "postgres:16"}
(deploy.pkg / "postgres-image.tar").write_text("postgres:16 \n")
deploy("install", "--postgres", **env)
calls = deploy.calls()
pg_args = calls[index_of(calls, runs_named("agl-x-db"))]
assert pg_args[-1] == "postgres:16" and "agl-x-v:/var/lib/postgresql/data" in pg_args
app_args = calls[index_of(calls, runs_named("agl-x"))]
assert "127.0.0.1:18766:8000" in app_args and app_args[app_args.index("--network") + 1] == "agl-x-n"
assert set(deploy.containers()) == {"agl-x", "agl-x-db"}
@pytest.mark.parametrize("name,value", [("DEPLOY_POSTGRES_CONTAINER", APP), ("DEPLOY_DOCKER_NETWORK", "bad name"),
("DEPLOY_BACKUP_KEEP", "0"), ("DEPLOY_POSTGRES_IMAGE", "a b")])
def test_bad_overrides_are_refused(deploy, name, value):
out = deploy("install", "--postgres", ok=False, **{name: value})
assert out.returncode == 1 and "错误:" in out.stderr
assert deploy.containers() == {}
# --------------------------------------------------------------------------
# start / stop / restart / status / logs / uninstall
# --------------------------------------------------------------------------
def test_start_stop_restart_order_the_two_containers(deploy):
deploy("install", "--postgres")
deploy("stop")
calls = deploy.calls()
assert index_of(calls, is_cmd("stop", APP)) < index_of(calls, is_cmd("stop", PG)), "先停服务再停库"
assert {c["state"] for c in deploy.containers().values()} == {"exited"}
deploy("start")
calls = deploy.calls()
pg_start = index_of(calls, is_cmd("start", PG))
pg_ready = index_of(calls, lambda a: a[:1] == ["inspect"] and a[-1] == PG and ".State.Health" in a[2])
assert pg_start < pg_ready < index_of(calls, is_cmd("start", APP)), "先起库、等就绪,再起服务"
deploy("restart")
calls = deploy.calls()
order = [index_of(calls, is_cmd("stop", APP)), index_of(calls, is_cmd("stop", PG)),
index_of(calls, is_cmd("start", PG)), index_of(calls, is_cmd("start", APP))]
assert order == sorted(order)
assert {c["state"] for c in deploy.containers().values()} == {"running"}
def test_status_and_logs_show_the_database(deploy):
deploy("install", "--postgres")
out = deploy("status").stdout
assert "内置 PostgreSQL" in out and "pg_isready:healthy" in out and "端口不对外" in out
deploy("logs", "--postgres", "--tail", "5")
assert deploy.calls()[-1] == ["logs", "--tail", "5", PG]
deploy("logs")
assert deploy.calls()[-1] == ["logs", "--tail", "200", APP]
def test_logs_postgres_without_builtin_says_so(deploy):
deploy("install")
out = deploy("logs", "--postgres", ok=False)
assert "没用内置 PostgreSQL" in out.stderr
def test_uninstall_removes_both_containers_and_the_network_but_keeps_the_volume(deploy):
deploy("install", "--postgres")
out = deploy("uninstall")
state = deploy.state()
assert state["containers"] == {} and NET not in state["networks"]
assert VOL in state["volumes"], "数据卷留着"
assert f"docker volume rm {VOL}" in out.stdout
assert PG_IMAGE in state["images"], "PostgreSQL 的镜像不删"
assert (deploy.data / ".postgres" / "password").exists()
# 卸载后同一个数据目录再装:照旧用那个数据卷和密码
deploy("install")
assert deploy.containers()[PG]["mounts"]["/var/lib/postgresql/data"] == VOL
def test_uninstall_refuses_a_foreign_container_with_the_postgres_name(deploy):
deploy("install")
state = deploy.state()
state["images"]["redis:7"] = {"arch": host_arch(), "user": ""}
state["containers"][PG] = {"image": "redis:7", "state": "running", "env": {}, "labels": {}, "mounts": {},
"ports": [], "network": "bridge"}
deploy.state_file.write_text(json.dumps(state))
out = deploy("uninstall", ok=False)
assert "不是 deploy.sh 起的内置 PostgreSQL" in out.stderr
assert PG in deploy.containers()
# --------------------------------------------------------------------------
# upgrade:先备份
# --------------------------------------------------------------------------
def test_upgrade_builtin_dumps_after_stopping_the_app_and_keeps_the_last_n(deploy):
deploy("install", "--postgres")
folder = deploy.data / "backups" / "postgres"
folder.mkdir(parents=True)
for i in range(4):
(folder / f"agentlab-2026010{i}T000000Z.dump").write_bytes(b"old")
deploy("upgrade", DEPLOY_BACKUP_KEEP="3")
calls = deploy.calls()
stop = index_of(calls, is_cmd("stop", APP))
dump = index_of(calls, oneoff("pg_dump"))
new_app = index_of(calls, runs_named(APP))
assert stop < dump < new_app, "停了旧服务再备份,备份完才起新的"
dump_args = calls[dump]
assert dump_args[dump_args.index("--network") + 1] == NET and "PGPASSFILE=/data/.postgres/pgpass" in dump_args
kept = sorted(p.name for p in folder.iterdir())
assert len(kept) == 3 and kept[-1].startswith("agentlab-") and kept[-1] > "agentlab-20260103", kept
assert (folder / kept[-1]).read_bytes() == b"PGDMP fake"
def test_upgrade_sqlite_backs_up_the_database_files(deploy):
deploy("install")
make_sqlite_data(deploy.pkg / "data")
deploy("upgrade")
calls = deploy.calls()
assert index_of(calls, is_cmd("stop", APP)) < index_of(calls, oneoff("python", "backup-sqlite")) \
< index_of(calls, runs_named(APP))
(stamp,) = list((deploy.data / "backups" / "sqlite").iterdir())
copy = sqlite3.connect(stamp / "agentlab.db")
assert copy.execute("SELECT v FROM t").fetchall() == [("一",)]
copy.close()
def test_failed_backup_aborts_the_upgrade_and_restarts_the_old_service(deploy):
deploy("install", "--postgres")
before = deploy.containers()[APP]
out = deploy("upgrade", ok=False, FAKE_PGDUMP_FAIL="1")
assert "备份没做成,没有升级" in out.stderr
after = deploy.containers()[APP]
assert after["state"] == "running" and after == before
assert not any(runs_named(APP)(a) for a in deploy.calls())
def test_upgrade_with_a_new_postgres_image_recreates_the_database_container(deploy):
deploy("install", "--postgres")
(deploy.pkg / "postgres-image.tar").write_text("postgres:16.99 \n")
deploy("upgrade", DEPLOY_POSTGRES_IMAGE="postgres:16.99")
calls = deploy.calls()
assert index_of(calls, is_cmd("stop", APP)) < index_of(calls, runs_named(PG))
pg = deploy.containers()[PG]
assert pg["image"] == "postgres:16.99" and pg["mounts"]["/var/lib/postgresql/data"] == VOL
def test_a_postgres_image_that_cannot_start_is_rolled_back(deploy):
deploy("install", "--postgres")
(deploy.pkg / "postgres-image.tar").write_text("postgres:17.1 \n")
out = deploy("upgrade", ok=False, DEPLOY_POSTGRES_IMAGE="postgres:17.1", FAKE_UNHEALTHY_IMAGE="postgres:17.1")
assert "已换回原来的 postgres:16.15" in out.stderr
containers = deploy.containers()
assert containers[PG]["image"] == PG_IMAGE and containers[PG]["state"] == "running"
assert containers[APP]["state"] == "running", "原来在跑的服务起回来了"
# --------------------------------------------------------------------------
# migrate-to-postgres
# --------------------------------------------------------------------------
def _sqlite_deployment(deploy: Deploy) -> None:
make_sqlite_data(deploy.pkg / "data")
deploy("install")
assert deploy.mode() == "sqlite"
def test_migrate_runs_the_steps_in_order_and_switches(deploy):
_sqlite_deployment(deploy)
out = deploy("migrate-to-postgres")
calls = deploy.calls()
steps = [
index_of(calls, is_cmd("stop", APP)),
index_of(calls, oneoff("python", "backup-sqlite")),
index_of(calls, runs_named(PG)),
index_of(calls, lambda a: a[:2] == ["exec", PG] and "psql" in a),
index_of(calls, oneoff("python", "copy-to-postgres", "--dry-run")),
index_of(calls, lambda a: oneoff("python", "copy-to-postgres")(a) and "--dry-run" not in a),
index_of(calls, oneoff("python", "state", "builtin")),
index_of(calls, runs_named(APP)),
]
assert steps == sorted(steps), steps
copy = calls[steps[5]]
assert f"postgresql+asyncpg://agentlab@{PG}:5432/agentlab" in copy and "--assume-stopped" in copy
assert "PGPASSFILE=/data/.postgres/pgpass" in copy
assert deploy.mode() == "builtin"
app = deploy.containers()[APP]
assert app["env"]["AGENTLAB_DATABASE_URL"].endswith(f"@{PG}:5432/agentlab") and app["state"] == "running"
assert f"{APP}-previous" not in deploy.containers()
assert (deploy.data / "agentlab.db").exists(), "SQLite 的库文件留着"
assert list((deploy.data / "backups" / "sqlite").iterdir())
assert "已切到内置 PostgreSQL" in out.stdout
# 再跑一次:什么都不做
again = deploy("migrate-to-postgres")
assert "已经切到内置 PostgreSQL" in again.stdout
assert not any(a[:1] in (["stop"], ["run"]) for a in deploy.calls() if "--rm" not in a)
@pytest.mark.parametrize("fail", ["dry", "real"])
def test_a_failed_copy_leaves_the_sqlite_service_as_it_was_and_rerunning_works(deploy, fail):
_sqlite_deployment(deploy)
before = deploy.containers()[APP]
out = deploy("migrate-to-postgres", ok=False, FAKE_COPY_FAIL=fail)
assert "没有切换,仍用 SQLite" in out.stderr
containers = deploy.containers()
assert containers[APP] == before, "服务原样起回来,还是 SQLite 的那个容器"
assert containers[PG]["state"] == "exited", "内置库停掉"
assert deploy.mode() == "migrating"
assert "上次 migrate-to-postgres 没切成" in deploy("status").stdout
# 重新运行:数据卷认得出是自己上次留下的,清空再导
deploy("migrate-to-postgres")
calls = deploy.calls()
assert index_of(calls, lambda a: a[:2] == ["exec", PG] and "psql" in a) \
< index_of(calls, oneoff("python", "copy-to-postgres", "--dry-run"))
psql = calls[index_of(calls, lambda a: a[:2] == ["exec", PG] and "psql" in a)]
assert any("DROP DATABASE IF EXISTS" in a for a in psql)
assert deploy.mode() == "builtin"
def test_a_stopped_service_stays_stopped_after_a_failed_migration(deploy):
_sqlite_deployment(deploy)
deploy("stop")
deploy("migrate-to-postgres", ok=False, FAKE_COPY_FAIL="real")
assert deploy.containers()[APP]["state"] == "exited"
def test_an_unhealthy_new_service_is_swapped_back_to_the_sqlite_one(deploy):
_sqlite_deployment(deploy)
before = deploy.containers()[APP]
out = deploy("migrate-to-postgres", ok=False, FAKE_UNHEALTHY_ENV="AGENTLAB_DATABASE_URL")
assert "没有切换,仍用 SQLite" in out.stderr
containers = deploy.containers()
assert containers[APP] == before and f"{APP}-previous" not in containers
assert containers[PG]["state"] == "exited"
assert deploy.mode() == "migrating", "记成 builtin 的改回来了"
def test_migrate_without_an_app_container_removes_the_failed_one(deploy):
make_sqlite_data(deploy.pkg / "data")
deploy("migrate-to-postgres", ok=False, FAKE_UNHEALTHY_ENV="AGENTLAB_DATABASE_URL")
assert APP not in deploy.containers(), "按内置库起的、没通过健康检查的容器不留着"
assert deploy.mode() == "migrating"
def test_migrate_refuses_a_volume_it_did_not_create(deploy):
_sqlite_deployment(deploy)
state = deploy.state()
state["volumes"].append(VOL)
deploy.state_file.write_text(json.dumps(state))
out = deploy("migrate-to-postgres", ok=False)
assert VOL in out.stderr and "没有动它" in out.stderr
calls = deploy.calls()
assert not any(a[:1] == ["stop"] or a[:1] == ["exec"] for a in calls), "服务没停、库没碰"
assert deploy.mode() == "sqlite"
def test_migrate_needs_a_sqlite_database(deploy):
out = deploy("migrate-to-postgres", ok=False)
assert "install --postgres" in out.stderr
def test_package_script_takes_the_postgres_image_from_deploy_sh():
"""包里带的 PostgreSQL 镜像和 deploy.sh 要的是同一个标签:标签只在 deploy.sh 里定一处。"""
deploy_sh = (REPO / "deploy" / "deploy.sh").read_text(encoding="utf-8")
assert f'POSTGRES_IMAGE="{PG_IMAGE}"' in deploy_sh
package = (REPO / "scripts" / "package-docker.sh").read_text(encoding="utf-8")
assert "postgres:16" not in package, "打包脚本里别再写一份标签"
out = subprocess.run(["sed", "-n", r's/^POSTGRES_IMAGE="\([^"]*\)"$/\1/p', str(REPO / "deploy" / "deploy.sh")],
capture_output=True, text=True, check=True)
assert out.stdout.strip() == PG_IMAGE
def test_dev_script_refuses_unknown_arguments_and_documents_postgres():
env = {**os.environ, "PATH": "/usr/bin:/bin"}
out = subprocess.run(["bash", str(REPO / "scripts" / "dev.sh"), "--bogus"], capture_output=True, text=True, env=env)
assert out.returncode == 2 and "认不出的参数" in out.stdout
out = subprocess.run(["bash", str(REPO / "scripts" / "dev.sh"), "--help"], capture_output=True, text=True, env=env)
assert out.returncode == 0 and "--postgres" in out.stdout and "AGENTLAB_DEV_PG_PORT" in out.stdout
+533 -35
View File
@@ -11,6 +11,8 @@ IMAGE="agentlab:$VERSION"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$HERE/agentlab.env"
IMAGE_TAR="$HERE/image.tar"
# 内置 PostgreSQL 的镜像:离线包里带一份(scripts/package-docker.sh 打进去),本机已经有同名镜像就不导入
POSTGRES_IMAGE_TAR="$HERE/postgres-image.tar"
# 给沙箱用 bubblewrap 时容器要加的权限,说明见 agentlab.env 的沙箱一节
BWRAP_OPTS=(--security-opt seccomp=unconfined --security-opt systempaths=unconfined --security-opt apparmor=unconfined)
@@ -19,19 +21,33 @@ usage() {
cat <<USAGE
用法:./deploy.sh [子命令] [选项]
install 部署(默认):导入镜像、创建数据目录、启动容器,等健康检查通过后打印访问地址。
install [--postgres]
部署(默认):导入镜像、创建数据目录、启动容器,等健康检查通过后打印访问地址。
已经有同名容器的,先停掉再换成新的,数据保留。改了 agentlab.env 也用它让配置生效。
本机有 KVM 时会先试着启动一台 microVM,起得来才让代码在 microVM 里执行(见 agentlab.env)。
upgrade 用这个包里的镜像替换正在用的容器,数据保留。旧镜像留着,方便回退。
--postgres:新部署用内置的 PostgreSQL(再起一个 PostgreSQL 容器,同一个 docker 网络、端口不对外;
密码第一次自动生成,存在数据目录的 .postgres/ 里)。用过一次之后这个数据目录就一直是内置
PostgreSQL,之后的 install / upgrade / start 不用再加。已经在用 SQLite 的,用 migrate-to-postgres。
upgrade 用这个包里的镜像替换正在用的容器,数据保留。旧镜像留着,方便回退。换之前先自动备份库
(SQLite:数据目录的 backups/sqlite/;内置 PostgreSQL:pg_dump 到 backups/postgres/;各留最近
BACKUP_KEEP 份,缺省 5)。新版本启动时自动补表结构(迁移)和默认数据。
migrate-to-postgres
把正在用 SQLite 的部署切到内置 PostgreSQL:停服务 → 备份 → 起 PostgreSQL → 先核对再导入 → 记下
「已切换」→ 起服务。哪一步出错都停在原地、仍用 SQLite。重复执行是安全的;SQLite 的库文件原样留着。
start | stop | restart
启动 / 停止 / 重启容器。
status 查看容器状态、健康检查、数据目录、代码沙箱和访问地址。
logs [-f] [--tail N]
查看日志(默认最后 200 行),-f 持续跟随。
启动 / 停止 / 重启。内置 PostgreSQL 时先起库、等它就绪再起服务;停的时候先停服务再停库。
status 查看容器状态、健康检查、数据目录、用的哪种库、代码沙箱和访问地址。
logs [-f] [--tail N] [--postgres]
查看日志(默认最后 200 行),-f 持续跟随,--postgres 看内置 PostgreSQL 的。
uninstall [--purge-image]
删除容器。默认保留数据和镜像,--purge-image 连镜像一起删。数据永远不删。
删除容器(内置 PostgreSQL 的容器和网络一起删)。默认保留数据和镜像,--purge-image 连 AgentLab
的镜像一起删。数据永远不删:数据目录和 PostgreSQL 的数据卷都留着。
-h, --help 显示这段说明。
环境变量 DEPLOY_<名字> 盖过 agentlab.env 里的同名项(测试、一台机器上并排部署两套时用):HOST_PORT、BIND_ADDR、
DATA_DIR、CONTAINER_NAME,以及内置 PostgreSQL 的 POSTGRES_IMAGE、POSTGRES_CONTAINER、DOCKER_NETWORK、POSTGRES_VOLUME,
备份份数 BACKUP_KEEP。
这个包:AgentLab ${VERSION}(linux/${IMAGE_ARCH})
USAGE
}
@@ -48,6 +64,8 @@ CMD="install"
FOLLOW=""
TAIL="200"
PURGE_IMAGE=""
WITH_POSTGRES=""
LOGS_POSTGRES=""
if [ $# -gt 0 ]; then
case "$1" in
-h|--help) usage; exit 0 ;;
@@ -56,7 +74,7 @@ if [ $# -gt 0 ]; then
esac
fi
case "$CMD" in
install|upgrade|start|stop|restart|status|logs|uninstall) ;;
install|upgrade|migrate-to-postgres|start|stop|restart|status|logs|uninstall) ;;
*) printf '认不出的子命令:%s\n\n' "$CMD" >&2; usage >&2; exit 2 ;;
esac
while [ $# -gt 0 ]; do
@@ -68,6 +86,12 @@ while [ $# -gt 0 ]; do
TAIL="$2"; shift ;;
--tail=*) [ "$CMD" = logs ] || bad_arg "$1"; TAIL="${1#--tail=}" ;;
--purge-image) [ "$CMD" = uninstall ] || bad_arg "$1"; PURGE_IMAGE=1 ;;
--postgres)
case "$CMD" in
install) WITH_POSTGRES=1 ;;
logs) LOGS_POSTGRES=1 ;;
*) bad_arg "$1" ;;
esac ;;
*) bad_arg "$1" ;;
esac
shift
@@ -106,6 +130,18 @@ DATA_DIR="./data"
CONTAINER_NAME="agentlab"
ENABLE_BWRAP="0"
ENABLE_MICROVM="auto"
# 内置 PostgreSQL。小版本固定:同一个包到哪台机器都起同一个版本(pg_dump 和库同版本);要换在 agentlab.env 里写
# POSTGRES_IMAGE。容器、网络、数据卷的名字缺省跟着 CONTAINER_NAME 走
POSTGRES_IMAGE="postgres:16.15"
POSTGRES_CONTAINER=""
DOCKER_NETWORK=""
POSTGRES_VOLUME=""
BACKUP_KEEP="5"
# agentlab.env 里设了外接的库(AGENTLAB_DATABASE_URL)没有:只记有没有,值(带密码)不读进来
EXTERNAL_DB=""
# 内置 PostgreSQL 里的库名、用户名(PostgreSQL 容器第一次初始化时按它建)
PG_DB="agentlab"
PG_USER="agentlab"
read_env() {
[ -f "$ENV_FILE" ] || die "找不到 ${ENV_FILE},它应该和 deploy.sh 在同一个目录"
@@ -120,8 +156,11 @@ read_env() {
key="$(trim "${line%%=*}")"
val="${line#*=}"
case "$key" in
HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP|ENABLE_MICROVM)
HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP|ENABLE_MICROVM|POSTGRES_IMAGE|POSTGRES_CONTAINER|\
DOCKER_NETWORK|POSTGRES_VOLUME|BACKUP_KEEP)
printf -v "$key" '%s' "$(unquote "$(trim "$val")")" ;;
AGENTLAB_DATABASE_URL|AGENTLAB_CHECKPOINT_URL)
[ -z "$(trim "$val")" ] || EXTERNAL_DB="$key" ;;
AGENTLAB_DATA_DIR|AGENTLAB_WEB_DIST|AGENTLAB_PORT|AGENTLAB_HOST)
die "agentlab.env 第 $n 行:不要设 ${key}。容器里的路径和端口是固定的,改宿主机这边用 DATA_DIR、HOST_PORT、BIND_ADDR" ;;
*)
@@ -131,13 +170,30 @@ read_env() {
esac
done < "$ENV_FILE"
# 环境变量 DEPLOY_<名字> 盖过 agentlab.env(测试、并排部署两套时用)
local k v
for k in HOST_PORT BIND_ADDR DATA_DIR CONTAINER_NAME POSTGRES_IMAGE POSTGRES_CONTAINER DOCKER_NETWORK \
POSTGRES_VOLUME BACKUP_KEEP; do
v="$(printenv "DEPLOY_$k" 2>/dev/null || true)"
[ -z "$v" ] || printf -v "$k" '%s' "$v"
done
[ -n "$POSTGRES_CONTAINER" ] || POSTGRES_CONTAINER="$CONTAINER_NAME-postgres"
[ -n "$DOCKER_NETWORK" ] || DOCKER_NETWORK="$CONTAINER_NAME-net"
[ -n "$POSTGRES_VOLUME" ] || POSTGRES_VOLUME="$CONTAINER_NAME-pgdata"
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
for k in CONTAINER_NAME POSTGRES_CONTAINER DOCKER_NETWORK POSTGRES_VOLUME; do
v="${!k}"
case "$v" in
''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9_.-]*) die "$k 只能用字母、数字和 _ . -,并以字母或数字开头:$v" ;;
esac
done
[ "$POSTGRES_CONTAINER" != "$CONTAINER_NAME" ] || die "POSTGRES_CONTAINER 不能和 CONTAINER_NAME 同名:$CONTAINER_NAME"
case "$BACKUP_KEEP" in ''|*[!0-9]*|0) die "BACKUP_KEEP 要是正整数:$BACKUP_KEEP" ;; esac
case "$POSTGRES_IMAGE" in ''|*[[:space:]]*) die "POSTGRES_IMAGE 写错了:$POSTGRES_IMAGE" ;; esac
case "$ENABLE_BWRAP" in 0|1) ;; *) die "ENABLE_BWRAP 只能是 0 或 1:$ENABLE_BWRAP" ;; esac
case "$ENABLE_MICROVM" in auto|0|1) ;; *) die "ENABLE_MICROVM 只能是 auto、0 或 1:$ENABLE_MICROVM" ;; esac
@@ -192,19 +248,19 @@ image_present() { docker image inspect "$IMAGE" >/dev/null 2>&1; }
verify_tar() {
# 拷贝中途断了、U 盘出错,docker load 的报错不好懂:先对一下校验和
local name="${1:-image.tar}" line
[ -f "$HERE/SHA256SUMS" ] || return 0
command -v sha256sum >/dev/null 2>&1 || return 0
local line
line="$(grep ' image.tar$' "$HERE/SHA256SUMS" || true)"
line="$(grep " $name\$" "$HERE/SHA256SUMS" || true)"
[ -n "$line" ] || return 0
say "校验 image.tar"
say "校验 $name"
(cd "$HERE" && printf '%s\n' "$line" | sha256sum -c --quiet -) \
|| die "image.tar 校验和对不上,文件不完整或损坏。重新拷一份离线包再试"
|| die "$name 校验和对不上,文件不完整或损坏。重新拷一份离线包再试"
}
load_image() {
[ -f "$IMAGE_TAR" ] || die "本地没有镜像 ${IMAGE},包里也找不到 image.tar"
verify_tar
verify_tar image.tar
say "导入镜像:docker load -i image.tar(性能较弱的 ARM 单板机上可能要一两分钟)"
docker load -i "$IMAGE_TAR"
image_present || die "导入后仍找不到 ${IMAGE},image.tar 可能不是这个版本的"
@@ -266,10 +322,11 @@ fix_data_owner() {
owner="$(ls -nd "$DATA_ABS" | awk '{print $3}')"
[ "$owner" = "$uid" ] && return 0
# 改属主前确认这确实是一个新目录或者 AgentLab 的数据目录,免得 DATA_DIR 写错改了别的东西
# 认这三样里的一样:agentlab.db(库在 SQLite)、.secret_key(自动生成的加密密钥)、.agentlab-data(服务启动时建的标记,
# 库放在 PostgreSQL、密钥又由 AGENTLAB_SECRET_KEY 给时只有它)。只有 uploads/ 这类普通目录名的不认,免得 chown 错了别人的目录
# 认这几样里的一样:agentlab.db(库在 SQLite)、.secret_key(自动生成的加密密钥)、.agentlab-data(服务启动时建的标记,
# 库放在 PostgreSQL、密钥又由 AGENTLAB_SECRET_KEY 给时只有它)、.postgres/state(deploy.sh 记的用哪种库:内置库起来了、
# 服务还没起过时只有它)。只有 uploads/ 这类普通目录名的不认,免得 chown 错了别人的目录
if [ -n "$(ls -A "$DATA_ABS" 2>/dev/null)" ] && [ ! -e "$DATA_ABS/agentlab.db" ] \
&& [ ! -e "$DATA_ABS/.secret_key" ] && [ ! -e "$DATA_ABS/.agentlab-data" ]; then
&& [ ! -e "$DATA_ABS/.secret_key" ] && [ ! -e "$DATA_ABS/.agentlab-data" ] && [ ! -e "$DATA_ABS/.postgres/state" ]; then
die "DATA_DIR 指向的 $DATA_ABS 不是空目录,也不像 AgentLab 的数据目录(里面没有 agentlab.db、.secret_key、.agentlab-data)。
为免改错别的文件的属主,请指向一个新目录,或者原来的 AgentLab 数据目录"
fi
@@ -398,12 +455,13 @@ run_container() {
--log-opt max-size=10m --log-opt max-file=3 \
${extra[@]+"${extra[@]}"} \
${MICROVM_OPTS[@]+"${MICROVM_OPTS[@]}"} \
${DB_OPTS[@]+"${DB_OPTS[@]}"} \
"$IMAGE" >/dev/null
}
wait_healthy() {
local name="$1" state health i
say "等健康检查通过"
say "等 $name 通过健康检查"
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)"
@@ -490,6 +548,246 @@ deploy_container() {
die "容器没通过健康检查。看上面的日志;常见原因见 README 的常见问题"
}
# ---------------------------------------------------------------------------
# 用的哪种库:sqlite(缺省)、builtin(内置 PostgreSQL,数据目录里记着)、external(agentlab.env 里设了
# AGENTLAB_DATABASE_URL)。内置和外接互斥
# ---------------------------------------------------------------------------
PG_STATE_REL=".postgres/state"
# 应用容器里连内置库:不带密码,密码在数据目录的 pgpass 里(PGPASSFILE),不进容器的环境变量、不进日志
PG_PASSFILE="/data/.postgres/pgpass"
MODE="sqlite"
DB_OPTS=()
# 数据目录里记的状态:sqlite、builtin,或者 migrating(migrate-to-postgres 开始往内置库里导了、还没切成,用的仍是
# SQLite)。.postgres/ 是 0711、state 是 0644,一般宿主机上直接读得到;读不到(权限)就交给容器里的 app.ops 读
stored_state() {
local file="$DATA_ABS/$PG_STATE_REL" dir="$DATA_ABS/.postgres" line
if [ -r "$file" ]; then
line="$(grep '^mode=' "$file" 2>/dev/null | head -n 1 || true)"
case "${line#mode=}" in builtin|migrating) echo "${line#mode=}" ;; *) echo sqlite ;; esac
elif [ -e "$file" ] || { [ -e "$dir" ] && [ ! -x "$dir" ]; } || [ ! -x "$DATA_ABS" ]; then
ops state | tail -n 1
else
echo sqlite
fi
}
# 实际用的:只有 builtin 算内置,migrating 还是 SQLite
stored_mode() {
case "$(stored_state)" in builtin) echo builtin ;; *) echo sqlite ;; esac
}
volume_exists() { docker volume inspect "$1" >/dev/null 2>&1; }
resolve_mode() {
local stored
stored="$(stored_mode)"
if [ -n "$EXTERNAL_DB" ]; then
if [ "$stored" = builtin ] || [ -n "$WITH_POSTGRES" ]; then
die "两种库只能选一种:agentlab.env 里设了 ${EXTERNAL_DB}(外接 PostgreSQL),这个数据目录又是内置 PostgreSQL$( \
[ -n "$WITH_POSTGRES" ] && [ "$stored" != builtin ] && printf '(--postgres)')。
- 用内置的:把 agentlab.env 里的 $EXTERNAL_DB 那一行删掉;
- 用外接的:去掉 --postgres;数据目录已经是内置 PostgreSQL 的,先用 pg_dump 把内置库的数据导出来,换一个新的数据目录再部署"
fi
MODE="external"
elif [ "$stored" = builtin ]; then
MODE="builtin"
elif [ -n "$WITH_POSTGRES" ]; then
if [ -e "$DATA_ABS/agentlab.db" ]; then
die "数据目录 $DATA_ABS 里已经有 SQLite 的库(agentlab.db)。install --postgres 只给新部署用;要把现有数据搬进内置
PostgreSQL,运行 ./deploy.sh migrate-to-postgres"
fi
MODE="builtin"
else
MODE="sqlite"
fi
DB_OPTS=()
if [ "$MODE" = builtin ]; then
DB_OPTS=(--network "$DOCKER_NETWORK"
-e "AGENTLAB_DATABASE_URL=postgresql+asyncpg://$PG_USER@$POSTGRES_CONTAINER:5432/$PG_DB"
-e "PGPASSFILE=$PG_PASSFILE")
fi
}
mode_label() {
case "$MODE" in
builtin) echo "内置 PostgreSQL(容器 ${POSTGRES_CONTAINER},数据卷 ${POSTGRES_VOLUME})" ;;
external) echo "外接 PostgreSQL(agentlab.env 里的 ${EXTERNAL_DB})" ;;
*) echo "SQLite(数据目录里的 agentlab.db)" ;;
esac
}
# 在一个用完就删的容器里、以容器里的用户跑 python -m app.ops(数据目录归它,宿主机上的人不一定写得进去)。
# start / stop 这类用容器正在用的镜像(这个包的镜像可能已经删了)
OPS_IMAGE=""
ops() {
docker run --rm --network none -v "$DATA_ABS:/data" --entrypoint python "${OPS_IMAGE:-$IMAGE}" -m app.ops "$@"
}
# ---------------------------------------------------------------------------
# 内置 PostgreSQL:镜像、网络、密码、容器、备份
# ---------------------------------------------------------------------------
pg_image_present() { docker image inspect "$POSTGRES_IMAGE" >/dev/null 2>&1; }
ensure_pg_image() {
if pg_image_present; then
return 0
fi
[ -f "$POSTGRES_IMAGE_TAR" ] || die "本地没有 PostgreSQL 的镜像 ${POSTGRES_IMAGE},包里也没有 postgres-image.tar。
- 能联网:docker pull ${POSTGRES_IMAGE}
- 离线:用 scripts/package-docker.sh 重新打包(缺省会带上它),或者在能联网的机器上 docker save 一份拷过来 docker load"
verify_tar postgres-image.tar
say "导入 PostgreSQL 镜像:docker load -i postgres-image.tar"
docker load -i "$POSTGRES_IMAGE_TAR"
pg_image_present || die "导入后仍找不到 ${POSTGRES_IMAGE}:postgres-image.tar 里的不是这个标签。agentlab.env 里的 POSTGRES_IMAGE 要和包里的一致"
}
ensure_network() {
docker network inspect "$DOCKER_NETWORK" >/dev/null 2>&1 && return 0
say "创建 docker 网络 ${DOCKER_NETWORK}(PostgreSQL 只在这个网络里可见,端口不对外)"
docker network create "$DOCKER_NETWORK" >/dev/null
}
pg_secrets() {
# 第一次生成密码(0600),之后沿用;pgpass 按容器名重写。密码只在数据目录里,不打印
ops postgres-secrets --host "$POSTGRES_CONTAINER" --port 5432 --db "$PG_DB" --user "$PG_USER" >/dev/null \
|| die "没能在数据目录里生成 PostgreSQL 的密码文件($DATA_ABS/.postgres/)"
}
check_pg_is_ours() {
local role
role="$(docker inspect -f '{{index .Config.Labels "agentlab.role"}}' "$POSTGRES_CONTAINER" 2>/dev/null || true)"
[ "$role" = postgres ] || die "已经有一个叫 $POSTGRES_CONTAINER 的容器,但它不是 deploy.sh 起的内置 PostgreSQL。
改一下 agentlab.env 里的 POSTGRES_CONTAINER(缺省是 <CONTAINER_NAME>-postgres)"
}
run_pg_container() {
docker run -d \
--name "$POSTGRES_CONTAINER" \
--label agentlab.role=postgres \
--restart unless-stopped \
--network "$DOCKER_NETWORK" \
-e "POSTGRES_USER=$PG_USER" \
-e "POSTGRES_DB=$PG_DB" \
-e POSTGRES_PASSWORD_FILE=/run/agentlab/password \
-v "$POSTGRES_VOLUME:/var/lib/postgresql/data" \
-v "$DATA_ABS/.postgres/password:/run/agentlab/password:ro" \
--health-cmd "pg_isready -h 127.0.0.1 -U $PG_USER -d $PG_DB" \
--health-interval 5s --health-timeout 5s --health-retries 5 --health-start-period 60s \
--stop-timeout 60 \
--log-opt max-size=10m --log-opt max-file=3 \
"$POSTGRES_IMAGE" >/dev/null
}
pg_logs_to_stderr() {
echo >&2; echo "---- PostgreSQL 最后 40 行日志 ----" >&2
docker logs --tail 40 "$POSTGRES_CONTAINER" >&2 2>&1 || true
echo "----" >&2
}
# install / upgrade 时 POSTGRES_IMAGE 和现有容器的不一样(新包换了小版本,或者 agentlab.env 里改了):服务先停,库的容器
# 按新镜像重建,数据卷不动。新镜像起不来(多半是跨了大版本,数据格式不认)就换回原来的镜像,什么都不改
PG_APPLY_IMAGE=""
replace_pg_image() {
local cur="$1" was_running=""
[ "$(container_state "$CONTAINER_NAME")" = running ] && was_running=1
say "内置 PostgreSQL 换镜像:$cur → ${POSTGRES_IMAGE}(数据卷 $POSTGRES_VOLUME 不动)"
stop_app
docker stop "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$POSTGRES_CONTAINER" >/dev/null
if run_pg_container && wait_healthy "$POSTGRES_CONTAINER"; then
return 0
fi
pg_logs_to_stderr
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
local wanted="$POSTGRES_IMAGE"
POSTGRES_IMAGE="$cur"
if run_pg_container && wait_healthy "$POSTGRES_CONTAINER"; then
[ -z "$was_running" ] || docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true
die "内置 PostgreSQL 用 $wanted 起不来(见上面的日志;跨大版本时数据格式不通用,要 pg_dump 导出、新版本里导回),
已换回原来的 ${cur},什么都没改。agentlab.env 里的 POSTGRES_IMAGE 改回 $cur 再运行"
fi
die "内置 PostgreSQL 用 $wanted 起不来,换回 $cur 也没起来。看 ./deploy.sh logs --postgres"
}
# 起内置库(没有就建,停着就起),等 pg_isready 通过
start_pg() {
ensure_network
if container_exists "$POSTGRES_CONTAINER"; then
check_pg_is_ours
local cur
cur="$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER")"
if [ -n "$PG_APPLY_IMAGE" ] && [ "$cur" != "$POSTGRES_IMAGE" ]; then
replace_pg_image "$cur"
return 0
fi
if [ "$(container_state "$POSTGRES_CONTAINER")" != running ]; then
say "启动内置 PostgreSQL(${POSTGRES_CONTAINER})"
docker start "$POSTGRES_CONTAINER" >/dev/null || die "内置 PostgreSQL 的容器起不来,见上面 docker 的报错"
fi
else
say "启动内置 PostgreSQL(${POSTGRES_CONTAINER},${POSTGRES_IMAGE},数据卷 ${POSTGRES_VOLUME})"
run_pg_container || die "PostgreSQL 容器启动失败,见上面 docker 的报错"
fi
if ! wait_healthy "$POSTGRES_CONTAINER"; then
pg_logs_to_stderr
die "内置 PostgreSQL 没就绪(pg_isready 不通过)。看上面的日志"
fi
}
stop_pg() {
container_exists "$POSTGRES_CONTAINER" || return 0
[ "$(container_state "$POSTGRES_CONTAINER")" = running ] || return 0
say "停止内置 PostgreSQL(${POSTGRES_CONTAINER},数据保留)"
docker stop "$POSTGRES_CONTAINER" >/dev/null
}
stop_app() {
container_exists "$CONTAINER_NAME" || return 0
[ "$(container_state "$CONTAINER_NAME")" = running ] || return 0
say "停止 ${CONTAINER_NAME}(数据保留)"
docker stop "$CONTAINER_NAME" >/dev/null
}
image_user() { docker image inspect -f '{{.Config.User}}' "$IMAGE"; }
# pg_dump 一份到数据目录的 backups/postgres/,只留最近 BACKUP_KEEP 份。用 PostgreSQL 镜像里的 pg_dump(和库同版本),
# 以数据目录的属主写文件
backup_pg() {
local stamp file
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
file="backups/postgres/$PG_DB-$stamp.dump"
say "备份内置 PostgreSQL:pg_dump 到 $DATA_ABS/$file"
docker run --rm --network none -v "$DATA_ABS:/data" --user "$(image_user)" --entrypoint mkdir "$POSTGRES_IMAGE" \
-p /data/backups/postgres >/dev/null \
|| { warn "没能在数据目录里建备份目录 backups/postgres/"; return 1; }
docker run --rm --network "$DOCKER_NETWORK" -v "$DATA_ABS:/data" --user "$(image_user)" -e "PGPASSFILE=$PG_PASSFILE" \
--entrypoint pg_dump "$POSTGRES_IMAGE" -h "$POSTGRES_CONTAINER" -U "$PG_USER" -d "$PG_DB" -Fc -f "/data/$file" \
|| { warn "pg_dump 失败"; return 1; }
ops prune --dir /data/backups/postgres --keep "$BACKUP_KEEP" >/dev/null || warn "旧备份没删掉,不影响这次升级"
}
backup_sqlite() {
local out
say "备份 SQLite 库到 $DATA_ABS/backups/sqlite/"
out="$(ops backup-sqlite --keep "$BACKUP_KEEP")" || { warn "SQLite 库备份失败"; return 1; }
# 容器里看到的是 /data/…,换成宿主机上的路径
case "$out" in
/data/*) say "备份好了:${out/#\/data/$DATA_ABS}" ;;
*) say "$out" ;;
esac
}
backup_before_upgrade() {
case "$MODE" in
builtin) backup_pg ;;
sqlite) backup_sqlite ;;
external) say "外接 PostgreSQL:升级前请自己备份那个库(pg_dump),这里不替你做" ;;
esac
}
# ---------------------------------------------------------------------------
# 子命令
# ---------------------------------------------------------------------------
@@ -501,6 +799,26 @@ cmd_install() {
prepare_data
check_existing
fix_data_owner
resolve_mode
say "应用库:$(mode_label)"
PG_APPLY_IMAGE=1
if [ "$MODE" = builtin ]; then
ensure_pg_image
if [ "$(stored_mode)" != builtin ]; then
# 新部署:数据卷要是已经有了,里面的库是按别的密码初始化的(多半是以前 uninstall 留下的),连不上,也不该拿来用
volume_exists "$POSTGRES_VOLUME" && die "docker 数据卷 $POSTGRES_VOLUME 已经存在,不是这个数据目录建的(多半是以前卸载时留下的
内置 PostgreSQL 的数据)。
- 要接着用那份数据:把 DATA_DIR 指回原来的数据目录再 ./deploy.sh install;
- 确定不要了:docker volume rm ${POSTGRES_VOLUME}(不可恢复),再运行;
- 两份都留着:agentlab.env 里另起一个 POSTGRES_VOLUME 的名字"
pg_secrets
# 先记下再起库:新数据目录里没有 SQLite 的库,没什么可「切」的;起库失败了重来也认得出数据卷是自己的
ops state --set builtin >/dev/null || die "没能在数据目录里记下「用内置 PostgreSQL」"
else
pg_secrets
fi
start_pg
fi
decide_microvm
deploy_container
}
@@ -522,39 +840,181 @@ cmd_upgrade() {
prepare_data
check_existing
fix_data_owner
resolve_mode
say "应用库:$(mode_label)"
PG_APPLY_IMAGE=1
if [ "$MODE" = builtin ]; then
ensure_pg_image
pg_secrets
start_pg
fi
decide_microvm
# 先停旧服务再备份:备份的是升级前最后的样子。备份失败就把旧服务起回来、不升级
stop_app
if ! backup_before_upgrade; then
docker start "$CONTAINER_NAME" >/dev/null 2>&1 || true
die "升级前的备份没做成,没有升级,原来的服务已经起回来了"
fi
# 新版本启动时自动补表结构(迁移)和默认数据,几个工作进程同时起也只做一次
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
# 把正在用 SQLite 的部署切到内置 PostgreSQL。记下「已切换」之前任何一步出错:服务按 SQLite 原样恢复(原来在跑就起回来)、
# 内置库的容器停掉,数据目录里只多了 backups/ 里的一份备份和 .postgres/ 里的密码、状态(migrating)。重复执行:已经切过的
# 什么都不做;上次没切成留在内置库里的半份数据先清空再导,不会和 SQLite 里后来改过的混在一起
MIGRATE_WAS_RUNNING=""
MIGRATE_HAD_APP=""
MIGRATE_DEPLOYING=""
migrate_rollback() {
local prev="$CONTAINER_NAME-previous"
echo >&2
warn "$1"
if [ -n "$MIGRATE_DEPLOYING" ]; then
# 按内置库起的新容器不能留着(它带着 --restart,会一直去连内置库):换回原来的,原来就没有就删掉
if container_exists "$prev"; then
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rename "$prev" "$CONTAINER_NAME" >/dev/null 2>&1 || warn "没能把原来的容器改回 ${CONTAINER_NAME}:docker rename $prev $CONTAINER_NAME"
elif [ -z "$MIGRATE_HAD_APP" ]; then
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
fi
# 已经记成 builtin 的改回 migrating:仍用 SQLite,数据卷还认得出是这个数据目录的
ops state --set migrating >/dev/null 2>&1 || warn "没能把数据目录的状态改回去:手动把 $DATA_ABS/$PG_STATE_REL 里的 mode=builtin 改成 mode=migrating"
fi
if container_exists "$CONTAINER_NAME"; then
if [ -n "$MIGRATE_WAS_RUNNING" ]; then
docker start "$CONTAINER_NAME" >/dev/null 2>&1 && say "服务已按 SQLite 起回来" || warn "服务没能起回来:./deploy.sh start"
else
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
fi
fi
stop_pg
die "没有切换,仍用 SQLite(数据目录里的 agentlab.db 没动过)。照上面的报错处理好之后重新运行 ./deploy.sh migrate-to-postgres,
重复执行是安全的:内置库里这次导了一半的会先清空再导"
}
copy_to_pg() {
# --assume-stopped:服务是这里刚停的(强杀留下的 -wal 也不挡);快照放数据目录里(和库同一块盘,空间够)。
# 逐表的结果里来源是空表的几十行不列(没什么可核对的),别的原样打出来;退出码照 copy-to-postgres 的(pipefail)
docker run --rm --network "$DOCKER_NETWORK" -v "$DATA_ABS:/data" -e "PGPASSFILE=$PG_PASSFILE" \
--entrypoint python "$IMAGE" -m app.db copy-to-postgres /data \
"postgresql+asyncpg://$PG_USER@$POSTGRES_CONTAINER:5432/$PG_DB" --assume-stopped --work-dir /data "$@" \
| grep -v ' 空表$'
}
cmd_migrate_to_postgres() {
check_docker
check_arch
read_env
ensure_image
prepare_data
check_existing
[ -z "$EXTERNAL_DB" ] || die "agentlab.env 里设了 ${EXTERNAL_DB}(外接 PostgreSQL)。内置和外接只能选一种:要切到内置的,先删掉那一行;
要搬进外接的库,用镜像里的 python -m app.db copy-to-postgres(见 README「用 PostgreSQL」)"
local state
state="$(stored_state)"
if [ "$state" = builtin ]; then
say "这个数据目录已经切到内置 PostgreSQL 了,什么都不用做(./deploy.sh status 看状态)"
return 0
fi
[ -e "$DATA_ABS/agentlab.db" ] || die "数据目录 $DATA_ABS 里没有 SQLite 的库(agentlab.db),没有可搬的。新部署直接用 ./deploy.sh install --postgres"
# 数据卷已经有了、又不是这个数据目录上次没切成留下的:里面可能是别人的数据,下面要清空它,不能碰
if [ "$state" != migrating ] && volume_exists "$POSTGRES_VOLUME"; then
die "docker 数据卷 $POSTGRES_VOLUME 已经存在,不是这个数据目录上次没切成留下的(多半是以前卸载时留下的内置 PostgreSQL
的数据)。切换要清空内置库,为免删错数据,没有动它。
- 确定不要了:docker volume rm ${POSTGRES_VOLUME}(不可恢复),再运行;
- 要留着:agentlab.env 里另起一个 POSTGRES_VOLUME 的名字"
fi
if container_exists "$POSTGRES_CONTAINER"; then check_pg_is_ours; fi
fix_data_owner
ensure_pg_image
PG_APPLY_IMAGE=1
container_exists "$CONTAINER_NAME" && MIGRATE_HAD_APP=1
[ "$(container_state "$CONTAINER_NAME")" = running ] && MIGRATE_WAS_RUNNING=1
say "1/6 停服务(导入要在服务停着时做)"
stop_app
say "2/6 备份 SQLite 库"
backup_sqlite || migrate_rollback "SQLite 库备份失败"
say "3/6 起内置 PostgreSQL"
( pg_secrets ) || migrate_rollback "没能生成内置 PostgreSQL 的密码"
ops state --set migrating >/dev/null || migrate_rollback "没能在数据目录里记下「开始切换」"
( start_pg ) || migrate_rollback "内置 PostgreSQL 没起来"
# 内置库里要是有东西,只能是上次没切成留下的半份(上面查过数据卷),清空重来
docker exec "$POSTGRES_CONTAINER" psql -q -U "$PG_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$PG_DB\" WITH (FORCE)" -c "CREATE DATABASE \"$PG_DB\" OWNER \"$PG_USER\"" >/dev/null \
|| migrate_rollback "没能清空内置库里上次留下的数据"
say "4/6 先核对(只数行数,不写)"
copy_to_pg --dry-run || migrate_rollback "核对没过"
say "5/6 导入并逐表核对"
copy_to_pg || migrate_rollback "导入或核对没过"
MIGRATE_DEPLOYING=1
ops state --set builtin >/dev/null || migrate_rollback "没能在数据目录里记下「已切到内置 PostgreSQL」"
say "6/6 按内置 PostgreSQL 起服务"
resolve_mode
decide_microvm
( deploy_container ) || migrate_rollback "按内置 PostgreSQL 起的服务没通过健康检查"
echo
say "已切到内置 PostgreSQL。SQLite 的库文件(agentlab.db、checkpoints.db)原样留在 $DATA_ABS 里、不再使用,"
echo " 另有一份备份在 backups/sqlite/。用一段时间确认没问题后再删(见 README「用 PostgreSQL」)"
}
# start / stop / restart / status / logs / uninstall:数据目录看容器实际挂的,没有容器再看配置
# 这几个只管容器,不改配置:模式照数据目录里记的认,agentlab.env 和它冲突(内置的又设了外接地址)也不在这里拦,留给 install
locate_data() {
DATA_ABS=""
if container_exists "$CONTAINER_NAME"; then
DATA_ABS="$(data_source_of "$CONTAINER_NAME")"
OPS_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME")"
fi
if [ -z "$DATA_ABS" ] && [ -d "$DATA_PATH" ]; then DATA_ABS="$(cd "$DATA_PATH" && pwd -P)"; fi
MODE="sqlite"
if [ -n "$DATA_ABS" ] && [ "$(stored_mode)" = builtin ]; then
MODE="builtin"
elif [ -n "$EXTERNAL_DB" ]; then
MODE="external"
fi
}
wait_app() {
if wait_healthy "$CONTAINER_NAME"; then
say "已启动"; print_access
say "$1"; print_access
else
show_failure; die "容器没通过健康检查"
fi
}
cmd_start() {
check_docker; read_env; require_container; locate_data
[ "$MODE" != builtin ] || start_pg
docker start "$CONTAINER_NAME" >/dev/null
wait_app "已启动"
}
cmd_stop() {
check_docker; read_env; require_container
check_docker; read_env; require_container; locate_data
# 先停服务再停库:服务收尾时还要写库
say "停止 ${CONTAINER_NAME}(数据保留)"
docker stop "$CONTAINER_NAME" >/dev/null
[ "$MODE" != builtin ] || stop_pg
say "已停止。开机也不会自动起来,要用时运行 ./deploy.sh start"
}
cmd_restart() {
check_docker; read_env; require_container
check_docker; read_env; require_container; locate_data
say "重启 $CONTAINER_NAME"
docker restart "$CONTAINER_NAME" >/dev/null
if wait_healthy "$CONTAINER_NAME"; then
say "已重启"; print_access
if [ "$MODE" = builtin ]; then
stop_app
stop_pg
start_pg
docker start "$CONTAINER_NAME" >/dev/null
else
show_failure; die "容器没通过健康检查"
docker restart "$CONTAINER_NAME" >/dev/null
fi
wait_app "已重启"
}
cmd_status() {
@@ -563,6 +1023,7 @@ cmd_status() {
echo "没有名为 $CONTAINER_NAME 的容器(还没部署,或已卸载)。部署:./deploy.sh install"
exit 1
fi
locate_data
local state health image src
state="$(container_state "$CONTAINER_NAME")"
health=""
@@ -575,22 +1036,42 @@ cmd_status() {
echo "镜像 $image"
echo "端口 $(docker port "$CONTAINER_NAME" 8000/tcp 2>/dev/null | tr '\n' ' ')"
echo "数据目录 ${src:-(未挂载)}"
echo "应用库 $(mode_label)"
if [ -n "$DATA_ABS" ] && [ "$MODE" = sqlite ] && [ "$(stored_state)" = migrating ]; then
echo " 上次 migrate-to-postgres 没切成(仍用 SQLite),数据卷 $POSTGRES_VOLUME 里留着导了一半的;重新运行会清空重来"
fi
if [ "$MODE" = builtin ]; then
local pstate phealth pimage
pstate="$(container_state "$POSTGRES_CONTAINER")"
phealth=""
[ "$pstate" != running ] || phealth="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$POSTGRES_CONTAINER")"
pimage="$(docker inspect -f '{{.Config.Image}}' "$POSTGRES_CONTAINER" 2>/dev/null || echo "$POSTGRES_IMAGE")"
echo "内置库 $POSTGRES_CONTAINER ${pstate}${phealth:+(pg_isready:${phealth})},镜像 ${pimage},网络 ${DOCKER_NETWORK}(端口不对外)"
echo "库的备份 ${src:-$DATA_ABS}/backups/postgres/(upgrade 前自动 pg_dump,留最近 $BACKUP_KEEP 份)"
fi
[ "$state" != running ] || echo "代码沙箱 $(sandbox_summary)"
echo "这个包 $IMAGE"
if [ "$state" = running ]; then print_access; fi
}
cmd_logs() {
check_docker; read_env; require_container
check_docker; read_env
local target="$CONTAINER_NAME"
if [ -n "$LOGS_POSTGRES" ]; then
target="$POSTGRES_CONTAINER"
container_exists "$target" || die "没有名为 $target 的容器:这个部署没用内置 PostgreSQL"
else
require_container
fi
local args=(--tail "$TAIL")
[ -z "$FOLLOW" ] || args+=(--follow)
# exec:Ctrl-C 直接交给 docker logs,不留一个还在跟随的子进程
exec docker logs "${args[@]}" "$CONTAINER_NAME"
exec docker logs "${args[@]}" "$target"
}
cmd_uninstall() {
check_docker; read_env
local src="" name
local src="" name pg=""
if container_exists "$CONTAINER_NAME"; then
check_is_agentlab
src="$(data_source_of "$CONTAINER_NAME")"
@@ -601,23 +1082,40 @@ cmd_uninstall() {
docker rm -f "$name" >/dev/null
fi
done
if container_exists "$POSTGRES_CONTAINER"; then
check_pg_is_ours
say "删除内置 PostgreSQL 的容器 ${POSTGRES_CONTAINER}(数据卷 $POSTGRES_VOLUME 留着)"
docker rm -f "$POSTGRES_CONTAINER" >/dev/null
pg=1
fi
if docker network inspect "$DOCKER_NETWORK" >/dev/null 2>&1; then
docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 && say "删除 docker 网络 $DOCKER_NETWORK" \
|| warn "docker 网络 $DOCKER_NETWORK 没删掉(可能还有别的容器连着)"
fi
if [ -n "$PURGE_IMAGE" ]; then
if image_present; then
say "删除镜像 $IMAGE"
docker rmi "$IMAGE" >/dev/null || warn "镜像 $IMAGE 没删掉(可能还有别的容器在用)"
fi
# PostgreSQL 的镜像不删:本机别的东西可能也在用同一个镜像
else
echo "镜像 $IMAGE 保留着,重新部署不用再导入。要删:./deploy.sh uninstall --purge-image"
fi
[ -n "$src" ] || src="$DATA_PATH"
echo
echo "数据没有删,还在:$src"
echo "确定不要了再手动删除(不可恢复):sudo rm -rf '$src'"
if [ -n "$pg" ] || docker volume inspect "$POSTGRES_VOLUME" >/dev/null 2>&1; then
echo "内置 PostgreSQL 的数据在 docker 数据卷 $POSTGRES_VOLUME 里,也没有删"
echo "确定不要了再手动删除(不可恢复):sudo rm -rf '$src' && docker volume rm $POSTGRES_VOLUME"
else
echo "确定不要了再手动删除(不可恢复):sudo rm -rf '$src'"
fi
}
case "$CMD" in
install) cmd_install ;;
upgrade) cmd_upgrade ;;
migrate-to-postgres) cmd_migrate_to_postgres ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
+41 -9
View File
@@ -6,6 +6,7 @@ set -euo pipefail
usage() {
cat <<'USAGE'
用法:./scripts/package-docker.sh [--platform linux/arm64|linux/amd64] [--version <版本>] [--out <目录>] [--gzip] [--no-microvm]
[--no-postgres]
--platform <平台> 目标机的平台。默认按本机架构取 linux/<arch>。64 位系统的 ARM 单板机是 linux/arm64,
普通 PC 和服务器是 linux/amd64。和本机架构不同时 docker 用模拟构建,会慢很多。
@@ -14,9 +15,13 @@ usage() {
--gzip 另外再产出一份 .tar.gz。docker 用 containerd 镜像存储时镜像层本身已经压缩过,.tar.gz 小不了多少。
--no-microvm 不在镜像里带 microVM 运行时和沙箱镜像,镜像小约 150 MB。目标机没有 KVM 时用不上它们。
默认带上:目标机有 KVM 时,deploy.sh 会自动启用 microVM,而且不用联网。
--no-postgres 不带内置 PostgreSQL 的镜像(postgres-image.tar,约 150 MB)。只用 SQLite 或外接 PostgreSQL 时用不上;
不带的包在目标机上 install --postgres 要先 docker pull 到同一个标签。默认带上,标签取 deploy.sh 里的
POSTGRES_IMAGE(只在那一处定)。
-h, --help 显示这段说明。
产物:<out>/agentlab-<版本>-linux-<arch>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS。
产物:<out>/agentlab-<版本>-linux-<arch>.tar,里面有 image.tar、postgres-image.tar、deploy.sh、agentlab.env、README.md、
SHA256SUMS。
构建需要联网(拉基础镜像、装依赖、拉 microVM 的沙箱镜像);部署不需要。镜像只留在本机,不会推到任何仓库。
USAGE
}
@@ -28,6 +33,7 @@ VERSION=""
OUT="$ROOT/release"
GZIP=""
WITH_MICROVM=1
WITH_POSTGRES=1
need_value() { [ $# -ge 2 ] && [ -n "$2" ] || { echo "$1 后面要跟一个值"; echo; usage; exit 2; }; }
while [ $# -gt 0 ]; do
case "$1" in
@@ -39,6 +45,7 @@ while [ $# -gt 0 ]; do
--out=*) OUT="${1#--out=}" ;;
--gzip) GZIP=1 ;;
--no-microvm) WITH_MICROVM=0 ;;
--no-postgres) WITH_POSTGRES=0 ;;
-h|--help) usage; exit 0 ;;
*) echo "认不出的参数:$1"; echo; usage; exit 2 ;;
esac
@@ -76,6 +83,10 @@ VERSION="${VERSION:0:120}"
IMAGE="agentlab:$VERSION"
NAME="agentlab-$VERSION-linux-$ARCH"
# 内置 PostgreSQL 的镜像标签只在 deploy.sh 里定一处,这里照它取,包里带的和 deploy.sh 要的才对得上
PG_IMAGE="$(sed -n 's/^POSTGRES_IMAGE="\([^"]*\)"$/\1/p' "$ROOT/deploy/deploy.sh" | head -n 1)"
[ -n "$PG_IMAGE" ] || { echo "deploy/deploy.sh 里找不到 POSTGRES_IMAGE=\"...\" 这一行"; exit 1; }
# ---- 工具 ----
command -v docker >/dev/null || { echo "缺少 docker"; exit 1; }
docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "连不上 docker 服务,先把 Docker 启动起来"; exit 1; }
@@ -91,7 +102,8 @@ STAGE="$OUT/$NAME"
TAR="$OUT/$NAME.tar"
if [ "$WITH_MICROVM" = 1 ]; then MICROVM_NOTE="带 microVM"; else MICROVM_NOTE="不带 microVM"; fi
echo "==> 版本 $VERSION · 平台 $PLATFORM · $MICROVM_NOTE · 产物 $TAR"
if [ "$WITH_POSTGRES" = 1 ]; then PG_NOTE="带 $PG_IMAGE"; else PG_NOTE="不带 PostgreSQL 镜像"; fi
echo "==> 版本 $VERSION · 平台 $PLATFORM · $MICROVM_NOTE · $PG_NOTE · 产物 $TAR"
# ---- 1. 构建 ----
# 不带 provenance / SBOM:它们会让导出的镜像多出几份附加清单,老版本的 docker load 认不全
@@ -108,16 +120,35 @@ docker buildx build \
GOT="$(docker image inspect -f '{{.Os}}/{{.Architecture}}' "$IMAGE")"
[ "$GOT" = "$PLATFORM" ] || { echo "构建出来的镜像是 ${GOT},不是 $PLATFORM"; exit 1; }
if [ "$WITH_POSTGRES" = 1 ]; then
# 本机没有、或者有的不是目标平台的(比如在 arm64 的机器上打 amd64 的包)就按目标平台拉。containerd 的镜像存储里一个标签
# 可以同时有几个平台,inspect 只报本机的那个,所以拉完不再按 inspect 核对:导出时 docker save --platform 取目标平台的
PG_GOT="$(docker image inspect -f '{{.Os}}/{{.Architecture}}' "$PG_IMAGE" 2>/dev/null || true)"
if [ "$PG_GOT" != "$PLATFORM" ]; then
echo "==> 拉取 ${PG_IMAGE}(${PLATFORM})"
docker pull --platform "$PLATFORM" "$PG_IMAGE"
fi
fi
# ---- 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"
save() {
if docker save --help 2>/dev/null | grep -q -- '--platform'; then
docker save --platform "$PLATFORM" -o "$1" "$2"
else
docker save -o "$1" "$2"
fi
}
save "$STAGE/image.tar" "$IMAGE"
PAYLOAD=(image.tar)
if [ "$WITH_POSTGRES" = 1 ]; then
echo "==> 导出 ${PG_IMAGE}(docker save)"
save "$STAGE/postgres-image.tar" "$PG_IMAGE"
PAYLOAD+=(postgres-image.tar)
fi
fill() {
@@ -128,12 +159,13 @@ 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"
chmod 644 "$STAGE/README.md" "$STAGE/agentlab.env"
for f in "${PAYLOAD[@]}"; do chmod 644 "$STAGE/$f"; done
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)
(cd "$STAGE" && "${SHA[@]}" "${PAYLOAD[@]}" deploy.sh agentlab.env README.md > SHA256SUMS)
chmod 644 "$STAGE/SHA256SUMS"
# ---- 3. 打包 ----
@@ -156,7 +188,7 @@ echo
echo "完成:"
echo " $TAR ($(size "$TAR"))"
[ -z "$GZIP" ] || echo " $TAR.gz ($(size "$TAR.gz"))"
echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库),$MICROVM_NOTE"
echo " 镜像 $IMAGE 留在本机(没有推送到任何仓库),${MICROVM_NOTE},$PG_NOTE"
echo
echo "下一步:"
echo " 1. 把 $NAME.tar 拷到目标机(scp、U 盘都行)"