diff --git a/backend/app/ops.py b/backend/app/ops.py index 92570089..14d3f53a 100644 --- a/backend/app/ops.py +++ b/backend/app/ops.py @@ -10,8 +10,9 @@ 用的仍是 SQLite,只是让下一次 migrate-to-postgres 认得出数据卷里那半份是自己上次留下的。 --container / --volume / --network 记下这个数据目录的内置库用的名字;第一次记时生成部署编号 id (deploy.sh 把它打在库的容器、数据卷、网络的标签上,按它认哪些是这个部署的)。--get 键 打印一项 - backup-sqlite 拷一份 agentlab.db、checkpoints.db 到 <数据目录>/backups/sqlite/<时间>/(SQLite 的备份接口,一致的一份), - 只留最近 --keep 份 + backup-sqlite 拷一份 agentlab.db、checkpoints.db 和填报的记录表 records/**/*.db 到 <数据目录>/backups/sqlite/<时间>/ + (SQLite 的备份接口,各自一致的一份,记录表保持 records/ 下的相对路径),只留最近 --keep 份。上传原件、 + 上传表格的版本库、工件、附件、沙箱工作区不在里面:那些要整个数据目录一起备份 backup-dir 建(或收紧)备份目录:从 backups/ 起每一层 0700(pg_dump 写进去之前用) prune 目录里按名字排序只留最近 --keep 个(pg_dump 的备份用) @@ -172,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 diff --git a/backend/tests/test_ops.py b/backend/tests/test_ops.py index e8a52634..1e1fc378 100644 --- a/backend/tests/test_ops.py +++ b/backend/tests/test_ops.py @@ -135,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) diff --git a/deploy/README.md b/deploy/README.md index 98eda627..cae696f4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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,6 +79,7 @@ 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 ```