diff --git a/backend/app/ops.py b/backend/app/ops.py index 00be80cf..92570089 100644 --- a/backend/app/ops.py +++ b/backend/app/ops.py @@ -7,7 +7,9 @@ 连库时读,PGPASSFILE 指过来)。已有就不动;密码不打印、不进任何参数和日志 state 数据目录用的是哪种库:没切过是 sqlite;--set builtin 记成「已切到内置 PostgreSQL」(deploy.sh 只在导入、 核对都通过之后才记,记下之前出错就还是 sqlite);migrating 是「开始往内置库里导了、还没切成」—— - 用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的 + 用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的。 + --container / --volume / --network 记下这个数据目录的内置库用的名字;第一次记时生成部署编号 id + (deploy.sh 把它打在库的容器、数据卷、网络的标签上,按它认哪些是这个部署的)。--get 键 打印一项 backup-sqlite 拷一份 agentlab.db、checkpoints.db 到 <数据目录>/backups/sqlite/<时间>/(SQLite 的备份接口,一致的一份), 只留最近 --keep 份 backup-dir 建(或收紧)备份目录:从 backups/ 起每一层 0700(pg_dump 写进去之前用) @@ -62,28 +64,56 @@ def postgres_secrets(data: Path, *, folder: str, host: str, port: int, database: return made -def read_state(data: Path) -> str: - """数据目录用的是哪种库:sqlite(没切过)、builtin(已切到内置 PostgreSQL)或 migrating(切到一半,用的仍是 SQLite)。""" +#: 状态文件里的几项(顺序就是写出来的顺序)。名字三项:这个数据目录的内置库用的容器、数据卷、网络 +STATE_KEYS = ("mode", "since", "id", "container", "volume", "network") +NAME_KEYS = ("container", "volume", "network") + + +def read_state_values(data: Path) -> dict[str, str]: path = data / POSTGRES_DIR / STATE_FILE try: - for line in path.read_text(encoding="utf-8").splitlines(): - if line.startswith("mode="): - mode = line.split("=", 1)[1].strip() - return mode if mode in MODES else "sqlite" + text = path.read_text(encoding="utf-8") except FileNotFoundError: - pass - return "sqlite" + return {} + values: dict[str, str] = {} + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep and key.strip() in STATE_KEYS: + values[key.strip()] = value.strip() + return values -def write_state(data: Path, mode: str) -> None: - if mode not in MODES: +def read_state(data: Path) -> str: + """数据目录用的是哪种库:sqlite(没切过)、builtin(已切到内置 PostgreSQL)或 migrating(切到一半,用的仍是 SQLite)。""" + mode = read_state_values(data).get("mode", "sqlite") + return mode if mode in MODES else "sqlite" + + +def write_state(data: Path, mode: str | None = None, **names: str | None) -> dict[str, str]: + """改模式(mode 给了才改;改了才换 since)、记名字(给了的那几项);别的照旧。第一次写时生成部署编号 id,之后不变。""" + if mode is not None and mode not in MODES: raise SystemExit(f"认不出的模式:{mode}({' / '.join(MODES)})") + unknown = set(names) - set(NAME_KEYS) + if unknown: + raise SystemExit(f"认不出的名字:{'、'.join(sorted(unknown))}") + values = read_state_values(data) + if mode is not None and values.get("mode") != mode: + values["mode"] = mode + values["since"] = datetime.now(timezone.utc).isoformat() + values.setdefault("mode", "sqlite") + values.setdefault("since", datetime.now(timezone.utc).isoformat()) + values.setdefault("id", secrets.token_hex(6)) + for key, value in names.items(): + if value: + values[key] = value root = data / POSTGRES_DIR if not root.exists(): root.mkdir(parents=True) root.chmod(0o711) # 不是密钥:宿主机上的 deploy.sh 直接读它,0644 - atomic.write_text(root / STATE_FILE, f"mode={mode}\nsince={datetime.now(timezone.utc).isoformat()}\n", mode=0o644) + text = "".join(f"{key}={values[key]}\n" for key in STATE_KEYS if values.get(key)) + atomic.write_text(root / STATE_FILE, text, mode=0o644) + return values @contextlib.contextmanager @@ -171,6 +201,9 @@ def main(argv: list[str] | None = None) -> int: s = sub.add_parser("state") s.add_argument("--data", default="/data") s.add_argument("--set", choices=MODES) + for key in NAME_KEYS: + s.add_argument(f"--{key}") + s.add_argument("--get", choices=STATE_KEYS) b = sub.add_parser("backup-sqlite") b.add_argument("--data", default="/data") b.add_argument("--keep", type=int, default=5) @@ -187,9 +220,13 @@ def main(argv: list[str] | None = None) -> int: user=args.user) print("生成了新密码" if made else "沿用已有的密码") elif args.command == "state": - if args.set: - write_state(Path(args.data), args.set) - print(read_state(Path(args.data))) + names = {key: getattr(args, key) for key in NAME_KEYS if getattr(args, key)} + if args.set or names: + write_state(Path(args.data), args.set, **names) + if args.get: + print(read_state_values(Path(args.data)).get(args.get, "")) + else: + print(read_state(Path(args.data))) elif args.command == "backup-sqlite": target = backup_sqlite(Path(args.data), keep=args.keep) print(target if target is not None else "没有 SQLite 库,不用备份") diff --git a/backend/tests/fake_docker.py b/backend/tests/fake_docker.py index 9ff3cd24..cbe2db54 100644 --- a/backend/tests/fake_docker.py +++ b/backend/tests/fake_docker.py @@ -18,6 +18,7 @@ from __future__ import annotations import json import os import platform +import re import sys from pathlib import Path @@ -40,9 +41,14 @@ def host_arch() -> str: def load() -> dict: - if STATE.exists(): - return json.loads(STATE.read_text()) - return {"images": {}, "containers": {}, "networks": [], "volumes": []} + state = json.loads(STATE.read_text()) if STATE.exists() else {} + state.setdefault("images", {}) + state.setdefault("containers", {}) + # 网络、数据卷:名字 → 标签。测试里直接写列表(只有名字、没标签)的也认 + for kind in ("networks", "volumes"): + value = state.get(kind) or {} + state[kind] = {name: {} for name in value} if isinstance(value, list) else value + return state def save(state: dict) -> None: @@ -80,12 +86,13 @@ def fmt_container(c: dict, fmt: str) -> str: 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 ".Mounts" in fmt and (m := re.search(r'eq \.Destination "([^"]+)"', fmt)): + # .Source(绑定挂载的宿主机路径)和 .Name(数据卷名)这里存的是同一个值 + return c["mounts"].get(m.group(1), "") if ".State.Health" in fmt: return container_health(c) - if '"agentlab.role"' in fmt: - return c["labels"].get("agentlab.role", "") + if ".Config.Labels" in fmt and (m := re.search(r'index \.Config\.Labels "([^"]+)"', fmt)): + return c["labels"].get(m.group(1), "") if ".HostConfig.PortBindings" in fmt: return "".join(p.rsplit(":", 2)[-2] for p in c["ports"]) raise KeyError(fmt) @@ -264,8 +271,7 @@ def main(argv: list[str]) -> int: 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["volumes"].setdefault(vol, {}) state["containers"][name] = { "image": image, "state": "running", "env": opts["env"], "labels": opts["labels"], "mounts": opts["mounts"], "ports": opts["ports"], "network": network or "bridge", @@ -331,31 +337,29 @@ def main(argv: list[str]) -> int: print(host_part) return 0 - if cmd == "network": + if cmd in ("network", "volume"): + kind = cmd + "s" sub, name = args[0], args[-1] if sub == "inspect": - return 0 if name in state["networks"] else 1 + if name not in state[kind]: + return 1 + if len(args) >= 3 and args[1] == "-f": + m = re.search(r'index \.Labels "([^"]+)"', args[2]) + if not m: + unknown(argv) + print(state[kind][name].get(m.group(1), "")) + return 0 if sub == "create": - if name in state["networks"]: + labels = dict(a.split("=", 1) for a in (args[i + 1] for i, x in enumerate(args) if x == "--label")) + if name in state[kind] and cmd == "network": fail(f"network with name {name} already exists") - state["networks"].append(name) + state[kind].setdefault(name, labels) save(state) return 0 if sub == "rm": - if any(c["network"] == name for c in state["containers"].values()): + if cmd == "network" and 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) + state[kind].pop(name, None) save(state) return 0 unknown(argv) diff --git a/backend/tests/test_deploy_script.py b/backend/tests/test_deploy_script.py index 87a36497..a7f5e306 100644 --- a/backend/tests/test_deploy_script.py +++ b/backend/tests/test_deploy_script.py @@ -32,8 +32,13 @@ 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: + """一份解开的离线包。share 给了另一份:两份用同一个假 docker(同一台机器上的两个部署)。""" + + def __init__(self, root: Path, *, env_lines: list[str] | None = None, name: str = APP, + share: "Deploy | None" = None) -> None: self.root = root + self.name = name + root.mkdir(parents=True, exist_ok=True) self.pkg = root / "pkg" self.pkg.mkdir() text = (REPO / "deploy" / "deploy.sh").read_text(encoding="utf-8") @@ -47,8 +52,8 @@ class Deploy: 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.state_file = share.state_file if share else root / "docker-state.json" + self.log_file = share.log_file if share else 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), @@ -59,7 +64,8 @@ class Deploy: 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}", + port = 18765 if self.name == APP else 18766 + lines = [f"HOST_PORT={port}", "BIND_ADDR=127.0.0.1", "DATA_DIR=./data", f"CONTAINER_NAME={self.name}", "ENABLE_MICROVM=0", "ENABLE_BWRAP=0", "AGENTLAB_SECRET_KEY=", *extra] (self.pkg / "agentlab.env").write_text("\n".join(lines) + "\n") @@ -80,11 +86,14 @@ class Deploy: def containers(self) -> dict: return self.state()["containers"] - def mode(self) -> str: + def state_values(self) -> dict[str, 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=")) + return {} + return dict(line.split("=", 1) for line in path.read_text().splitlines() if "=" in line) + + def mode(self) -> str: + return self.state_values().get("mode", "sqlite") def index_of(calls: list[list[str]], pred) -> int: @@ -408,9 +417,9 @@ def test_uninstall_refuses_a_foreign_container_with_the_postgres_name(deploy): 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() + out = deploy("uninstall") + assert "不是 deploy.sh 起的内置 PostgreSQL" in out.stderr and "没有删它" in out.stderr + assert PG in deploy.containers() and APP not in deploy.containers(), "别人的留着,自己的删掉" # -------------------------------------------------------------------------- @@ -579,7 +588,7 @@ def test_migrate_without_an_app_container_removes_the_failed_one(deploy): def test_migrate_refuses_a_volume_it_did_not_create(deploy): _sqlite_deployment(deploy) state = deploy.state() - state["volumes"].append(VOL) + state["volumes"][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 @@ -593,6 +602,95 @@ def test_migrate_needs_a_sqlite_database(deploy): assert "install --postgres" in out.stderr +# -------------------------------------------------------------------------- +# 同一台机器上两个部署:名字记在数据目录里,容器、数据卷带部署编号(复核 R-d1) +# -------------------------------------------------------------------------- + + +def _two_deployments(tmp_path) -> tuple[Deploy, Deploy]: + """a 用内置库在跑;b 是 SQLite 部署,一次 migrate 没切成(记成 migrating)。""" + a = Deploy(tmp_path / "a") + a("install", "--postgres") + b = Deploy(tmp_path / "b", name="agl-b", share=a) + make_sqlite_data(b.pkg / "data") + b("install") + b("migrate-to-postgres", ok=False, FAKE_COPY_FAIL="real") + assert b.mode() == "migrating" + return a, b + + +def _touched(calls: list[list[str]], name: str) -> list[list[str]]: + """动了 name 这个容器的调用(清空库、删、停、重建)。""" + return [c for c in calls if (c[:2] == ["exec", name]) or (c[:1] in (["rm"], ["stop"]) and name in c) + or runs_named(name)(c)] + + +def test_the_state_file_records_names_and_labels_carry_the_deployment_id(deploy): + deploy("install", "--postgres") + values = deploy.state_values() + assert values["mode"] == "builtin" and values["container"] == PG and values["volume"] == VOL and values["network"] == NET + dep = values["id"] + assert len(dep) == 12 + state = deploy.state() + assert state["containers"][PG]["labels"]["agentlab.deployment"] == dep + assert state["volumes"][VOL] == {"agentlab.role": "pgdata", "agentlab.deployment": dep} + + +def test_renaming_to_another_deployments_database_is_refused_before_anything_is_touched(tmp_path): + a, b = _two_deployments(tmp_path) + before = a.containers()[PG] + b.write_env([f"POSTGRES_CONTAINER={PG}", f"POSTGRES_VOLUME={VOL}"]) + for args in (["migrate-to-postgres"], ["uninstall"], ["stop"]): + out = b(*args, ok=False) + assert "记着它的内置 PostgreSQL 用的是 agl-b-postgres" in out.stderr, args + assert _touched(b.calls(), PG) == [], f"{args}:a 的库一点没动" + assert a.containers()[PG] == before + + +def test_an_old_state_file_is_backfilled_and_another_deployments_container_still_refused(tmp_path): + a, b = _two_deployments(tmp_path) + # 早先的状态文件只记了模式;agentlab.env 又改成了 a 的名字 + (b.data / ".postgres" / "state").write_text("mode=migrating\nsince=2026-10-01T00:00:00+00:00\n") + b.write_env([f"POSTGRES_CONTAINER={PG}", f"POSTGRES_VOLUME={VOL}", f"DOCKER_NETWORK={NET}"]) + out = b("migrate-to-postgres", ok=False) + assert "按 agentlab.env 现在的名字补记" in out.stdout + assert "另一个部署" in out.stderr + assert not any(c[:2] == ["exec", PG] for c in b.calls()), "没有在 a 的库里 DROP" + assert a.containers()[PG]["state"] == "running" + assert b.state_values()["container"] == PG, "照实补记了现在的名字" + + +def test_an_unlabelled_container_is_recognised_by_its_password_mount(tmp_path): + """早先起的库容器没有部署编号的标签:挂着这个数据目录的密码文件、挂的是 POSTGRES_VOLUME 的才认。""" + a = Deploy(tmp_path / "a") + a("install", "--postgres") + state = a.state() + del state["containers"][PG]["labels"]["agentlab.deployment"] + a.state_file.write_text(json.dumps(state)) + a("restart", "--postgres") + assert a.containers()[PG]["state"] == "running" + state["containers"][PG]["mounts"]["/run/agentlab/password"] = "/elsewhere/.postgres/password" + a.state_file.write_text(json.dumps(state)) + out = a("restart", "--postgres", ok=False) + assert "不是这个数据目录" in out.stderr + out = a("uninstall") + assert PG in a.containers() and "没有删它" in out.stderr + + +def test_upgrade_with_a_new_image_does_not_recreate_another_deployments_container(tmp_path): + a = Deploy(tmp_path / "a") + a("install", "--postgres") + b = Deploy(tmp_path / "b", name="agl-b", share=a) + b("install", "--postgres") + # b 的状态文件是早先记的、名字又改成了 a 的:补记之后部署编号对不上,不碰 a 的库 + (b.data / ".postgres" / "state").write_text("mode=builtin\n") + b.write_env([f"POSTGRES_CONTAINER={PG}", f"POSTGRES_VOLUME={VOL}", f"DOCKER_NETWORK={NET}"]) + (b.pkg / "postgres-image.tar").write_text("postgres:16.99 \n") + out = b("upgrade", ok=False, DEPLOY_POSTGRES_IMAGE="postgres:16.99") + assert "另一个部署" in out.stderr + assert a.containers()[PG]["image"] == PG_IMAGE and not any(runs_named(PG)(c) for c in b.calls()) + + 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") diff --git a/backend/tests/test_ops.py b/backend/tests/test_ops.py index 92c27604..e8a52634 100644 --- a/backend/tests/test_ops.py +++ b/backend/tests/test_ops.py @@ -62,6 +62,34 @@ def test_state_defaults_to_sqlite_and_records_switches(tmp_path, capsys): ops.main(["state", "--data", str(tmp_path), "--set", "external"]) +def test_state_records_names_and_a_stable_deployment_id(tmp_path, capsys): + assert ops.main(["state", "--data", str(tmp_path), "--set", "migrating", "--container", "agl-postgres", + "--volume", "agl-pgdata", "--network", "agl-net"]) == 0 + values = ops.read_state_values(tmp_path) + assert values["mode"] == "migrating" and values["container"] == "agl-postgres" and values["volume"] == "agl-pgdata" + dep = values["id"] + assert len(dep) == 12 + since = values["since"] + # 改模式:名字、编号照旧,since 换新 + ops.write_state(tmp_path, "builtin") + again = ops.read_state_values(tmp_path) + assert again["id"] == dep and again["network"] == "agl-net" and again["mode"] == "builtin" + assert again["since"] >= since + # 只补记名字:模式不变 + ops.write_state(tmp_path, None, container="agl-db") + assert ops.read_state(tmp_path) == "builtin" and ops.read_state_values(tmp_path)["container"] == "agl-db" + capsys.readouterr() + assert ops.main(["state", "--data", str(tmp_path), "--get", "id"]) == 0 + assert capsys.readouterr().out.strip() == dep + # 早先的状态文件(只有模式):第一次写时补上编号 + (tmp_path / ".postgres" / "state").write_text("mode=migrating\n") + ops.write_state(tmp_path, None, container="c", volume="v", network="n") + old = ops.read_state_values(tmp_path) + assert old["mode"] == "migrating" and old["id"] and old["id"] != dep + with pytest.raises(SystemExit): + ops.write_state(tmp_path, None, image="x") + + def test_prune_keeps_the_newest_by_name(tmp_path): for name in ("agentlab-20260101T000000Z.dump", "agentlab-20260103T000000Z.dump", "agentlab-20260102T000000Z.dump"): (tmp_path / name).write_bytes(b"x") diff --git a/deploy/README.md b/deploy/README.md index a528316f..98eda627 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -119,7 +119,10 @@ sudo tar -czf ../agentlab-data-$(date +%Y%m%d).tar.gz data 不进 `docker inspect`,`agentlab.env` 里什么都不用填。AgentLab 的容器按 `PGPASSFILE` 读它。**丢了数据目录就连不上这个库**, 所以数据目录照样要整份备份。 - 用过一次之后这个数据目录就一直是内置 PostgreSQL(记在 `.postgres/state`),之后的 `install`、`upgrade`、`start` 不用再加 - `--postgres`。 + `--postgres`。状态文件里同时记着库的容器、数据卷、网络的名字和一个部署编号,deploy.sh 起的库容器、建的数据卷和网络都带着 + `agentlab.deployment=<编号>` 的标签:之后 `agentlab.env` 里的名字和记着的对不上(比如改成了同一台机器上另一个部署的名字), + 或者同名的容器、数据卷是别的部署的,deploy.sh 照实报错、不碰它们——清空库、删容器、换镜像重建之前都核对。早先的状态文件只记了 + 模式的,第一次遇到时按当时的名字补记。 - `start` 先起库、等 `pg_isready` 通过再起服务;`stop` 先停服务再停库。`restart` 缺省只重启服务(库停着的话先把库起来), `restart --postgres` 连库一起重启(先停服务再停库,再先起库后起服务)。开机时两个容器一起被 docker 拉起来,库还没就绪时 服务按退避重试(日志里写着「应用库还没就绪……秒后重试」),最多等 90 秒(`AGENTLAB_DB_STARTUP_TIMEOUT`),不会一上来就崩。 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index bb2cd5f1..a921c824 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -597,6 +597,83 @@ stored_mode() { volume_exists() { docker volume inspect "$1" >/dev/null 2>&1; } +# --------------------------------------------------------------------------- +# 哪些是这个部署的内置库:状态文件第一次记 migrating / builtin 时一并记下容器、数据卷、网络的名字和一个部署编号(id); +# deploy.sh 起的库容器、建的数据卷和网络都打上 agentlab.deployment=。之后 agentlab.env 里的名字和记着的对不上就拒绝 +# (换了名字会去动同一台机器上另一个部署的库),容器、数据卷的部署编号对不上也拒绝,都对上了才清空库、删容器 +# --------------------------------------------------------------------------- +DEPLOY_ID="" + +# 状态文件里的一项(id、container、volume、network);读不到(权限)交给容器里的 app.ops +state_value() { + local key="$1" file="$DATA_ABS/$PG_STATE_REL" + if [ -r "$file" ]; then + sed -n "s/^$key=//p" "$file" | head -n 1 + elif [ -e "$file" ] || [ ! -x "$DATA_ABS" ] || { [ -e "$DATA_ABS/.postgres" ] && [ ! -x "$DATA_ABS/.postgres" ]; }; then + ops state --get "$key" | tail -n 1 + fi +} + +# 记下(或补记)名字;$1 给了就同时改模式。第一次写时 app.ops 生成部署编号 +record_pg_state() { + local args=(state --container "$POSTGRES_CONTAINER" --volume "$POSTGRES_VOLUME" --network "$DOCKER_NETWORK") + [ -z "${1:-}" ] || args+=(--set "$1") + ops "${args[@]}" >/dev/null || return 1 + DEPLOY_ID="$(state_value id)" + [ -n "$DEPLOY_ID" ] +} + +# 数据目录记着的名字和 agentlab.env 现在的一致才往下走。老的状态文件(只记了模式)第一次遇到时按现在的名字补记、照实打一行 +check_pg_names() { + local key want got env_key missing="" + for key in container volume network; do + case "$key" in + container) want="$POSTGRES_CONTAINER"; env_key=POSTGRES_CONTAINER ;; + volume) want="$POSTGRES_VOLUME"; env_key=POSTGRES_VOLUME ;; + network) want="$DOCKER_NETWORK"; env_key=DOCKER_NETWORK ;; + esac + got="$(state_value "$key")" + if [ -z "$got" ]; then + missing=1 + elif [ "$got" != "$want" ]; then + die "数据目录 $DATA_ABS 记着它的内置 PostgreSQL 用的是 ${got}(${env_key}),agentlab.env 现在是 ${want}。 +名字一换就会去动同一台机器上别的部署的库(或者找不到这个部署自己的),没有往下做。 + - 把 agentlab.env 里的 $env_key 改回 ${got}(没写这一项的,看 CONTAINER_NAME 是不是改过:缺省名字跟着它走); + - 确实要换名字:先 ./deploy.sh stop,docker rename / 新建好之后,把 $DATA_ABS/$PG_STATE_REL 里的 ${key}= 那一行改成新名字(要 sudo)" + fi + done + if [ -n "$missing" ] || [ -z "$(state_value id)" ]; then + say "数据目录的状态文件是早先记的(没记名字),按 agentlab.env 现在的名字补记:容器 ${POSTGRES_CONTAINER}、数据卷 ${POSTGRES_VOLUME}、网络 ${DOCKER_NETWORK}" + record_pg_state || die "没能补记数据目录的状态文件($DATA_ABS/${PG_STATE_REL})" + fi + DEPLOY_ID="$(state_value id)" +} + +volume_label() { docker volume inspect -f "{{index .Labels \"$2\"}}" "$1" 2>/dev/null || true; } + +# 数据卷的部署编号:$1=strict 时没有编号的(早先建的)也不认——清空库之前用 +check_volume_is_ours() { + volume_exists "$POSTGRES_VOLUME" || return 0 + local dep + dep="$(volume_label "$POSTGRES_VOLUME" agentlab.deployment)" + if [ -n "$dep" ] && [ "$dep" != "$DEPLOY_ID" ]; then + die "docker 数据卷 $POSTGRES_VOLUME 是另一个部署的内置 PostgreSQL 的(部署编号 ${dep},这个数据目录是 ${DEPLOY_ID}),没有碰它。 +agentlab.env 里的 POSTGRES_VOLUME 写成这个部署自己的(看 $DATA_ABS/$PG_STATE_REL 里的 volume=)" + fi + if [ -z "$dep" ] && [ "${1:-}" = strict ]; then + die "docker 数据卷 $POSTGRES_VOLUME 没有部署编号的标签,认不出是不是这个数据目录的,切换要清空它,没有动。 +确定是这个数据目录上次没切成留下的:docker volume rm ${POSTGRES_VOLUME},再运行 ./deploy.sh migrate-to-postgres" + fi +} + +ensure_volume() { + if volume_exists "$POSTGRES_VOLUME"; then + check_volume_is_ours + return 0 + fi + docker volume create --label agentlab.role=pgdata --label "agentlab.deployment=$DEPLOY_ID" "$POSTGRES_VOLUME" >/dev/null +} + resolve_mode() { local stored stored="$(stored_mode)" @@ -698,17 +775,43 @@ pg_secrets() { || die "没能在数据目录里生成 PostgreSQL 的密码文件($DATA_ABS/.postgres/)" } -check_pg_is_ours() { - local role +# 叫 POSTGRES_CONTAINER 的容器是不是这个部署的内置库:deploy.sh 起的(agentlab.role=postgres)、部署编号对得上(早先起的 +# 没有编号标签时,看它挂的密码文件是不是这个数据目录的)、挂的数据卷就是 POSTGRES_VOLUME。不是的话 PG_NOT_OURS 里是原因 +PG_NOT_OURS="" + +pg_is_ours() { + local role dep pw vol 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)" + dep="$(docker inspect -f '{{index .Config.Labels "agentlab.deployment"}}' "$POSTGRES_CONTAINER" 2>/dev/null || true)" + pw="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/run/agentlab/password"}}{{.Source}}{{end}}{{end}}' \ + "$POSTGRES_CONTAINER" 2>/dev/null || true)" + vol="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}' \ + "$POSTGRES_CONTAINER" 2>/dev/null || true)" + if [ "$role" != postgres ]; then + PG_NOT_OURS="它不是 deploy.sh 起的内置 PostgreSQL" + elif [ -n "$dep" ] && [ "$dep" != "$DEPLOY_ID" ]; then + PG_NOT_OURS="它是另一个部署的内置 PostgreSQL(部署编号 ${dep},这个数据目录是 ${DEPLOY_ID:-还没有})" + elif [ -z "$dep" ] && [ "$pw" != "$DATA_ABS/.postgres/password" ]; then + PG_NOT_OURS="它挂的密码文件是 ${pw:-(没有)},不是这个数据目录(${DATA_ABS})的,是另一个部署的内置 PostgreSQL" + elif [ "$vol" != "$POSTGRES_VOLUME" ]; then + PG_NOT_OURS="它挂的数据卷是 ${vol:-(没有)},不是 agentlab.env 里的 $POSTGRES_VOLUME" + else + PG_NOT_OURS="" + return 0 + fi + return 1 +} + +check_pg_is_ours() { + pg_is_ours || die "已经有一个叫 $POSTGRES_CONTAINER 的容器,但${PG_NOT_OURS},没有碰它。 +改一下 agentlab.env 里的 POSTGRES_CONTAINER(缺省是 -postgres),或者看 $DATA_ABS/$PG_STATE_REL 里记着的名字" } run_pg_container() { docker run -d \ --name "$POSTGRES_CONTAINER" \ --label agentlab.role=postgres \ + --label "agentlab.deployment=$DEPLOY_ID" \ --restart unless-stopped \ --network "$DOCKER_NETWORK" \ -e "POSTGRES_USER=$PG_USER" \ @@ -757,7 +860,9 @@ replace_pg_image() { # 起内置库(没有就建,停着就起),等 pg_isready 通过 start_pg() { + [ -n "$DEPLOY_ID" ] || die "内部错误:还不知道这个数据目录的部署编号(状态文件 $DATA_ABS/${PG_STATE_REL})" ensure_network + ensure_volume if container_exists "$POSTGRES_CONTAINER"; then check_pg_is_ours local cur @@ -854,9 +959,10 @@ cmd_install() { - 确定不要了:docker volume rm ${POSTGRES_VOLUME}(不可恢复),再运行; - 两份都留着:agentlab.env 里另起一个 POSTGRES_VOLUME 的名字" pg_secrets - # 先记下再起库:新数据目录里没有 SQLite 的库,没什么可「切」的;起库失败了重来也认得出数据卷是自己的 - ops state --set builtin >/dev/null || die "没能在数据目录里记下「用内置 PostgreSQL」" + # 先记下再起库:新数据目录里没有 SQLite 的库,没什么可「切」的;起库失败了重来也认得出数据卷是自己的。连同名字、部署编号 + record_pg_state builtin || die "没能在数据目录里记下「用内置 PostgreSQL」" else + check_pg_names pg_secrets fi start_pg @@ -886,6 +992,7 @@ cmd_upgrade() { say "应用库:$(mode_label)" PG_APPLY_IMAGE=1 if [ "$MODE" = builtin ]; then + check_pg_names ensure_pg_image pg_secrets start_pg @@ -973,6 +1080,11 @@ cmd_migrate_to_postgres() { - 确定不要了:docker volume rm ${POSTGRES_VOLUME}(不可恢复),再运行; - 要留着:agentlab.env 里另起一个 POSTGRES_VOLUME 的名字" fi + if [ "$state" = migrating ]; then + # 上次没切成:名字要和那次记下的一样,数据卷、库容器要是这个部署的——下面要清空那个库 + check_pg_names + check_volume_is_ours strict + fi if container_exists "$POSTGRES_CONTAINER"; then check_pg_is_ours; fi fix_data_owner ensure_pg_image @@ -986,9 +1098,11 @@ cmd_migrate_to_postgres() { backup_sqlite || migrate_rollback "SQLite 库备份失败" say "3/6 起内置 PostgreSQL" ( pg_secrets ) || migrate_rollback "没能生成内置 PostgreSQL 的密码" - ops state --set migrating >/dev/null || migrate_rollback "没能在数据目录里记下「开始切换」" + record_pg_state migrating || migrate_rollback "没能在数据目录里记下「开始切换」" ( start_pg ) || migrate_rollback "内置 PostgreSQL 没起来" - # 内置库里要是有东西,只能是上次没切成留下的半份(上面查过数据卷),清空重来 + # 内置库里要是有东西,只能是上次没切成留下的半份(上面查过名字和数据卷),清空重来。清空之前再核对一遍:库容器、数据卷都是 + # 这个部署的(start_pg 刚起的也一样) + ( check_pg_is_ours && check_volume_is_ours strict ) || migrate_rollback "库的容器或数据卷认不出是这个部署的,没有清空" 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 "没能清空内置库里上次留下的数据" @@ -1022,6 +1136,10 @@ locate_data() { elif [ -n "$EXTERNAL_DB" ]; then MODE="external" fi + # 内置库用着、或者切到一半:起停、卸载都要动库的容器,先核对名字(顺带拿到部署编号) + if [ -n "$DATA_ABS" ]; then + case "$(stored_state)" in builtin|migrating) check_pg_names ;; esac + fi } wait_app() { @@ -1125,8 +1243,10 @@ cmd_uninstall() { local src="" name pg="" if container_exists "$CONTAINER_NAME"; then check_is_agentlab - src="$(data_source_of "$CONTAINER_NAME")" fi + # 数据目录(容器实际挂的,没有容器看配置)里记着内置库的名字和部署编号:对不上的不删 + locate_data + src="$DATA_ABS" for name in "$CONTAINER_NAME" "$CONTAINER_NAME-previous"; do if container_exists "$name"; then say "删除容器 $name" @@ -1134,10 +1254,13 @@ cmd_uninstall() { 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 + if pg_is_ours; then + say "删除内置 PostgreSQL 的容器 ${POSTGRES_CONTAINER}(数据卷 $POSTGRES_VOLUME 留着)" + docker rm -f "$POSTGRES_CONTAINER" >/dev/null + pg=1 + else + warn "有一个叫 $POSTGRES_CONTAINER 的容器,但${PG_NOT_OURS}:没有删它" + fi 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" \