mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:12:07 +00:00
259 lines
12 KiB
Python
259 lines
12 KiB
Python
"""部署脚本(deploy/deploy.sh、scripts/dev.sh)在容器里调的几件小事:`python -m app.ops <命令>`。
|
||
|
||
放在镜像里用 Python 做,不在宿主机上用 shell 做:数据目录归容器里的用户(uid 10001),宿主机上跑 deploy.sh 的人不一定写得
|
||
进去;生成密码、改权限、拷 SQLite、删旧备份在容器里以同一个用户做,属主和权限都对。只用标准库,不连应用库、不读设置。
|
||
|
||
postgres-secrets 内置 PostgreSQL 的密码:<数据目录>/.postgres/password(给 PostgreSQL 容器建库时读)和 pgpass(给应用
|
||
连库时读,PGPASSFILE 指过来)。已有就不动;密码不打印、不进任何参数和日志
|
||
state 数据目录用的是哪种库:没切过是 sqlite;--set builtin 记成「已切到内置 PostgreSQL」(deploy.sh 只在导入、
|
||
核对都通过之后才记,记下之前出错就还是 sqlite);migrating 是「开始往内置库里导了、还没切成」——
|
||
用的仍是 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 的备份用)
|
||
|
||
备份里是整个库(含加密存的密钥、业务数据),目录一律 0700、文件 0600,属主是容器里的用户(同数据目录);宿主机上要读用 sudo。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import contextlib
|
||
import os
|
||
import secrets
|
||
import sqlite3
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from app.core import atomic
|
||
|
||
#: 内置 PostgreSQL 的东西放在数据目录的这个子目录里
|
||
POSTGRES_DIR = ".postgres"
|
||
STATE_FILE = "state"
|
||
MODES = ("sqlite", "builtin", "migrating")
|
||
BACKUPS_DIR = "backups"
|
||
#: 备份的目录、文件的权限:只有属主(容器里的用户)能读
|
||
BACKUP_DIR_MODE = 0o700
|
||
BACKUP_FILE_MODE = 0o600
|
||
|
||
|
||
def _stamp() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||
|
||
|
||
def postgres_secrets(data: Path, *, folder: str, host: str, port: int, database: str, user: str) -> bool:
|
||
"""建密码文件和 pgpass(都是 0600)。已有密码就沿用(库是按它初始化的),pgpass 按现在的主机名重写。交回是不是新建的密码。"""
|
||
root = data / folder
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
# 0711:别人列不出目录、读不到密码(两个文件 0600),但宿主机上跑 deploy.sh 的人能按名字读到 state(0644)
|
||
root.chmod(0o711)
|
||
password_file = root / "password"
|
||
made = False
|
||
if not password_file.exists():
|
||
made = atomic.create_text(password_file, secrets.token_urlsafe(32) + "\n", mode=0o600)
|
||
password = password_file.read_text(encoding="utf-8").strip()
|
||
if not password:
|
||
raise SystemExit(f"{password_file} 是空的:删掉它再来会生成新的(库已经按旧密码初始化过的话,要连库一起重建)")
|
||
# pgpass 的格式:主机:端口:库:用户:密码,冒号和反斜杠要转义
|
||
escaped = password.replace("\\", "\\\\").replace(":", "\\:")
|
||
atomic.write_text(root / "pgpass", f"{host}:{port}:{database}:{user}:{escaped}\n", mode=0o600)
|
||
return made
|
||
|
||
|
||
#: 状态文件里的几项(顺序就是写出来的顺序)。名字三项:这个数据目录的内置库用的容器、数据卷、网络
|
||
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:
|
||
text = path.read_text(encoding="utf-8")
|
||
except FileNotFoundError:
|
||
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 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
|
||
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
|
||
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/<parts…>:从 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()
|
||
|
||
|
||
def prune(folder: Path, keep: int) -> list[Path]:
|
||
"""folder 里按名字(名字里带时间,排序就是先后)只留最近 keep 个,交回删掉的。"""
|
||
if keep < 1 or not folder.is_dir():
|
||
return []
|
||
items = sorted((p for p in folder.iterdir() if not p.name.startswith(".")), key=lambda p: p.name)
|
||
gone = items[:-keep] if len(items) > keep else []
|
||
for path in gone:
|
||
if path.is_dir():
|
||
for child in sorted(path.rglob("*"), reverse=True):
|
||
child.unlink() if child.is_file() or child.is_symlink() else child.rmdir()
|
||
path.rmdir()
|
||
else:
|
||
path.unlink()
|
||
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、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 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
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(prog="python -m app.ops", description="部署脚本在容器里调的几件小事")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
p = sub.add_parser("postgres-secrets")
|
||
p.add_argument("--data", default="/data")
|
||
p.add_argument("--dir", default=POSTGRES_DIR)
|
||
p.add_argument("--host", required=True)
|
||
p.add_argument("--port", type=int, default=5432)
|
||
p.add_argument("--db", default="agentlab")
|
||
p.add_argument("--user", default="agentlab")
|
||
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)
|
||
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)
|
||
args = parser.parse_args(argv)
|
||
|
||
if args.command == "postgres-secrets":
|
||
made = postgres_secrets(Path(args.data), folder=args.dir, host=args.host, port=args.port, database=args.db,
|
||
user=args.user)
|
||
print("生成了新密码" if made else "沿用已有的密码")
|
||
elif args.command == "state":
|
||
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 库,不用备份")
|
||
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}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|