diff --git a/backend/app/ops.py b/backend/app/ops.py index 5694a489..00be80cf 100644 --- a/backend/app/ops.py +++ b/backend/app/ops.py @@ -10,12 +10,17 @@ 用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的 backup-sqlite 拷一份 agentlab.db、checkpoints.db 到 <数据目录>/backups/sqlite/<时间>/(SQLite 的备份接口,一致的一份), 只留最近 --keep 份 + backup-dir 建(或收紧)备份目录:从 backups/ 起每一层 0700(pg_dump 写进去之前用) prune 目录里按名字排序只留最近 --keep 个(pg_dump 的备份用) + +备份里是整个库(含加密存的密钥、业务数据),目录一律 0700、文件 0600,属主是容器里的用户(同数据目录);宿主机上要读用 sudo。 """ from __future__ import annotations import argparse +import contextlib +import os import secrets import sqlite3 import sys @@ -28,6 +33,10 @@ from app.core import atomic POSTGRES_DIR = ".postgres" STATE_FILE = "state" MODES = ("sqlite", "builtin", "migrating") +BACKUPS_DIR = "backups" +#: 备份的目录、文件的权限:只有属主(容器里的用户)能读 +BACKUP_DIR_MODE = 0o700 +BACKUP_FILE_MODE = 0o600 def _stamp() -> str: @@ -77,16 +86,42 @@ def write_state(data: Path, mode: str) -> None: atomic.write_text(root / STATE_FILE, f"mode={mode}\nsince={datetime.now(timezone.utc).isoformat()}\n", mode=0o644) +@contextlib.contextmanager +def _private_umask(): + """这一段里新建的文件、目录别人读不到(SQLite 备份时建的 -journal 也一样),出来恢复原来的 umask。""" + old = os.umask(0o077) + try: + yield + finally: + os.umask(old) + + +def backup_dir(data: Path, *parts: str) -> Path: + """<数据目录>/backups/:从 backups/ 起每一层建出来(已有的收紧)成 0700,交回最里面那层。""" + folder = data / BACKUPS_DIR + levels = [folder] + for part in parts: + folder = folder / part + levels.append(folder) + for level in levels: + level.mkdir(exist_ok=True) + level.chmod(BACKUP_DIR_MODE) + return folder + + def _copy_sqlite(src: Path, dst: Path) -> None: # 读写打开、只许读:WAL 模式的库用 mode=ro 打开,关掉时会留下它自己建的 -wal(同 db/to_postgres.snapshot) conn = sqlite3.connect(src) conn.execute("PRAGMA query_only = 1") try: + # 先以 0600 建出空文件再交给 SQLite 写:从有这个文件起就只有属主读得到 + os.close(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, BACKUP_FILE_MODE)) out = sqlite3.connect(dst) try: conn.backup(out) finally: out.close() + dst.chmod(BACKUP_FILE_MODE) finally: conn.close() @@ -112,11 +147,13 @@ def backup_sqlite(data: Path, *, keep: int) -> Path | None: sources = [p for p in (data / "agentlab.db", data / "checkpoints.db") if p.is_file()] if not sources: return None - root = data / "backups" / "sqlite" - target = root / _stamp() - target.mkdir(parents=True, exist_ok=True) - for src in sources: - _copy_sqlite(src, target / src.name) + 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) prune(root, keep) return target @@ -137,6 +174,9 @@ def main(argv: list[str] | None = None) -> int: b = sub.add_parser("backup-sqlite") b.add_argument("--data", default="/data") b.add_argument("--keep", type=int, default=5) + d = sub.add_parser("backup-dir") + d.add_argument("--data", default="/data") + d.add_argument("parts", nargs="*", help="backups/ 下面的几层,比如 postgres") r = sub.add_parser("prune") r.add_argument("--dir", required=True) r.add_argument("--keep", type=int, default=5) @@ -153,6 +193,8 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "backup-sqlite": target = backup_sqlite(Path(args.data), keep=args.keep) print(target if target is not None else "没有 SQLite 库,不用备份") + elif args.command == "backup-dir": + print(backup_dir(Path(args.data), *args.parts)) elif args.command == "prune": for gone in prune(Path(args.dir), args.keep): print(f"删掉旧备份 {gone.name}") diff --git a/backend/tests/fake_docker.py b/backend/tests/fake_docker.py index c7993abf..9ff3cd24 100644 --- a/backend/tests/fake_docker.py +++ b/backend/tests/fake_docker.py @@ -154,7 +154,7 @@ def run_oneoff(state: dict, opts: dict, image: str, command: list[str]) -> int: from app import ops rest = [to_host(a, data) for a in command[2:]] - if rest and rest[0] in ("postgres-secrets", "state", "backup-sqlite") and "--data" not in rest: + if rest and rest[0] in ("postgres-secrets", "state", "backup-sqlite", "backup-dir") and "--data" not in rest: rest[1:1] = ["--data", data or "/data"] return ops.main(rest) if entry == "python" and command[:3] == ["-m", "app.db", "copy-to-postgres"]: @@ -171,12 +171,17 @@ def run_oneoff(state: dict, opts: dict, image: str, command: list[str]) -> int: if not a.startswith("-"): Path(to_host(a, data)).mkdir(parents=True, exist_ok=True) return 0 - if entry == "pg_dump": + if entry == "sh" and command[:1] == ["-c"] and 'pg_dump "$@"' in command[1]: + # sh -c '<脚本>' pg_dump <参数…>:照脚本里的 umask 建文件 + umask = 0o077 if "umask 077" in command[1] else 0o022 if os.environ.get("FAKE_PGDUMP_FAIL"): print("pg_dump: fake 的失败", file=sys.stderr) return 1 - out = command[command.index("-f") + 1] - Path(to_host(out, data)).write_bytes(b"PGDMP fake") + args = command[3:] + out = Path(to_host(args[args.index("-f") + 1], data)) + fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o666 & ~umask) + with os.fdopen(fd, "wb") as fh: + fh.write(b"PGDMP fake") return 0 unknown(["run", "--rm", image, *command]) return 99 diff --git a/backend/tests/test_deploy_script.py b/backend/tests/test_deploy_script.py index 30e52c24..38d3bd7c 100644 --- a/backend/tests/test_deploy_script.py +++ b/backend/tests/test_deploy_script.py @@ -127,7 +127,8 @@ def deploy(tmp_path): # -------------------------------------------------------------------------- -@pytest.mark.parametrize("args", [["bogus"], ["start", "--postgres"], ["upgrade", "--postgres"], +@pytest.mark.parametrize("args", [["bogus"], ["start", "--postgres"], ["upgrade", "--postgres"], ["status", "--postgres"], + ["restart", "--all"], ["install", "--purge-image"], ["migrate-to-postgres", "-f"], ["status", "--tail", "3"]]) def test_unknown_arguments_are_refused_before_doing_anything(deploy, args): out = deploy(*args, ok=False) @@ -138,7 +139,8 @@ def test_unknown_arguments_are_refused_before_doing_anything(deploy, args): def test_help_lists_the_postgres_commands(deploy): out = deploy("--help") - for word in ("install [--postgres]", "migrate-to-postgres", "logs [-f] [--tail N] [--postgres]", "DEPLOY_<名字>"): + for word in ("install [--postgres]", "migrate-to-postgres", "restart [--postgres]", "logs [-f] [--tail N] [--postgres]", + "DEPLOY_<名字>"): assert word in out.stdout @@ -282,14 +284,39 @@ def test_start_stop_restart_order_the_two_containers(deploy): pg_ready = index_of(calls, lambda a: a[:1] == ["inspect"] and a[-1] == PG and ".State.Health" in a[2]) assert pg_start < pg_ready < index_of(calls, is_cmd("start", APP)), "先起库、等就绪,再起服务" - deploy("restart") + deploy("restart", "--postgres") calls = deploy.calls() order = [index_of(calls, is_cmd("stop", APP)), index_of(calls, is_cmd("stop", PG)), index_of(calls, is_cmd("start", PG)), index_of(calls, is_cmd("start", APP))] - assert order == sorted(order) + assert order == sorted(order), "--postgres:先停服务、再停库,先起库、再起服务" assert {c["state"] for c in deploy.containers().values()} == {"running"} +def test_restart_by_default_leaves_the_database_alone(deploy): + deploy("install", "--postgres") + out = deploy("restart") + calls = deploy.calls() + assert ["restart", APP] in calls + assert not any(a[:1] in (["stop"], ["start"], ["restart"]) and a[-1] == PG for a in calls), "库不跟着重启" + assert "已重启" in out.stdout + # 库停着时先把库起来,服务才连得上 + state = deploy.state() + state["containers"][PG]["state"] = "exited" + deploy.state_file.write_text(json.dumps(state)) + deploy("restart") + calls = deploy.calls() + assert index_of(calls, is_cmd("start", PG)) < index_of(calls, lambda a: a == ["restart", APP]) + assert not any(is_cmd("stop", PG)(a) for a in calls) + assert deploy.containers()[PG]["state"] == "running" + + +def test_restart_postgres_needs_the_builtin_database(deploy): + deploy("install") + out = deploy("restart", "--postgres", ok=False) + assert "没用内置 PostgreSQL" in out.stderr + assert not any(a[:1] in (["stop"], ["restart"]) for a in deploy.calls()), "什么都没动" + + def test_status_and_logs_show_the_database(deploy): deploy("install", "--postgres") out = deploy("status").stdout @@ -346,14 +373,19 @@ def test_upgrade_builtin_dumps_after_stopping_the_app_and_keeps_the_last_n(deplo deploy("upgrade", DEPLOY_BACKUP_KEEP="3") calls = deploy.calls() stop = index_of(calls, is_cmd("stop", APP)) - dump = index_of(calls, oneoff("pg_dump")) + dump = index_of(calls, oneoff("sh", "pg_dump")) new_app = index_of(calls, runs_named(APP)) assert stop < dump < new_app, "停了旧服务再备份,备份完才起新的" dump_args = calls[dump] assert dump_args[dump_args.index("--network") + 1] == NET and "PGPASSFILE=/data/.postgres/pgpass" in dump_args + assert dump_args[dump_args.index("-c") + 1].startswith("umask 077 && "), "备份文件从建出来就是 0600" + assert dump_args[dump_args.index("--user") + 1] == "10001:10001", "属主照数据目录:容器里的用户" kept = sorted(p.name for p in folder.iterdir()) assert len(kept) == 3 and kept[-1].startswith("agentlab-") and kept[-1] > "agentlab-20260103", kept assert (folder / kept[-1]).read_bytes() == b"PGDMP fake" + assert stat.S_IMODE((folder / kept[-1]).stat().st_mode) == 0o600 + for level in (deploy.data / "backups", folder): + assert stat.S_IMODE(level.stat().st_mode) == 0o700, level def test_upgrade_sqlite_backs_up_the_database_files(deploy): @@ -367,6 +399,9 @@ def test_upgrade_sqlite_backs_up_the_database_files(deploy): copy = sqlite3.connect(stamp / "agentlab.db") assert copy.execute("SELECT v FROM t").fetchall() == [("一",)] copy.close() + for level in (deploy.data / "backups", deploy.data / "backups" / "sqlite", stamp): + assert stat.S_IMODE(level.stat().st_mode) == 0o700, level + assert stat.S_IMODE((stamp / "agentlab.db").stat().st_mode) == 0o600 def test_failed_backup_aborts_the_upgrade_and_restarts_the_old_service(deploy): @@ -515,6 +550,28 @@ def test_package_script_takes_the_postgres_image_from_deploy_sh(): assert out.stdout.strip() == PG_IMAGE +def test_env_template_lists_the_postgres_settings_commented_out_with_the_defaults(): + """agentlab.env 模板末尾的「内置 PostgreSQL(可选)」:几项都注释着(不改任何人的部署),缺省值和 deploy.sh 里的一致。""" + template = (REPO / "deploy" / "agentlab.env").read_text(encoding="utf-8") + section = template[template.index("# ---- 内置 PostgreSQL(可选)----"):] + assert template.rstrip().endswith(section.rstrip()), "追加在文件末尾" + commented = dict(line[2:].split("=", 1) for line in section.splitlines() + if line.startswith("# ") and "=" in line and line[2:3].isupper()) + assert commented == {"POSTGRES_IMAGE": PG_IMAGE, "POSTGRES_CONTAINER": "agentlab-postgres", + "DOCKER_NETWORK": "agentlab-net", "POSTGRES_VOLUME": "agentlab-pgdata", "BACKUP_KEEP": "5"} + assert not any(line.split("=", 1)[0] in commented for line in template.splitlines() if not line.startswith("#")), \ + "都是注释" + + +def test_settings_written_in_agentlab_env_take_effect(tmp_path): + deploy = Deploy(tmp_path, env_lines=["POSTGRES_CONTAINER=agl-db", "DOCKER_NETWORK=agl-n", "POSTGRES_VOLUME=agl-v", + "BACKUP_KEEP=1"]) + deploy("install", "--postgres") + assert set(deploy.containers()) == {APP, "agl-db"} + assert deploy.containers()["agl-db"]["mounts"]["/var/lib/postgresql/data"] == "agl-v" + assert deploy.containers()[APP]["network"] == "agl-n" + + def test_dev_script_refuses_unknown_arguments_and_documents_postgres(): env = {**os.environ, "PATH": "/usr/bin:/bin"} out = subprocess.run(["bash", str(REPO / "scripts" / "dev.sh"), "--bogus"], capture_output=True, text=True, env=env) diff --git a/backend/tests/test_ops.py b/backend/tests/test_ops.py index 2d905c85..92c27604 100644 --- a/backend/tests/test_ops.py +++ b/backend/tests/test_ops.py @@ -95,6 +95,11 @@ def test_backup_sqlite_copies_a_consistent_snapshot_including_the_wal(tmp_path, assert copy.execute("SELECT v FROM t").fetchall() == [("还在 WAL 里",)] copy.close() assert (stamp / "checkpoints.db").exists() + # 备份里是整个库:目录只有属主进得去,文件只有属主读得到 + for level in (tmp_path / "backups", target, stamp): + assert _mode(level) == 0o700, level + assert _mode(stamp / "agentlab.db") == 0o600 and _mode(stamp / "checkpoints.db") == 0o600 + assert sorted(p.name for p in stamp.iterdir()) == ["agentlab.db", "checkpoints.db"], "没留下 -journal 之类的" # 只留最近 keep 份 for name in ("20200101T000000Z", "20200102T000000Z"): (target / name).mkdir() @@ -102,6 +107,15 @@ 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_dir_creates_and_tightens_every_level(tmp_path, capsys): + (tmp_path / "backups").mkdir(mode=0o755) + (tmp_path / "backups").chmod(0o755) + assert ops.main(["backup-dir", "--data", str(tmp_path), "postgres"]) == 0 + assert capsys.readouterr().out.strip() == str(tmp_path / "backups" / "postgres") + assert _mode(tmp_path / "backups") == 0o700, "已有的收紧" + assert _mode(tmp_path / "backups" / "postgres") == 0o700 + + def test_backup_sqlite_without_databases_does_nothing(tmp_path, capsys): assert ops.main(["backup-sqlite", "--data", str(tmp_path)]) == 0 assert "没有 SQLite 库" in capsys.readouterr().out diff --git a/deploy/README.md b/deploy/README.md index c36a842d..51dd6598 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,7 +10,7 @@ | 文件 | 用途 | | --- | --- | | `image.tar` | Docker 镜像(`docker save` 导出),镜像名 `agentlab:__AGENTLAB_VERSION__` | -| `postgres-image.tar` | 内置 PostgreSQL 的镜像(`install --postgres` 时用,见「用 PostgreSQL」)。打包时用了 `--no-postgres` 的包里没有 | +| `postgres-image.tar` | 内置 PostgreSQL 的镜像(`install --postgres` 时用,见「用 PostgreSQL」)。包因此多约 160 MB,导入后在 docker 里占约 0.7 GB;只用 SQLite 或外接 PostgreSQL 时用不上,打包时用 `--no-postgres` 就没有这个文件 | | `deploy.sh` | 一键部署脚本:安装、启动、停止、升级、卸载 | | `agentlab.env` | 配置:端口、监听地址、数据目录、模型 Key、代理、沙箱 | | `README.md` | 本说明 | @@ -67,8 +67,21 @@ deploy.sh 依次:检查 docker 和架构 → 导入镜像(`docker load -i im `./deploy.sh upgrade` 换版本之前会自动备份一次库:SQLite 拷到数据目录的 `backups/sqlite/<时间>/`(SQLite 的备份接口, 一致的一份),内置 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` 不在里面。 +用 SQLite 的部署要从 `backups/sqlite/` 恢复(以缺省的 `./data` 为例,`<时间>` 换成目录名): + +```bash +./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 +./deploy.sh start +``` + +`-wal`、`-shm` 要先删:留着的话 SQLite 会把它们当成换回来的库的一部分。 + deploy.sh 会把数据目录的属主改成容器里的用户(uid 10001),所以备份、删除要用 sudo。 停下来备份最稳妥(以默认的 `./data` 为例,在包目录里执行): @@ -107,7 +120,8 @@ sudo tar -czf ../agentlab-data-$(date +%Y%m%d).tar.gz data 所以数据目录照样要整份备份。 - 用过一次之后这个数据目录就一直是内置 PostgreSQL(记在 `.postgres/state`),之后的 `install`、`upgrade`、`start` 不用再加 `--postgres`。 -- `start`、`restart` 先起库、等 `pg_isready` 通过再起服务;`stop` 先停服务再停库。开机时两个容器一起被 docker 拉起来,库还没就绪时 +- `start` 先起库、等 `pg_isready` 通过再起服务;`stop` 先停服务再停库。`restart` 缺省只重启服务(库停着的话先把库起来), + `restart --postgres` 连库一起重启(先停服务再停库,再先起库后起服务)。开机时两个容器一起被 docker 拉起来,库还没就绪时 服务按退避重试(日志里写着「应用库还没就绪……秒后重试」),最多等 90 秒(`AGENTLAB_DB_STARTUP_TIMEOUT`),不会一上来就崩。 - 表结构和默认数据(内置模板、技能)每次启动自动补齐;几个工作进程(`AGENTLAB_WORKERS`)同时起也只做一次(库里的锁)。 - `./deploy.sh status` 显示两个容器的状态,`./deploy.sh logs --postgres` 看库的日志。 @@ -125,15 +139,17 @@ sudo tar -czf ../agentlab-data-$(date +%Y%m%d).tar.gz data 环境变量 `DEPLOY_<名字>` 盖过 `agentlab.env` 里的同名项(这几项加上 `HOST_PORT`、`BIND_ADDR`、`DATA_DIR`、`CONTAINER_NAME`), 一台机器上并排部署两套、或者测试时用。 -**备份。** `upgrade` 前自动 `pg_dump` 一份到 `backups/postgres/`。平时要手动备份(服务开着也行,`pg_dump` 读的是一致的快照): +**备份。** `upgrade` 前自动 `pg_dump` 一份到 `backups/postgres/`(0600,见「数据和备份」)。平时要手动备份(服务开着也行, +`pg_dump` 读的是一致的快照;`umask 077` 让导出的文件只有自己读得到): ```bash -docker exec agentlab-postgres pg_dump -U agentlab -d agentlab -Fc > agentlab-$(date +%Y%m%d).dump +(umask 077; docker exec agentlab-postgres pg_dump -U agentlab -d agentlab -Fc > agentlab-$(date +%Y%m%d).dump) ``` 再加上数据目录(见「数据和备份」),两样合起来才是完整的一份。 -**恢复。** 用 `pg_dump` 的备份把库换回去(以缺省的容器名为例,`<备份>` 换成文件名): +**恢复。** 用 `backups/postgres/` 里的备份把库换回去(以缺省的名字、在包目录里为例,`<备份>` 换成文件名,`sudo ls data/backups/postgres/` +看有哪些)。备份文件是 0600、属主 10001,宿主机上的人读不到,所以最后一步在一个以 10001 运行的临时容器里读它,不用 sudo: ```bash ./deploy.sh stop # 两个都停 @@ -141,10 +157,14 @@ docker start agentlab-postgres # 只起库 until docker exec agentlab-postgres pg_isready -q -h 127.0.0.1 -U agentlab; do sleep 1; done docker exec agentlab-postgres psql -U agentlab -d postgres \ -c 'DROP DATABASE agentlab WITH (FORCE)' -c 'CREATE DATABASE agentlab OWNER agentlab' -docker exec -i agentlab-postgres pg_restore -U agentlab -d agentlab --no-owner < <备份> +docker run --rm --network agentlab-net -v "$(pwd)/data:/data" --user 10001:10001 -e PGPASSFILE=/data/.postgres/pgpass \ + --entrypoint pg_restore postgres:16.15 -h agentlab-postgres -U agentlab -d agentlab --no-owner /data/backups/postgres/<备份> ./deploy.sh start ``` +自己手动导出、放在别处的备份(上面「备份」那条命令导出的,属主是你自己)直接从宿主机喂进去: +`docker exec -i agentlab-postgres pg_restore -U agentlab -d agentlab --no-owner < agentlab-<日期>.dump`。 + 数据目录要换回同一时间的备份,工件、上传文件和库里的记录才对得上。 **从 SQLite 切过来。** 正在用 SQLite 的部署运行 `./deploy.sh migrate-to-postgres`,它依次: @@ -166,6 +186,23 @@ docker exec -i agentlab-postgres pg_restore -U agentlab -d agentlab --no-owner < `sudo rm <数据目录>/.postgres/state`、`./deploy.sh install`,就回到切换那一刻的 SQLite(切换之后在 PostgreSQL 上新产生的东西 不会带回来;库的容器停着不用,`docker rm -f agentlab-postgres` 可删)。 +**首次在 Linux 上切换前的检查。** 第一次在一台机器上 `install --postgres` 或 `migrate-to-postgres` 之前,花几分钟看一遍 +(以缺省的 `./data` 为例,在包目录里执行): + +- **docker 版本**:`docker version --format '{{.Server.Version}}'`,要 20.10 以上(健康检查、自建网络、`--stop-timeout` 都要用); + 运行 deploy.sh 的用户在 `docker` 组里(`id -nG` 里有 docker),否则整条命令前加 sudo。 +- **属主**:`ls -lnd data` 第三列应是 `10001`(deploy.sh 改的)。数据目录不要放在 NFS、SMB 这类不认 uid 的挂载上:属主改不过去, + 服务写不进去。 +- **权限**:切换(或第一次起库)之后看一眼——`ls -ld data/.postgres` 应是 `drwx--x--x`(0711),`cat data/.postgres/state` + 不用 sudo 就能读到(deploy.sh 在宿主机上直接读它,读不到时会退回去用容器读,只是慢一点);`sudo ls -ln data/.postgres` + 里 `password`、`pgpass` 是 `-rw-------`、属主 10001;`sudo ls -ld data/backups` 是 `drwx------`。 +- **磁盘空间**:切换时数据目录里要放得下两份库(`backups/sqlite/` 里的一份备份,加上导入时的临时快照,用完就删), + 另外 docker 的存储目录要放得下 PostgreSQL 的镜像(约 0.7 GB)和库本身(大致和 SQLite 的库一样大,索引多的表会大一些)。 + 看大小:`sudo du -sh data/agentlab.db data/checkpoints.db`;看剩余:`df -h data` 和 + `df -h "$(docker info --format '{{.DockerRootDir}}')"`。 +- **整目录备份**:切换会先自动备份一份库,但还是建议先按「数据和备份」停服务整份打包一次数据目录。切换期间服务是停着的, + 时长大致是导入的时间(每秒几千到几万行,看机器)。 + **卸载。** `./deploy.sh uninstall` 连库的容器和网络一起删,数据卷和数据目录都留着;同一个数据目录再 `./deploy.sh install` 就接着用原来的库。确定不要了:`docker volume rm agentlab-pgdata`(不可恢复)。 @@ -244,12 +281,14 @@ cp ../agentlab-<旧版本>-linux-__AGENTLAB_ARCH__/agentlab.env . # 沿用原 ```bash ./deploy.sh stop # 停止(开机也不会自动起来) ./deploy.sh start # 再启动 -./deploy.sh restart # 重启 +./deploy.sh restart # 重启服务(内置 PostgreSQL 不跟着重启) +./deploy.sh restart --postgres # 连内置 PostgreSQL 一起重启 ./deploy.sh uninstall # 删除容器,保留数据和镜像 ./deploy.sh uninstall --purge-image # 连镜像一起删(PostgreSQL 的镜像不删,别的东西可能也在用) ``` -内置 PostgreSQL 时这几个命令按顺序管两个容器(起的时候先起库,停的时候先停服务),卸载连库的容器和网络一起删。 +内置 PostgreSQL 时这几个命令按顺序管两个容器(起的时候先起库,停的时候先停服务;`restart` 不加 `--postgres` 只动服务), +卸载连库的容器和网络一起删。 卸载**永远不删数据**。确定不要了再手动删除数据目录(不可恢复):`sudo rm -rf <数据目录>`;内置 PostgreSQL 的数据卷另删: `docker volume rm -pgdata`。 diff --git a/deploy/agentlab.env b/deploy/agentlab.env index d524a69f..5cc6a4ce 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -111,3 +111,20 @@ ENABLE_MICROVM=auto # sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 # 这一步会放宽整台机器的限制,自行权衡。 ENABLE_BWRAP=0 + +# ---- 内置 PostgreSQL(可选)---- +# 缺省库是数据目录里的 SQLite。新部署要用内置的 PostgreSQL 运行 ./deploy.sh install --postgres,已经在用 SQLite 的运行 +# ./deploy.sh migrate-to-postgres(见 README.md「用 PostgreSQL」)。密码自动生成在数据目录的 .postgres/ 里,这里不用填。 +# 和外接 PostgreSQL(AGENTLAB_DATABASE_URL)只能选一种。下面几项是给 deploy.sh 用的,都有缺省值,一般不用改;要改去掉 +# 行首的 # 再 ./deploy.sh install。 +# +# 库的镜像(小版本固定;离线包里带着同一个标签的 postgres-image.tar) +# POSTGRES_IMAGE=postgres:16.15 +# 库的容器名,缺省 -postgres +# POSTGRES_CONTAINER=agentlab-postgres +# 两个容器共用的 docker 网络,缺省 -net(库的端口不对外,只在这个网络里可见) +# DOCKER_NETWORK=agentlab-net +# 库的数据放在这个 docker 数据卷里,缺省 -pgdata(卸载不删) +# POSTGRES_VOLUME=agentlab-pgdata +# upgrade 前自动备份库(SQLite 拷到 backups/sqlite/,内置 PostgreSQL 用 pg_dump 存到 backups/postgres/),只留最近几份 +# BACKUP_KEEP=5 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 0cd39ee0..59470975 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -34,8 +34,10 @@ usage() { migrate-to-postgres 把正在用 SQLite 的部署切到内置 PostgreSQL:停服务 → 备份 → 起 PostgreSQL → 先核对再导入 → 记下 「已切换」→ 起服务。哪一步出错都停在原地、仍用 SQLite。重复执行是安全的;SQLite 的库文件原样留着。 - start | stop | restart - 启动 / 停止 / 重启。内置 PostgreSQL 时先起库、等它就绪再起服务;停的时候先停服务再停库。 + start | stop 启动 / 停止。内置 PostgreSQL 时先起库、等它就绪再起服务;停的时候先停服务再停库。 + restart [--postgres] + 重启服务(库停着的话先把库起来)。--postgres:内置 PostgreSQL 也一起重启(先停服务、再停库, + 起的时候先起库)。 status 查看容器状态、健康检查、数据目录、用的哪种库、代码沙箱和访问地址。 logs [-f] [--tail N] [--postgres] 查看日志(默认最后 200 行),-f 持续跟随,--postgres 看内置 PostgreSQL 的。 @@ -66,6 +68,7 @@ TAIL="200" PURGE_IMAGE="" WITH_POSTGRES="" LOGS_POSTGRES="" +RESTART_POSTGRES="" if [ $# -gt 0 ]; then case "$1" in -h|--help) usage; exit 0 ;; @@ -90,6 +93,7 @@ while [ $# -gt 0 ]; do case "$CMD" in install) WITH_POSTGRES=1 ;; logs) LOGS_POSTGRES=1 ;; + restart) RESTART_POSTGRES=1 ;; *) bad_arg "$1" ;; esac ;; *) bad_arg "$1" ;; @@ -754,17 +758,16 @@ stop_app() { image_user() { docker image inspect -f '{{.Config.User}}' "$IMAGE"; } # pg_dump 一份到数据目录的 backups/postgres/,只留最近 BACKUP_KEEP 份。用 PostgreSQL 镜像里的 pg_dump(和库同版本), -# 以数据目录的属主写文件 +# 以数据目录的属主写文件。备份里是整个库:目录 0700(app.ops backup-dir)、文件 0600(umask 077 下建,没有别人读得到的那一刻) backup_pg() { local stamp file stamp="$(date -u +%Y%m%dT%H%M%SZ)" file="backups/postgres/$PG_DB-$stamp.dump" say "备份内置 PostgreSQL:pg_dump 到 $DATA_ABS/$file" - docker run --rm --network none -v "$DATA_ABS:/data" --user "$(image_user)" --entrypoint mkdir "$POSTGRES_IMAGE" \ - -p /data/backups/postgres >/dev/null \ - || { warn "没能在数据目录里建备份目录 backups/postgres/"; return 1; } + ops backup-dir postgres >/dev/null || { warn "没能在数据目录里建备份目录 backups/postgres/"; return 1; } docker run --rm --network "$DOCKER_NETWORK" -v "$DATA_ABS:/data" --user "$(image_user)" -e "PGPASSFILE=$PG_PASSFILE" \ - --entrypoint pg_dump "$POSTGRES_IMAGE" -h "$POSTGRES_CONTAINER" -U "$PG_USER" -d "$PG_DB" -Fc -f "/data/$file" \ + --entrypoint sh "$POSTGRES_IMAGE" -c 'umask 077 && exec pg_dump "$@"' pg_dump \ + -h "$POSTGRES_CONTAINER" -U "$PG_USER" -d "$PG_DB" -Fc -f "/data/$file" \ || { warn "pg_dump 失败"; return 1; } ops prune --dir /data/backups/postgres --keep "$BACKUP_KEEP" >/dev/null || warn "旧备份没删掉,不影响这次升级" } @@ -1003,15 +1006,21 @@ cmd_stop() { say "已停止。开机也不会自动起来,要用时运行 ./deploy.sh start" } +# 缺省只重启服务:库不用跟着断一次(库停着的话先把它起来,服务才连得上)。--postgres 连库一起重启 cmd_restart() { check_docker; read_env; require_container; locate_data - say "重启 $CONTAINER_NAME" - if [ "$MODE" = builtin ]; then + if [ -n "$RESTART_POSTGRES" ]; then + [ "$MODE" = builtin ] || die "这个部署没用内置 PostgreSQL,--postgres 没有可重启的库。只重启服务:./deploy.sh restart" + say "重启 $CONTAINER_NAME 和内置 PostgreSQL(${POSTGRES_CONTAINER})" stop_app stop_pg start_pg docker start "$CONTAINER_NAME" >/dev/null else + say "重启 $CONTAINER_NAME" + if [ "$MODE" = builtin ] && [ "$(container_state "$POSTGRES_CONTAINER")" != running ]; then + start_pg + fi docker restart "$CONTAINER_NAME" >/dev/null fi wait_app "已重启"