Project repo_url: worker can run tasks from any git repo

Projects gain repo_url (validated: https:// or git@ only, no
userinfo/password, no file:// or local paths; old DBs get the
column via idempotent ALTER in both SQLite and Postgres init).
Worker resolves a project's repo_url to a local checkout under
--repos-dir (default ./repos) before dispatch: clone if absent,
verify remote origin matches and fetch if present, never force
reset/clean, never push (push needs write credentials the
control plane does not store). Resolve failure fails the task
with a clear error, never completed. Docker workers need
repos_dir under a same-path extra mount (e.g.
/var/lib/codex-app-server/repos).
Also: driver-missing test is now mocked instead of depending
on the host really having no psycopg driver installed.
Deploy package: Caddyfile template exposing only /webhooks/*
and /oauth/* (tasks/projects/admin stay 403) plus a
webhook-secret generator script that never prints the secret.
Tests: host 27 pass, cloud 286 pass (8 skipped are
environment-dependent real-Postgres tests).
This commit is contained in:
host committed 2026-10-26 04:16:11 +08:00
1 parent 71510a32d4
commit bed3fc0dff
10 files changed
+788 -17

No files matched your search

+38 -6
View File
@@ -67,6 +67,7 @@ from cloud.oauth import (
map_external_identity_to_local_user_with_key,
start_oauth_login,
)
from cloud.repos import validate_repo_url
from cloud.push import (
create_subscription as _push_create,
delete_subscription as _push_delete,
@@ -192,6 +193,20 @@ def migrate_tasks_table(conn):
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
def migrate_projects_table(conn):
"""幂等迁移:给老库 projects 表补 repo_url 列,不丢数据。
与 migrate_tasks_table 同款写法:PRAGMA table_info 查现有列
(Postgres 适配层把它翻译成 information_schema 查询,调用方
取 row[1] 即列名),缺 repo_url 时 ALTER TABLE 补列,已迁移
的库再跑一次是 no-op。SQLite 与 Postgres 两支都走它。
"""
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(projects)").fetchall()}
if "repo_url" not in cols:
conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT")
def _is_postgres_backend():
backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite"
return str(backend).strip().lower() in ("postgres", "postgresql")
@@ -210,6 +225,7 @@ def init_db(db_path):
if _is_postgres_backend():
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
conn.commit()
return
conn.execute(
@@ -217,7 +233,8 @@ def init_db(db_path):
"id TEXT PRIMARY KEY, "
"name TEXT, "
"owner_id TEXT, "
"created_at REAL)"
"created_at REAL, "
"repo_url TEXT)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS tasks("
@@ -235,6 +252,7 @@ def init_db(db_path):
"UNIQUE(project_id, idempotency_key))"
)
migrate_tasks_table(conn)
migrate_projects_table(conn)
conn.commit()
finally:
conn.close()
@@ -296,6 +314,9 @@ def _project_to_dict(row):
"name": row["name"],
"owner_id": row["owner_id"],
"created_at": row["created_at"],
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
"repo_url": _row_get(row, "repo_url"),
}
@@ -547,6 +568,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not isinstance(project_id, str):
self._send_json(400, {"error": "id must be a string"})
return
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
# 400 且不建项目,绝不把带凭证的 URL 存进库。
repo_url = body.get("repo_url")
if repo_url is not None:
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
now = time.time()
conn = self._db()
try:
@@ -557,9 +588,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(409, {"error": "project exists"})
return
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at)"
" VALUES(?, ?, ?, ?)",
(project_id, name, user_id, now),
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url) VALUES(?, ?, ?, ?, ?)",
(project_id, name, user_id, now, repo_url),
)
conn.commit()
project = {
@@ -567,6 +598,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"name": name,
"owner_id": user_id,
"created_at": now,
"repo_url": repo_url,
}
except (sqlite3.IntegrityError, _PgIntegrityError):
self._send_json(409, {"error": "project exists"})
@@ -591,8 +623,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
).fetchall()
}
rows = conn.execute(
"SELECT id, name, owner_id, created_at FROM projects"
" ORDER BY created_at, id"
"SELECT id, name, owner_id, created_at, repo_url"
" FROM projects ORDER BY created_at, id"
).fetchall()
finally:
conn.close()