feat: 命令台云端环境——选、建、管 + 任务绑定环境

环境是项目仓库的命名基准检出(项目 + 仓库快照 + 分支 + 执行器),
不是官方 Codex 云端环境(无公开调用口,不宣称能建它)。
- DB:environments 表(creating/ready/failed/archived,部分唯一索引
  保证同项目未归档名唯一、归档名可重用)+ tasks.environment_id,
  server/worker/db 三处迁移同步(含 #67 的 goal_mode,不丢新语义)。
- API:POST/GET /environments、GET /environments/{id}、
  POST /environments/{id}/archive;任务创建选环境须本项目已 ready、
  执行器一致或继承、cloud 执行器拒绝。
- Worker:run_all 每轮开头准备 creating 环境(真实 git clone +
  checkout 分支,幂等、无 preparing 卡死态,准备期归档 CAS 不覆盖);
  环境任务派发时 task[repo] 指到环境检出(租户 Docker 容器内路径 +
  envs 整目录挂载),任务仍一任务一 worktree。
- Console:环境卡(列表/新建/归档,未 ready 置灰)+ 新建任务环境下拉
  + 任务列表 [环境] 标记;公网 nginx 须同批代理 /environments。
- 单测:新增 test_task_environments.py 14 个;本地实弹(真实 git
  clone octocat/Hello-World,不跑模型)项目→环境→任务→归档全通。
This commit is contained in:
host committed 2026-10-27 04:07:54 +08:00
1 parent c86b450851
commit 3d45edc8b4
6 files changed
+1224 -11

No files matched your search

+499
View File
@@ -0,0 +1,499 @@
"""云端环境单测:选、建、管 + 任务绑定环境。
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
SQLite;worker 侧注入假 git runner(clone/fetch/checkout 只
操作临时目录,不调真实 git);console 侧只查 HTML 标记。
链路覆盖:建环境(creating)-> worker 准备(ready/failed)
-> 建任务选环境(执行器继承/冲突拒绝)-> 任务派发指到环境
检出 -> 归档(进行中任务在用时拒绝、幂等)。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
import uuid
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _req(port, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _set_env_status(db_path, env_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE environments SET status = ? WHERE id = ?",
(status, env_id))
conn.commit()
finally:
conn.close()
def _set_task_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class ServerEnvironmentsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.other_id, self.other_key = self.auth.create_user("other")
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()
status, self.project = _req(
self.port, "POST", "/projects",
{"name": "demo", "repo_url": "https://github.com/octocat/Hello-World.git"},
self.owner_key)
self.assertEqual(status, 201, self.project)
status, self.plain_project = _req(
self.port, "POST", "/projects", {"name": "plain"},
self.owner_key)
self.assertEqual(status, 201, self.plain_project)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _create_env(self, project_id=None, name="dev", **extra):
body = {"project_id": project_id or self.project["id"],
"name": name}
body.update(extra)
return _req(self.port, "POST", "/environments", body,
self.owner_key)
def _ready_env(self, name="dev", **extra):
status, env = self._create_env(name=name, **extra)
self.assertEqual(status, 201, env)
_set_env_status(self.db_path, env["id"], "ready")
return env
def test_create_environment_roundtrip(self):
status, env = self._create_env(
name="dev", branch="main", executor="local")
self.assertEqual(status, 201, env)
self.assertEqual(env["status"], "creating")
self.assertEqual(env["project_id"], self.project["id"])
self.assertEqual(env["branch"], "main")
self.assertEqual(env["executor"], "local")
self.assertEqual(env["repo_url"],
"https://github.com/octocat/Hello-World.git")
status, got = _req(
self.port, "GET", "/environments/" + env["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(got["id"], env["id"])
def test_create_environment_validation(self):
# 项目没有 repo_url:环境无从检出。
status, _ = self._create_env(
project_id=self.plain_project["id"], name="x")
self.assertEqual(status, 400)
# 名字、分支、执行器非法。
status, _ = self._create_env(name=" ")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", branch="../evil")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", branch="-bad")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", executor="cloud")
self.assertEqual(status, 400)
# 非成员、不存在的环境。
status, _ = _req(
self.port, "POST", "/environments",
{"project_id": self.project["id"], "name": "x"},
self.other_key)
self.assertEqual(status, 403)
status, _ = _req(self.port, "GET", "/environments/nope",
key=self.owner_key)
self.assertEqual(status, 404)
def test_environment_name_unique_per_project(self):
status, _ = self._create_env(name="dev")
self.assertEqual(status, 201)
status, _ = self._create_env(name="dev")
self.assertEqual(status, 409)
# 另一项目同名可以;归档后原项目同名可重用。
status, env2 = self._create_env(
project_id=self.plain_project["id"], name="dev")
# plain 项目无 repo_url,上面应 400——换一个带仓库的项目测同名。
self.assertEqual(status, 400)
status, other = _req(
self.port, "POST", "/projects",
{"name": "demo2",
"repo_url": "https://github.com/octocat/Hello-World.git"},
self.owner_key)
self.assertEqual(status, 201)
status, _ = self._create_env(project_id=other["id"], name="dev")
self.assertEqual(status, 201)
def test_list_environments_and_archive(self):
env = self._ready_env(name="dev")
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(listing["environments"]), 1)
# 缺 project_id 400;非成员 403。
status, _ = _req(self.port, "GET", "/environments",
key=self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.other_key)
self.assertEqual(status, 403)
# 归档后默认列表排除,include_archived=1 才出现;再归档幂等。
status, archived = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(archived["status"], "archived")
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(listing["environments"], [])
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"]
+ "&include_archived=1",
key=self.owner_key)
self.assertEqual(len(listing["environments"]), 1)
status, again = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
def test_archive_blocked_when_task_using_it(self):
env = self._ready_env(name="dev")
status, task = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 201, task)
status, _ = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 409)
_set_task_status(self.db_path, task["id"], "completed")
status, archived = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(archived["status"], "archived")
def test_task_with_environment_rules(self):
# creating 的环境不能建任务。
status, env = self._create_env(name="dev", executor="docker")
self.assertEqual(status, 201)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
# ready 后:执行器继承、冲突拒绝、cloud 拒绝、跨项目拒绝。
_set_env_status(self.db_path, env["id"], "ready")
status, task = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 201, task)
self.assertEqual(task["executor"], "docker")
self.assertEqual(task["environment_id"], env["id"])
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"], "executor": "local"},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"], "executor": "cloud"},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.plain_project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": "nope"},
self.owner_key)
self.assertEqual(status, 404)
# failed 的环境也不能建任务。
_set_env_status(self.db_path, env["id"], "failed")
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
def test_console_has_environment_ui(self):
path = Path(__file__).resolve().parents[1] / "console.html"
html = path.read_text(encoding="utf-8")
for marker in ("envSelect", "loadEnvironments",
"createEnvironment", "refreshEnvironments",
"newEnvName", "newEnvBranch", "newEnvExecutor",
"/environments?project_id="):
self.assertIn(marker, html)
class FakeGit:
"""假 git:clone 在临时目录建 .git 并记 origin;fetch 恒成功;
checkout 只接受白名单分支,其余报分支不存在。"""
def __init__(self, allowed_branches=("main", "feature/x")):
self.allowed = set(allowed_branches)
self.origins = {}
self.commands = []
def __call__(self, cmd):
self.commands.append(list(cmd))
if cmd[1] == "clone":
target = cmd[3]
os.makedirs(os.path.join(target, ".git"), exist_ok=True)
self.origins[target] = cmd[2]
return SimpleNamespace(returncode=0, stdout="", stderr="")
if cmd[1] == "-C":
target = cmd[2]
if cmd[3] == "remote":
return SimpleNamespace(
returncode=0, stdout=self.origins.get(target, ""),
stderr="")
if cmd[3] == "fetch":
return SimpleNamespace(returncode=0, stdout="", stderr="")
if cmd[3] == "checkout":
branch = cmd[4]
if branch in self.allowed:
return SimpleNamespace(returncode=0, stdout="",
stderr="")
return SimpleNamespace(
returncode=1, stdout="",
stderr="pathspec '%s' did not match" % branch)
return SimpleNamespace(returncode=1, stdout="",
stderr="unexpected git command")
def _insert_env(db_path, env_id="env1", project_id="p1",
status="creating", branch=None, executor=None,
name="dev",
repo_url="https://github.com/octocat/Hello-World.git"):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO environments(id, project_id, name, branch,"
" executor, status, error_text, repo_url, created_by,"
" created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?,"
" NULL, ?, 'tester', ?, ?)",
(env_id, project_id, name, branch, executor, status,
repo_url, now, now))
conn.commit()
finally:
conn.close()
def _env_row(db_path, env_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT * FROM environments WHERE id = ?",
(env_id,)).fetchone()
cols = [d[0] for d in conn.execute(
"SELECT * FROM environments LIMIT 0").description]
return dict(zip(cols, row)) if row else None
finally:
conn.close()
class WorkerEnvironmentsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.repos_dir = os.path.join(self.tmp.name, "repos")
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, git=None, **kwargs):
kwargs.setdefault("env", self.env)
kwargs.setdefault("repos_dir", self.repos_dir)
kwargs.setdefault("repos_runner", git)
return Worker(self.db_path, "/fake/host.py", **kwargs)
def test_prepare_environment_ready(self):
_insert_env(self.db_path, branch="feature/x")
worker = self._worker(git=FakeGit())
results = worker._prepare_pending_environments()
self.assertEqual(results, [("env1", "ready", None)])
row = _env_row(self.db_path, "env1")
self.assertEqual(row["status"], "ready")
target = os.path.join(self.repos_dir, "envs", "env1")
self.assertTrue(os.path.isdir(os.path.join(target, ".git")))
def test_prepare_environment_failed_branch(self):
_insert_env(self.db_path, branch="no-such-branch")
worker = self._worker(git=FakeGit())
results = worker._prepare_pending_environments()
self.assertEqual(results[0][1], "failed")
self.assertIn("checkout", results[0][2])
self.assertEqual(_env_row(self.db_path, "env1")["status"],
"failed")
def test_prepare_environment_archived_during_prepare(self):
# 准备期间被归档:CAS 改不到行,状态保持 archived。
_insert_env(self.db_path)
worker = self._worker(git=FakeGit())
orig = worker._prepare_environment
def _prepare_and_archive(env):
err = orig(env)
_set_env_status(self.db_path, env["id"], "archived")
return err
worker._prepare_environment = _prepare_and_archive
results = worker._prepare_pending_environments()
self.assertEqual(results[0][1], "archived")
self.assertEqual(_env_row(self.db_path, "env1")["status"],
"archived")
def test_apply_environment_repo_local(self):
_insert_env(self.db_path, status="ready", executor="local")
worker = self._worker()
task = {"id": "T", "project_id": "p1", "environment_id": "env1",
"executor": "local"}
self.assertIsNone(worker._apply_project_repo(task))
self.assertEqual(
task["repo"],
os.path.join(self.repos_dir, "envs", "env1"))
def test_apply_environment_repo_tenant_docker(self):
_insert_env(self.db_path, status="ready", executor="docker")
worker = self._worker(
executor=DockerExecutor(config_path="/fake/cfg.json"),
tenant_isolation=True,
tenants_root=os.path.join(self.tmp.name, "tenants"))
task = {"id": "T", "project_id": "p1", "environment_id": "env1",
"executor": "docker"}
self.assertIsNone(worker._apply_project_repo(task))
self.assertEqual(task["repo"],
"/var/lib/codex-app-server/envs/env1")
def test_apply_environment_repo_rejections(self):
worker = self._worker()
# 环境不存在。
task = {"id": "T", "project_id": "p1", "environment_id": "nope"}
self.assertIn("not found", worker._apply_project_repo(task))
# 未 ready、跨项目、执行器冲突(同项目多环境须不同名,
# 部分唯一索引只拦同项目同名未归档行)。
_insert_env(self.db_path, env_id="e2", name="e2",
status="creating")
task = {"id": "T", "project_id": "p1", "environment_id": "e2"}
self.assertIn("not ready", worker._apply_project_repo(task))
_insert_env(self.db_path, env_id="e3", project_id="p2",
name="e3", status="ready")
task = {"id": "T", "project_id": "p1", "environment_id": "e3"}
self.assertIn("another project",
worker._apply_project_repo(task))
_insert_env(self.db_path, env_id="e4", name="e4",
status="ready", executor="docker")
task = {"id": "T", "project_id": "p1", "environment_id": "e4",
"executor": "local"}
self.assertIn("does not match",
worker._apply_project_repo(task))
def test_tenant_mounts_include_envs(self):
worker = self._worker(
executor=DockerExecutor(config_path="/fake/cfg.json"),
tenant_isolation=True,
tenants_root=os.path.join(self.tmp.name, "tenants"))
# 先播种租户目录(codex-home 模板缺失会报错,这里只测
# envs 挂载条件:手动建好租户目录结构再调 _tenant_mounts
# 的 envs 分支——目录不存在时不挂载,存在时挂载)。
base = os.path.join(self.tmp.name, "tenants", "p1")
os.makedirs(os.path.join(base, "work"), exist_ok=True)
os.makedirs(os.path.join(base, "codex-home"), exist_ok=True)
with open(os.path.join(base, "codex-home", "config.toml"),
"w") as f:
f.write("")
os.makedirs(os.path.join(base, "test-repo"), exist_ok=True)
with open(os.path.join(base, "test-repo", "sample.py"),
"w") as f:
f.write("")
mounts = worker._tenant_mounts("p1")
self.assertFalse(any("envs" in m for m in mounts))
os.makedirs(os.path.join(base, "envs", "env1"), exist_ok=True)
mounts = worker._tenant_mounts("p1")
self.assertTrue(any(
m.endswith(":/var/lib/codex-app-server/envs:rw")
for m in mounts))
if __name__ == "__main__":
unittest.main()