245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""项目级仓库解析:把项目的 repo_url 变成 worker 可派发的本地检出。
|
||
|
||
零第三方依赖,只用 Python 标准库;不改 host.py 的 worktree 逻辑,
|
||
不存任何仓库凭证、不推远端。
|
||
|
||
- validate_repo_url:API(POST /projects)与 worker 共用的同一套
|
||
校验,绝不另写第二份校验逻辑让两者漂移。只接受:
|
||
https:// 开头(且不含 userinfo,即不含用户名/密码),或
|
||
git@ 开头(git@host:path 形)。file://、ssh://、本地路径、
|
||
带密码的 URL 一律拒绝。
|
||
- checkout_dir:按 project_id 的安全字符命名,防路径穿越。
|
||
- resolve_repo:目录不存在时 git clone;已存在时校验它是 git
|
||
仓库且 remote origin 与 repo_url 一致(不一致时报错,绝不强制
|
||
改 remote),一致时 git fetch origin(失败明确报错,不静默用
|
||
旧代码);fetch 成功后不自动 checkout/pull、不 git reset/clean,
|
||
工作树脏不脏由 host.py 的 worktree 逻辑处理。
|
||
- 私有仓库 clone 失败时错误文本写明:需要仓库访问凭证,控制面
|
||
不存仓库凭证——绝不尝试用任何 key 去 clone。
|
||
- check_repo_access:建项目/建任务前的快速预检,只跑
|
||
`git ls-remote <url> HEAD`,不 clone、不写盘、不改库;
|
||
失败时返回 (False, 原因),成功时返回 (True, HEAD 哈希)。
|
||
本机 https 到 GitHub 常被出口策略挡、ssh 才通(2026-10-30
|
||
实测),预检失败的 https 私有仓应先改走 git@ 形再测,
|
||
不要等任务派发后在 worker 里花几分钟超时才发现。
|
||
CLI:`python3 -m cloud.repos check <url>`,退出码 0/1,
|
||
只打印结果与原因,绝不打印任何凭证。
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from urllib.parse import urlparse
|
||
|
||
__all__ = [
|
||
"RepoError",
|
||
"validate_repo_url",
|
||
"checkout_dir",
|
||
"resolve_repo",
|
||
"check_repo_access",
|
||
"check_repo_access_many",
|
||
]
|
||
|
||
|
||
class RepoError(ValueError):
|
||
"""仓库解析失败(校验、clone、remote 不一致、fetch 失败)。"""
|
||
|
||
|
||
_GIT_SCP_RE = re.compile(r"^git@[^/:@\s]+:[^@\s]+$")
|
||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]")
|
||
|
||
|
||
def validate_repo_url(repo_url):
|
||
"""校验 repo_url,合法时返回原值,否则抛 ValueError。
|
||
|
||
拒绝:非字符串、空串、含空白/控制字符、非 https:// 与 git@ 开头、
|
||
https:// 带 userinfo(username 或 password 任一存在即拒绝,
|
||
含 https://user@host/ 这种只有用户名无密码的也拒绝,因为
|
||
userinfo 本身就是凭证载体)、https:// 无 host、git@ 形不合规。
|
||
"""
|
||
if not isinstance(repo_url, str):
|
||
raise ValueError("repo_url 必须是非空字符串")
|
||
# 含空白/控制字符的 URL 直接拒绝,不静默 strip:strip 会把
|
||
# “https://…\n”这种畸形输入洗成合法 URL 存进库,git 调用时
|
||
# 再出难以定位的问题;调用方应传干净的 URL。
|
||
if not repo_url or any(ch.isspace() for ch in repo_url):
|
||
raise ValueError("repo_url 必须是非空字符串且不得含空白字符")
|
||
url = repo_url
|
||
if url.startswith("https://"):
|
||
parsed = urlparse(url)
|
||
if parsed.username is not None or parsed.password is not None:
|
||
raise ValueError(
|
||
"repo_url 不得含 userinfo(用户名/密码): %s" % url)
|
||
if not parsed.hostname:
|
||
raise ValueError("repo_url 缺少 host: %s" % url)
|
||
return url
|
||
if url.startswith("git@"):
|
||
if not _GIT_SCP_RE.match(url):
|
||
raise ValueError("git@ 形 repo_url 不合法: %s" % url)
|
||
return url
|
||
raise ValueError(
|
||
"repo_url 只接受 https:// 或 git@ 开头,"
|
||
"不接受 file://、ssh://、本地路径: %s" % url)
|
||
|
||
|
||
def checkout_dir(repos_dir, project_id):
|
||
"""按 project_id 的安全字符命名检出目录,防路径穿越。
|
||
|
||
project_id 本身是 hex,仍做安全字符过滤;过滤后为空时报错。
|
||
返回的绝对路径一定在 repos_dir 之下,否则报错(防御性双检)。
|
||
"""
|
||
raw = str(project_id)
|
||
safe = _SAFE_NAME_RE.sub("-", raw).strip(".")
|
||
if not safe or safe in (".", ".."):
|
||
raise RepoError("project_id 无法映射为安全的检出目录名: %r" % raw)
|
||
base = os.path.abspath(repos_dir)
|
||
path = os.path.join(base, safe)
|
||
if os.path.commonpath([base, path]) != base:
|
||
raise RepoError("检出目录越出 repos_dir,拒绝: %s" % path)
|
||
return path
|
||
|
||
|
||
def _default_runner(cmd, timeout=None):
|
||
return subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=timeout)
|
||
|
||
|
||
def _run(runner, cmd):
|
||
"""调 runner:优先只传 cmd,假 runner 只接受 cmd 时即走此路;
|
||
接受 (cmd, timeout) 的 runner 也兼容(TypeError 时回退)。"""
|
||
try:
|
||
return runner(cmd)
|
||
except TypeError:
|
||
return runner(cmd, timeout=None)
|
||
|
||
|
||
def resolve_repo(repo_url, repos_dir, project_id, runner=None):
|
||
"""把 repo_url 解析为本地检出路径,返回该路径。
|
||
|
||
目录不存在:git clone repo_url 到该目录;clone 失败抛 RepoError,
|
||
文本含“需要仓库访问凭证,控制面不存仓库凭证”的提示(私有仓
|
||
库、仓库不存在、无权访问都走同一文案,不猜测、不尝试凭证)。
|
||
目录已存在:须是 git 仓库(.git 目录或文件存在)且 remote
|
||
origin 的 URL 与 repo_url 一致;不是 git 仓库、取不到 origin、
|
||
origin 不一致时都抛 RepoError,绝不强制改 remote、不覆盖。
|
||
一致时 git fetch origin:失败抛 RepoError,不静默用旧代码;
|
||
成功后不 checkout/pull/reset/clean,工作树由 host.py 处理。
|
||
"""
|
||
url = validate_repo_url(repo_url)
|
||
run = runner if runner is not None else _default_runner
|
||
target = checkout_dir(repos_dir, project_id)
|
||
os.makedirs(repos_dir, exist_ok=True)
|
||
if not os.path.exists(target):
|
||
result = _run(run, ["git", "clone", url, target])
|
||
if getattr(result, "returncode", 1) != 0:
|
||
err = (getattr(result, "stderr", "") or "").strip()
|
||
raise RepoError(
|
||
"git clone 失败: %s;若为私有仓库,需要仓库访问凭证,"
|
||
"控制面不存仓库凭证,请先在可访问该仓库的机器上完成"
|
||
"检出或改用公开仓库" % (err or url))
|
||
return target
|
||
git_path = os.path.join(target, ".git")
|
||
if not (os.path.isdir(git_path) or os.path.isfile(git_path)):
|
||
raise RepoError(
|
||
"检出目录已存在但不是 git 仓库,拒绝覆盖: %s" % target)
|
||
result = _run(run, ["git", "-C", target, "remote", "get-url", "origin"])
|
||
if getattr(result, "returncode", 1) != 0:
|
||
raise RepoError(
|
||
"检出目录 %s 取不到 remote origin,拒绝派发" % target)
|
||
origin = (getattr(result, "stdout", "") or "").strip()
|
||
if origin != url:
|
||
raise RepoError(
|
||
"检出目录 %s 的 remote origin(%s)与项目 repo_url(%s)"
|
||
"不一致,拒绝派发(绝不强制改 remote)"
|
||
% (target, origin, url))
|
||
fetched = _run(run, ["git", "-C", target, "fetch", "origin"])
|
||
if getattr(fetched, "returncode", 1) != 0:
|
||
err = (getattr(fetched, "stderr", "") or "").strip()
|
||
raise RepoError(
|
||
"git fetch origin 失败,拒绝用旧代码派发: %s"
|
||
% (err or target))
|
||
return target
|
||
|
||
|
||
def check_repo_access(repo_url, runner=None, timeout=30):
|
||
"""预检仓库是否可读:只跑 `git ls-remote <url> HEAD`。
|
||
|
||
返回 (ok, detail):ok 时 detail 为 HEAD 哈希;失败时
|
||
detail 为原因(不含凭证)。runner 缺省走真实 git,超时
|
||
由 subprocess 的 timeout 控(假 runner 不接受 timeout 时
|
||
_run 已回退);runner 抛 TimeoutExpired 时同样返回失败,
|
||
不抛异常——预检失败绝不能炸掉调用方。
|
||
https 形在主机上失败时 detail 末尾追加 ssh 形提示,
|
||
因为本机 https 到 GitHub 常被出口策略挡、ssh 才通。
|
||
"""
|
||
try:
|
||
url = validate_repo_url(repo_url)
|
||
except ValueError as exc:
|
||
return (False, str(exc))
|
||
run = runner if runner is not None else _default_runner
|
||
|
||
def _ls_remote(cmd):
|
||
try:
|
||
return run(cmd, timeout=timeout)
|
||
except TypeError:
|
||
return run(cmd)
|
||
|
||
try:
|
||
result = _ls_remote(["git", "ls-remote", url, "HEAD"])
|
||
except Exception as exc: # noqa: BLE001 - 超时、缺 git 等一律失败
|
||
detail = "git ls-remote 失败: %s" % (exc,)
|
||
if url.startswith("https://"):
|
||
detail += ";本机 https 到 GitHub 常被出口策略挡,"
|
||
detail += "请改走 git@ 形再测"
|
||
return (False, detail)
|
||
if getattr(result, "returncode", 1) != 0:
|
||
err = (getattr(result, "stderr", "") or "").strip()
|
||
detail = "git ls-remote 失败: %s" % (err or url)
|
||
if url.startswith("https://"):
|
||
detail += ";私有仓库请改走 git@ 形并给 codexsvc 配"
|
||
detail += "只读 deploy key,控制面不存仓库凭证"
|
||
return (False, detail)
|
||
head = (getattr(result, "stdout", "") or "").split()
|
||
return (True, head[0] if head else "")
|
||
|
||
|
||
def check_repo_access_many(repo_urls, runner=None, timeout=30):
|
||
"""批量预检:逐个调 check_repo_access,返回 {url: (ok, detail)}。
|
||
|
||
顺序与输入一致;单个失败不中断其余(批量预检的意义就是一次
|
||
看清哪些仓通、哪些不通)。空列表返回空 dict。
|
||
"""
|
||
out = {}
|
||
for url in repo_urls or []:
|
||
out[url] = check_repo_access(url, runner=runner, timeout=timeout)
|
||
return out
|
||
|
||
|
||
def _main(argv):
|
||
if len(argv) >= 3 and argv[1] == "check-many":
|
||
results = check_repo_access_many(argv[2:])
|
||
failed = 0
|
||
for url, (ok, detail) in results.items():
|
||
if ok:
|
||
print("ok %s HEAD=%s" % (url, detail))
|
||
else:
|
||
failed += 1
|
||
print("fail %s: %s" % (url, detail))
|
||
return 1 if failed else 0
|
||
if len(argv) != 3 or argv[1] != "check":
|
||
sys.stderr.write(
|
||
"用法: python3 -m cloud.repos check <repo_url>\n"
|
||
" python3 -m cloud.repos check-many <repo_url>...\n")
|
||
return 2
|
||
ok, detail = check_repo_access(argv[2])
|
||
if ok:
|
||
print("ok HEAD=%s" % detail)
|
||
return 0
|
||
print("fail: %s" % detail)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(_main(sys.argv))
|