diff --git a/backend/tests/fake_docker.py b/backend/tests/fake_docker.py new file mode 100644 index 00000000..c7993abf --- /dev/null +++ b/backend/tests/fake_docker.py @@ -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:])) diff --git a/backend/tests/test_deploy_script.py b/backend/tests/test_deploy_script.py new file mode 100644 index 00000000..30e52c24 --- /dev/null +++ b/backend/tests/test_deploy_script.py @@ -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 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 0d579dda..0cd39ee0 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -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 < 盖过 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(缺省是 -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 ;; diff --git a/scripts/package-docker.sh b/scripts/package-docker.sh index 8b23150b..11a81c09 100755 --- a/scripts/package-docker.sh +++ b/scripts/package-docker.sh @@ -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/。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 显示这段说明。 -产物:/agentlab-<版本>-linux-.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS。 +产物:/agentlab-<版本>-linux-.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 盘都行)"