Merge pull request '部署:修复代码频道共享权限警告' (#6) from deploy/hlcc-native-shared-repository-20260806 into main
Reviewed-on: #6
This commit is contained in:
commit
daa41e03bc
6 changed files with 179 additions and 44 deletions
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"schema": "guanghu.architecture-provision-request/v1",
|
||||
"request_id": "HLCC-NATIVE-SHARED-REPOSITORY-20260806",
|
||||
"target_node": "JD-FD-PRIMARY",
|
||||
"architecture_id": "GLS-HLCC-009",
|
||||
"module": {
|
||||
"code": "HLCC-JD-CANDIDATE-01",
|
||||
"name": "光湖代码频道原生共享仓库权限修复",
|
||||
"bind": "loopback:3340,3341",
|
||||
"owner": "systemd",
|
||||
"unit": "hlcc-jd-candidate.service",
|
||||
"run_user": "guanghu",
|
||||
"writable_paths": [
|
||||
"/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1"
|
||||
],
|
||||
"read_only_paths": []
|
||||
},
|
||||
"source_ref": "REPO-012:refs/heads/main",
|
||||
"deployed_commit_policy": "use the exact human-merged commit and record it in the server-owned deployment receipt",
|
||||
"source_paths": [
|
||||
"server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py",
|
||||
"server-tools/hololake-code-channel/jd-candidate/app.ini",
|
||||
"server-tools/hololake-code-channel/jd-candidate/hlcc-jd-candidate.service"
|
||||
],
|
||||
"initial_provision": {
|
||||
"kind": "new-architecture-unit",
|
||||
"not_an_existing_action_bridge_extension": true,
|
||||
"requires": [
|
||||
"back up the currently installed hlcc-jd-candidate.service",
|
||||
"install only the declared immutable source files",
|
||||
"restart only hlcc-jd-candidate.service",
|
||||
"set core.sharedRepository=group on the exact bingshuo/guanghu-ice-heart bare repository",
|
||||
"remove only the identified obsolete guanghu-ice-heart sharing hook",
|
||||
"preserve UMask=0077 for all other code-channel state",
|
||||
"do not alter repository content, branches, owner identity or authentication"
|
||||
]
|
||||
},
|
||||
"verification": [
|
||||
"the candidate health endpoint returns ready=true after restart",
|
||||
"the exact repository reports core.sharedRepository as group",
|
||||
"the obsolete post-receive chmod hook is absent",
|
||||
"a later public push no longer emits recursive chmod permission warnings",
|
||||
"both code-channel and authorized direct-receiver repository checks remain valid"
|
||||
],
|
||||
"runtime_check": {
|
||||
"url": "http://127.0.0.1:3341/health",
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"mode": "isolated-candidate",
|
||||
"version": "16.0.1",
|
||||
"code": "HLCC-JD-CANDIDATE-01",
|
||||
"ready": true,
|
||||
"stage": "ready"
|
||||
}
|
||||
},
|
||||
"rollback": [
|
||||
"restore the backed-up hlcc-jd-candidate.service",
|
||||
"restart the previous immutable release",
|
||||
"retain repository content and all authentication state",
|
||||
"record any manual permission reconciliation separately"
|
||||
],
|
||||
"status": "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING"
|
||||
}
|
||||
|
|
@ -26,6 +26,13 @@ STATE_ROOT = pathlib.Path("/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1")
|
|||
LEGACY_DB = STATE_ROOT / "data" / "owner-identity-source.db"
|
||||
OWNER_NAME = "bingshuo"
|
||||
CHANNEL_REPOSITORY = "guanghu-ice-heart"
|
||||
CHANNEL_REPOSITORY_PATH = (
|
||||
STATE_ROOT
|
||||
/ "data"
|
||||
/ "repositories"
|
||||
/ OWNER_NAME
|
||||
/ f"{CHANNEL_REPOSITORY}.git"
|
||||
)
|
||||
LEGACY_REPOSITORY_URL = "https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git"
|
||||
SEED_COMMIT_NUMBER = "HLCC-ICE-000001"
|
||||
SEED_CONTRIBUTION_NUMBER = "ZY-CONTRIB-20260723-001"
|
||||
|
|
@ -473,6 +480,59 @@ def seed_fifth_domain_channel(binary: pathlib.Path) -> str:
|
|||
delete_bootstrap_token(channel_db, token_name)
|
||||
|
||||
|
||||
def configure_shared_channel_repository(
|
||||
repository: pathlib.Path = CHANNEL_REPOSITORY_PATH,
|
||||
) -> str:
|
||||
"""Use Git's native shared-repository mode instead of a post-receive chmod."""
|
||||
if not repository.is_dir():
|
||||
raise RuntimeError("channel repository path unavailable")
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(repository),
|
||||
"config",
|
||||
"core.sharedRepository",
|
||||
"group",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
configured = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(repository),
|
||||
"config",
|
||||
"--get",
|
||||
"core.sharedRepository",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if configured not in {"1", "group"}:
|
||||
raise RuntimeError("shared repository configuration verification failed")
|
||||
|
||||
obsolete_hook = (
|
||||
repository
|
||||
/ "hooks"
|
||||
/ "post-receive.d"
|
||||
/ "guanghu-ice-heart-share"
|
||||
)
|
||||
if obsolete_hook.exists():
|
||||
if obsolete_hook.is_symlink() or not obsolete_hook.is_file():
|
||||
raise RuntimeError("obsolete sharing hook path is unsafe")
|
||||
hook_text = obsolete_hook.read_text(encoding="utf-8")
|
||||
if "post_receive_permissions_reconciled" not in hook_text:
|
||||
raise RuntimeError("obsolete sharing hook identity mismatch")
|
||||
obsolete_hook.unlink()
|
||||
|
||||
return "configured"
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
|
|
@ -514,6 +574,8 @@ def bootstrap() -> None:
|
|||
migrate_owner_identity()
|
||||
set_status(stage="seeding-fifth-domain-channel")
|
||||
seed_fifth_domain_channel(binary)
|
||||
set_status(stage="configuring-shared-repository")
|
||||
configure_shared_channel_repository()
|
||||
set_status(mode="isolated-candidate", ready=True, stage="ready")
|
||||
return_code = process.wait()
|
||||
raise RuntimeError(f"candidate stopped with code {return_code}")
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ assert.match(bootstrap, /password_hash_preserved/);
|
|||
assert.match(bootstrap, /access_tokens_migrated": False/);
|
||||
assert.match(bootstrap, /repositories_migrated": False/);
|
||||
assert.match(bootstrap, /delete from access_token/);
|
||||
assert.match(bootstrap, /core\.sharedRepository/);
|
||||
assert.match(bootstrap, /"group"/);
|
||||
assert.match(bootstrap, /obsolete sharing hook identity mismatch/);
|
||||
assert.match(bootstrap, /configure_shared_channel_repository\(\)/);
|
||||
assert.doesNotMatch(
|
||||
bootstrap,
|
||||
/print\s*\([^)]*token|stderr\.write\s*\([^)]*token|stdout\.write\s*\([^)]*token/,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import importlib.util
|
|||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
|
@ -138,5 +139,50 @@ class OwnerMigrationTests(unittest.TestCase):
|
|||
self.assertEqual(MODULE.migrate_owner_identity(self.old, self.new, self.receipt), "already-present")
|
||||
|
||||
|
||||
class SharedRepositoryTests(unittest.TestCase):
|
||||
def test_configures_native_group_sharing_and_removes_only_known_hook(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
repository = pathlib.Path(temporary) / "channel.git"
|
||||
subprocess.run(["git", "init", "--bare", str(repository)], check=True, capture_output=True)
|
||||
hook = repository / "hooks" / "post-receive.d" / "guanghu-ice-heart-share"
|
||||
hook.parent.mkdir(parents=True)
|
||||
hook.write_text(
|
||||
"#!/bin/sh\nprintf '%s\\n' post_receive_permissions_reconciled\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
MODULE.configure_shared_channel_repository(repository),
|
||||
"configured",
|
||||
)
|
||||
configured = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(repository),
|
||||
"config",
|
||||
"--get",
|
||||
"core.sharedRepository",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
self.assertIn(configured, {"1", "group"})
|
||||
self.assertFalse(hook.exists())
|
||||
|
||||
def test_refuses_unknown_hook_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
repository = pathlib.Path(temporary) / "channel.git"
|
||||
subprocess.run(["git", "init", "--bare", str(repository)], check=True, capture_output=True)
|
||||
hook = repository / "hooks" / "post-receive.d" / "guanghu-ice-heart-share"
|
||||
hook.parent.mkdir(parents=True)
|
||||
hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "identity mismatch"):
|
||||
MODULE.configure_shared_channel_repository(repository)
|
||||
self.assertTrue(hook.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -124,11 +124,10 @@ node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
|||
- 当裸仓库归属代码频道服务账户时,在系统 Git 配置中把该精确路径登记为
|
||||
`safe.directory`,不得使用通配符。
|
||||
|
||||
光湖代码频道本身保留 `UMask=0077`,以免放宽数据库和其他状态目录。仅在
|
||||
`guanghu-ice-heart.git/hooks/post-receive.d/guanghu-ice-heart-share` 安装仓库随附的
|
||||
`hooks/guanghu-ice-heart-post-receive`,让成功的公共 Git 推送完成后校正
|
||||
`objects` 与 `refs` 的共享组权限。不要为了共享一个裸仓库而修改整个代码频道服务的
|
||||
UMask。
|
||||
光湖代码频道本身保留 `UMask=0077`,以免放宽数据库和其他状态目录。对需要由直达
|
||||
接收器共同读写的精确裸仓库设置 Git 原生 `core.sharedRepository=group`,让新建的
|
||||
`objects` 与 `refs` 从写入时就继承专用共享组权限。不要安装推送后递归 `chmod` 的
|
||||
钩子,也不要为了共享一个裸仓库而修改整个代码频道服务的 UMask。
|
||||
|
||||
部署后用当前主分支生成无变化验收 bundle,经 `receiveBundle` 完整执行一次;验收前后
|
||||
主分支 SHA 必须一致,并且代码频道账户与授权服务账户执行 `git fsck` 均通过。
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# The code-channel service runs with UMask=0077. Keep that isolation for its
|
||||
# database and other state, but make Git objects and refs readable/writable by
|
||||
# the repository's dedicated shared group after an accepted public push.
|
||||
cat >/dev/null
|
||||
repo_dir=$(git rev-parse --absolute-git-dir)
|
||||
expected_repo=/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git
|
||||
[ "$repo_dir" = "$expected_repo" ] || exit 0
|
||||
cd "$repo_dir"
|
||||
owner_uid=$(id -u)
|
||||
|
||||
share_tree() {
|
||||
# RestrictSUIDSGID forbids chmod calls that preserve a setgid directory.
|
||||
# Existing shared directories are already traversable, so touch only new
|
||||
# private directories and use a numeric mode that drops the special bit.
|
||||
find "$1" -user "$owner_uid" -type d ! -perm -g=x -exec chmod 0770 {} +
|
||||
find "$1" -user "$owner_uid" -type f ! -perm -g=r -exec chmod g+r {} +
|
||||
}
|
||||
|
||||
share_tree "$repo_dir/objects"
|
||||
share_tree "$repo_dir/refs"
|
||||
|
||||
# receive-pack may keep new objects in a quarantine directory until hooks have
|
||||
# completed. Normalize that directory before Git moves the objects into place.
|
||||
if [ -n "${GIT_OBJECT_DIRECTORY:-}" ] && [ -d "$GIT_OBJECT_DIRECTORY" ]; then
|
||||
case "$GIT_OBJECT_DIRECTORY/" in
|
||||
"$repo_dir/"*) share_tree "$GIT_OBJECT_DIRECTORY" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
for shared_file in HEAD packed-refs; do
|
||||
if [ -f "$repo_dir/$shared_file" ]; then
|
||||
find "$repo_dir/$shared_file" -user "$owner_uid" -exec chmod g+rw {} +
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "post_receive_permissions_reconciled" >"$repo_dir/hooks/post-receive-share.last"
|
||||
Loading…
Reference in a new issue