合入 nr8/d 的复核修复 R-d1–R-d6(内置库按部署编号与记下的名字认、起不来的库容器回退后不再重启、卸载只删自己建的网络、升级备份含记录表、dev.sh 读 .env 的写法与数据目录、sudo 下的 ~ 按 SUDO_USER 展开)

This commit is contained in:
ThearchyHelios
2026-10-19 16:19:44 +08:00
8 changed files with 595 additions and 104 deletions
+2 -1
View File
@@ -53,7 +53,8 @@
# ---- 开发用 PostgreSQL(./scripts/dev.sh --postgres)----
# 这几项只有 dev.sh 读:写成环境变量或写在这个 .env 里都行(环境变量优先)。数据在 docker 数据卷里,
# 密码第一次自动生成在 data/.postgres-dev/,dev.sh 照它设 AGENTLAB_DATABASE_URL(盖过上面写的)
# 密码第一次自动生成在数据目录(和后端一样照 AGENTLAB_DATA_DIR,缺省 data/)的 .postgres-dev/ 里,
# dev.sh 照它设 AGENTLAB_DATABASE_URL(盖过上面写的)
# AGENTLAB_DEV_PG_CONTAINER=agentlab-dev-postgres
# AGENTLAB_DEV_PG_PORT=55432
# AGENTLAB_DEV_PG_VOLUME=agentlab-dev-pgdata
+75 -22
View File
@@ -7,9 +7,12 @@
连库时读,PGPASSFILE 指过来)。已有就不动;密码不打印、不进任何参数和日志
state 数据目录用的是哪种库:没切过是 sqlite;--set builtin 记成「已切到内置 PostgreSQL」(deploy.sh 只在导入、
核对都通过之后才记,记下之前出错就还是 sqlite);migrating 是「开始往内置库里导了、还没切成」——
用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的
backup-sqlite 拷一份 agentlab.db、checkpoints.db 到 <数据目录>/backups/sqlite/<时间>/(SQLite 的备份接口,一致的一份),
只留最近 --keep 份
用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的。
--container / --volume / --network 记下这个数据目录的内置库用的名字;第一次记时生成部署编号 id
(deploy.sh 把它打在库的容器、数据卷、网络的标签上,按它认哪些是这个部署的)。--get 键 打印一项
backup-sqlite 拷一份 agentlab.db、checkpoints.db 和填报的记录表 records/**/*.db 到 <数据目录>/backups/sqlite/<时间>/
(SQLite 的备份接口,各自一致的一份,记录表保持 records/ 下的相对路径),只留最近 --keep 份。上传原件、
上传表格的版本库、工件、附件、沙箱工作区不在里面:那些要整个数据目录一起备份
backup-dir 建(或收紧)备份目录:从 backups/ 起每一层 0700(pg_dump 写进去之前用)
prune 目录里按名字排序只留最近 --keep 个(pg_dump 的备份用)
@@ -62,28 +65,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
@@ -142,18 +173,33 @@ def prune(folder: Path, keep: int) -> list[Path]:
return gone
def _sqlite_sources(data: Path) -> list[Path]:
"""要备份的 SQLite 文件(相对数据目录):应用库、断点库,和填报的记录表(records/ 下,原地追加写,开着服务直接拷可能拷到
写了一半的,所以也走备份接口)。"""
found = [Path(name) for name in ("agentlab.db", "checkpoints.db") if (data / name).is_file()]
records = data / "records"
if records.is_dir():
found += sorted(p.relative_to(data) for p in records.rglob("*.db") if p.is_file())
return found
def backup_sqlite(data: Path, *, keep: int) -> Path | None:
"""agentlab.db、checkpoints.db 拷到 backups/sqlite/<时间>/。两个都没有(库在 PostgreSQL)交回 None。"""
sources = [p for p in (data / "agentlab.db", data / "checkpoints.db") if p.is_file()]
if not sources:
"""agentlab.db、checkpoints.db、records/**/*.db 拷到 backups/sqlite/<时间>/(相对路径照旧)。应用库和断点库都没有(库在
PostgreSQL)交回 None——记录表那时由整目录备份管。"""
sources = _sqlite_sources(data)
if not any(p.parent == Path(".") for p in sources):
return None
with _private_umask():
root = backup_dir(data, "sqlite")
target = root / _stamp()
target.mkdir(exist_ok=True)
target.chmod(BACKUP_DIR_MODE)
for src in sources:
_copy_sqlite(src, target / src.name)
for rel in sources:
dst = target / rel
for folder in reversed(dst.relative_to(target).parents[:-1]):
(target / folder).mkdir(exist_ok=True)
(target / folder).chmod(BACKUP_DIR_MODE)
_copy_sqlite(data / rel, dst)
prune(root, keep)
return target
@@ -171,6 +217,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 +236,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 库,不用备份")
+48 -28
View File
@@ -11,6 +11,7 @@ FAKE_COPY_FAIL 成功或失败。
FAKE_PSQL_FAIL=1 docker exec … psql 失败
FAKE_UNHEALTHY_ENV=<变量名> 带这个环境变量(-e)起的容器健康检查不过
FAKE_UNHEALTHY_IMAGE=<镜像> 用这个镜像起的容器健康检查不过
FAKE_RESTARTING_IMAGE=<镜像> 用这个镜像起的容器一直在重启(restarting)
"""
from __future__ import annotations
@@ -18,6 +19,7 @@ from __future__ import annotations
import json
import os
import platform
import re
import sys
from pathlib import Path
@@ -40,9 +42,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 +87,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,16 +272,30 @@ 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, {})
flapping = os.environ.get("FAKE_RESTARTING_IMAGE") == image
state["containers"][name] = {
"image": image, "state": "running", "env": opts["env"], "labels": opts["labels"],
"mounts": opts["mounts"], "ports": opts["ports"], "network": network or "bridge",
"image": image, "state": "restarting" if flapping else "running", "env": opts["env"],
"labels": opts["labels"], "mounts": opts["mounts"], "ports": opts["ports"], "network": network or "bridge",
"restart": opts.get("--restart", "no"),
}
save(state)
print("f" * 64)
return 0
if cmd == "update":
name = args[-1]
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
for i, a in enumerate(args[:-1]):
if a.startswith("--restart="):
c["restart"] = a.split("=", 1)[1]
elif a == "--restart":
c["restart"] = args[i + 1]
save(state)
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:
@@ -331,31 +353,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)
+195 -17
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:
@@ -292,6 +301,27 @@ def test_more_than_one_worker_needs_postgres(tmp_path):
assert deploy.containers()[APP]["state"] == "running", "内置 PostgreSQL 时可以多进程"
def test_tilde_in_data_dir_is_the_sudo_users_home(tmp_path):
"""复核 R-d6:DATA_DIR=~/… 用 sudo 运行时按 SUDO_USER 的家目录展开(getent passwd 取),不是 /root;展开后的绝对路径打印出来。"""
deploy = Deploy(tmp_path, env_lines=["DATA_DIR=~/agentlab_data"])
alice = tmp_path / "home" / "alice"
alice.mkdir(parents=True)
root_home = tmp_path / "root"
root_home.mkdir()
getent = tmp_path / "bin" / "getent"
getent.write_text(f'#!/bin/sh\n[ "$1" = passwd ] && [ "$2" = alice ] && echo "alice:x:1000:1000::{alice}:/bin/bash"\n')
getent.chmod(0o755)
out = deploy("install", HOME=str(root_home), SUDO_USER="alice")
target = (alice / "agentlab_data").resolve()
assert target.is_dir() and not (root_home / "agentlab_data").exists()
assert f"数据目录:{target}(DATA_DIR=~/agentlab_data,~ 是 {alice})" in out.stdout
assert deploy.containers()[APP]["mounts"]["/data"] == str(target)
# 取不到 SUDO_USER 的家目录:退回 $HOME
other = Deploy(tmp_path / "other", env_lines=["DATA_DIR=~/agentlab_data"], name="agl-b")
other("install", HOME=str(root_home), SUDO_USER="nobody-here")
assert (root_home / "agentlab_data").is_dir()
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)
@@ -408,9 +438,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(), "别人的留着,自己的删掉"
# --------------------------------------------------------------------------
@@ -551,6 +581,30 @@ def test_a_failed_copy_leaves_the_sqlite_service_as_it_was_and_rerunning_works(d
assert deploy.mode() == "builtin"
def test_a_database_that_keeps_restarting_is_stopped_and_left_without_a_restart_policy(deploy):
"""复核 R-d2:库的镜像起不来、容器一遍遍重启(restarting)时,回退要真停下它、关掉重启策略;再起时恢复。"""
_sqlite_deployment(deploy)
(deploy.pkg / "postgres-image.tar").write_text(f"alpine:latest \n{PG_IMAGE} \n")
out = deploy("migrate-to-postgres", ok=False, DEPLOY_POSTGRES_IMAGE="alpine:latest", FAKE_RESTARTING_IMAGE="alpine:latest")
assert "没有切换,仍用 SQLite" in out.stderr
pg = deploy.containers()[PG]
assert pg["state"] == "exited" and pg["restart"] == "no", "停下了、不会再被拉起来"
calls = deploy.calls()
assert index_of(calls, lambda a: a == ["update", "--restart=no", PG]) < index_of(calls, is_cmd("stop", PG))
# 换回能起的镜像重跑:库的容器按新镜像重建,重启策略照常
deploy("migrate-to-postgres")
assert deploy.containers()[PG]["restart"] == "unless-stopped" and deploy.mode() == "builtin"
# ./deploy.sh stop 同一个函数:在重启的也停
state = deploy.state()
state["containers"][PG]["state"] = "restarting"
deploy.state_file.write_text(json.dumps(state))
deploy("stop")
assert deploy.containers()[PG]["state"] == "exited"
deploy("start")
calls = deploy.calls()
assert index_of(calls, lambda a: a == ["update", "--restart", "unless-stopped", PG]) < index_of(calls, is_cmd("start", PG))
def test_a_stopped_service_stays_stopped_after_a_failed_migration(deploy):
_sqlite_deployment(deploy)
deploy("stop")
@@ -579,7 +633,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 +647,124 @@ 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_uninstall_removes_only_its_own_network(tmp_path):
"""复核 R-d3:网络带着部署编号的标签,卸载时只删自己建的;早先建的没标签,按数据目录里记着的网络名认。"""
a = Deploy(tmp_path / "a")
a("install", "--postgres")
dep = a.state_values()["id"]
assert a.state()["networks"][NET] == {"agentlab.role": "network", "agentlab.deployment": dep}
# 别人建的、名字正好一样的网络(没有这个部署的标签):不删
state = a.state()
state["networks"][NET] = {"agentlab.role": "network", "agentlab.deployment": "someone-else"}
a.state_file.write_text(json.dumps(state))
out = a("uninstall")
assert NET in a.state()["networks"] and "不是这个部署建的" in out.stdout
# 早先建的、没标签的:数据目录里记着这个名字就是自己的
state = a.state()
state["networks"][NET] = {}
a.state_file.write_text(json.dumps(state))
a("install")
a("uninstall")
assert NET not in a.state()["networks"]
# 没标签、数据目录里也没记着的(比如这是个 SQLite 部署,同名网络是别人的):不删
b = Deploy(tmp_path / "b", name="agl-b", share=a)
b("install")
state = b.state()
state["networks"]["agl-b-net"] = {}
b.state_file.write_text(json.dumps(state))
b("uninstall")
assert "agl-b-net" in b.state()["networks"]
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")
@@ -662,7 +834,7 @@ def test_env_example_lists_every_database_pool_and_multiprocess_setting():
names = {f"AGENTLAB_{name.upper()}" for name in Settings.model_fields if name.startswith(prefixes)}
assert {"AGENTLAB_DATABASE_URL", "AGENTLAB_DATABASE_DIRECT_URL", "AGENTLAB_DB_PGBOUNCER", "AGENTLAB_WORKERS",
"AGENTLAB_WORKER_LEASE_S", "AGENTLAB_DB_STARTUP_TIMEOUT", "AGENTLAB_SCHEDULER_TICK_S"} <= names
dev = set(re.findall(r"dev_pg_setting (AGENTLAB_DEV_PG_[A-Z_]+)", (REPO / "scripts" / "dev.sh").read_text()))
dev = set(re.findall(r"dotenv_setting (AGENTLAB_DEV_PG_[A-Z_]+)", (REPO / "scripts" / "dev.sh").read_text()))
assert len(dev) == 4
example = (REPO / ".env.example").read_text(encoding="utf-8")
listed = set(re.findall(r"^# (AGENTLAB_[A-Z_]+)=", example, re.M))
@@ -676,9 +848,12 @@ def test_dev_script_reads_its_postgres_settings_from_dotenv(tmp_path):
(root / "scripts").mkdir(parents=True)
(root / "frontend" / "node_modules").mkdir(parents=True)
shutil.copy(REPO / "scripts" / "dev.sh", root / "scripts" / "dev.sh")
(root / "backend").symlink_to(REPO / "backend")
(root / ".env").write_text("AGENTLAB_DEV_PG_CONTAINER=from-root-env\nAGENTLAB_DEV_PG_PORT=55491\n"
"AGENTLAB_DEV_PG_VOLUME='quoted-vol' \n")
(root / ".env").write_text("AGENTLAB_DEV_PG_CONTAINER=from-root-env\nAGENTLAB_DEV_PG_PORT = 55491 # 注释\n"
"export AGENTLAB_DEV_PG_VOLUME = 'quoted-vol' \nAGENTLAB_DATA_DIR=from-root\n")
# backend/.env 比 .env 优先(同后端):数据目录写相对路径,按后端的工作目录 backend/ 算
(root / "backend").mkdir()
(root / "backend" / ".env").write_text("AGENTLAB_DATA_DIR = ../devdata\n")
(root / "backend" / "app").symlink_to(REPO / "backend" / "app")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
log = tmp_path / "docker.log"
@@ -688,7 +863,7 @@ def test_dev_script_reads_its_postgres_settings_from_dotenv(tmp_path):
'container) exit 1 ;; run) exit 1 ;; esac\nexit 0\n')
for f in bin_dir.iterdir():
f.chmod(0o755)
env = {"PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(tmp_path), "AGENTLAB_DATA_DIR": str(tmp_path / "data"),
env = {"PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(tmp_path),
"AGENTLAB_DEV_PG_IMAGE": "postgres:from-env", "AGENTLAB_PORT": "18799", "LANG": "C.UTF-8"}
out = subprocess.run(["bash", str(root / "scripts" / "dev.sh"), "--postgres"], capture_output=True, text=True, env=env,
timeout=60)
@@ -697,7 +872,10 @@ def test_dev_script_reads_its_postgres_settings_from_dotenv(tmp_path):
assert "--name from-root-env" in run and "127.0.0.1:55491:5432" in run, ".env 里的生效"
assert "quoted-vol:/var/lib/postgresql/data" in run, "引号和行尾空白去掉"
assert run.endswith("postgres:from-env"), "环境变量优先"
(root / "backend").unlink()
data = root / "backend" / ".." / "devdata"
assert f"{data}/.postgres-dev/password:/run/agentlab/password:ro" in run, "密码放在后端实际用的数据目录里"
assert (root / "devdata" / ".postgres-dev" / "pgpass").exists()
(root / "backend" / "app").unlink()
def test_dev_script_refuses_unknown_arguments_and_documents_postgres():
+60
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")
@@ -107,6 +135,38 @@ def test_backup_sqlite_copies_a_consistent_snapshot_including_the_wal(tmp_path,
assert len(list(target.iterdir())) == 2 and not (target / "20200101T000000Z").exists()
def test_backup_sqlite_also_copies_the_record_tables(tmp_path):
"""复核 R-d4:填报的记录表 records/**/*.db(原地追加写的 SQLite)也用备份接口拷进同一份备份,相对路径照旧。"""
sqlite3.connect(tmp_path / "agentlab.db").close()
(tmp_path / "records" / "nested").mkdir(parents=True)
rec = sqlite3.connect(tmp_path / "records" / "sales.v1.db")
rec.execute("PRAGMA journal_mode=WAL")
rec.execute("CREATE TABLE records (v TEXT)")
rec.execute("INSERT INTO records VALUES ('还在 WAL 里的一行')")
rec.commit()
sqlite3.connect(tmp_path / "records" / "nested" / "visits.v2.db").close()
(tmp_path / "records" / "notes.txt").write_text("不是库,不拷")
try:
target = ops.backup_sqlite(tmp_path, keep=5)
finally:
rec.close()
copied = sorted(str(p.relative_to(target)) for p in target.rglob("*") if p.is_file())
assert copied == ["agentlab.db", "records/nested/visits.v2.db", "records/sales.v1.db"]
db = sqlite3.connect(target / "records" / "sales.v1.db")
assert db.execute("SELECT v FROM records").fetchall() == [("还在 WAL 里的一行",)]
db.close()
for folder in (target / "records", target / "records" / "nested"):
assert _mode(folder) == 0o700
assert _mode(target / "records" / "sales.v1.db") == 0o600
def test_record_tables_alone_are_left_to_the_whole_directory_backup(tmp_path):
"""库在 PostgreSQL(没有 agentlab.db、checkpoints.db)时不做这份备份:记录表跟着整个数据目录备份。"""
(tmp_path / "records").mkdir()
sqlite3.connect(tmp_path / "records" / "sales.v1.db").close()
assert ops.backup_sqlite(tmp_path, keep=5) is None
def test_backup_dir_creates_and_tightens_every_level(tmp_path, capsys):
(tmp_path / "backups").mkdir(mode=0o755)
(tmp_path / "backups").chmod(0o755)
+13 -3
View File
@@ -66,10 +66,12 @@ deploy.sh 依次:检查 docker 和架构 → 导入镜像(`docker load -i im
另用 `pg_dump` 备份数据库。
`./deploy.sh upgrade` 换版本之前会自动备份一次库:SQLite 拷到数据目录的 `backups/sqlite/<时间>/`(SQLite 的备份接口,
一致的一份),内置 PostgreSQL 用 `pg_dump` 存到 `backups/postgres/`,各只留最近 5 份(`agentlab.env` 里 `BACKUP_KEEP=` 改)。
各自一致的一份:`agentlab.db`、`checkpoints.db`,和填报的记录表 `records/` 下的各个 `.db`,相对路径照旧),内置 PostgreSQL
用 `pg_dump` 存到 `backups/postgres/`(整个库,含断点),各只留最近 5 份(`agentlab.env` 里 `BACKUP_KEEP=` 改)。
备份里是整个库(含加密存着的密钥),所以 `backups/` 和下面每一层目录都是 0700、备份文件 0600,属主同数据目录(uid 10001):
宿主机上要看、要拷走都得用 sudo(`sudo ls -l data/backups/sqlite/`、`sudo cp -a data/backups /别处/`)。
这只是升级的保险,不代替下面的整目录备份:工件、上传文件和 `.secret_key` 不在里面。
这只是升级的保险,不代替下面的整目录备份:上传的原件和上传表格的各版本库、工件库、表单附件、知识库、沙箱工作区、加密密钥
`.secret_key` 都不在里面(用内置 PostgreSQL 时记录表也不在 `pg_dump` 里,它们是数据目录里的文件)。
用 SQLite 的部署要从 `backups/sqlite/` 恢复(以缺省的 `./data` 为例,`<时间>` 换成目录名):
@@ -77,11 +79,16 @@ deploy.sh 依次:检查 docker 和架构 → 导入镜像(`docker load -i im
./deploy.sh stop
sudo rm -f data/agentlab.db-wal data/agentlab.db-shm data/checkpoints.db-wal data/checkpoints.db-shm
sudo cp -p data/backups/sqlite/<时间>/agentlab.db data/backups/sqlite/<时间>/checkpoints.db data/ # -p:属主仍是 10001
sudo cp -rp data/backups/sqlite/<时间>/records/. data/records/ # 记录表(有的话),同样先删掉它们的 -wal、-shm
./deploy.sh start
```
`-wal`、`-shm` 要先删:留着的话 SQLite 会把它们当成换回来的库的一部分。
`DATA_DIR` 写成 `~/…` 时,`~` 是运行 deploy.sh 的那个人的家目录:用 `sudo ./deploy.sh` 运行时按调用 sudo 的用户
(`SUDO_USER`)的家目录展开,不是 `/root`。`install`、`upgrade` 开头会打印展开后的绝对路径(`==> 数据目录:…`),对一眼;
不放心就直接写绝对路径。
deploy.sh 会把数据目录的属主改成容器里的用户(uid 10001),所以备份、删除要用 sudo。
停下来备份最稳妥(以默认的 `./data` 为例,在包目录里执行):
@@ -119,7 +126,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`),不会一上来就崩。
+183 -21
View File
@@ -215,15 +215,29 @@ read_env() {
case "$ENABLE_MICROVM" in auto|0|1) ;; *) die "ENABLE_MICROVM 只能是 auto、0 或 1:$ENABLE_MICROVM" ;; esac
# 相对路径以 deploy.sh 所在的目录为准,和从哪里运行无关
# 下面比较的就是字面上的 ~,不需要展开
# 下面比较的就是字面上的 ~,不需要展开。~ 是运行 deploy.sh 的那个人的家目录:sudo ./deploy.sh 时 $HOME 常是 /root,
# 按 SUDO_USER 的家目录展开(见 invoking_home)
INVOKER_HOME="$(invoking_home)"
TILDE_NOTE=""
# shellcheck disable=SC2088
case "$DATA_DIR" in
/*) DATA_PATH="$DATA_DIR" ;;
"~"|"~/"*) DATA_PATH="$HOME${DATA_DIR#\~}" ;;
"~"|"~/"*)
DATA_PATH="$INVOKER_HOME${DATA_DIR#\~}"
TILDE_NOTE="(DATA_DIR=${DATA_DIR},~ 是 ${INVOKER_HOME})" ;;
*) DATA_PATH="$HERE/${DATA_DIR#./}" ;;
esac
}
# 家目录:用 sudo 运行时是调用 sudo 的那个人的(getent passwd 取),取不到退回 $HOME
invoking_home() {
local home=""
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ] && command -v getent >/dev/null 2>&1; then
home="$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6 || true)"
fi
printf '%s' "${home:-$HOME}"
}
# ---------------------------------------------------------------------------
# docker 与镜像
# ---------------------------------------------------------------------------
@@ -307,7 +321,8 @@ prepare_data() {
/|/bin|/boot|/dev|/etc|/home|/lib|/opt|/proc|/root|/sbin|/srv|/sys|/usr|/var)
die "DATA_DIR 不能是系统目录:$DATA_ABS" ;;
esac
[ "$DATA_ABS" != "$HOME" ] || die "DATA_DIR 不能是家目录本身:$DATA_ABS"
[ "$DATA_ABS" != "$HOME" ] && [ "$DATA_ABS" != "${INVOKER_HOME:-$HOME}" ] || die "DATA_DIR 不能是家目录本身:$DATA_ABS"
say "数据目录:${DATA_ABS}${TILDE_NOTE:-}"
}
# 已经有同名容器时:它得是 AgentLab 的,数据目录也得和配置的是同一个。
@@ -597,6 +612,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)"
@@ -689,7 +781,19 @@ ensure_pg_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
docker network create --label agentlab.role=network --label "agentlab.deployment=$DEPLOY_ID" "$DOCKER_NETWORK" >/dev/null
}
# 网络是不是这个部署建的:标签和部署编号对得上;早先建的没有标签,按数据目录里记着的网络名认
network_is_ours() {
local role dep
role="$(docker network inspect -f '{{index .Labels "agentlab.role"}}' "$DOCKER_NETWORK" 2>/dev/null || true)"
dep="$(docker network inspect -f '{{index .Labels "agentlab.deployment"}}' "$DOCKER_NETWORK" 2>/dev/null || true)"
if [ -n "$role$dep" ]; then
[ "$role" = network ] && [ -n "$DEPLOY_ID" ] && [ "$dep" = "$DEPLOY_ID" ]
else
[ -n "$DATA_ABS" ] && [ "$(state_value network)" = "$DOCKER_NETWORK" ]
fi
}
pg_secrets() {
@@ -698,17 +802,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 +887,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
@@ -768,6 +900,8 @@ start_pg() {
fi
if [ "$(container_state "$POSTGRES_CONTAINER")" != running ]; then
say "启动内置 PostgreSQL(${POSTGRES_CONTAINER})"
# 回退时关掉过重启策略(stop_pg --no-restart),起的时候恢复
docker update --restart unless-stopped "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
docker start "$POSTGRES_CONTAINER" >/dev/null || die "内置 PostgreSQL 的容器起不来,见上面 docker 的报错"
fi
else
@@ -780,9 +914,15 @@ start_pg() {
fi
}
# 在跑的、在反复重启的(镜像起不来、--restart unless-stopped 一遍遍拉)都停。--no-restart:再关掉重启策略(回退时用:
# 一个起不来的库容器不该在后台一直重启),下次 start_pg 起它时恢复
stop_pg() {
container_exists "$POSTGRES_CONTAINER" || return 0
[ "$(container_state "$POSTGRES_CONTAINER")" = running ] || return 0
[ "${1:-}" != --no-restart ] || docker update --restart=no "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
case "$(container_state "$POSTGRES_CONTAINER")" in
running|restarting) ;;
*) return 0 ;;
esac
say "停止内置 PostgreSQL(${POSTGRES_CONTAINER},数据保留)"
docker stop "$POSTGRES_CONTAINER" >/dev/null
}
@@ -854,9 +994,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 +1027,7 @@ cmd_upgrade() {
say "应用库:$(mode_label)"
PG_APPLY_IMAGE=1
if [ "$MODE" = builtin ]; then
check_pg_names
ensure_pg_image
pg_secrets
start_pg
@@ -933,7 +1075,7 @@ migrate_rollback() {
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
fi
fi
stop_pg
stop_pg --no-restart
die "没有切换,仍用 SQLite(数据目录里的 agentlab.db 没动过)。照上面的报错处理好之后重新运行 ./deploy.sh migrate-to-postgres,
重复执行是安全的:内置库里这次导了一半的会先清空再导"
}
@@ -973,6 +1115,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 +1133,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 +1171,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 +1278,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,14 +1289,21 @@ 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" \
|| warn "docker 网络 $DOCKER_NETWORK 没删掉(可能还有别的容器连着)"
if network_is_ours; then
docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 && say "删除 docker 网络 $DOCKER_NETWORK" \
|| warn "docker 网络 $DOCKER_NETWORK 没删掉(可能还有别的容器连着)"
else
echo "docker 网络 $DOCKER_NETWORK 不是这个部署建的(没有这个部署的标签,数据目录里也没记着它),没有删"
fi
fi
if [ -n "$PURGE_IMAGE" ]; then
if image_present; then
+19 -12
View File
@@ -73,19 +73,24 @@ fi
export AGENTLAB_PORT="$API_PORT"
# ---- --postgres:本地开发用的 PostgreSQL ----
# 这几项(AGENTLAB_DEV_PG_*)只有这个脚本用。和后端的配置一样认 .env:环境变量 > backend/.env > .env > 缺省(后端的 settings
# 读这两个文件,后一个优先)。不 source:.env 是给后端按 KEY=VALUE 读的,不是 shell(值里的 $、空格、反引号不该被执行)
# 这几项(AGENTLAB_DEV_PG_*,还有数据目录 AGENTLAB_DATA_DIR)和后端的配置一样认 .env:环境变量 > backend/.env > .env > 缺省
# (后端的 settings 读这两个文件,后一个优先)。写法照后端的 dotenv:KEY=value、KEY = value、export KEY=value、引号、行尾 # 注释。
# 不 source:.env 是给后端按 KEY=VALUE 读的,不是 shell(值里的 $、空格、反引号不该被执行)
dotenv_value() {
local key="$1" file line val="" found=""
local key="$1" file line name val="" found=""
for file in "$ROOT/.env" "$ROOT/backend/.env"; do
[ -f "$file" ] || continue
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line#export }"
case "$line" in
"$key="*) val="${line#*=}"; found=1 ;;
esac
case "$line" in *=*) ;; *) continue ;; esac
name="${line%%=*}"
name="${name%"${name##*[![:space:]]}"}"
[ "$name" = "$key" ] || continue
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
found=1
done < "$file"
done
[ -n "$found" ] || return 1
@@ -96,18 +101,20 @@ dotenv_value() {
esac
printf '%s' "$val"
}
dev_pg_setting() {
dotenv_setting() {
local value
value="$(printenv "$1" 2>/dev/null || true)"
[ -n "$value" ] || value="$(dotenv_value "$1" || true)"
printf '%s' "${value:-$2}"
}
if [ -n "$WITH_PG" ]; then
PG_CONTAINER="$(dev_pg_setting AGENTLAB_DEV_PG_CONTAINER agentlab-dev-postgres)"
PG_PORT="$(dev_pg_setting AGENTLAB_DEV_PG_PORT 55432)"
PG_VOLUME="$(dev_pg_setting AGENTLAB_DEV_PG_VOLUME agentlab-dev-pgdata)"
PG_IMAGE="$(dev_pg_setting AGENTLAB_DEV_PG_IMAGE postgres:16)"
DEV_DATA="${AGENTLAB_DATA_DIR:-$ROOT/data}"
PG_CONTAINER="$(dotenv_setting AGENTLAB_DEV_PG_CONTAINER agentlab-dev-postgres)"
PG_PORT="$(dotenv_setting AGENTLAB_DEV_PG_PORT 55432)"
PG_VOLUME="$(dotenv_setting AGENTLAB_DEV_PG_VOLUME agentlab-dev-pgdata)"
PG_IMAGE="$(dotenv_setting AGENTLAB_DEV_PG_IMAGE postgres:16)"
# 密码文件放在后端实际用的数据目录里:和后端一样读 AGENTLAB_DATA_DIR,相对路径按后端的工作目录(backend/)算
DEV_DATA="$(dotenv_setting AGENTLAB_DATA_DIR "$ROOT/data")"
case "$DEV_DATA" in /*) ;; *) DEV_DATA="$ROOT/backend/$DEV_DATA" ;; esac
case "$PG_PORT" in ''|*[!0-9]*) echo "AGENTLAB_DEV_PG_PORT 要是端口号:$PG_PORT"; exit 2 ;; esac
command -v docker >/dev/null || { echo "--postgres 要用 docker,没找到它"; exit 1; }
docker version --format '{{.Server.Version}}' >/dev/null 2>&1 || { echo "连不上 docker 服务,先把 Docker 启动起来"; exit 1; }