From d117237bbc60cf9aea9b2273cd135ef15a55840a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 15:41:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=A8=A1=E6=9D=BF=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20PostgreSQL=20=E5=92=8C=E5=A4=9A=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=9A=84=E5=87=A0=E9=A1=B9=EF=BC=9A.env.example=20=E7=9A=84?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=BA=93=E4=B8=80=E8=8A=82=E5=88=86=E6=88=90?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=BA=93=E3=80=81=E8=BF=9E=E6=8E=A5=E6=B1=A0?= =?UTF-8?q?=E3=80=81PgBouncer=E3=80=81=E5=A4=9A=E8=BF=9B=E7=A8=8B=E3=80=81?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=94=A8=20PostgreSQL=20=E4=BA=94=E7=BB=84?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E4=B8=8A=20AGENTLAB=5FDATABASE=5FDIRECT=5FUR?= =?UTF-8?q?L=E3=80=81AGENTLAB=5FDB=5FPGBOUNCER=E3=80=81AGENTLAB=5FDB=5FPOO?= =?UTF-8?q?L=5FTIMEOUT=E3=80=81AGENTLAB=5FDB=5FPOOL=5FRECYCLE=E3=80=81AGEN?= =?UTF-8?q?TLAB=5FDB=5FSTARTUP=5FTIMEOUT=E3=80=81AGENTLAB=5FWORKERS?= =?UTF-8?q?=E3=80=81AGENTLAB=5FWORKER=5FLEASE=5FS=E3=80=81AGENTLAB=5FWORKE?= =?UTF-8?q?R=5FHEARTBEAT=5FS=E3=80=81AGENTLAB=5FSCHEDULER=E3=80=81AGENTLAB?= =?UTF-8?q?=5FSCHEDULER=5FTICK=5FS=E3=80=81AGENTLAB=5FBUILD=20=E5=92=8C=20?= =?UTF-8?q?dev.sh=20--postgres=20=E7=9A=84=20AGENTLAB=5FDEV=5FPG=5F*?= =?UTF-8?q?=EF=BC=88=E9=83=BD=E6=B3=A8=E9=87=8A=E7=9D=80=EF=BC=8C=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E3=80=81=E7=BC=BA=E7=9C=81=E5=80=BC=E7=85=A7=20core/c?= =?UTF-8?q?onfig.py=20=E5=92=8C=E8=84=9A=E6=9C=AC=EF=BC=89=EF=BC=9Bdev.sh?= =?UTF-8?q?=20=E7=9A=84=20AGENTLAB=5FDEV=5FPG=5F*=20=E4=B9=9F=E8=AE=A4=20.?= =?UTF-8?q?env=20/=20backend/.env=EF=BC=88=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E4=BC=98=E5=85=88=EF=BC=8C=E5=92=8C=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E5=90=8C=E4=B8=80=E4=B8=AA=E8=A7=84?= =?UTF-8?q?=E7=9F=A9=EF=BC=8C=E4=B8=8D=20source=EF=BC=89=E3=80=82deploy/ag?= =?UTF-8?q?entlab.env=20=E6=9C=AB=E5=B0=BE=E5=86=8D=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=80=8C=E5=A4=96=E6=8E=A5=20PostgreSQL=20=E4=B8=8E=E5=A4=9A?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=EF=BC=88=E5=8F=AF=E9=80=89=EF=BC=89=E3=80=8D?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=EF=BC=88=E5=9C=B0=E5=9D=80=E5=86=99=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E7=BD=91=E6=A1=A5=20172.17.0.1=E3=80=81?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E5=AE=B9=E5=99=A8=E7=BD=91=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E5=92=8C=E5=86=85=E7=BD=AE=E5=BA=93=E4=BA=8C=E9=80=89=E4=B8=80?= =?UTF-8?q?=EF=BC=9B=E6=96=AD=E7=82=B9=E5=BA=93=E3=80=81PgBouncer=E3=80=81?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E5=9C=B0=E5=9D=80=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E3=80=81=E7=A7=9F=E7=BA=A6=E3=80=81=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=B1=A0=E3=80=81=E5=90=AF=E5=8A=A8=E6=97=B6=E7=AD=89?= =?UTF-8?q?=E5=BA=93=EF=BC=89=EF=BC=8C=E5=B7=B2=E6=9C=89=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=80=E8=A1=8C=E6=B2=A1=E5=8A=A8=E3=80=82deploy.sh=EF=BC=9A?= =?UTF-8?q?AGENTLAB=5FDATABASE=5FDIRECT=5FURL=20=E4=B9=9F=E7=AE=97?= =?UTF-8?q?=E5=A4=96=E6=8E=A5=E7=9A=84=E5=BA=93=EF=BC=88=E5=92=8C=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E4=BA=92=E6=96=A5=EF=BC=89=EF=BC=8C=E5=8F=AA=E8=AE=BE?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E5=9C=B0=E5=9D=80=E4=B8=8D=E8=AE=BE=20AGENTL?= =?UTF-8?q?AB=5FDATABASE=5FURL=E3=80=81=E5=86=85=E7=BD=AE=E5=BA=93?= =?UTF-8?q?=E5=BC=80=20AGENTLAB=5FDB=5FPGBOUNCER=E3=80=81SQLite=20?= =?UTF-8?q?=E6=97=B6=20AGENTLAB=5FWORKERS=20=E5=A4=A7=E4=BA=8E=201=20?= =?UTF-8?q?=E9=83=BD=E5=9C=A8=E8=B5=B7=E5=AE=B9=E5=99=A8=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=85=A7=E5=AE=9E=E6=8B=A6=E4=B8=8B=EF=BC=88migrate-to-postgre?= =?UTF-8?q?s=20=E5=9C=A8=E5=81=9C=E6=9C=8D=E5=8A=A1=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E6=9F=A5=EF=BC=89=E3=80=82=E5=8D=95=E6=B5=8B=EF=BC=9A=E8=BF=99?= =?UTF-8?q?=E4=BA=9B=20AGENTLAB=5F*=20=E7=BB=8F=20--env-file=20=E8=BF=9B?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E3=80=81=E5=87=A0=E6=9D=A1=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E6=9D=BF=E4=B8=A4=E6=AE=B5=E7=9A=84=E9=A1=B9?= =?UTF-8?q?=E5=92=8C=E7=BC=BA=E7=9C=81=E5=80=BC=E3=80=81dev.sh=20=E4=BB=8E?= =?UTF-8?q?=20.env=20=E8=AF=BB=E8=AE=BE=E7=BD=AE=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E5=AE=88=E5=8D=AB=E2=80=94=E2=80=94config.py=20?= =?UTF-8?q?=E9=87=8C=E5=BA=94=E7=94=A8=E5=BA=93=E3=80=81=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B1=A0=E3=80=81=E5=A4=9A=E8=BF=9B=E7=A8=8B=E3=80=81=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=87=A0=E7=B1=BB=E9=85=8D=E7=BD=AE=E6=AF=8F=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E9=83=BD=E8=A6=81=E5=9C=A8=20.env.example=20=E9=87=8C?= =?UTF-8?q?=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 39 +++++++- README.md | 2 +- backend/tests/test_deploy_script.py | 132 +++++++++++++++++++++++++++- deploy/README.md | 7 +- deploy/agentlab.env | 26 ++++++ deploy/deploy.sh | 47 +++++++++- scripts/dev.sh | 40 +++++++-- 7 files changed, 279 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 28405256..582ab6c4 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,46 @@ # AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:密码@db.example.com:5432/agentlab?sslmode=verify-full # 断点放另一个库时才设(不设就和应用库同一个库) # AGENTLAB_CHECKPOINT_URL=postgresql://agentlab:密码@127.0.0.1:5432/agentlab_checkpoints -# 连接池:每个进程常驻几条、高峰再多开几条;设成 0 不用连接池(前面有 PgBouncer 时) +# 启动时库还连不上(和库一起起、库还在初始化)最多等几秒,按退避重试;密码不对、库不存在这种不等。缺省 90 +# AGENTLAB_DB_STARTUP_TIMEOUT=90 + +# ---- 连接池(PostgreSQL;每个进程一套)---- +# 常驻几条,缺省 10;设成 0 不用连接池、每次新建连接 # AGENTLAB_DB_POOL_SIZE=10 +# 高峰再多开几条,缺省 20 # AGENTLAB_DB_MAX_OVERFLOW=20 +# 池子满了等一条空出来最多等几秒,缺省 30 +# AGENTLAB_DB_POOL_TIMEOUT=30 +# 一条连接用多久换新的(秒),缺省 1800 +# AGENTLAB_DB_POOL_RECYCLE=1800 + +# ---- PgBouncer(README「多进程」的「直连与 PgBouncer」)---- +# 直连地址(写法同 AGENTLAB_DATABASE_URL):进程间通知、跨进程锁、断点这几样依赖会话状态的连接走它,普通查询走上面的地址。 +# 不设就全用 AGENTLAB_DATABASE_URL(直连 PostgreSQL、或 PgBouncer 的会话模式时都不用设) +# AGENTLAB_DATABASE_DIRECT_URL=postgresql+asyncpg://agentlab:密码@db.example.com:5432/agentlab +# AGENTLAB_DATABASE_URL 指的是 PgBouncer 的事务模式时设成 1(缺省 0):不用客户端连接池、关掉预备语句缓存; +# 这时必须另给上面的直连地址,没给就拒绝启动 +# AGENTLAB_DB_PGBOUNCER=0 + +# ---- 多进程(README「多进程」;只有应用库是 PostgreSQL 时才能大于 1)---- +# 几个工作进程,缺省 1(scripts/prod.sh、Docker 的入口按它起;dev.sh 总是一个进程、热重载) +# AGENTLAB_WORKERS=1 +# 每个进程在库里登记、续租约:租约多长(秒,缺省 30)、隔多久续一次(秒,缺省 5)。租约到期没续上算进程没了,别的进程接手它的活 +# AGENTLAB_WORKER_LEASE_S=30 +# AGENTLAB_WORKER_HEARTBEAT_S=5 +# 后台调度循环(定时任务、事件派发、流程时限)开不开,缺省开;拷一份数据目录另起的服务要关成 0。一拍隔几秒,缺省 15 +# AGENTLAB_SCHEDULER=1 +# AGENTLAB_SCHEDULER_TICK_S=15 +# 构建号:进程登记的代码版本里带上它(新旧版本不许同时连一个库)。Docker 镜像构建时自动写进去,不设就用 git 提交 +# AGENTLAB_BUILD= + +# ---- 开发用 PostgreSQL(./scripts/dev.sh --postgres)---- +# 这几项只有 dev.sh 读:写成环境变量或写在这个 .env 里都行(环境变量优先)。数据在 docker 数据卷里, +# 密码第一次自动生成在 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 +# AGENTLAB_DEV_PG_IMAGE=postgres:16 # ---- 沙箱 ---- # auto(挑第一个已就绪的:microvm → bubblewrap → seatbelt → local) diff --git a/README.md b/README.md index 8cd85445..35959acd 100644 --- a/README.md +++ b/README.md @@ -1079,7 +1079,7 @@ AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agen `postgres:16` 容器 `agentlab-dev-postgres`,只开在 `127.0.0.1:55432`,数据在 docker 数据卷 `agentlab-dev-pgdata`,密码第一次 自动生成在 `data/.postgres-dev/`(后端按 `PGPASSFILE` 读),后端照它设 `AGENTLAB_DATABASE_URL`;Ctrl-C 只停前后端,库的容器 留着下次复用。容器名、端口、数据卷、镜像用 `AGENTLAB_DEV_PG_CONTAINER`、`AGENTLAB_DEV_PG_PORT`、`AGENTLAB_DEV_PG_VOLUME`、 -`AGENTLAB_DEV_PG_IMAGE` 改。不加 `--postgres` 和原来一样:SQLite,不用 docker。 +`AGENTLAB_DEV_PG_IMAGE` 改(环境变量,或写在 `.env` 里,环境变量优先;`.env.example` 列着应用库、连接池、PgBouncer、多进程和这几项)。不加 `--postgres` 和原来一样:SQLite,不用 docker。 **从 SQLite 搬过来。** `python -m app.db copy-to-postgres` 把数据目录里的 `agentlab.db` 和 `checkpoints.db` 导进 PostgreSQL, 停在审批上的、中断了的运行导过去之后照样能接着跑: diff --git a/backend/tests/test_deploy_script.py b/backend/tests/test_deploy_script.py index 38d3bd7c..87a36497 100644 --- a/backend/tests/test_deploy_script.py +++ b/backend/tests/test_deploy_script.py @@ -238,6 +238,60 @@ def test_install_postgres_on_a_sqlite_data_dir_points_to_migrate(deploy): assert deploy.containers() == {} and deploy.mode() == "sqlite" +def test_agentlab_settings_reach_the_container_through_the_env_file(tmp_path): + """agentlab.env 里的 AGENTLAB_*(多进程、连接池……)经 --env-file 原样进应用容器;内置库的地址、密码文件另用 -e 给。""" + deploy = Deploy(tmp_path, env_lines=["AGENTLAB_WORKERS=3", "AGENTLAB_DB_POOL_SIZE=5", "AGENTLAB_WORKER_LEASE_S=40"]) + deploy("install", "--postgres") + calls = deploy.calls() + app_args = calls[index_of(calls, runs_named(APP))] + env_file = Path(app_args[app_args.index("--env-file") + 1]) + assert env_file == deploy.pkg / "agentlab.env" + lines = env_file.read_text().splitlines() + assert {"AGENTLAB_WORKERS=3", "AGENTLAB_DB_POOL_SIZE=5", "AGENTLAB_WORKER_LEASE_S=40"} <= set(lines) + + +@pytest.mark.parametrize("line", ["AGENTLAB_DATABASE_URL=postgresql+asyncpg://u:p@db.example:5432/agentlab", + "AGENTLAB_CHECKPOINT_URL=postgresql://u:p@db.example:5432/ckpt"]) +def test_any_external_database_setting_conflicts_with_builtin(tmp_path, line): + deploy = Deploy(tmp_path, env_lines=[line]) + out = deploy("install", "--postgres", ok=False) + assert "两种库只能选一种" in out.stderr and line.split("=")[0] in out.stderr + assert deploy.containers() == {} + + +def test_direct_url_needs_the_external_url(tmp_path): + deploy = Deploy(tmp_path, env_lines=["AGENTLAB_DATABASE_DIRECT_URL=postgresql+asyncpg://u:p@db.example:5432/agentlab"]) + out = deploy("install", ok=False) + assert "AGENTLAB_DATABASE_DIRECT_URL" in out.stderr and "要和 AGENTLAB_DATABASE_URL 一起设" in out.stderr + assert deploy.containers() == {} + out = deploy("install", "--postgres", ok=False) + assert "两种库只能选一种" in out.stderr, "和内置库一起:照互斥报" + + +def test_pgbouncer_switch_is_refused_for_the_builtin_database(tmp_path): + deploy = Deploy(tmp_path, env_lines=["AGENTLAB_DB_PGBOUNCER=1"]) + out = deploy("install", "--postgres", ok=False) + assert "内置 PostgreSQL 是直连" in out.stderr and deploy.containers() == {} + make_sqlite_data(deploy.pkg / "data") + out = deploy("migrate-to-postgres", ok=False) + assert "内置 PostgreSQL 是直连" in out.stderr + assert not any(a[:1] == ["stop"] for a in deploy.calls()), "切换之前就拦下,服务没停" + # 外接的库配 PgBouncer:照常 + deploy.write_env(["AGENTLAB_DB_PGBOUNCER=1", "AGENTLAB_DATABASE_URL=postgresql+asyncpg://u:p@bouncer.example:6432/agentlab", + "AGENTLAB_DATABASE_DIRECT_URL=postgresql+asyncpg://u:p@db.example:5432/agentlab"]) + deploy("install") + assert "外接 PostgreSQL" in deploy("status").stdout + + +def test_more_than_one_worker_needs_postgres(tmp_path): + deploy = Deploy(tmp_path, env_lines=["AGENTLAB_WORKERS=4"]) + out = deploy("install", ok=False) + assert "SQLite 只支持单进程" in out.stderr and "migrate-to-postgres" in out.stderr + assert deploy.containers() == {} + deploy("install", "--postgres") + assert deploy.containers()[APP]["state"] == "running", "内置 PostgreSQL 时可以多进程" + + 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) @@ -553,8 +607,10 @@ def test_package_script_takes_the_postgres_image_from_deploy_sh(): 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()), "追加在文件末尾" + start = template.index("# ---- 内置 PostgreSQL(可选)----") + tail = template[start:] + assert template.rstrip().endswith(tail.rstrip()), "追加在文件末尾" + section = tail.split("\n# ---- 外接 PostgreSQL 与多进程(可选)----")[0] 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", @@ -563,6 +619,29 @@ def test_env_template_lists_the_postgres_settings_commented_out_with_the_default "都是注释" +def test_env_template_ends_with_the_external_postgres_and_multiprocess_settings(): + """模板最后一段「外接 PostgreSQL 与多进程(可选)」:要的几项都在、都注释着,缺省值和代码里的一致。""" + from app.core.config import Settings + + template = (REPO / "deploy" / "agentlab.env").read_text(encoding="utf-8") + section = template[template.index("# ---- 外接 PostgreSQL 与多进程(可选)----"):] + commented = dict(line[2:].split("=", 1) for line in section.splitlines() + if line.startswith("# AGENTLAB_") and "=" in line) + assert set(commented) == { + "AGENTLAB_DATABASE_URL", "AGENTLAB_CHECKPOINT_URL", "AGENTLAB_DB_PGBOUNCER", "AGENTLAB_DATABASE_DIRECT_URL", + "AGENTLAB_WORKERS", "AGENTLAB_WORKER_LEASE_S", "AGENTLAB_WORKER_HEARTBEAT_S", "AGENTLAB_DB_POOL_SIZE", + "AGENTLAB_DB_MAX_OVERFLOW", "AGENTLAB_DB_POOL_TIMEOUT", "AGENTLAB_DB_POOL_RECYCLE", "AGENTLAB_DB_STARTUP_TIMEOUT"} + assert "172.17.0.1" in commented["AGENTLAB_DATABASE_URL"] and "172.17.0.0/16" in section + fields = Settings.model_fields + for key, shown in commented.items(): + default = fields[key.removeprefix("AGENTLAB_").lower()].default + if isinstance(default, bool): + assert shown == str(int(default)), key + elif isinstance(default, (int, float)): + assert float(shown) == float(default), key + assert not any(line.startswith("AGENTLAB_") for line in section.splitlines()), "都是注释" + + 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"]) @@ -572,6 +651,55 @@ def test_settings_written_in_agentlab_env_take_effect(tmp_path): assert deploy.containers()[APP]["network"] == "agl-n" +def test_env_example_lists_every_database_pool_and_multiprocess_setting(): + """core/config.py 里应用库、连接池、PgBouncer、多进程、调度这几类配置,每一项都在 .env.example 里有一行(注释掉的示例); + dev.sh --postgres 读的 AGENTLAB_DEV_PG_* 也在。以后加了配置忘了写,这里会报出来。""" + import re + + from app.core.config import Settings + + prefixes = ("database_", "checkpoint_", "db_", "worker", "scheduler") + 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())) + assert len(dev) == 4 + example = (REPO / ".env.example").read_text(encoding="utf-8") + listed = set(re.findall(r"^# (AGENTLAB_[A-Z_]+)=", example, re.M)) + assert names | dev <= listed, f".env.example 里缺:{sorted((names | dev) - listed)}" + + +def test_dev_script_reads_its_postgres_settings_from_dotenv(tmp_path): + """dev.sh --postgres 的几项:环境变量优先,没设时读 .env / backend/.env(后一个优先),和后端的配置同一个规矩。 + 在一份拷出来的脚本上跑,假的 conda、pnpm、docker:docker run 记下参数就失败退出,不真起容器、不起前后端。""" + root = tmp_path / "repo" + (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") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "docker.log" + (bin_dir / "conda").write_text(f'#!/bin/sh\ncase "$1" in env) echo "agentlab /x" ;; run) echo "{sys.executable}" ;; esac\n') + (bin_dir / "pnpm").write_text("#!/bin/sh\nexit 0\n") + (bin_dir / "docker").write_text(f'#!/bin/sh\necho "$*" >> "{log}"\ncase "$1" in version) echo 27 ;; ' + '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"), + "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) + assert out.returncode != 0, "假的 docker run 失败,脚本停在这里" + [run] = [line for line in log.read_text().splitlines() if line.startswith("run ")] + 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() + + 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/deploy/README.md b/deploy/README.md index 5a8b453f..a528316f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -216,7 +216,9 @@ AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:<密码>@<数据库地址>:5 容器里的 `127.0.0.1` 是容器自己:数据库在别的机器上就写那台的地址;就在这台机器上,写宿主机在 docker 网桥上的地址 (一般是 `172.17.0.1`),并让 PostgreSQL 监听这个地址、`pg_hba.conf` 放行容器的网段。执行断点也进同一个库。 -表结构和默认数据同样在每次启动时自动补齐,库暂时连不上时同样按退避重试。 +表结构和默认数据同样在每次启动时自动补齐,库暂时连不上时同样按退避重试。`agentlab.env` 末尾「外接 PostgreSQL 与多进程(可选)」 +一段列着这几项(地址、断点库、PgBouncer、工作进程、连接池、启动时等库),都注释着,要用的去掉行首的 `#`;它们和别的 `AGENTLAB_*` +一样经 `--env-file` 原样进容器。 已经在用 SQLite 的,先把数据导过去(同一个镜像里带着导入命令;`--dry-run` 只核对、不写): @@ -241,7 +243,8 @@ docker run --rm --user 10001:10001 -v "$(pwd)/data:/data" --env-file agentlab.en 问数据和后台任务(规矩见仓库 README「多进程」)。SQLite 时设大于 1 会启动失败(照实报错:SQLite 只支持单进程)。 几台机器、几个容器指着同一个库也行,但数据目录要放在共享存储上、每台挂在同一个路径,限制见仓库 README「多进程」。 -内置 PostgreSQL 是直连(中间没有 PgBouncer),多进程时什么都不用另设(不用 `AGENTLAB_DATABASE_DIRECT_URL`)。新旧版本的进程不能同时 +内置 PostgreSQL 是直连(中间没有 PgBouncer),多进程时什么都不用另设(不用 `AGENTLAB_DATABASE_DIRECT_URL`;设了它、或者 +`AGENTLAB_DB_PGBOUNCER=1`,deploy.sh 照实拦下)。库还是 SQLite 时 `AGENTLAB_WORKERS` 大于 1,deploy.sh 在起容器之前就拦下。新旧版本的进程不能同时 连着一个库(代码里拦着:新版本发现库里还有活着的旧版本进程就拒绝启动)。`./deploy.sh upgrade` 就是按这个顺序做的:先停掉旧容器 (里面的全部工作进程一起停)、备份,再起新版本;旧进程要是被强杀、登记还没过期,新版本启动时会等它过期(最多半分钟左右)。 同一个库还接着别处的 AgentLab 进程(另一台机器、另一个容器)的,升级前先把它们也停掉。 diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 5cc6a4ce..90a05478 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -128,3 +128,29 @@ ENABLE_BWRAP=0 # POSTGRES_VOLUME=agentlab-pgdata # upgrade 前自动备份库(SQLite 拷到 backups/sqlite/,内置 PostgreSQL 用 pg_dump 存到 backups/postgres/),只留最近几份 # BACKUP_KEEP=5 + +# ---- 外接 PostgreSQL 与多进程(可选)---- +# 库放在你自己的 PostgreSQL 上(库要先建好):去掉 AGENTLAB_DATABASE_URL 行首的 # 填好地址,再 ./deploy.sh install。 +# 和上面的内置 PostgreSQL 只能选一种:两个都用时 deploy.sh 照实报错、不起容器。容器里的 127.0.0.1 是容器自己:库就在这台机器上时 +# 写宿主机在 docker 网桥上的地址(一般是 172.17.0.1),并让 PostgreSQL 监听这个地址、pg_hba.conf 放行容器的网段(一般是 +# 172.17.0.0/16);库在别的机器上就写那台的地址。下面这些 AGENTLAB_* 都原样传进容器(docker run --env-file 这个文件) +# AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:密码@172.17.0.1:5432/agentlab +# 断点放另一个库时才设(不设就和应用库同一个库) +# AGENTLAB_CHECKPOINT_URL=postgresql://agentlab:密码@172.17.0.1:5432/agentlab_checkpoints +# 前面有 PgBouncer 的事务模式时:AGENTLAB_DB_PGBOUNCER 设成 1,并另给直连 PostgreSQL 的地址(会话模式、直连都不用这两项)。 +# 内置 PostgreSQL 是直连,设了 deploy.sh 会拦下 +# AGENTLAB_DB_PGBOUNCER=0 +# AGENTLAB_DATABASE_DIRECT_URL=postgresql+asyncpg://agentlab:密码@172.17.0.1:5432/agentlab +# 容器里起几个工作进程,缺省 1。只有用 PostgreSQL(内置或外接)时才能大于 1:SQLite 时 deploy.sh 先拦下 +# AGENTLAB_WORKERS=1 +# 工作进程在库里的租约多长、隔多久续一次(秒,缺省 30、5)。租约到期没续上算进程没了,别的进程接手它的活 +# AGENTLAB_WORKER_LEASE_S=30 +# AGENTLAB_WORKER_HEARTBEAT_S=5 +# 连接池(每个工作进程一套):常驻几条、高峰再多开几条、等空闲连接最多几秒、一条连接用多久换新(缺省 10、20、30、1800 秒)。 +# 进程多了连接数跟着乘,PostgreSQL 的 max_connections 要够(见 README「用 PostgreSQL」的连接数的账) +# AGENTLAB_DB_POOL_SIZE=10 +# AGENTLAB_DB_MAX_OVERFLOW=20 +# AGENTLAB_DB_POOL_TIMEOUT=30 +# AGENTLAB_DB_POOL_RECYCLE=1800 +# 启动时库还没就绪最多等几秒(按退避重试,内置、外接都一样),缺省 90 +# AGENTLAB_DB_STARTUP_TIMEOUT=90 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 792df5c7..bb2cd5f1 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -141,8 +141,13 @@ POSTGRES_CONTAINER="" DOCKER_NETWORK="" POSTGRES_VOLUME="" BACKUP_KEEP="5" -# agentlab.env 里设了外接的库(AGENTLAB_DATABASE_URL)没有:只记有没有,值(带密码)不读进来 +# agentlab.env 里设了外接的库(AGENTLAB_DATABASE_URL 等)没有:只记设了哪一项,值(带密码)不读进来 EXTERNAL_DB="" +EXTERNAL_URL_SET="" +DIRECT_URL_SET="" +# 这两项要看值:PgBouncer 事务模式的开关(内置库是直连,开了就拦下)、工作进程数(SQLite 只能是 1)。都原样经 --env-file 进容器 +PGBOUNCER_VALUE="" +WORKERS_VALUE="" # 内置 PostgreSQL 里的库名、用户名(PostgreSQL 容器第一次初始化时按它建) PG_DB="agentlab" PG_USER="agentlab" @@ -163,8 +168,16 @@ read_env() { HOST_PORT|BIND_ADDR|DATA_DIR|CONTAINER_NAME|ENABLE_BWRAP|ENABLE_MICROVM|POSTGRES_IMAGE|POSTGRES_CONTAINER|\ DOCKER_NETWORK|POSTGRES_VOLUME|BACKUP_KEEP) printf -v "$key" '%s' "$(unquote "$(trim "$val")")" ;; - AGENTLAB_DATABASE_URL|AGENTLAB_CHECKPOINT_URL) - [ -z "$(trim "$val")" ] || EXTERNAL_DB="$key" ;; + AGENTLAB_DATABASE_URL|AGENTLAB_CHECKPOINT_URL|AGENTLAB_DATABASE_DIRECT_URL) + if [ -n "$(trim "$val")" ]; then + [ -n "$EXTERNAL_DB" ] || EXTERNAL_DB="$key" + case "$key" in + AGENTLAB_DATABASE_URL) EXTERNAL_URL_SET=1 ;; + AGENTLAB_DATABASE_DIRECT_URL) DIRECT_URL_SET=1 ;; + esac + fi ;; + AGENTLAB_DB_PGBOUNCER) PGBOUNCER_VALUE="$(unquote "$(trim "$val")")" ;; + AGENTLAB_WORKERS) WORKERS_VALUE="$(unquote "$(trim "$val")")" ;; AGENTLAB_DATA_DIR|AGENTLAB_WEB_DIST|AGENTLAB_PORT|AGENTLAB_HOST) die "agentlab.env 第 $n 行:不要设 ${key}。容器里的路径和端口是固定的,改宿主机这边用 DATA_DIR、HOST_PORT、BIND_ADDR" ;; *) @@ -606,6 +619,7 @@ PostgreSQL,运行 ./deploy.sh migrate-to-postgres" else MODE="sqlite" fi + check_db_settings DB_OPTS=() if [ "$MODE" = builtin ]; then DB_OPTS=(--network "$DOCKER_NETWORK" @@ -614,6 +628,31 @@ PostgreSQL,运行 ./deploy.sh migrate-to-postgres" fi } +# agentlab.env 里和库有关的几项是否和用的哪种库对得上(resolve_mode 定了 MODE 之后查;migrate-to-postgres 按切过去的内置库查) +pgbouncer_on() { + case "$(printf '%s' "$PGBOUNCER_VALUE" | tr '[:upper:]' '[:lower:]')" in 1|true|yes|on) return 0 ;; *) return 1 ;; esac +} + +check_db_settings() { + local mode="${1:-$MODE}" + if [ -n "$DIRECT_URL_SET" ] && [ -z "$EXTERNAL_URL_SET" ]; then + die "agentlab.env 里设了 AGENTLAB_DATABASE_DIRECT_URL,却没设 AGENTLAB_DATABASE_URL:直连地址是给外接 PostgreSQL 配 PgBouncer 用的, +要和 AGENTLAB_DATABASE_URL 一起设。不用外接的库就把它删掉" + fi + if [ "$mode" = builtin ] && pgbouncer_on; then + die "agentlab.env 里 AGENTLAB_DB_PGBOUNCER=${PGBOUNCER_VALUE},但内置 PostgreSQL 是直连、中间没有 PgBouncer。把这一行删掉或改成 0" + fi + case "$WORKERS_VALUE" in + ''|*[!0-9]*) ;; + *) + if [ "$mode" = sqlite ] && [ "$WORKERS_VALUE" -gt 1 ]; then + die "agentlab.env 里 AGENTLAB_WORKERS=${WORKERS_VALUE},但库是 SQLite:SQLite 只支持单进程,服务会拒绝启动。 + - 改回 1(或删掉这一行); + - 要多进程,先换成 PostgreSQL:./deploy.sh migrate-to-postgres(内置)或设 AGENTLAB_DATABASE_URL(外接)" + fi ;; + esac +} + mode_label() { case "$MODE" in builtin) echo "内置 PostgreSQL(容器 ${POSTGRES_CONTAINER},数据卷 ${POSTGRES_VOLUME})" ;; @@ -918,6 +957,8 @@ cmd_migrate_to_postgres() { check_existing [ -z "$EXTERNAL_DB" ] || die "agentlab.env 里设了 ${EXTERNAL_DB}(外接 PostgreSQL)。内置和外接只能选一种:要切到内置的,先删掉那一行; 要搬进外接的库,用镜像里的 python -m app.db copy-to-postgres(见 README「用 PostgreSQL」)" + # 切过去就是内置库:和它对不上的设置(PgBouncer 开关)先拦下,别停了服务才发现 + check_db_settings builtin local state state="$(stored_state)" if [ "$state" = builtin ]; then diff --git a/scripts/dev.sh b/scripts/dev.sh index 9abd6e16..4ed44005 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -13,7 +13,8 @@ usage() { --postgres 应用库用 PostgreSQL:起(已经有就复用)一个本地开发用的 PostgreSQL 容器,端口只开在 127.0.0.1, 后端照它设 AGENTLAB_DATABASE_URL。密码第一次自动生成,在 data/.postgres-dev/ 里;库的数据在 docker 数据卷里,Ctrl-C 只停前后端,库的容器留着下次复用(不要了:docker rm -f 容器名 && docker volume rm 卷名)。 - 不加就和原来一样:SQLite,不用 docker。下面几个环境变量可以改缺省: + 不加就和原来一样:SQLite,不用 docker。下面几项可以改缺省(环境变量,或者写在 .env / backend/.env 里, + 环境变量优先): AGENTLAB_DEV_PG_CONTAINER 容器名,缺省 agentlab-dev-postgres AGENTLAB_DEV_PG_PORT 本机端口,缺省 55432 AGENTLAB_DEV_PG_VOLUME 数据卷,缺省 agentlab-dev-pgdata @@ -72,11 +73,40 @@ fi export AGENTLAB_PORT="$API_PORT" # ---- --postgres:本地开发用的 PostgreSQL ---- +# 这几项(AGENTLAB_DEV_PG_*)只有这个脚本用。和后端的配置一样认 .env:环境变量 > backend/.env > .env > 缺省(后端的 settings +# 读这两个文件,后一个优先)。不 source:.env 是给后端按 KEY=VALUE 读的,不是 shell(值里的 $、空格、反引号不该被执行) +dotenv_value() { + local key="$1" file line 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 + done < "$file" + done + [ -n "$found" ] || return 1 + val="${val%"${val##*[![:space:]]}"}" + case "$val" in + \"*\"|\'*\') val="${val:1:${#val}-2}" ;; + *) val="${val%%[[:space:]]#*}"; val="${val%"${val##*[![:space:]]}"}" ;; + esac + printf '%s' "$val" +} +dev_pg_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="${AGENTLAB_DEV_PG_CONTAINER:-agentlab-dev-postgres}" - PG_PORT="${AGENTLAB_DEV_PG_PORT:-55432}" - PG_VOLUME="${AGENTLAB_DEV_PG_VOLUME:-agentlab-dev-pgdata}" - PG_IMAGE="${AGENTLAB_DEV_PG_IMAGE:-postgres:16}" + 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}" 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; }