Merge pull request #8 from openqj/ops-batch
Ops batch: deploy automation, backup restore drill, monitoring, multi-user verification
This commit is contained in:
14 files changed
+1386
-2
No files matched your search
@@ -11,3 +11,5 @@ __pycache__/
|
||||
host-config.json
|
||||
host-config-real.json
|
||||
host-state-real.json.bak*
|
||||
worker-heartbeat.json
|
||||
worker-heartbeat.json.tmp
|
||||
@@ -570,3 +570,59 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
||||
任务仍需 worker 认领执行;`CloudExecutor` 仍是占位符;公网 https
|
||||
入口仍需部署侧反向代理 + TLS。
|
||||
|
||||
## 运维批(本轮新增:部署、恢复演练、监控、心跳、多用户验证)
|
||||
|
||||
细则在 `deploy/README.md`,这里只记代码侧的接口与默认值:
|
||||
|
||||
- 部署与回滚(`deploy/deploy.sh` / `deploy/rollback.sh`,VM 侧跑,
|
||||
不碰 `cloud/` 代码):部署前在主机上备份当前 `/opt/codex-app-server`
|
||||
到 `/opt/codex-app-server-backups/`(时间戳命名,只留最近 3 份,排除
|
||||
work/),解包新代码、chown codexsvc、重启 server+worker、循环验
|
||||
`localhost:8765/health` 最多 60 秒(要求 ok=true、backend=postgres、
|
||||
db_ok=true),失败自动恢复备份并重启;回滚只回代码,不回数据库
|
||||
(迁移只加列、幂等,老代码仍能读新列,见 deploy/README 的回滚边界)。
|
||||
两个脚本都不接受密码参数、不读 env 文件、不打印密钥;需经代理时用
|
||||
环境变量 `SSH_PROXY_CMD` 传入 ProxyCommand,未设则直连。
|
||||
- 备份恢复演练(`cloud.backup.restore_postgres` + `deploy/restore-drill.sh`,
|
||||
主机侧跑):`restore_postgres(backup_file, target_dbname, runner=None)`,
|
||||
用 psql 把 .sql 备份恢复到目标库;目标库名等于 `CLOUD_DB_NAME` 时
|
||||
直接拒绝(除非显式传 `allow_production=True`,演练脚本永远不设它);
|
||||
备份文件不存在或为空时报错;密码只经 PGPASSWORD 环境变量传,绝不进
|
||||
命令行、返回值或日志。演练脚本先跑一次 `backup_postgres` 生成新鲜
|
||||
备份,再恢复到 `codex_control_restore_test_<时间戳>` 测试库,比对
|
||||
tasks/projects/users/audit_log 四张表行数(只比行数,不打印行内容),
|
||||
比对完立即删测试库(trap 保证中途失败也删);绝不恢复到生产库,
|
||||
绝不删生产库。
|
||||
- 监控与心跳:worker 的 `run_loop` 每次循环结束时写心跳文件,内容只有
|
||||
`{"last_seen": epoch秒, "pid": 进程号}`,写失败只记日志不炸 worker;
|
||||
路径由 `--heartbeat-file` 指定,未指定时看环境变量
|
||||
`CLOUD_WORKER_HEARTBEAT_FILE`,再未指定则为 `./worker-heartbeat.json`
|
||||
(VM 本地默认,主机路径不在代码里硬编码;主机部署时两边指到同一个
|
||||
`/var/lib/codex-app-server/worker-heartbeat.json`,server 侧在 env 文件
|
||||
里加 `CLOUD_WORKER_HEARTBEAT_FILE` 一行,worker 单元加
|
||||
`--heartbeat-file` 参数,deploy.sh 不自动改 env 文件)。`GET /health`
|
||||
在原有 ok/service/backend/db_ok 之外加 `worker_last_seen_seconds_ago`
|
||||
(无文件或读失败时为 None)与 `worker_ok`(心跳 180 秒内为 true,
|
||||
阈值可经 `CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS` 调,默认 180);
|
||||
health 本身仍返回 200,worker 不 ok 由调用方看字段判断,不要改成 503。
|
||||
`deploy/monitor.sh`(主机侧,codexsvc 可读,不读 env 文件)检查
|
||||
server/worker 的 systemctl 状态、health 四项、pg_isready、docker ps 里
|
||||
codex-postgres 在运行、磁盘 <90%、app 目录存在,输出一行 JSON,任一
|
||||
项 fail 则退出非零;`--self-test` 只验证脚本机制(health 解析 + JSON
|
||||
合法),不验证主机健康。`deploy/monitor.service` /
|
||||
`deploy/monitor.timer` 每 5 分钟跑一次,日志进 journal。
|
||||
- 多用户真实验证(`deploy/verify-multiuser.py`,主机侧以 codexsvc 身份
|
||||
跑):内存里建 4 个测试用户与 2 个测试项目(P2 只有 owner 是成员),
|
||||
用 HTTP API 验证建任务、读任务、merge 的角色权限与跨项目隔离;
|
||||
merge 用的 completed 测试任务是直接在 DB 里置 completed,不跑模型;
|
||||
测试任务全部带永不存在的依赖 `multiuser-test-never-completes`,生产
|
||||
worker 只派依赖全部 completed 的任务,所以测试任务永远不会被认领去
|
||||
跑模型,不花模型钱、不改仓库;测试行全部用 `multiuser-test-` 前缀,
|
||||
清理时只删这些行,清理失败退出非零并列出残留 id(不含 key);审计
|
||||
日志是 append-only 的,验证审计条目会留下,脚本不删审计表。
|
||||
- 仍未做(不要宣称已做):推送告警通道(monitor 失败时通知到 Muse 聊天
|
||||
的通道还没定);回滚不回数据库(改类型/删列的迁移需单独评估);恢复
|
||||
演练只比行数,恢复到生产库的真实灾难恢复流程还没演练过;部署脚本、
|
||||
恢复演练、monitor、多用户验证四项的主机实跑还没做(本轮只在 VM 上
|
||||
跑了单测、deploy 干跑、monitor 自测与 VM 实跑)。
|
||||
|
||||
@@ -101,6 +101,61 @@ def backup_postgres(out_dir, keep=3, timestamp=None, runner=None,
|
||||
return dest
|
||||
|
||||
|
||||
def restore_postgres(backup_file, target_dbname, runner=None,
|
||||
psql_cmd="psql", allow_production=False):
|
||||
"""Postgres 恢复:用 psql 把 .sql 备份恢复到目标库,返回目标库名。
|
||||
|
||||
安全规则(恢复演练的核心,绝不放宽):
|
||||
- 目标库名绝不能等于生产库名(CLOUD_DB_NAME 环境变量读到的值),
|
||||
相等时直接报错拒绝;只有调用方显式传 allow_production=True
|
||||
才放行,而部署脚本(deploy/restore-drill.sh)永远不设这个值。
|
||||
- 恢复前要求备份文件存在且非空,否则报错,不跑 psql。
|
||||
- 连接参数与 backup_postgres 同款:host/port/user 走 CLOUD_DB_*
|
||||
环境变量,密码只以 PGPASSWORD 环境变量传给子进程,绝不
|
||||
出现在命令行参数、返回值或日志里。
|
||||
- 目标库必须已存在(调用方先 CREATE DATABASE,见恢复演练
|
||||
脚本);psql 非零退出时明确报错(stderr 截断,不含密码)。
|
||||
- 本函数只恢复,不删库、不改生产库;删测试库是调用方的事。
|
||||
"""
|
||||
if not backup_file or not os.path.isfile(backup_file):
|
||||
raise FileNotFoundError("备份文件不存在:%s" % backup_file)
|
||||
if os.path.getsize(backup_file) == 0:
|
||||
raise ValueError("备份文件为空,拒绝恢复:%s" % backup_file)
|
||||
if not target_dbname or not isinstance(target_dbname, str) \
|
||||
or not target_dbname.strip() \
|
||||
or any(c.isspace() for c in target_dbname):
|
||||
raise ValueError("目标库名非法:%r" % (target_dbname,))
|
||||
production = os.environ.get("CLOUD_DB_NAME") or ""
|
||||
if production and target_dbname == production \
|
||||
and not allow_production:
|
||||
raise ValueError(
|
||||
"拒绝恢复到生产库 %s:目标库名与 CLOUD_DB_NAME 相同;"
|
||||
"恢复演练必须用测试库名" % production)
|
||||
host = os.environ.get("CLOUD_DB_HOST") or "127.0.0.1"
|
||||
port = os.environ.get("CLOUD_DB_PORT") or "5432"
|
||||
user = os.environ.get("CLOUD_DB_USER")
|
||||
if not user:
|
||||
raise RuntimeError("Postgres 恢复需要 CLOUD_DB_USER")
|
||||
if shutil.which(psql_cmd) is None and runner is None:
|
||||
raise RuntimeError("psql 不可用:本机找不到 %s" % psql_cmd)
|
||||
cmd = [psql_cmd, "-h", host, "-p", str(port), "-U", user,
|
||||
"-d", target_dbname, "-v", "ON_ERROR_STOP=1",
|
||||
"-f", backup_file]
|
||||
env = dict(os.environ)
|
||||
password = os.environ.get("CLOUD_DB_PASSWORD")
|
||||
if password:
|
||||
env["PGPASSWORD"] = password
|
||||
if runner is not None:
|
||||
result = runner(cmd, env)
|
||||
else:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||
env=env)
|
||||
if getattr(result, "returncode", 1) != 0:
|
||||
err = (getattr(result, "stderr", "") or "")[:500]
|
||||
raise RuntimeError("psql 恢复失败:%s" % err)
|
||||
return target_dbname
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="cloud control-plane backup")
|
||||
parser.add_argument("--db", default=None,
|
||||
|
||||
@@ -156,6 +156,47 @@ def _db_backend_name():
|
||||
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
|
||||
|
||||
|
||||
def _worker_heartbeat_status(server=None):
|
||||
"""读 worker 运维心跳,返回 (age_seconds_or_None, worker_ok)。
|
||||
|
||||
心跳文件路径:测试注入优先(server.worker_heartbeat_file),
|
||||
其次环境变量 CLOUD_WORKER_HEARTBEAT_FILE,未设则默认
|
||||
./worker-heartbeat.json——与 worker 的默认路径保持一致,主机
|
||||
部署时两边都要指到同一个主机路径(见 deploy/README)。
|
||||
阈值:server.worker_heartbeat_max_age_seconds(测试注入)或
|
||||
环境变量 CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS,默认 180 秒。
|
||||
文件不存在、读失败、JSON 非法、last_seen 非数字时一律返回
|
||||
(None, False),不抛异常、不影响 health 本身的 200。
|
||||
"""
|
||||
path = None
|
||||
max_age = 180
|
||||
if server is not None:
|
||||
path = getattr(server, "worker_heartbeat_file", None)
|
||||
override = getattr(server, "worker_heartbeat_max_age_seconds", None)
|
||||
if override is not None:
|
||||
try:
|
||||
max_age = float(override)
|
||||
except (TypeError, ValueError):
|
||||
max_age = 180
|
||||
if not path:
|
||||
path = os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \
|
||||
"./worker-heartbeat.json"
|
||||
if max_age == 180:
|
||||
try:
|
||||
max_age = float(os.environ.get(
|
||||
"CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS") or 180)
|
||||
except (TypeError, ValueError):
|
||||
max_age = 180
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
last_seen = float(payload.get("last_seen"))
|
||||
except Exception:
|
||||
return None, False
|
||||
age = time.time() - last_seen
|
||||
return round(age, 1), bool(age <= max_age)
|
||||
|
||||
|
||||
def _connect(db_path):
|
||||
"""每个请求新建一个连接;后端按 CLOUD_DB_BACKEND 分发。
|
||||
|
||||
@@ -448,9 +489,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
except Exception:
|
||||
db_ok = False
|
||||
# worker 字段:worker_ok 只表示“worker 心跳新鲜”,worker
|
||||
# 不 ok 不代表 server 挂了,所以 health 本身仍返回 200,
|
||||
# 调用方看 worker_ok 字段判断,不要把 health 改成 503
|
||||
# 破坏已有调用(monitor.sh 也是按字段判断,不按状态码)。
|
||||
age, worker_ok = _worker_heartbeat_status(self.server)
|
||||
self._send_json(200, {
|
||||
"ok": True, "service": "cloud-control-plane",
|
||||
"backend": _db_backend_name(), "db_ok": db_ok,
|
||||
"worker_last_seen_seconds_ago": age,
|
||||
"worker_ok": worker_ok,
|
||||
})
|
||||
return
|
||||
if path.startswith("/oauth/"):
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""运维批单测:恢复演练、worker 心跳、health worker 字段、monitor 自测。
|
||||
|
||||
只用标准库,不跑真实模型回合、不联网、不连真实 GitHub、不连生产库;
|
||||
restore 的 psql 与 monitor 的 systemctl/docker 一律用假 runner 或
|
||||
--self-test 的 canned server 验证,不在本机起真实服务。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.backup import restore_postgres
|
||||
from cloud.server import create_server
|
||||
from cloud.worker import Worker
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.backup import restore_postgres
|
||||
from cloud.server import create_server
|
||||
from cloud.worker import Worker
|
||||
|
||||
|
||||
def _fake_result(returncode=0, stdout="", stderr=""):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout,
|
||||
stderr=stderr)
|
||||
|
||||
|
||||
class RestorePostgresTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.backup = os.path.join(self.tmp.name, "backup.sql")
|
||||
with open(self.backup, "w") as f:
|
||||
f.write("-- fake dump\n")
|
||||
self.old_env = dict(os.environ)
|
||||
os.environ["CLOUD_DB_HOST"] = "127.0.0.1"
|
||||
os.environ["CLOUD_DB_PORT"] = "5432"
|
||||
os.environ["CLOUD_DB_NAME"] = "prod_db"
|
||||
os.environ["CLOUD_DB_USER"] = "codexsvc"
|
||||
os.environ["CLOUD_DB_PASSWORD"] = "test-secret-pw"
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self.old_env)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_target_equal_to_production_rejected(self):
|
||||
# 目标库名等于生产库名时直接拒绝,allow_production 默认 False。
|
||||
with self.assertRaises(ValueError):
|
||||
restore_postgres(self.backup, "prod_db",
|
||||
runner=lambda c, e: _fake_result())
|
||||
|
||||
def test_target_equal_to_production_allowed_only_explicitly(self):
|
||||
calls = []
|
||||
|
||||
def runner(cmd, env):
|
||||
calls.append((list(cmd), dict(env)))
|
||||
return _fake_result()
|
||||
|
||||
out = restore_postgres(self.backup, "prod_db", runner=runner,
|
||||
allow_production=True)
|
||||
self.assertEqual(out, "prod_db")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_missing_backup_file_rejected(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
restore_postgres(os.path.join(self.tmp.name, "nope.sql"),
|
||||
"restore_test",
|
||||
runner=lambda c, e: _fake_result())
|
||||
|
||||
def test_empty_backup_file_rejected(self):
|
||||
empty = os.path.join(self.tmp.name, "empty.sql")
|
||||
open(empty, "w").close()
|
||||
with self.assertRaises(ValueError):
|
||||
restore_postgres(empty, "restore_test",
|
||||
runner=lambda c, e: _fake_result())
|
||||
|
||||
def test_password_only_in_env_not_in_command(self):
|
||||
calls = []
|
||||
|
||||
def runner(cmd, env):
|
||||
calls.append((list(cmd), dict(env)))
|
||||
return _fake_result()
|
||||
|
||||
out = restore_postgres(self.backup, "restore_test", runner=runner)
|
||||
self.assertEqual(out, "restore_test")
|
||||
cmd, env = calls[0]
|
||||
self.assertNotIn("test-secret-pw", " ".join(cmd))
|
||||
self.assertEqual(env.get("PGPASSWORD"), "test-secret-pw")
|
||||
self.assertIn("-f", cmd)
|
||||
self.assertIn(self.backup, cmd)
|
||||
self.assertIn("restore_test", cmd)
|
||||
|
||||
def test_psql_failure_raises(self):
|
||||
def runner(cmd, env):
|
||||
return _fake_result(returncode=1, stderr="psql boom")
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
restore_postgres(self.backup, "restore_test", runner=runner)
|
||||
|
||||
|
||||
class WorkerHeartbeatTestCase(unittest.TestCase):
|
||||
def test_heartbeat_file_written_with_last_seen_and_pid(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
hb = os.path.join(tmp.name, "worker-heartbeat.json")
|
||||
worker = Worker(os.path.join(tmp.name, "db.db"), "/nonexistent/host.py",
|
||||
env_key="TEST_KEY", heartbeat_file=hb)
|
||||
worker._write_heartbeat()
|
||||
with open(hb) as f:
|
||||
payload = json.load(f)
|
||||
self.assertIn("last_seen", payload)
|
||||
self.assertEqual(payload["pid"], os.getpid())
|
||||
self.assertLess(abs(payload["last_seen"] - time.time()), 5)
|
||||
tmp.cleanup()
|
||||
|
||||
def test_heartbeat_write_failure_does_not_crash_worker(self):
|
||||
# 心跳目录不可写时只记日志,不抛异常(写到一个文件路径下当目录)。
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
blocker = os.path.join(tmp.name, "blocker")
|
||||
open(blocker, "w").close()
|
||||
worker = Worker(os.path.join(tmp.name, "db.db"), "/nonexistent/host.py",
|
||||
env_key="TEST_KEY",
|
||||
heartbeat_file=os.path.join(blocker, "hb.json"))
|
||||
worker._write_heartbeat() # 不抛异常即通过
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
class HealthWorkerFieldTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _health(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
conn.request("GET", "/health")
|
||||
resp = conn.getresponse()
|
||||
body = json.loads(resp.read())
|
||||
conn.close()
|
||||
return resp.status, body
|
||||
|
||||
def test_health_worker_fresh_ok(self):
|
||||
hb = os.path.join(self.tmp.name, "hb.json")
|
||||
with open(hb, "w") as f:
|
||||
json.dump({"last_seen": time.time(), "pid": 123}, f)
|
||||
self.server.worker_heartbeat_file = hb
|
||||
status, body = self._health()
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(body["ok"])
|
||||
self.assertTrue(body["worker_ok"])
|
||||
self.assertIsNotNone(body["worker_last_seen_seconds_ago"])
|
||||
self.assertLess(body["worker_last_seen_seconds_ago"], 5)
|
||||
|
||||
def test_health_worker_stale_not_ok_but_still_200(self):
|
||||
hb = os.path.join(self.tmp.name, "hb.json")
|
||||
with open(hb, "w") as f:
|
||||
json.dump({"last_seen": time.time() - 1000, "pid": 123}, f)
|
||||
self.server.worker_heartbeat_file = hb
|
||||
status, body = self._health()
|
||||
# worker 不 ok 不代表 server 挂了,health 本身仍返回 200。
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(body["worker_ok"])
|
||||
self.assertGreater(body["worker_last_seen_seconds_ago"], 500)
|
||||
|
||||
def test_health_worker_missing_file_none_false(self):
|
||||
self.server.worker_heartbeat_file = os.path.join(
|
||||
self.tmp.name, "no-such-hb.json")
|
||||
status, body = self._health()
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsNone(body["worker_last_seen_seconds_ago"])
|
||||
self.assertFalse(body["worker_ok"])
|
||||
|
||||
|
||||
class MonitorSelfTestCase(unittest.TestCase):
|
||||
def test_monitor_self_test_passes(self):
|
||||
# monitor.sh --self-test 只验证脚本机制(health 解析 + JSON 合法),
|
||||
# 不断言主机健康;VM 上其它项 fail 是正常的,self-test 仍应通过。
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
script = repo_root / "deploy" / "monitor.sh"
|
||||
self.assertTrue(script.exists())
|
||||
result = subprocess.run(
|
||||
["sh", str(script), "--self-test"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("SELF-TEST PASS", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+40
-2
@@ -208,7 +208,7 @@ class Worker:
|
||||
cost_store=None, process_factory=None,
|
||||
task_timeout=1800, max_retries=1,
|
||||
retry_backoff_seconds=5.0, repos_dir="./repos",
|
||||
repos_runner=None):
|
||||
repos_runner=None, heartbeat_file=None):
|
||||
self.db_path = str(db_path)
|
||||
self.host_py_path = host_py_path
|
||||
self.config_path = config_path
|
||||
@@ -252,6 +252,31 @@ class Worker:
|
||||
# repos_runner 可注入(单测用假 git runner),缺省走真实 git。
|
||||
self.repos_dir = repos_dir
|
||||
self.repos_runner = repos_runner
|
||||
# 运维心跳:路径优先显式参数,其次环境变量,默认当前目录下
|
||||
# 的 worker-heartbeat.json;主机路径不在代码里硬编码,主机
|
||||
# 部署时用 --heartbeat-file 或环境变量传入(见 deploy/README)。
|
||||
# 文件里只有 last_seen 与 pid,绝无任何密钥。
|
||||
self.heartbeat_file = heartbeat_file or \
|
||||
os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \
|
||||
"./worker-heartbeat.json"
|
||||
|
||||
def _write_heartbeat(self):
|
||||
"""写一次运维心跳:写失败只记日志,绝不炸 worker。
|
||||
|
||||
先写临时文件再 os.replace,保证 server 读到的要么是旧的
|
||||
完整文件、要么是新的完整文件,不会读到写一半的 JSON。
|
||||
"""
|
||||
try:
|
||||
payload = {"last_seen": time.time(), "pid": os.getpid()}
|
||||
tmp = "%s.tmp" % self.heartbeat_file
|
||||
parent = os.path.dirname(os.path.abspath(self.heartbeat_file))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload))
|
||||
os.replace(tmp, self.heartbeat_file)
|
||||
except Exception as exc:
|
||||
log_event(self.logger, "worker_heartbeat_failed",
|
||||
message="worker heartbeat write failed: %s" % exc)
|
||||
|
||||
# -- 成本存储(惰性建表,避免只认领不执行时也建表)--------------
|
||||
@property
|
||||
@@ -954,6 +979,9 @@ class Worker:
|
||||
# 常驻启动清理兜底:清掉上一轮残留的 codex- 容器,
|
||||
# 失败只记日志(见 cleanup_stale_containers),不炸循环。
|
||||
self.cleanup_stale_containers()
|
||||
# 运维心跳:启动时先写一次,之后每轮结束再写,server 的
|
||||
# /health 据此算 worker_ok(见 server.py 的健康检查)。
|
||||
self._write_heartbeat()
|
||||
started = time.time()
|
||||
all_results = []
|
||||
try:
|
||||
@@ -972,6 +1000,9 @@ class Worker:
|
||||
message="worker_loop_error: %s" % exc)
|
||||
results = []
|
||||
all_results.extend(results)
|
||||
# 每轮结束写心跳:哪怕本轮一个任务都没派,worker 活着
|
||||
# 这件事本身也要被 server 看到。
|
||||
self._write_heartbeat()
|
||||
# 按 0.2 秒切片睡,期间可响应 stop_event/_loop_stop。
|
||||
deadline = time.time() + interval
|
||||
while time.time() < deadline:
|
||||
@@ -1025,6 +1056,12 @@ def main(argv=None):
|
||||
help="host.py 路径,缺省为 cloud/ 上一级的 host.py")
|
||||
parser.add_argument("--config", default=None)
|
||||
parser.add_argument("--repo", default=None)
|
||||
parser.add_argument("--heartbeat-file", default=None,
|
||||
help="运维心跳文件路径(默认:环境变量 "
|
||||
"CLOUD_WORKER_HEARTBEAT_FILE,未设则为 "
|
||||
"./worker-heartbeat.json;主机部署时用此"
|
||||
"参数指到 /var/lib/codex-app-server/ 下,"
|
||||
"与 server 的 health 读取路径保持一致)")
|
||||
parser.add_argument("--repos-dir", default="./repos",
|
||||
help="项目仓库检出根目录(默认 ./repos);项目有"
|
||||
" repo_url 时 worker 在此 clone/fetch 各项目"
|
||||
@@ -1081,7 +1118,8 @@ def main(argv=None):
|
||||
task_timeout=args.task_timeout,
|
||||
max_retries=args.max_retries,
|
||||
retry_backoff_seconds=args.retry_backoff_seconds,
|
||||
repos_dir=args.repos_dir)
|
||||
repos_dir=args.repos_dir,
|
||||
heartbeat_file=args.heartbeat_file)
|
||||
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
|
||||
worker.cleanup_stale_containers()
|
||||
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# 部署与运维(deploy/)
|
||||
|
||||
本目录是主机部署与运维的脚本集,与 `cloud/` 代码分开:部署脚本只负责
|
||||
传代码、备份、重启、验证,不改任务逻辑;运维脚本只负责检查与演练,
|
||||
不跑模型、不推代码。
|
||||
|
||||
## 部署:deploy.sh
|
||||
|
||||
VM 侧运行:
|
||||
|
||||
```sh
|
||||
deploy/deploy.sh <host> [user] [--dry-run]
|
||||
# 例如:deploy/deploy.sh 124.222.165.170 root
|
||||
# 干跑(只打印步骤,不传代码、不动主机):
|
||||
# deploy/deploy.sh <host> --dry-run
|
||||
```
|
||||
|
||||
- 密码:脚本绝不接受密码参数、不读 env 文件、不打印密钥。SSH 密码由
|
||||
ssh/scp 交互提示输入,或由调用方自己的 SSH 通道(key)提供。
|
||||
- 经代理:设环境变量 `SSH_PROXY_CMD` 传入 ProxyCommand 内容,ssh 会自行
|
||||
展开 `%h %p`,例如:
|
||||
`SSH_PROXY_CMD="python3 /tmp/proxy_tunnel.py %h %p" deploy/deploy.sh <host>`;
|
||||
未设则直连 ssh。
|
||||
- 备份保留策略:每次部署前在主机上把当前 `/opt/codex-app-server` 打包到
|
||||
`/opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz`(排除
|
||||
work/ 目录以免过大),只留最近 3 份,多余的自动删掉。
|
||||
- 健康门:重启后循环 curl `localhost:8765/health` 最多 60 秒,要求
|
||||
`ok=true` 且 `backend=postgres` 且 `db_ok=true`;失败时自动恢复刚打的
|
||||
备份包并重启,再验一次。回滚后 health 恢复也算部署失败(退出非零),
|
||||
因为新代码没上去;回滚后 health 仍失败则退出非零并明说
|
||||
“已回滚但 health 仍失败,需人工介入”。
|
||||
- 成功时只打印新代码的 git commit(从本机 git 取,不编造版本号)。
|
||||
|
||||
## 回滚:rollback.sh
|
||||
|
||||
VM 侧运行,恢复主机上最新的一份备份包:
|
||||
|
||||
```sh
|
||||
deploy/rollback.sh <host> [user]
|
||||
```
|
||||
|
||||
恢复后同样重启并验 health(60 秒),成功/失败都明说;主机上没有备份包
|
||||
时直接报错退出,不编造。
|
||||
|
||||
### 回滚边界:只回代码,不回数据库
|
||||
|
||||
数据库迁移是只加列的幂等操作(例如 projects.repo_url、tasks 的 6 列),
|
||||
回滚代码后老代码仍能读新列——新列对老代码只是多余的列,不影响已有
|
||||
查询(老代码的 SELECT 只取它认识的列,或用 `_row_get` 容错)。所以只回
|
||||
代码是安全的;数据库本身绝不会被 deploy.sh / rollback.sh 动到。这一点
|
||||
逻辑上成立的前提是:迁移永远只加列、不改列类型、不删列;如果将来有一次
|
||||
迁移要改类型或删列,必须先单独评估回滚方案,不能直接套用本脚本。
|
||||
|
||||
## 备份恢复演练:restore-drill.sh
|
||||
|
||||
主机侧运行(root,或能读 `/etc/codex-app-server/env` 的用户):
|
||||
|
||||
```sh
|
||||
sh /opt/codex-app-server/deploy/restore-drill.sh
|
||||
# 可选:BACKUP_DIR=/var/lib/codex-app-server/backups(默认就是这个)
|
||||
```
|
||||
|
||||
步骤:跑一次 `backup_postgres` 生成新鲜备份 → 新建测试库
|
||||
`codex_control_restore_test_<时间戳>` → 用 `cloud.backup.restore_postgres`
|
||||
把备份恢复进去 → 比对生产库与测试库的 tasks / projects / users /
|
||||
audit_log 行数(行数相等即通过,只比行数,不比对具体内容,不打印任何
|
||||
行内容)→ 比对完立即删掉测试库(trap 保证中途失败也删)。
|
||||
|
||||
安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名;
|
||||
`restore_postgres` 本身也会拒绝目标库名等于 `CLOUD_DB_NAME`
|
||||
(`allow_production` 绝不在本脚本里设为 True),双重保险。本脚本绝不
|
||||
恢复到生产库,绝不删生产库;全程只打印 PASS/FAIL 与行数,不打印密码,
|
||||
不打印备份内容。
|
||||
|
||||
## 监控:monitor.sh + monitor.service / monitor.timer
|
||||
|
||||
主机侧运行,codexsvc 可读权限即可,不读 env 文件:
|
||||
|
||||
```sh
|
||||
sh /opt/codex-app-server/deploy/monitor.sh # 输出一行 JSON,任一项 fail 则退出非零
|
||||
sh /opt/codex-app-server/deploy/monitor.sh --self-test # 只验证脚本机制(见下)
|
||||
```
|
||||
|
||||
检查项:systemctl is-active server/worker、curl health(ok/backend/db_ok/
|
||||
worker_ok)、pg_isready、docker ps 里 codex-postgres 在运行、磁盘使用率
|
||||
<90%、/opt/codex-app-server 存在。health 与 pg_isready 都不需要密码;
|
||||
需要密码的 SELECT 1 那一步直接跳过,不在脚本里读密码。
|
||||
|
||||
`--self-test` 只验证脚本本身的机制(health JSON 解析 + 输出是合法 JSON):
|
||||
它起一个本地 canned health server,断言 health 那一项为 pass;其它项在
|
||||
非主机机器上 fail 是正常的,self-test 不因此失败。不要把 --self-test
|
||||
通过误读成“主机健康”。
|
||||
|
||||
定时(主机上安装,示例):
|
||||
|
||||
```sh
|
||||
cp /opt/codex-app-server/deploy/monitor.service /etc/systemd/system/monitor.service
|
||||
cp /opt/codex-app-server/deploy/monitor.timer /etc/systemd/system/monitor.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now monitor.timer
|
||||
journalctl -u monitor.service -f # 看每次检查的输出
|
||||
```
|
||||
|
||||
timer 每 5 分钟跑一次,日志进 journal。
|
||||
|
||||
### 监控边界:本轮只有“主机侧检查 + journal 日志”
|
||||
|
||||
monitor.sh 本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行
|
||||
决定通道(例如 SSH key 常驻通道让外部 cron 拉 journal,或者一个
|
||||
webhook URL 让 monitor 失败时 POST 过去),在通道定下来之前,不要假装
|
||||
monitor.sh 会通知任何人。手工验证步骤:主机上跑一次 monitor.sh 看 JSON
|
||||
与退出码,再跑一次 --self-test 看 SELF-TEST PASS,最后
|
||||
`systemctl list-timers | grep monitor` 确认 timer 已启用。
|
||||
|
||||
## worker 心跳与 health 的 worker 字段
|
||||
|
||||
worker 的 `run_loop` 每次循环结束时写心跳文件,内容只有
|
||||
`{"last_seen": epoch秒, "pid": 进程号}`,不含任何密钥;写失败只记日志,
|
||||
不炸 worker。心跳文件路径:worker 用 `--heartbeat-file` 指定(主机部署
|
||||
时指到 `/var/lib/codex-app-server/worker-heartbeat.json`),未指定时看
|
||||
环境变量 `CLOUD_WORKER_HEARTBEAT_FILE`,再未指定则为
|
||||
`./worker-heartbeat.json`(VM 本地跑的默认,不在代码里硬编码主机路径)。
|
||||
|
||||
server 的 `GET /health` 在原有 ok/service/backend/db_ok 之外加两个字段:
|
||||
`worker_last_seen_seconds_ago`(心跳文件不存在或读失败时为 None)与
|
||||
`worker_ok`(心跳在 180 秒内为 true,否则 false;阈值可经环境变量
|
||||
`CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS` 调,默认 180)。health 本身仍
|
||||
返回 200——worker 不 ok 不代表 server 挂了,调用方看 worker_ok 字段判断,
|
||||
不要把 health 改成 503 破坏已有调用。
|
||||
|
||||
主机部署时两边路径必须一致:worker 单元加
|
||||
`--heartbeat-file /var/lib/codex-app-server/worker-heartbeat.json`,同时
|
||||
在 server 的 env 文件(`/etc/codex-app-server/env`,server 单元的
|
||||
EnvironmentFile)里加一行
|
||||
`CLOUD_WORKER_HEARTBEAT_FILE=/var/lib/codex-app-server/worker-heartbeat.json`。
|
||||
deploy.sh 不会自动改 env 文件(那是 root-only 的密钥文件),这一行由
|
||||
部署操作人在主机上手动加,加完重启 server 即可。
|
||||
|
||||
## 多用户真实验证:verify-multiuser.py
|
||||
|
||||
主机侧以 codexsvc 身份运行,直接对生产库 + 本地 HTTP API 验证:
|
||||
|
||||
```sh
|
||||
python3 /opt/codex-app-server/deploy/verify-multiuser.py
|
||||
# 可选:--base http://127.0.0.1:8765 --db /var/lib/codex-app-server/control.db
|
||||
```
|
||||
|
||||
它在内存里创建 4 个测试用户(owner / maintainer / contributor / viewer)
|
||||
与 2 个测试项目(P2 只有 owner 是成员,用于隔离验证),再用 HTTP API
|
||||
验证:owner/maintainer/contributor/viewer 的建任务、读任务、merge 权限,
|
||||
以及跨项目隔离(P1 成员读 P2 任务 403)、无 key 401。merge 验证用的
|
||||
completed 测试任务是直接在 DB 里把测试任务置 completed,不跑真实模型。
|
||||
|
||||
防误伤要点(脚本逻辑里已实现):
|
||||
- 测试用户、项目、任务全部用 `multiuser-test-` 前缀,清理时只删这些
|
||||
前缀的行,绝不碰其它生产数据;清理失败时退出非零并列出残留测试 id
|
||||
(不含 key),不静默假装干净。
|
||||
- 测试任务创建时都带一个永不存在的依赖 `multiuser-test-never-completes`:
|
||||
worker 只派依赖全部 completed 的 queued 任务,所以这些测试任务永远
|
||||
不会被生产 worker 认领去跑模型,不花模型钱、不改任何仓库。
|
||||
- API key 只存在内存里,脚本全程只打印每项 PASS/FAIL 与总数,绝不打印
|
||||
任何 API key;任务 prompt 与用户 id 也不打印,只打印检查名与结果。
|
||||
- 审计日志是 append-only 的:验证过程的审计条目会留在 audit_log 里,
|
||||
脚本不删审计表——审计就该留痕,这一点不是清理遗漏。
|
||||
|
||||
## 公网入口与 webhook secret(已有文件,不属于本轮新增)
|
||||
|
||||
- `caddy/Caddyfile`:只暴露 `/webhooks/*` 与 `/oauth/*`,任务/项目/管理
|
||||
API 对外一律 403;域名与邮箱占位符需部署时替换。
|
||||
- `setup-webhook-secret.sh`:在主机上生成 webhook secret 并写入 env 文件,
|
||||
绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。
|
||||
|
||||
## 仍未做(不要假装已做)
|
||||
|
||||
- 推送告警通道:monitor 失败时通知到 Muse 聊天的通道还没定(见监控一节)。
|
||||
- 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。
|
||||
- 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的
|
||||
真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。
|
||||
- CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/bin/sh
|
||||
# 部署自动化:VM 侧运行,把本地代码同步到外部主机并重启验证,失败自动回滚。
|
||||
#
|
||||
# 用法:
|
||||
# deploy/deploy.sh <host> [user] [--dry-run]
|
||||
# host:主机 IP 或域名(必填);user:SSH 用户(默认 root)。
|
||||
# --dry-run:只打印将要做的步骤,不 scp、不 ssh、不改主机。
|
||||
#
|
||||
# 密码:本脚本绝不接受密码参数、不读任何 env 文件、不打印任何密钥。
|
||||
# SSH 密码由 ssh/scp 交互提示输入,或由调用方自己的 SSH 通道提供
|
||||
# (例如已配好的 key)。需要经代理时,用环境变量 SSH_PROXY_CMD
|
||||
# 传入 ProxyCommand 内容(ssh 会自行展开 %h %p),未设则直连:
|
||||
# SSH_PROXY_CMD="python3 /tmp/proxy_tunnel.py %h %p" deploy/deploy.sh <host>
|
||||
#
|
||||
# 步骤:
|
||||
# 1. 本机打代码 tar 包(排除 bin/、work/、.git、__pycache__、state、
|
||||
# host-config-real.json,与之前手工同步的排除规则一致)。
|
||||
# 2. scp 到主机 /tmp/codex-app-server-code.tar.gz。
|
||||
# 3. ssh 到主机:先把当前 /opt/codex-app-server 打备份包到
|
||||
# /opt/codex-app-server-backups/(时间戳命名,只留最近 3 份,
|
||||
# 备份排除 work/ 目录以免过大);解包新代码并 chown codexsvc;
|
||||
# 重启 server+worker;循环 curl localhost:8765/health 最多 60 秒,
|
||||
# 要求 ok=true 且 backend=postgres 且 db_ok=true。
|
||||
# 4. 健康检查失败时自动恢复刚打的备份包、重启、再验 health;回滚后
|
||||
# health 恢复也算部署失败(退出非零),因为新代码没上去;回滚后
|
||||
# health 仍失败则退出非零并明说“已回滚但 health 仍失败,需人工介入”。
|
||||
# 5. 成功时只打印新代码的 git commit(从本机 git 取,不编造版本号)。
|
||||
#
|
||||
# 回滚边界:回滚只回代码,不回数据库。数据库迁移是只加列的幂等操作
|
||||
# (projects.repo_url、tasks 的 6 列等),回滚代码后老代码仍能读新列,
|
||||
# 所以只回代码是安全的;数据库本身绝不会被本脚本动到。
|
||||
set -eu
|
||||
|
||||
HOST=${1:-}
|
||||
USER_ARG=${2:-root}
|
||||
DRY_RUN=0
|
||||
for a in "$@"; do
|
||||
if [ "$a" = "--dry-run" ]; then DRY_RUN=1; fi
|
||||
done
|
||||
if [ -z "$HOST" ] || [ "$HOST" = "--dry-run" ]; then
|
||||
echo "用法:deploy/deploy.sh <host> [user] [--dry-run]" >&2
|
||||
exit 2
|
||||
fi
|
||||
# user 位置可能是 --dry-run,处理一下
|
||||
if [ "$USER_ARG" = "--dry-run" ]; then USER_ARG=root; fi
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
TARBALL="$REPO_ROOT/work/external-install/codex-app-server-code.tar.gz"
|
||||
REMOTE_TARBALL="/tmp/codex-app-server-code.tar.gz"
|
||||
|
||||
# ssh/scp 命令构造:有 SSH_PROXY_CMD 时走代理,否则直连。
|
||||
ssh_cmd() {
|
||||
if [ -n "${SSH_PROXY_CMD:-}" ]; then
|
||||
ssh -o "ProxyCommand $SSH_PROXY_CMD" -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null "$@"
|
||||
else
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@"
|
||||
fi
|
||||
}
|
||||
scp_cmd() {
|
||||
if [ -n "${SSH_PROXY_CMD:-}" ]; then
|
||||
scp -o "ProxyCommand $SSH_PROXY_CMD" -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null "$@"
|
||||
else
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "部署目标:$USER_ARG@$HOST,代码 commit:$COMMIT"
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "[dry-run] 将打 tar 包:$TARBALL(排除 bin/ work/ .git __pycache__ state host-config-real.json)"
|
||||
echo "[dry-run] 将 scp 到 $USER_ARG@$HOST:$REMOTE_TARBALL"
|
||||
echo "[dry-run] 将在主机上备份 /opt/codex-app-server 到 /opt/codex-app-server-backups/(留 3 份),解包、chown codexsvc、重启 server+worker、验 health 60 秒,失败自动回滚"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$REPO_ROOT/work/external-install"
|
||||
tar --exclude='./bin' --exclude='./work' --exclude='./.git' \
|
||||
--exclude='__pycache__' --exclude='*.pyc' \
|
||||
--exclude='host-state*.json*' --exclude='host-daemon.*' \
|
||||
--exclude='host-config-real.json' --exclude='host-run.log' \
|
||||
-czf "$TARBALL" .
|
||||
echo "tar 包已生成:$TARBALL"
|
||||
|
||||
scp_cmd "$TARBALL" "$USER_ARG@$HOST:$REMOTE_TARBALL"
|
||||
echo "tar 包已传到主机"
|
||||
|
||||
# 主机侧部署脚本:备份 -> 解包 -> 重启 -> 验 health -> 失败回滚。
|
||||
# 注意:这里用单引号 heredoc 传过去,主机侧变量不被本机展开;
|
||||
# 健康判断用 python3 解析 JSON,不依赖 jq(主机未必装 jq)。
|
||||
ssh_cmd "$USER_ARG@$HOST" 'sh -s' <<'HOST_SCRIPT'
|
||||
set -eu
|
||||
APP_DIR=/opt/codex-app-server
|
||||
BACKUP_DIR=/opt/codex-app-server-backups
|
||||
STAMP=$(date +%Y%m%d%H%M%S)
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE="$BACKUP_DIR/codex-app-server-$STAMP.tar.gz"
|
||||
|
||||
check_health() {
|
||||
# 要求 ok=true 且 backend=postgres 且 db_ok=true,满足返回 0。
|
||||
OUT=$(curl -s --max-time 5 http://127.0.0.1:8765/health || true)
|
||||
[ -n "$OUT" ] && printf '%s' "$OUT" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres"
|
||||
and d.get("db_ok") is True) else 1)
|
||||
'
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
i=0
|
||||
while [ "$i" -lt 12 ]; do
|
||||
if check_health; then return 0; fi
|
||||
sleep 5
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 1. 备份当前代码(排除 work/,只留最近 3 份)
|
||||
tar --exclude='./work' -czf "$BACKUP_FILE" -C /opt codex-app-server
|
||||
echo "已备份当前代码到:$BACKUP_FILE"
|
||||
ls -t "$BACKUP_DIR"/codex-app-server-*.tar.gz 2>/dev/null \
|
||||
| tail -n +4 | xargs -r rm -f
|
||||
|
||||
# 2. 解包新代码并改属主
|
||||
tar -xzf /tmp/codex-app-server-code.tar.gz -C "$APP_DIR"
|
||||
chown -R codexsvc:codexsvc "$APP_DIR"
|
||||
|
||||
# 3. 重启并验 health
|
||||
systemctl restart codex-cloud-server codex-cloud-worker
|
||||
if wait_health; then
|
||||
echo "部署成功,health 通过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4. 失败:恢复备份并重启,再验一次
|
||||
echo "新代码 health 未通过,开始回滚到备份:$BACKUP_FILE"
|
||||
systemctl stop codex-cloud-worker codex-cloud-server || true
|
||||
tar -xzf "$BACKUP_FILE" -C /opt
|
||||
chown -R codexsvc:codexsvc "$APP_DIR"
|
||||
systemctl start codex-cloud-server codex-cloud-worker
|
||||
if wait_health; then
|
||||
echo "部署失败,已回滚到备份,health 已恢复(新代码未上线)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "已回滚但 health 仍失败,需人工介入" >&2
|
||||
exit 1
|
||||
HOST_SCRIPT
|
||||
|
||||
echo "部署完成,代码 commit:$COMMIT"
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Codex cloud monitor check (one-shot)
|
||||
After=network.target codex-cloud-server.service codex-cloud-worker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=codexsvc
|
||||
WorkingDirectory=/opt/codex-app-server
|
||||
# monitor.sh 只做检查、不读 env 文件、不发消息;失败时 systemd 会把
|
||||
# 退出码与输出记进 journal,告警通道见 deploy/README.md(另行决定)。
|
||||
ExecStart=/bin/sh /opt/codex-app-server/deploy/monitor.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/bin/sh
|
||||
# 主机侧监控检查:输出一行 JSON 状态,任一项 fail 则退出非零。
|
||||
#
|
||||
# 用法(主机上,codexsvc 可读权限即可):
|
||||
# sh /opt/codex-app-server/deploy/monitor.sh
|
||||
# sh /opt/codex-app-server/deploy/monitor.sh --self-test
|
||||
#
|
||||
# 检查项(全部不需要密码、不读 env 文件):
|
||||
# server:systemctl is-active codex-cloud-server 为 active
|
||||
# worker:systemctl is-active codex-cloud-worker 为 active
|
||||
# health:curl health,要求 ok=true、backend=postgres、db_ok=true、
|
||||
# worker_ok=true(worker 心跳 180 秒内,见 server.py)
|
||||
# postgres:pg_isready -h 127.0.0.1 -p 5432(只测就绪,不连库查数据,
|
||||
# 所以不需要密码;需要密码的 SELECT 1 那一步直接跳过,不做)
|
||||
# docker_postgres:docker ps 里 codex-postgres 在运行
|
||||
# disk:磁盘使用率 < 90%(默认看 /var/lib/codex-app-server,不存在则看 /)
|
||||
# app_dir:/opt/codex-app-server 目录存在
|
||||
#
|
||||
# 输出示例(单行 JSON,值只会是 pass/fail,不含任何密钥):
|
||||
# {"overall": "pass", "checks": {"server": "pass", ...}}
|
||||
#
|
||||
# --self-test:只验证脚本本身的机制(health JSON 解析 + 输出是合法 JSON),
|
||||
# 不验证主机健康。它会起一个本地 canned health server(只返回固定健康
|
||||
# JSON),断言 health 那一项为 pass;其它项在非主机机器上 fail 是正常的,
|
||||
# self-test 不因此失败。不要把 --self-test 通过误读成“主机健康”。
|
||||
#
|
||||
# 告警边界:本脚本只做“检查 + journal 日志”(配合 monitor.timer 每 5 分钟
|
||||
# 跑一次),它本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行
|
||||
# 决定通道(SSH key 常驻通道或 webhook URL),见 deploy/README.md。
|
||||
set -eu
|
||||
|
||||
HEALTH_URL=${MONITOR_HEALTH_URL:-http://127.0.0.1:8765/health}
|
||||
APP_DIR=${MONITOR_APP_DIR:-/opt/codex-app-server}
|
||||
DISK_PATH=${MONITOR_DISK_PATH:-/var/lib/codex-app-server}
|
||||
if [ ! -d "$DISK_PATH" ]; then DISK_PATH="/"; fi
|
||||
|
||||
check_server() {
|
||||
if systemctl is-active codex-cloud-server >/dev/null 2>&1; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
}
|
||||
|
||||
check_worker() {
|
||||
if systemctl is-active codex-cloud-worker >/dev/null 2>&1; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
}
|
||||
|
||||
check_health() {
|
||||
OUT=$(curl -s --max-time 5 "$HEALTH_URL" || true)
|
||||
if [ -z "$OUT" ]; then echo fail; return; fi
|
||||
if printf '%s' "$OUT" | HEALTH_JSON="$OUT" python3 -c '
|
||||
import json, os, sys
|
||||
try:
|
||||
d = json.loads(os.environ["HEALTH_JSON"])
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres"
|
||||
and d.get("db_ok") is True
|
||||
and d.get("worker_ok") is True) else 1)
|
||||
' 2>/dev/null; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
}
|
||||
|
||||
check_postgres() {
|
||||
if command -v pg_isready >/dev/null 2>&1 \
|
||||
&& pg_isready -h 127.0.0.1 -p 5432 >/dev/null 2>&1; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
}
|
||||
|
||||
check_docker_postgres() {
|
||||
if command -v docker >/dev/null 2>&1 \
|
||||
&& docker ps --format '{{.Names}}' 2>/dev/null \
|
||||
| grep -qx 'codex-postgres'; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
}
|
||||
|
||||
check_disk() {
|
||||
USE=$(df -P "$DISK_PATH" 2>/dev/null | awk 'NR==2 {print $5}' \
|
||||
| tr -d '% ' || true)
|
||||
case "$USE" in
|
||||
''|*[!0-9]*) echo fail ;;
|
||||
*) if [ "$USE" -lt 90 ]; then echo pass; else echo fail; fi ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_app_dir() {
|
||||
if [ -d "$APP_DIR" ]; then echo pass; else echo fail; fi
|
||||
}
|
||||
|
||||
run_checks() {
|
||||
S=$(check_server); W=$(check_worker); H=$(check_health)
|
||||
P=$(check_postgres); D=$(check_docker_postgres)
|
||||
K=$(check_disk); A=$(check_app_dir)
|
||||
OVERALL=pass
|
||||
for v in "$S" "$W" "$H" "$P" "$D" "$K" "$A"; do
|
||||
if [ "$v" != "pass" ]; then OVERALL=fail; break; fi
|
||||
done
|
||||
printf '{"overall": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s"}}\n' \
|
||||
"$OVERALL" "$S" "$W" "$H" "$P" "$D" "$K" "$A"
|
||||
if [ "$OVERALL" = "pass" ]; then return 0; else return 1; fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
# 起一个 canned health server:只返回固定健康 JSON,不读任何真实服务。
|
||||
SELF_PORT=18999
|
||||
python3 - "$SELF_PORT" <<'PY' &
|
||||
import http.server
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = json.dumps({
|
||||
"ok": True, "service": "cloud-control-plane",
|
||||
"backend": "postgres", "db_ok": True,
|
||||
"worker_last_seen_seconds_ago": 1.0, "worker_ok": True,
|
||||
}).encode()
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
http.server.HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
|
||||
PY
|
||||
SERVER_PID=$!
|
||||
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
|
||||
sleep 1
|
||||
MONITOR_HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
|
||||
HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
|
||||
sh "$0" > /tmp/monitor-self-test.json 2>/dev/null || true
|
||||
# 注意:上面那行是跑完整检查,health 以外项在非主机上 fail 是正常的;
|
||||
# self-test 只断言两件事:输出是合法 JSON,且 health 解析为 pass。
|
||||
if python3 -c '
|
||||
import json
|
||||
import sys
|
||||
with open("/tmp/monitor-self-test.json") as f:
|
||||
d = json.load(f)
|
||||
sys.exit(0 if d["checks"]["health"] == "pass" else 1)
|
||||
'; then
|
||||
echo "SELF-TEST PASS:输出是合法 JSON 且 health 解析为 pass(其它项在非主机上 fail 属正常,不影响 self-test)"
|
||||
cat /tmp/monitor-self-test.json
|
||||
exit 0
|
||||
else
|
||||
echo "SELF-TEST FAIL:health 解析未通过或输出不是合法 JSON" >&2
|
||||
cat /tmp/monitor-self-test.json >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
run_checks
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Run Codex cloud monitor check every 5 minutes
|
||||
|
||||
[Timer]
|
||||
# 每 5 分钟跑一次 monitor.sh;Persistent=true 表示主机停机错过的那次
|
||||
# 会在下次开机时补跑一次,不会静默丢检查。
|
||||
OnCalendar=*:0/5
|
||||
Persistent=true
|
||||
Unit=monitor.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/sh
|
||||
# 备份恢复演练:主机侧运行(root,或能读 /etc/codex-app-server/env 的用户)。
|
||||
#
|
||||
# 用法(主机上):sh /opt/codex-app-server/deploy/restore-drill.sh
|
||||
# 可选环境变量:BACKUP_DIR(默认 /var/lib/codex-app-server/backups)。
|
||||
#
|
||||
# 步骤:
|
||||
# 1. 跑一次 backup_postgres 生成新鲜备份(.sql)。
|
||||
# 2. 新建一个测试库 codex_control_restore_test_<时间戳>,用
|
||||
# cloud.backup.restore_postgres 把备份恢复进去。
|
||||
# 3. 比对生产库与测试库的 tasks / projects / users / audit_log 行数,
|
||||
# 行数相等即通过;只比行数,不比对具体内容,不打印任何行内容。
|
||||
# 4. 比对完立即删掉测试库(trap 保证即使中途失败也删)。
|
||||
#
|
||||
# 安全红线(写进脚本逻辑里,不是只写在注释里):
|
||||
# - 恢复目标永远是新生成的测试库名,绝不等于生产库名;restore_postgres
|
||||
# 本身也会拒绝目标库名等于 CLOUD_DB_NAME(allow_production 绝不在
|
||||
# 本脚本里设为 True),双重保险。
|
||||
# - 本脚本绝不恢复到生产库,绝不删生产库;删的只会是本次生成的测试库。
|
||||
# - 全程只打印 PASS/FAIL 与行数,不打印密码,不打印备份内容。
|
||||
# - 本脚本以 root 身份读 env 文件是为了拿到数据库连接参数与密码,
|
||||
# 密码只经 PGPASSWORD 环境变量传给 psql/python 子进程,绝不出现在
|
||||
# 命令行参数里,也绝不被 echo 出来。
|
||||
set -eu
|
||||
|
||||
APP_DIR=${APP_DIR:-/opt/codex-app-server}
|
||||
BACKUP_DIR=${BACKUP_DIR:-/var/lib/codex-app-server/backups}
|
||||
ENV_FILE=${ENV_FILE:-/etc/codex-app-server/env}
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "FAIL:找不到 env 文件:$ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
if [ -z "${CLOUD_DB_NAME:-}" ] || [ -z "${CLOUD_DB_USER:-}" ]; then
|
||||
echo "FAIL:env 文件里缺少 CLOUD_DB_NAME 或 CLOUD_DB_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PGPASSWORD="${CLOUD_DB_PASSWORD:-}"
|
||||
HOST_ARG=${CLOUD_DB_HOST:-127.0.0.1}
|
||||
PORT_ARG=${CLOUD_DB_PORT:-5432}
|
||||
|
||||
STAMP=$(date +%Y%m%d%H%M%S)
|
||||
TEST_DB="codex_control_restore_test_$STAMP"
|
||||
|
||||
drop_test_db() {
|
||||
# trap 保证:无论演练成功失败,测试库都会被删掉,不留垃圾库。
|
||||
psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \
|
||||
-d postgres -c "DROP DATABASE IF EXISTS \"$TEST_DB\"" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap drop_test_db EXIT
|
||||
|
||||
# 1. 新鲜备份(backup_postgres 的连接参数与密码都走环境变量,
|
||||
# 密码只经 PGPASSWORD 传给子进程,绝不出现在命令行或输出里)。
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP_FILE=$(python3 - "$APP_DIR" "$BACKUP_DIR" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from cloud.backup import backup_postgres
|
||||
print(backup_postgres(sys.argv[2]))
|
||||
PY
|
||||
)
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "FAIL:备份生成失败" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "备份已生成(只打印路径,不打印内容):$BACKUP_FILE"
|
||||
|
||||
# 2. 建测试库并恢复
|
||||
psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \
|
||||
-d postgres -c "CREATE DATABASE \"$TEST_DB\"" >/dev/null
|
||||
python3 - "$APP_DIR" "$BACKUP_FILE" "$TEST_DB" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from cloud.backup import restore_postgres
|
||||
# allow_production 故意不传(默认 False):目标库名绝不能等于生产库名。
|
||||
print(restore_postgres(sys.argv[2], sys.argv[3]))
|
||||
PY
|
||||
echo "恢复完成,目标测试库:$TEST_DB"
|
||||
|
||||
# 3. 行数比对(只比行数,不打印行内容)
|
||||
FAIL=0
|
||||
for TABLE in tasks projects users audit_log; do
|
||||
PROD_N=$(psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \
|
||||
-d "$CLOUD_DB_NAME" -t -A -c "SELECT COUNT(*) FROM $TABLE" | tr -d ' \n')
|
||||
REST_N=$(psql -h "$HOST_ARG" -p "$PORT_ARG" -U "$CLOUD_DB_USER" \
|
||||
-d "$TEST_DB" -t -A -c "SELECT COUNT(*) FROM $TABLE" | tr -d ' \n')
|
||||
if [ "$PROD_N" = "$REST_N" ]; then
|
||||
echo "PASS $TABLE:生产库=$PROD_N 恢复库=$REST_N"
|
||||
else
|
||||
echo "FAIL $TABLE:生产库=$PROD_N 恢复库=$REST_N" >&2
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. 删测试库由 trap 执行;这里先显式删一次,trap 再删是空操作。
|
||||
drop_test_db
|
||||
trap - EXIT
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "恢复演练 FAIL:有表行数不一致" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "恢复演练 PASS:全部 4 张表行数一致,测试库已删除"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
# 回滚:VM 侧运行,把主机恢复到最近一份代码备份包并重启验证。
|
||||
#
|
||||
# 用法:deploy/rollback.sh <host> [user]
|
||||
# host:主机 IP 或域名(必填);user:SSH 用户(默认 root)。
|
||||
#
|
||||
# 密码:与 deploy.sh 同款规则——绝不接受密码参数、不读 env 文件、
|
||||
# 不打印任何密钥;SSH 密码由交互提示或调用方通道提供;需要代理时
|
||||
# 用环境变量 SSH_PROXY_CMD 传入 ProxyCommand,未设则直连。
|
||||
#
|
||||
# 回滚边界:只回代码,不回数据库(理由见 deploy.sh 头注释与
|
||||
# deploy/README.md:数据库迁移是只加列的幂等操作,老代码仍能读新列)。
|
||||
# 备份包来自 deploy.sh 每次部署前在主机上打的
|
||||
# /opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz,
|
||||
# 本脚本恢复其中最新的一份;主机上没有备份包时直接报错退出,不编造。
|
||||
set -eu
|
||||
|
||||
HOST=${1:-}
|
||||
USER_ARG=${2:-root}
|
||||
if [ -z "$HOST" ]; then
|
||||
echo "用法:deploy/rollback.sh <host> [user]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ssh_cmd() {
|
||||
if [ -n "${SSH_PROXY_CMD:-}" ]; then
|
||||
ssh -o "ProxyCommand $SSH_PROXY_CMD" -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null "$@"
|
||||
else
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_cmd "$USER_ARG@$HOST" 'sh -s' <<'HOST_SCRIPT'
|
||||
set -eu
|
||||
APP_DIR=/opt/codex-app-server
|
||||
BACKUP_DIR=/opt/codex-app-server-backups
|
||||
|
||||
check_health() {
|
||||
OUT=$(curl -s --max-time 5 http://127.0.0.1:8765/health || true)
|
||||
[ -n "$OUT" ] && printf '%s' "$OUT" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres"
|
||||
and d.get("db_ok") is True) else 1)
|
||||
'
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
i=0
|
||||
while [ "$i" -lt 12 ]; do
|
||||
if check_health; then return 0; fi
|
||||
sleep 5
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
BACKUP_FILE=$(ls -t "$BACKUP_DIR"/codex-app-server-*.tar.gz 2>/dev/null \
|
||||
| head -n 1 || true)
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "主机上没有代码备份包,无法回滚:$BACKUP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "将恢复备份包:$BACKUP_FILE"
|
||||
systemctl stop codex-cloud-worker codex-cloud-server || true
|
||||
tar -xzf "$BACKUP_FILE" -C /opt
|
||||
chown -R codexsvc:codexsvc "$APP_DIR"
|
||||
systemctl start codex-cloud-server codex-cloud-worker
|
||||
if wait_health; then
|
||||
echo "回滚成功,health 通过,当前代码为备份包:$BACKUP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
echo "回滚后 health 仍失败,需人工介入" >&2
|
||||
exit 1
|
||||
HOST_SCRIPT
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""多用户真实验证:主机侧以 codexsvc 身份运行,直接对生产库 + 本地 HTTP API 验证。
|
||||
|
||||
用法(主机上):
|
||||
python3 /opt/codex-app-server/deploy/verify-multiuser.py [--base URL] [--db PATH]
|
||||
默认 --base http://127.0.0.1:8765,--db /var/lib/codex-app-server/control.db
|
||||
(Postgres 后端时 db 参数只作占位,连接参数走 CLOUD_DB_* 环境变量,
|
||||
与 server/worker 一致;SQLite 后端时就是库文件路径)。
|
||||
|
||||
安全与防误伤(写进逻辑里,不是只写在注释里):
|
||||
- 测试用户、项目、任务全部用 multiuser-test- 前缀(项目 id 固定为
|
||||
multiuser-test-p1 / multiuser-test-p2,用户按用户名 multiuser-test-*
|
||||
查找),清理时只删这些前缀的行,绝不碰其它生产数据。
|
||||
- 测试任务创建时都带一个永不存在的依赖 multiuser-test-never-completes:
|
||||
worker 只派依赖全部 completed 的 queued 任务(见 cloud/queue.py 的
|
||||
ready_tasks),所以这些测试任务永远不会被生产 worker 认领去跑模型——
|
||||
不花模型钱、不改任何仓库。验证完任务会被删掉,依赖本身只是一个
|
||||
字符串,不对应任何真实任务行。
|
||||
- API key 只存在内存里:创建用户时 AuthStore 返回的明文 key 只保存在
|
||||
本进程的变量里,用完即弃;脚本全程只打印每项 PASS/FAIL 与总数,
|
||||
绝不打印任何 API key;任务 prompt、用户 id 等内容也不打印,只打印
|
||||
检查名与结果。
|
||||
- 审计日志是 append-only 的:验证过程产生的审计条目(建项目、设成员、
|
||||
建任务、merge 等)会留在 audit_log 里,脚本不删审计表——这是故意的,
|
||||
审计就该留痕;README 里也写清了这一点。
|
||||
- 清理失败时脚本退出非零并列出残留测试 id(不含 key),不静默假装干净。
|
||||
- 本脚本不跑真实模型、不推代码、不改生产项目与任务,只动 multiuser-test-*
|
||||
的行;跑之前不需要停 worker(见上,测试任务不会被认领)。
|
||||
|
||||
检查项(预期值写死在断言里,实际不符即 FAIL):
|
||||
1. owner 建 P1、P2 成功(201)
|
||||
2. owner 建任务成功(201,contributor 建任务也成功)
|
||||
3. viewer 建任务 403;非成员(viewer 对 P2)建任务 403
|
||||
4. viewer 读 P1 任务成功(200)
|
||||
5. viewer 读 P2 任务 403(跨项目隔离:P1 成员不是 P2 成员)
|
||||
6. maintainer 对 completed 测试任务调 merge 成功(200,状态变 merged)
|
||||
7. contributor 对 completed 测试任务调 merge 403
|
||||
8. maintainer 对 queued 测试任务调 merge 409(状态不对)
|
||||
9. 无 key 调 API 401
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from cloud.auth import AuthStore # noqa: E402
|
||||
from cloud.db import connect as db_connect # noqa: E402
|
||||
|
||||
PREFIX = "multiuser-test-"
|
||||
P1 = PREFIX + "p1"
|
||||
P2 = PREFIX + "p2"
|
||||
NEVER_DEP = PREFIX + "never-completes"
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
RESULTS.append((name, bool(ok)))
|
||||
print("%s %s" % ("PASS" if ok else "FAIL", name))
|
||||
|
||||
|
||||
def req(base, method, path, key=None, body=None):
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
r = urllib.request.Request(base + path, data=data, method=method)
|
||||
if key is not None:
|
||||
r.add_header("X-API-Key", key)
|
||||
if body is not None:
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=15) as resp:
|
||||
raw = resp.read()
|
||||
return resp.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
payload = json.loads(raw) if raw else {}
|
||||
except ValueError:
|
||||
payload = {}
|
||||
return e.code, payload
|
||||
except Exception as e:
|
||||
return None, {"error": str(e)}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="multiuser verification")
|
||||
ap.add_argument("--base", default="http://127.0.0.1:8765")
|
||||
ap.add_argument("--db", default="/var/lib/codex-app-server/control.db")
|
||||
args = ap.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
|
||||
auth = AuthStore(args.db)
|
||||
created_task_ids = []
|
||||
created_user_ids = []
|
||||
try:
|
||||
# -- 预清理:上一轮残留的测试行(可重复跑)-------------------
|
||||
conn = db_connect(args.db)
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM tasks WHERE project_id LIKE ?",
|
||||
(PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM project_members WHERE project_id LIKE ?",
|
||||
(PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM projects WHERE id LIKE ?", (PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM users WHERE username LIKE ?", (PREFIX + "%",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- 建 4 个测试用户(key 只在内存里)------------------------
|
||||
users = {}
|
||||
for role in ("owner", "maintainer", "contributor", "viewer"):
|
||||
uid, key = auth.create_user(PREFIX + role)
|
||||
users[role] = (uid, key)
|
||||
created_user_ids.append(uid)
|
||||
|
||||
# -- 建 2 个测试项目(owner 建,P2 只有 owner 是成员)--------
|
||||
s, p1 = req(base, "POST", "/projects",
|
||||
users["owner"][1], {"id": P1, "name": P1})
|
||||
check("owner 创建项目 P1(201)", s == 201)
|
||||
s, p2 = req(base, "POST", "/projects",
|
||||
users["owner"][1], {"id": P2, "name": P2})
|
||||
check("owner 创建项目 P2(201)", s == 201)
|
||||
if s != 201 or p1.get("id") != P1:
|
||||
print("项目创建失败,无法继续验证")
|
||||
return 1
|
||||
for role in ("maintainer", "contributor", "viewer"):
|
||||
auth.set_member(P1, users["owner"][0], users[role][0], role)
|
||||
|
||||
# -- 建任务权限 ----------------------------------------------
|
||||
# 全部测试任务带永不满足的依赖,生产 worker 绝不会认领(见模块注释)。
|
||||
s, t_owner = req(base, "POST", "/tasks", users["owner"][1], {
|
||||
"project_id": P1, "prompt": "multiuser test task",
|
||||
"depends_on": [NEVER_DEP]})
|
||||
check("owner 在 P1 建任务(201)", s == 201)
|
||||
if s == 201:
|
||||
created_task_ids.append(t_owner["id"])
|
||||
s, t_contrib = req(base, "POST", "/tasks",
|
||||
users["contributor"][1], {
|
||||
"project_id": P1, "prompt": "multiuser test task",
|
||||
"depends_on": [NEVER_DEP]})
|
||||
check("contributor 在 P1 建任务(201)", s == 201)
|
||||
if s == 201:
|
||||
created_task_ids.append(t_contrib["id"])
|
||||
s, _ = req(base, "POST", "/tasks", users["viewer"][1], {
|
||||
"project_id": P1, "prompt": "multiuser test task"})
|
||||
check("viewer 在 P1 建任务(403)", s == 403)
|
||||
s, _ = req(base, "POST", "/tasks", users["viewer"][1], {
|
||||
"project_id": P2, "prompt": "multiuser test task"})
|
||||
check("非成员 viewer 在 P2 建任务(403)", s == 403)
|
||||
|
||||
# -- 读任务权限与跨项目隔离 ----------------------------------
|
||||
if created_task_ids:
|
||||
tid = created_task_ids[0]
|
||||
s, _ = req(base, "GET", "/tasks/" + tid, users["viewer"][1])
|
||||
check("viewer 读 P1 任务(200)", s == 200)
|
||||
s, t_p2 = req(base, "POST", "/tasks", users["owner"][1], {
|
||||
"project_id": P2, "prompt": "multiuser test task",
|
||||
"depends_on": [NEVER_DEP]})
|
||||
check("owner 在 P2 建任务(201)", s == 201)
|
||||
if s == 201:
|
||||
created_task_ids.append(t_p2["id"])
|
||||
s, _ = req(base, "GET", "/tasks/" + t_p2["id"],
|
||||
users["viewer"][1])
|
||||
check("P1 成员 viewer 读 P2 任务(403,跨项目隔离)", s == 403)
|
||||
|
||||
# -- merge 权限(需先把测试任务直接置 completed,不跑模型)--
|
||||
conn = db_connect(args.db)
|
||||
try:
|
||||
for tid in created_task_ids:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'completed' "
|
||||
"WHERE id = ?", (tid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if len(created_task_ids) >= 1:
|
||||
s, merged = req(base, "POST",
|
||||
"/tasks/" + created_task_ids[0] + "/merge",
|
||||
users["maintainer"][1])
|
||||
check("maintainer 对 completed 任务调 merge(200 且变 merged)",
|
||||
s == 200 and merged.get("status") == "merged")
|
||||
if len(created_task_ids) >= 2:
|
||||
s, _ = req(base, "POST",
|
||||
"/tasks/" + created_task_ids[1] + "/merge",
|
||||
users["contributor"][1])
|
||||
check("contributor 对 completed 任务调 merge(403)", s == 403)
|
||||
# queued 任务调 merge 应为 409:再建一个(不置 completed)。
|
||||
s, t_queued = req(base, "POST", "/tasks", users["owner"][1], {
|
||||
"project_id": P1, "prompt": "multiuser test task",
|
||||
"depends_on": [NEVER_DEP]})
|
||||
if s == 201:
|
||||
created_task_ids.append(t_queued["id"])
|
||||
s, _ = req(base, "POST",
|
||||
"/tasks/" + t_queued["id"] + "/merge",
|
||||
users["maintainer"][1])
|
||||
check("maintainer 对 queued 任务调 merge(409)", s == 409)
|
||||
|
||||
# -- 无 key ---------------------------------------------------
|
||||
s, _ = req(base, "GET", "/tasks/" + created_task_ids[0]
|
||||
if created_task_ids else "/tasks/none")
|
||||
check("无 key 调 API(401)", s == 401)
|
||||
finally:
|
||||
# -- 清理:只删 multiuser-test-* 的行,审计表不动 ------------
|
||||
leftover = []
|
||||
try:
|
||||
conn = db_connect(args.db)
|
||||
try:
|
||||
for tid in created_task_ids:
|
||||
conn.execute("DELETE FROM tasks WHERE id = ?", (tid,))
|
||||
conn.execute(
|
||||
"DELETE FROM tasks WHERE project_id LIKE ?",
|
||||
(PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM project_members WHERE project_id LIKE ?",
|
||||
(PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM projects WHERE id LIKE ?", (PREFIX + "%",))
|
||||
conn.execute(
|
||||
"DELETE FROM users WHERE username LIKE ?",
|
||||
(PREFIX + "%",))
|
||||
conn.commit()
|
||||
# 校验残留(只查测试前缀的行,不打印 key 或内容)。
|
||||
for table, col in (("tasks", "project_id"),
|
||||
("projects", "id"),
|
||||
("users", "username")):
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM %s WHERE %s LIKE ?"
|
||||
% (table, col), (PREFIX + "%",)).fetchone()
|
||||
if row and row[0]:
|
||||
leftover.append("%s:%d" % (table, row[0]))
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
leftover.append("cleanup-error:%s" % e)
|
||||
try:
|
||||
auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if leftover:
|
||||
print("清理未完成,残留测试行:%s" % ", ".join(leftover))
|
||||
print("注意:审计日志里的测试条目是故意留下的,不算残留。")
|
||||
|
||||
passed = sum(1 for _, ok in RESULTS if ok)
|
||||
total = len(RESULTS)
|
||||
print("多用户验证:%d/%d 通过" % (passed, total))
|
||||
if leftover:
|
||||
return 1
|
||||
return 0 if passed == total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user