feat(guanghu-os): stage independent Forgejo continuity
This commit is contained in:
parent
ace87cfba7
commit
e4750c57a7
10 changed files with 467 additions and 3 deletions
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
type: ADR
|
||||
id: "0181"
|
||||
title: "Independent full Forgejo continuity before JD native cutover"
|
||||
status: accepted
|
||||
date: 2026-08-15
|
||||
depends_on:
|
||||
- "0180"
|
||||
---
|
||||
|
||||
# 京东原生切换前建立独立完整 Forgejo 连续性
|
||||
|
||||
## 为什么上一阶段仍没有完成
|
||||
|
||||
ADR-0180 解决了原生存活状态和固定只读锚点的认证投影,但当前公共 `/code/` 仍经
|
||||
BS-GZ-006 的 SSH 隧道进入 JD Linux。物理原生驻留已经证明这条隧道会消失并返回 502。
|
||||
只读代码摘要不能冒充 Forgejo 的仓库对象、账号、议题、发布、权限和 Git 协议。
|
||||
|
||||
## 现场纠正
|
||||
|
||||
BS-GZ-006 已有一套从五月运行的 Forgejo,但只含另一组 2 个仓库;它不是京东代码频道的
|
||||
7 个仓库。另一个同名 systemd 单元长期每五秒启动第二进程并抢同一 LevelDB 锁,累计约
|
||||
249950 次失败。该重复守卫已被单独禁用,正在运行的旧实例、数据和公网均被保留。
|
||||
|
||||
## 决定
|
||||
|
||||
在 BS-SG-003 建立京东完整 Forgejo 的独立影子副本。选择 BS-SG 而不是 BS-GZ,是因为
|
||||
BS-SG 在京东原生驻留时仍在线,并有足够内存和磁盘;BS-GZ 继续只承担公网前门和认证
|
||||
隧道。
|
||||
|
||||
影子验收必须同时匹配:Forgejo 二进制哈希、HTTP 版本、SQLite 完整性、数据库仓库数、
|
||||
用户数、精确仓库路径集合,以及每个仓库的 main 提交。任一不符即为 0。
|
||||
|
||||
初次在线快照只用于影子验证,不得成为切换权威。真正切 `/code/` 前必须开启有界写冻结、
|
||||
执行第二次精确同步、再次全量回读,并保留京东 Linux 原数据作为回滚。nginx、京东启动和
|
||||
生产原生切换均不由影子 HTTP 200 自动触发。
|
||||
|
||||
## 当前边界
|
||||
|
||||
验证器源码与隔离测试为 100。BS-SG 完整影子、广州到新加坡认证隧道、nginx 代码路由、
|
||||
原生驻留期间公共代码频道、完整 Forgejo 等价和生产原生切换仍为 0。
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
schema: guanghu.repository-projected-server-receipt/v1
|
||||
receipt_id: GHOS-BS-GZ-006-DUPLICATE-FORGEJO-GUARD-REMOVAL-20260815-001
|
||||
observed_at: 2026-08-15T20:50:55+08:00
|
||||
node_id: BS-GZ-006
|
||||
instance_id: b2ecf109-1999-4643-b223-e67e24d7667d
|
||||
trigger: user required removal of useless guards that repeatedly consumed runtime without progress
|
||||
emergence: live Forgejo PID 2192816 already owned the data and port 3001; forgejo.service repeatedly launched a second process against the same LevelDB lock
|
||||
lock: disable only the duplicate forgejo.service and preserve the live process, data, nginx, JD tunnels, and public routes
|
||||
server_receipt: /var/lib/guanghu-os/receipts/BS-GZ-006-DUPLICATE-FORGEJO-GUARD-REMOVAL-RECEIPT.hldp
|
||||
server_receipt_sha256: 186075da7629c289705e31d580df1f5a77c590a3214392a382459030207c8214
|
||||
prior_failed_restart_counter_observed: 249950
|
||||
duplicate_unit_enabled_after: false
|
||||
duplicate_unit_active_after: false
|
||||
preserved_live_forgejo_http: 200
|
||||
public_code_http_after: 200
|
||||
data_deleted: false
|
||||
public_route_changed: false
|
||||
boot_changed: false
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[Unit]
|
||||
Description=Independent full Forgejo shadow for JD native service continuity
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu-code-shadow
|
||||
Group=guanghu-code-shadow
|
||||
WorkingDirectory=/var/lib/guanghu-code-shadow
|
||||
ExecStart=/var/lib/guanghu-code-shadow/bin/forgejo web --work-path /var/lib/guanghu-code-shadow --config /etc/guanghu-os/forgejo-shadow/app.ini
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ReadOnlyPaths=/etc/guanghu-os/forgejo-shadow
|
||||
ReadWritePaths=/var/lib/guanghu-code-shadow
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -22,6 +22,7 @@ grep -Fq -- '--fail-under-lines 100' "${runner}"
|
|||
grep -Fq -- '--fail-under-functions 100' "${runner}"
|
||||
grep -Fq -- '--test broadcast_library' "${runner}"
|
||||
grep -Fq 'test-native-public-projection.sh' "${runner}"
|
||||
grep -Fq 'test-independent-forgejo-shadow-verifier.py' "${runner}"
|
||||
grep -Fq 'GHNQG_PASS_100' "${runner}"
|
||||
grep -Fq 'GHNQG_FAIL_0' "${runner}"
|
||||
grep -Fq 'total_score: ${total_score}' "${runner}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("verify-independent-forgejo-shadow.py")
|
||||
SPEC = importlib.util.spec_from_file_location("forgejo_shadow_verifier", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class IndependentForgejoShadowVerifierTest(unittest.TestCase):
|
||||
def test_exact_shadow_passes_and_head_drift_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
binary = root / "forgejo"
|
||||
binary.write_bytes(b"fixed-forgejo-binary")
|
||||
repository = root / "repositories" / "bingshuo" / "example.git"
|
||||
repository.mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "--bare", "-q", str(repository)], check=True)
|
||||
tree = subprocess.run(
|
||||
["git", f"--git-dir={repository}", "mktree"],
|
||||
input="",
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
commit = subprocess.run(
|
||||
["git", f"--git-dir={repository}", "commit-tree", tree],
|
||||
input="source\n",
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
env={
|
||||
"GIT_AUTHOR_NAME": "test",
|
||||
"GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_AUTHOR_DATE": "2001-01-01T00:00:00+00:00",
|
||||
"GIT_COMMITTER_NAME": "test",
|
||||
"GIT_COMMITTER_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_DATE": "2001-01-01T00:00:00+00:00",
|
||||
},
|
||||
).stdout.strip()
|
||||
subprocess.run(
|
||||
["git", f"--git-dir={repository}", "update-ref", "refs/heads/main", commit],
|
||||
check=True,
|
||||
)
|
||||
database = root / "forgejo.db"
|
||||
connection = sqlite3.connect(database)
|
||||
connection.executescript(
|
||||
"CREATE TABLE repository(id INTEGER); INSERT INTO repository VALUES(1);"
|
||||
"CREATE TABLE user(id INTEGER); INSERT INTO user VALUES(1);"
|
||||
)
|
||||
connection.close()
|
||||
manifest = {
|
||||
"schema": MODULE.SCHEMA,
|
||||
"source_node": "JD-FD-PRIMARY",
|
||||
"forgejo_version": "16.0.1",
|
||||
"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
"database_repository_count": 1,
|
||||
"database_user_count": 1,
|
||||
"repositories": [{"path": "bingshuo/example.git", "head": commit}],
|
||||
}
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
loaded = MODULE.load_manifest(manifest_path)
|
||||
self.assertEqual(loaded["source_node"], "JD-FD-PRIMARY")
|
||||
self.assertEqual(
|
||||
MODULE.repository_heads(root / "repositories"),
|
||||
{"bingshuo/example.git": commit},
|
||||
)
|
||||
self.assertEqual(MODULE.database_readback(database), ("ok", 1, 1))
|
||||
drift = subprocess.run(
|
||||
["git", f"--git-dir={repository}", "commit-tree", tree, "-p", commit],
|
||||
input="drift\n",
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
env={
|
||||
"GIT_AUTHOR_NAME": "test",
|
||||
"GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_AUTHOR_DATE": "2001-01-02T00:00:00+00:00",
|
||||
"GIT_COMMITTER_NAME": "test",
|
||||
"GIT_COMMITTER_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_DATE": "2001-01-02T00:00:00+00:00",
|
||||
},
|
||||
).stdout.strip()
|
||||
subprocess.run(
|
||||
["git", f"--git-dir={repository}", "update-ref", "refs/heads/main", drift],
|
||||
check=True,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
MODULE.repository_heads(root / "repositories"),
|
||||
{"bingshuo/example.git": commit},
|
||||
)
|
||||
|
||||
def test_manifest_rejects_escape_and_duplicates(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = pathlib.Path(temporary) / "manifest.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": MODULE.SCHEMA,
|
||||
"repositories": [
|
||||
{"path": "../escape.git", "head": "1" * 40},
|
||||
{"path": "../escape.git", "head": "1" * 40},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.load_manifest(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify one independent Forgejo shadow against a source-side manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA = "guanghu.independent-forgejo-shadow-manifest/v1"
|
||||
|
||||
|
||||
def sha256(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_manifest(path: pathlib.Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or value.get("schema") != SCHEMA:
|
||||
raise ValueError("source manifest schema mismatch")
|
||||
repositories = value.get("repositories")
|
||||
if not isinstance(repositories, list) or not repositories:
|
||||
raise ValueError("source manifest must contain repositories")
|
||||
paths: set[str] = set()
|
||||
for repository in repositories:
|
||||
if not isinstance(repository, dict):
|
||||
raise ValueError("repository manifest entry must be an object")
|
||||
path_value = repository.get("path")
|
||||
head = repository.get("head")
|
||||
if (
|
||||
not isinstance(path_value, str)
|
||||
or path_value.startswith("/")
|
||||
or ".." in pathlib.PurePosixPath(path_value).parts
|
||||
or not path_value.endswith(".git")
|
||||
or path_value in paths
|
||||
):
|
||||
raise ValueError("repository path is unsafe or duplicated")
|
||||
if not isinstance(head, str) or len(head) != 40:
|
||||
raise ValueError("repository head must be a full commit")
|
||||
paths.add(path_value)
|
||||
return value
|
||||
|
||||
|
||||
def repository_heads(root: pathlib.Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for repository in sorted(root.glob("*/*.git")):
|
||||
if repository.is_symlink() or not repository.is_dir():
|
||||
raise ValueError("repository must be a real directory")
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={repository}", "rev-parse", "refs/heads/main"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
result[repository.relative_to(root).as_posix()] = completed.stdout.strip()
|
||||
return result
|
||||
|
||||
|
||||
def database_readback(path: pathlib.Path) -> tuple[str, int, int]:
|
||||
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
try:
|
||||
integrity = str(connection.execute("PRAGMA integrity_check").fetchone()[0])
|
||||
repositories = int(connection.execute("SELECT count(*) FROM repository").fetchone()[0])
|
||||
users = int(connection.execute("SELECT count(*) FROM user").fetchone()[0])
|
||||
return integrity, repositories, users
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def http_version(url: str) -> str:
|
||||
with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310
|
||||
if response.status != 200:
|
||||
raise ValueError("Forgejo version endpoint did not return 200")
|
||||
value = json.load(response)
|
||||
if not isinstance(value, dict) or not isinstance(value.get("version"), str):
|
||||
raise ValueError("Forgejo version response is malformed")
|
||||
return value["version"]
|
||||
|
||||
|
||||
def verify(args: argparse.Namespace) -> dict[str, object]:
|
||||
manifest = load_manifest(pathlib.Path(args.source_manifest))
|
||||
binary = pathlib.Path(args.binary)
|
||||
repository_root = pathlib.Path(args.repository_root)
|
||||
database = pathlib.Path(args.database)
|
||||
for path in (binary, repository_root, database):
|
||||
if path.is_symlink():
|
||||
raise ValueError("shadow inputs cannot be symlinks")
|
||||
expected_heads = {
|
||||
str(item["path"]): str(item["head"]) for item in manifest["repositories"]
|
||||
}
|
||||
integrity, repository_count, user_count = database_readback(database)
|
||||
checks = {
|
||||
"binary_sha256": sha256(binary) == manifest.get("binary_sha256"),
|
||||
"repository_heads": repository_heads(repository_root) == expected_heads,
|
||||
"database_integrity": integrity == "ok",
|
||||
"database_repository_count": repository_count
|
||||
== manifest.get("database_repository_count"),
|
||||
"database_user_count": user_count == manifest.get("database_user_count"),
|
||||
"http_version": http_version(args.version_url) == manifest.get("forgejo_version"),
|
||||
}
|
||||
if not all(checks.values()):
|
||||
failed = ",".join(key for key, passed in checks.items() if not passed)
|
||||
raise ValueError(f"independent Forgejo shadow mismatch: {failed}")
|
||||
return {
|
||||
"schema": "guanghu.independent-forgejo-shadow-verification/v1",
|
||||
"result": "PASS_100",
|
||||
"source_node": manifest.get("source_node"),
|
||||
"shadow_node": args.shadow_node,
|
||||
"repository_count": len(expected_heads),
|
||||
"database_repository_count": repository_count,
|
||||
"database_user_count": user_count,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-manifest", required=True)
|
||||
parser.add_argument("--binary", required=True)
|
||||
parser.add_argument("--repository-root", required=True)
|
||||
parser.add_argument("--database", required=True)
|
||||
parser.add_argument("--version-url", required=True)
|
||||
parser.add_argument("--shadow-node", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = verify(args)
|
||||
except (OSError, ValueError, sqlite3.Error, subprocess.SubprocessError) as error:
|
||||
raise SystemExit(str(error)) from error
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"schema": "guanghu.jd-independent-forgejo-continuity-contract/v1",
|
||||
"contract_id": "GHOS-JD-INDEPENDENT-FORGEJO-CONTINUITY-001",
|
||||
"state": "SOURCE_AND_TEST_ONLY",
|
||||
"trigger": "JD-FD-PRIMARY native residency stops the Linux-hosted Forgejo process and makes the current BS-GZ-006 tunnel return 502.",
|
||||
"causal_route": {
|
||||
"current": "BS-GZ-006 -> SSH tunnel -> JD Linux Forgejo",
|
||||
"shadow": "BS-GZ-006 -> dedicated SSH tunnel -> BS-SG-003 independent Forgejo",
|
||||
"reason_for_bs_sg": "BS-SG-003 has 7.5 GiB memory and 52 GiB free disk, remains available during JD native residency, and already carries the protected native relay role."
|
||||
},
|
||||
"source_binding": {
|
||||
"node_id": "JD-FD-PRIMARY",
|
||||
"forgejo_version": "16.0.1",
|
||||
"binary_sha256": "7a4c568136650c10498a9d3d62c7fd630a0cf09c166293ebd78708248f6398fc",
|
||||
"repository_count": 7,
|
||||
"work_path": "/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data"
|
||||
},
|
||||
"shadow_binding": {
|
||||
"node_id": "BS-SG-003",
|
||||
"listen": "127.0.0.1:19224",
|
||||
"publicly_reachable": false,
|
||||
"data_root": "/var/lib/guanghu-code-shadow",
|
||||
"configuration_root": "/etc/guanghu-os/forgejo-shadow"
|
||||
},
|
||||
"required_exact_readback": [
|
||||
"FORGEJO_BINARY_SHA256",
|
||||
"FORGEJO_HTTP_VERSION",
|
||||
"SQLITE_INTEGRITY_OK",
|
||||
"DATABASE_REPOSITORY_COUNT",
|
||||
"DATABASE_USER_COUNT",
|
||||
"EXACT_REPOSITORY_PATH_SET",
|
||||
"EXACT_MAIN_COMMIT_FOR_EVERY_REPOSITORY"
|
||||
],
|
||||
"migration_policy": {
|
||||
"initial_shadow_snapshot_may_be_online": true,
|
||||
"online_snapshot_is_cutover_authority": false,
|
||||
"final_cutover_requires_bounded_write_freeze": true,
|
||||
"final_cutover_requires_second_exact_sync": true,
|
||||
"source_is_deleted_after_cutover": false,
|
||||
"rollback_keeps_jd_linux_forgejo_intact": true
|
||||
},
|
||||
"acceptance_gates": {
|
||||
"verifier_source": 100,
|
||||
"isolated_verifier_test": 100,
|
||||
"bs_sg_full_forgejo_shadow_deployed": 0,
|
||||
"bs_sg_exact_source_snapshot_readback": 0,
|
||||
"bs_gz_to_bs_sg_authenticated_tunnel": 0,
|
||||
"nginx_code_route_switched": 0,
|
||||
"public_code_during_jd_native_residency": 0,
|
||||
"full_forgejo_equivalence": 0,
|
||||
"production_native_cutover": 0
|
||||
},
|
||||
"prohibited_claims": [
|
||||
"ONLINE_DUMP_IS_FINAL_CUTOVER_SNAPSHOT",
|
||||
"REPOSITORY_GIT_HEADS_ALONE_ARE_FULL_FORGEJO",
|
||||
"DATABASE_ONLY_IS_FULL_FORGEJO",
|
||||
"SHADOW_HTTP_200_IS_PUBLIC_ROUTE_SWITCH",
|
||||
"BS_GZ_EXISTING_UNRELATED_FORGEJO_IS_JD_CODE_CHANNEL",
|
||||
"SOURCE_IMPLEMENTATION_IS_DEPLOYMENT"
|
||||
]
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ run_gate unit_and_integration_tests \
|
|||
bash -c '
|
||||
cargo test --manifest-path "$1/Cargo.toml" --all-targets
|
||||
"$1/scripts/test-native-public-projection.sh"
|
||||
python3 "$1/scripts/test-independent-forgejo-shadow-verifier.py"
|
||||
' _ "${source_root}"
|
||||
run_gate zero_warning_lint \
|
||||
cargo clippy --manifest-path "${source_root}/Cargo.toml" --all-targets -- -D warnings
|
||||
|
|
|
|||
Loading…
Reference in a new issue