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:
1 parent
71510a32d4
commit
bed3fc0dff
10 files changed
+788
-17
No files matched your search
+38
-6
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user