Merge pull request #158 from openqj/feat-repo-access-check
feat(repos): 仓库接入预检 + codexsvc deploy key 配给脚本
This commit is contained in:
3 files changed
+173
-2
No files matched your search
+72
-2
@@ -16,11 +16,20 @@
|
||||
工作树脏不脏由 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__ = [
|
||||
@@ -28,6 +37,7 @@ __all__ = [
|
||||
"validate_repo_url",
|
||||
"checkout_dir",
|
||||
"resolve_repo",
|
||||
"check_repo_access",
|
||||
]
|
||||
|
||||
|
||||
@@ -89,8 +99,9 @@ def checkout_dir(repos_dir, project_id):
|
||||
return path
|
||||
|
||||
|
||||
def _default_runner(cmd):
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
def _default_runner(cmd, timeout=None):
|
||||
return subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
def _run(runner, cmd):
|
||||
@@ -148,3 +159,62 @@ def resolve_repo(repo_url, repos_dir, project_id, runner=None):
|
||||
"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 _main(argv):
|
||||
if len(argv) != 3 or argv[1] != "check":
|
||||
sys.stderr.write(
|
||||
"用法: python3 -m cloud.repos check <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))
|
||||
@@ -21,6 +21,7 @@ try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.repos import (
|
||||
RepoError,
|
||||
check_repo_access,
|
||||
checkout_dir,
|
||||
resolve_repo,
|
||||
validate_repo_url,
|
||||
@@ -32,6 +33,7 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.repos import (
|
||||
RepoError,
|
||||
check_repo_access,
|
||||
checkout_dir,
|
||||
resolve_repo,
|
||||
validate_repo_url,
|
||||
@@ -101,6 +103,50 @@ class ValidateRepoUrlTestCase(unittest.TestCase):
|
||||
validate_repo_url(123)
|
||||
|
||||
|
||||
class CheckRepoAccessTestCase(unittest.TestCase):
|
||||
def test_ok_returns_head(self):
|
||||
def runner(cmd, timeout=None):
|
||||
self.assertEqual(cmd[:3], ["git", "ls-remote",
|
||||
"git@github.com:o/r.git"])
|
||||
return SimpleNamespace(returncode=0,
|
||||
stdout="abc123\tHEAD\n", stderr="")
|
||||
ok, detail = check_repo_access("git@github.com:o/r.git",
|
||||
runner=runner)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(detail, "abc123")
|
||||
|
||||
def test_failure_returns_reason_not_exception(self):
|
||||
def runner(cmd, timeout=None):
|
||||
return SimpleNamespace(returncode=128, stdout="",
|
||||
stderr="fatal: Authentication failed")
|
||||
ok, detail = check_repo_access("git@github.com:o/private.git",
|
||||
runner=runner)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Authentication failed", detail)
|
||||
|
||||
def test_https_failure_hints_ssh_form(self):
|
||||
def runner(cmd, timeout=None):
|
||||
return SimpleNamespace(returncode=128, stdout="",
|
||||
stderr="fatal: unable to access")
|
||||
ok, detail = check_repo_access("https://github.com/o/r.git",
|
||||
runner=runner)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("git@", detail)
|
||||
|
||||
def test_invalid_url_returns_false(self):
|
||||
ok, detail = check_repo_access("file:///tmp/r")
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(detail)
|
||||
|
||||
def test_timeout_returns_false(self):
|
||||
def runner(cmd, timeout=None):
|
||||
raise TimeoutError("timed out")
|
||||
ok, detail = check_repo_access("git@github.com:o/r.git",
|
||||
runner=runner)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("timed out", detail)
|
||||
|
||||
|
||||
class CheckoutDirTestCase(unittest.TestCase):
|
||||
def test_sanitizes_and_stays_inside(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# 给 codexsvc 配一只读 deploy key(按仓库通用化,控制面不存凭证)。
|
||||
#
|
||||
# 用法(主机上以 root 跑):
|
||||
# ./provision_codexsvc_key.sh git@github.com:ORG/REPO.git [key-name]
|
||||
# ./provision_codexsvc_key.sh --check git@github.com:ORG/REPO.git [key-name]
|
||||
#
|
||||
# 流程:
|
||||
# 1. 在 /home/codexsvc/.ssh/ 生成 ed25519 key(已存在则复用,绝不覆盖);
|
||||
# 2. 打印公钥(只打印公钥,私钥绝不打印),请把公钥以只读 deploy key
|
||||
# 加到 GitHub 仓库 Settings -> Deploy keys;
|
||||
# 3. --check 时以 codexsvc 身份跑 git ls-remote,通了才算配好。
|
||||
# 4. 另有现成的 key 要复用时,把 key 文件拷到上述目录并 chown
|
||||
# codexsvc、chmod 600,再用 --check 验,不要改本脚本去读 root 的 key。
|
||||
#
|
||||
# 安全边界:本脚本不往控制面、环境文件或任何日志里写私钥;公钥
|
||||
# 本来就是公开的,打印它没有风险;key 名只允许 [A-Za-z0-9_-]。
|
||||
set -eu
|
||||
CHECK=0
|
||||
if [ "${1:-}" = "--check" ]; then
|
||||
CHECK=1; shift
|
||||
fi
|
||||
URL="${1:-}"
|
||||
NAME="${2:-codex-github}"
|
||||
if [ -z "$URL" ]; then
|
||||
echo "用法: $0 [--check] git@github.com:ORG/REPO.git [key-name]" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$URL" in
|
||||
git@github.com:*:*.git) ;;
|
||||
*) echo "只接受 git@github.com:ORG/REPO.git 形(https 形在本机常被出口策略挡)" >&2; exit 2 ;;
|
||||
esac
|
||||
case "$NAME" in
|
||||
*[!A-Za-z0-9_-]*) echo "key 名只允许字母数字 _ -" >&2; exit 2 ;;
|
||||
esac
|
||||
SSH_DIR=/home/codexsvc/.ssh
|
||||
KEY="$SSH_DIR/id_$NAME"
|
||||
mkdir -p "$SSH_DIR"
|
||||
chown codexsvc:codexsvc "$SSH_DIR"
|
||||
chmod 700 "$SSH_DIR"
|
||||
if [ ! -f "$KEY" ]; then
|
||||
sudo -u codexsvc ssh-keygen -t ed25519 -f "$KEY" -N "" -C "codexsvc-$NAME"
|
||||
chmod 600 "$KEY"
|
||||
chown codexsvc:codexsvc "$KEY"
|
||||
fi
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
chown codexsvc:codexsvc "$KEY" "$KEY.pub" 2>/dev/null || true
|
||||
echo "公钥(加到 GitHub 仓库的只读 Deploy keys):"
|
||||
cat "$KEY.pub"
|
||||
if [ "$CHECK" = "1" ]; then
|
||||
echo "以 codexsvc 身份预检 git ls-remote ..."
|
||||
sudo -u codexsvc env GIT_SSH_COMMAND="ssh -i $KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
git ls-remote "$URL" HEAD
|
||||
echo "预检通过:codexsvc 能读该仓库"
|
||||
fi
|
||||
Reference in new issue
Block a user