复核 R-d1(认库是不是自己的不够严):状态文件第一次记 migrating / builtin 时一并记下库的容器、数据卷、网络的名字和一个部署编号(app.ops state --container/--volume/--network,--get 取一项),库容器、数据卷带 agentlab.deployment=<编号> 标签(数据卷先 docker volume create 再起容器)。agentlab.env 的名字和记着的对不上就拒绝(install、upgrade、migrate、起停、状态、卸载都先核对),早先只记了模式的状态文件按当时的名字补记、照实打一行;库容器要标签和部署编号对得上(没编号的老容器看挂的密码文件是不是这个数据目录的)、挂的数据卷就是 POSTGRES_VOLUME,清空库之前数据卷还要带这个部署的编号(没编号的不清空);卸载碰上别人的库容器只提示不删。假 docker 单测:同一台机器上两个部署,改名指向对方、老状态文件补记之后,对方的库不被清空、不被删、不被重建

This commit is contained in:
ThearchyHelios
2026-10-19 16:07:03 +08:00
parent d117237bbc
commit 35bb634460
6 changed files with 359 additions and 66 deletions
+52 -15
View File
@@ -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 库,不用备份")
+30 -26
View File
@@ -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)
+109 -11
View File
@@ -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")
+28
View File
@@ -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")
+4 -1
View File
@@ -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`),不会一上来就崩。
+136 -13
View File
@@ -597,6 +597,83 @@ stored_mode() {
volume_exists() { docker volume inspect "$1" >/dev/null 2>&1; }
# ---------------------------------------------------------------------------
# 哪些是这个部署的内置库:状态文件第一次记 migrating / builtin 时一并记下容器、数据卷、网络的名字和一个部署编号(id);
# deploy.sh 起的库容器、建的数据卷和网络都打上 agentlab.deployment=<id>。之后 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(缺省是 <CONTAINER_NAME>-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(缺省是 <CONTAINER_NAME>-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" \