Files

183 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""app/ops.py:deploy.sh、dev.sh 在容器里调的几件小事(密码文件、状态、SQLite 备份、删旧备份)。"""
from __future__ import annotations
import sqlite3
import stat
import pytest
from app import ops
def _mode(path) -> int:
return stat.S_IMODE(path.stat().st_mode)
def test_secrets_are_generated_once_with_tight_permissions(tmp_path, capsys):
assert ops.main(["postgres-secrets", "--data", str(tmp_path), "--host", "agl-postgres"]) == 0
assert capsys.readouterr().out.strip() == "生成了新密码"
folder = tmp_path / ".postgres"
password = (folder / "password").read_text().strip()
assert len(password) >= 32
assert _mode(folder / "password") == 0o600 and _mode(folder / "pgpass") == 0o600
assert _mode(folder) == 0o711, "别人列不出目录,但按名字读得到 state"
assert (folder / "pgpass").read_text() == f"agl-postgres:5432:agentlab:agentlab:{password}\n"
assert ops.main(["postgres-secrets", "--data", str(tmp_path), "--host", "other", "--port", "55432"]) == 0
out = capsys.readouterr().out
assert out.strip() == "沿用已有的密码" and password not in out, "密码不打印"
assert (folder / "password").read_text().strip() == password
assert (folder / "pgpass").read_text().startswith("other:55432:agentlab:agentlab:"), "pgpass 按现在的主机名重写"
def test_pgpass_escapes_colons_and_backslashes(tmp_path):
folder = tmp_path / ".postgres-dev"
folder.mkdir()
(folder / "password").write_text("a:b\\c\n")
assert ops.postgres_secrets(tmp_path, folder=".postgres-dev", host="127.0.0.1", port=55432, database="agentlab",
user="agentlab") is False
assert (folder / "pgpass").read_text() == "127.0.0.1:55432:agentlab:agentlab:a\\:b\\\\c\n"
def test_an_empty_password_file_is_refused(tmp_path):
(tmp_path / ".postgres").mkdir()
(tmp_path / ".postgres" / "password").write_text("\n")
with pytest.raises(SystemExit, match="是空的"):
ops.postgres_secrets(tmp_path, folder=".postgres", host="h", port=5432, database="d", user="u")
def test_state_defaults_to_sqlite_and_records_switches(tmp_path, capsys):
assert ops.read_state(tmp_path) == "sqlite"
for mode in ("migrating", "builtin", "sqlite"):
assert ops.main(["state", "--data", str(tmp_path), "--set", mode]) == 0
assert capsys.readouterr().out.strip() == mode
assert ops.read_state(tmp_path) == mode
path = tmp_path / ".postgres" / "state"
assert _mode(path) == 0o644, "宿主机上的 deploy.sh 直接读它"
assert "since=" in path.read_text()
path.write_text("mode=whatever\n")
assert ops.read_state(tmp_path) == "sqlite", "认不出的当没切过"
with pytest.raises(SystemExit):
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")
(tmp_path / "20260101T000000Z").mkdir()
(tmp_path / "20260101T000000Z" / "agentlab.db").write_bytes(b"x")
(tmp_path / ".hidden").write_bytes(b"x")
gone = ops.prune(tmp_path, 2)
assert sorted(p.name for p in gone) == ["20260101T000000Z", "agentlab-20260101T000000Z.dump"]
assert sorted(p.name for p in tmp_path.iterdir()) == [".hidden", "agentlab-20260102T000000Z.dump",
"agentlab-20260103T000000Z.dump"]
assert ops.prune(tmp_path / "missing", 2) == []
def test_backup_sqlite_copies_a_consistent_snapshot_including_the_wal(tmp_path, capsys):
db = sqlite3.connect(tmp_path / "agentlab.db")
db.execute("PRAGMA journal_mode=WAL")
db.execute("CREATE TABLE t (v TEXT)")
db.execute("INSERT INTO t VALUES ('还在 WAL 里')")
db.commit()
# 不关连接、不做检查点:新写的那行还只在 -wal 里
assert (tmp_path / "agentlab.db-wal").exists()
sqlite3.connect(tmp_path / "checkpoints.db").close()
try:
assert ops.main(["backup-sqlite", "--data", str(tmp_path), "--keep", "2"]) == 0
finally:
db.close()
target = tmp_path / "backups" / "sqlite"
(stamp,) = list(target.iterdir())
assert capsys.readouterr().out.strip() == str(stamp)
copy = sqlite3.connect(stamp / "agentlab.db")
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()
ops.backup_sqlite(tmp_path, keep=2)
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)
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
assert not (tmp_path / "backups").exists()