mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
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:
@@ -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:]))
|
||||
@@ -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
@@ -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 ;;
|
||||
|
||||
@@ -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 盘都行)"
|
||||
|
||||
Reference in New Issue
Block a user