From 959dba988cf7cffa525ff9b0b34d1ae93d023c6a Mon Sep 17 00:00:00 2001 From: TheBadRoger Date: Thu, 15 Oct 2026 20:55:25 +0800 Subject: [PATCH] Fetch deployment dependencies over localhost SSH with GitHub fallback --- README.md | 33 +++++++-- TODO.md | 2 +- config/git-local.config | 10 +-- docs/decisions/0002-external-dependencies.md | 4 +- scripts/deploy.sh | 12 ++++ scripts/init-deps.py | 75 +++++++++++++------- scripts/test-init-deps.py | 33 +++++++++ 7 files changed, 130 insertions(+), 39 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/README.md b/README.md index f6fd596..05f2240 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,14 @@ ChuBlog/ python3 scripts/init-deps.py ``` -数据库地址为 `ChuGit:/srv/git/ChuSQL.git`,驱动为 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。客户端需要配置 `ChuGit` SSH 别名。服务器上存在这两个本地仓库时,初始化脚本会优先使用项目级 `insteadOf` 将链接映射到 `file:///srv/git/…`,不经过 SSH。`config/git-local.config` 同时包含 SSH/GitHub 链接到本地仓库的映射,只在本项目启用,不修改全局配置。 +子模块登记地址为 `ChuGit:/srv/git/ChuSQL.git` 和 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。初始化脚本按以下顺序获取**固定提交**: -开发电脑可明确选择已有本地仓库(工作树或 bare 仓库),不会把依赖的未提交改动复制进来: +1. 本机 SSH:`ssh://git@localhost/srv/git/ChuSQL.git`、`ssh://git@localhost/srv/git/ChuSQL-RustDriver.git`。 +2. 本机连接、访问或获取固定提交失败,回退到 `git@github.com:TheBadRoger/ChuSQL.git`、`git@github.com:TheBadRoger/ChuSQL-RustDriver.git`。 + +已正确初始化的固定版本直接复用;有未提交修改时拒绝覆盖。不会因回退而改为任意最新分支。`config/git-local.config` 提供 `ChuGit`/自建服务器地址到 localhost SSH 的可选 `insteadOf` 映射;刻意不改写 GitHub 地址,以免回退又被映射回本机。脚本不启用全局 `file` 协议,也不递归初始化未知子模块。 + +开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本: ```sh python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver @@ -41,7 +46,25 @@ cargo fmt --check cargo clippy --all-targets -- -D warnings ``` -部署基于 Debian 13,同架构构建 API 和 ChuSQL 后,组装运行镜像: +部署基于 Debian 13。项目和依赖工作目录应归 `deploy` 用户所有;为该用户配置可访问 `git@localhost` 与 GitHub 的 SSH 密钥,并预先核对两端主机密钥。私钥留在主机,不能放进镜像。 + +推荐入口(以 root 调 Docker,Git 与编译均以 deploy 运行): + +```sh +sudo /srv/apps/ChuBlog/scripts/deploy.sh +``` + +脚本先以 `sudo -H -u deploy` 运行 `init-deps.py`,再构建外部项目与 Docker 镜像。默认依赖工作树位于 `/srv/apps/ChuBlog/external/`。其数据库拉取等价于你指定的方式: + +```sh +sudo -u deploy git clone ssh://git@localhost/srv/git/ChuSQL.git /srv/apps/chusql +# 本机来源失败时: +sudo -u deploy git clone git@github.com:TheBadRoger/ChuSQL.git /srv/apps/chusql +``` + +如选择这个独立数据库目录,可用 `prepare-deploy.py --chusql-source /srv/apps/chusql --build-database` 构建;默认部署入口使用子模块固定版本。不要将数据库单独目录的源代码或产物加入博客提交。 + +首次部署先准备 `.env`,再运行入口。也可以手动执行以下同架构构建步骤;远程服务器上的 Git/编译命令应以 `deploy` 身份执行,Docker 命令由 root 执行: ```sh # ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。 @@ -56,6 +79,8 @@ docker compose --env-file .env -f deploy/compose.yaml up -d --build docker compose --env-file .env -f deploy/compose.yaml run --rm db-init ``` +部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。 + 构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 `prepare-deploy.py` 默认从数据库子模块读取编译产物;`--build-database` 会先构建其 Rust 存储库、Haskell server 和 bootstrap。也可用 `--chusql-source /已有构建的/ChuSQL` 选择已有外部构建,没有产物会明确失败。编译产物只暂存在被忽略的 `deploy/bin/`。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 @@ -149,4 +174,4 @@ python3 scripts/smoke.py --key-type rsa ## 历史清理后的协作 -驱动的旧源码副本会从主仓库历史中移除,后续只保留外部子模块配置。历史重写后,已有克隆建议重新克隆并初始化依赖;不要把旧 main 合并回来,否则可能重新引入已删除的依赖历史。自建服务器的不可达对象清理与其他人的旧克隆需要分别处理,GitHub 缓存和旧提交直链的回收由平台管理。 +驱动的旧源码副本已从主仓库可达历史移除,后续只保留外部子模块配置。历史重写后,已有克隆建议重新克隆并初始化依赖;不要把旧 main 合并回来,否则可能重新引入已删除的依赖历史。自建服务器的不可达对象清理与其他人的旧克隆需要分别处理,GitHub 缓存和旧提交直链的回收由平台管理。 diff --git a/TODO.md b/TODO.md index 6b45fab..b0a1470 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,7 @@ - [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 - [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 - [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 -- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置与本地路径优先映射;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 +- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、本机 SSH 优先拉取与 GitHub 回退;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 diff --git a/config/git-local.config b/config/git-local.config index 570d43d..d47e4c9 100644 --- a/config/git-local.config +++ b/config/git-local.config @@ -1,13 +1,9 @@ -# Optional on the self-hosted server; loaded by scripts/init-deps.py when these repos exist. -[url "file:///srv/git/ChuSQL.git"] +# Optional project-local aliases. GitHub URLs deliberately remain untouched for fallback. +[url "ssh://git@localhost/srv/git/ChuSQL.git"] insteadOf = ChuGit:/srv/git/ChuSQL.git insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL.git insteadOf = git@chunana.cn:/srv/git/ChuSQL.git - insteadOf = git@github.com:TheBadRoger/ChuSQL.git - insteadOf = https://github.com/TheBadRoger/ChuSQL.git -[url "file:///srv/git/ChuSQL-RustDriver.git"] +[url "ssh://git@localhost/srv/git/ChuSQL-RustDriver.git"] insteadOf = ChuGit:/srv/git/ChuSQL-RustDriver.git insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL-RustDriver.git insteadOf = git@chunana.cn:/srv/git/ChuSQL-RustDriver.git - insteadOf = git@github.com:TheBadRoger/ChuSQL-RustDriver.git - insteadOf = https://github.com/TheBadRoger/ChuSQL-RustDriver.git diff --git a/docs/decisions/0002-external-dependencies.md b/docs/decisions/0002-external-dependencies.md index 49378fc..542943d 100644 --- a/docs/decisions/0002-external-dependencies.md +++ b/docs/decisions/0002-external-dependencies.md @@ -8,6 +8,8 @@ 数据库服务与 Rust 驱动作为两个独立 Git 子模块维护。主仓库只提交远程地址、固定版本指针、初始化/构建配置,不提交依赖源码或编译产物。Cargo 引用初始化后的驱动路径,数据库从外部 checkout 构建并暂存到被忽略的 Docker 构建目录。 -正式来源使用 `ChuGit:/srv/git/ChuSQL.git` 与 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。在服务器可读本地 bare 仓库时,项目级 `insteadOf` 优先使用 `file:///srv/git/…`;开发者可显式指定已有本地来源。文件协议只在初始化这两个明确子模块的命令中启用,不设置全局许可,不递归获取未知子模块。 +子模块登记来源使用 `ChuGit:/srv/git/ChuSQL.git` 与 `ChuGit:/srv/git/ChuSQL-RustDriver.git`。服务器初始化由 `deploy` 用户执行,优先连接 `ssh://git@localhost/srv/git/…`;本机不可用或不含固定提交时,改用对应 GitHub 仓库。支持空仓库留下的半初始化状态恢复。回退仍保持固定提交,不自动跟随远程最新分支。 + +可选项目级 `insteadOf` 只将自建服务器别名转到 localhost SSH,不重写 GitHub,以保证回退独立。开发者可以显式使用已有文件路径;文件协议只对这种开发覆盖参数的一次命令启用。Docker 构建前通过同一个初始化脚本取依赖,Git 密钥不进入构建上下文。 已有驱动副本从主仓库的历史树中删除。外部项目仍各自保留完整历史;主仓库不能替代依赖仓库分发其对象,源仓库必须先具有固定指针所引用的提交。 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..fb857f1 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Fetch/build external dependencies as deploy, then start Docker Compose as root. +set -eu +PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +if [ "$(id -u)" -ne 0 ]; then + echo 'Run with sudo; Git/compilation run as deploy, Docker runs as root.' >&2 + exit 1 +fi +sudo -H -u deploy python3 "$PROJECT_ROOT/scripts/init-deps.py" +sudo -H -u deploy python3 "$PROJECT_ROOT/scripts/prepare-deploy.py" --build-database +docker compose --env-file "$PROJECT_ROOT/.env" -f "$PROJECT_ROOT/deploy/compose.yaml" up -d --build +printf '%s\n' 'When the database is ready, run the documented db-init command once.' diff --git a/scripts/init-deps.py b/scripts/init-deps.py index 3cab349..369cd2f 100755 --- a/scripts/init-deps.py +++ b/scripts/init-deps.py @@ -1,42 +1,65 @@ #!/usr/bin/env python3 -"""Initialize pinned external submodules, preferring local repositories over SSH.""" +"""Initialize pinned dependencies via localhost SSH, falling back to GitHub.""" import argparse from pathlib import Path import subprocess +import sys ROOT = Path(__file__).resolve().parents[1] PROJECTS = ['ChuSQL', 'ChuSQL-RustDriver'] -PREFIX = 'ChuGit:/srv/git/' + + +def git(*args, **kwargs): + return subprocess.run(['git', *args], cwd=ROOT, **kwargs) + + +def initialize(name, override): + path = f'external/{name}' + checkout = ROOT / path + pin = git('ls-files', '--stage', '--', path, text=True, capture_output=True, check=True).stdout.split() + if len(pin) != 4 or pin[0] != '160000': + raise RuntimeError(f'{path} is missing its committed submodule pin') + if (checkout / '.git').exists(): + dirty = git('-C', str(checkout), 'status', '--porcelain', text=True, capture_output=True, check=True).stdout + if dirty: + raise RuntimeError(f'{path} has local changes; refusing to overwrite them') + current = git('-C', str(checkout), 'rev-parse', '--verify', 'HEAD', text=True, capture_output=True, check=False) + if current.returncode == 0 and current.stdout.strip() == pin[1]: + print(f'{name}: pinned checkout already available', flush=True) + return + if override: + source = override.resolve() + if not source.is_dir(): + raise RuntimeError(f'Local repository does not exist: {source}') + sources = [source.as_uri()] + else: + sources = [f'ssh://git@localhost/srv/git/{name}.git', f'git@github.com:TheBadRoger/{name}.git'] + for url in sources: + print(f'{name}: trying {url}', flush=True) + git('config', '--local', f'submodule.{path}.url', url, check=True) + options = ['-c', 'protocol.file.allow=always'] if override else [] + options += ['-c', 'core.sshCommand=ssh -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1'] + if (checkout / '.git').exists(): + git('-C', str(checkout), 'remote', 'set-url', 'origin', url, check=True) + result = git(*options, '-C', str(checkout), 'fetch', '--no-tags', 'origin', pin[1], check=False) + if result.returncode == 0: + git('-C', str(checkout), 'checkout', '--detach', pin[1], check=True) + return + else: + result = git(*options, 'submodule', 'update', '--init', '--checkout', '--', path, check=False) + if result.returncode == 0: + return + print(f'{name}: source failed; trying next configured source', file=sys.stderr, flush=True) + raise RuntimeError(f'No source contains the pinned {name} commit {pin[1]}') def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--database-repo', type=Path, help='local clone/bare repository of ChuSQL') - parser.add_argument('--driver-repo', type=Path, help='local clone/bare repository of ChuSQL-RustDriver') + parser.add_argument('--database-repo', type=Path, help='explicit local development source for ChuSQL') + parser.add_argument('--driver-repo', type=Path, help='explicit local development source for the driver') args = parser.parse_args() - git_args = [] - local_config = ROOT / 'config/git-local.config' - if not args.database_repo and not args.driver_repo and all((Path('/srv/git') / f'{name}.git').is_dir() for name in PROJECTS): - current = subprocess.run(['git', 'config', '--local', '--get-all', 'include.path'], cwd=ROOT, text=True, capture_output=True, check=False) - if str(local_config) not in current.stdout.splitlines(): - subprocess.run(['git', 'config', '--local', '--add', 'include.path', str(local_config)], cwd=ROOT, check=True) - git_args.extend(['-c', f'include.path={local_config}']) for name, override in zip(PROJECTS, [args.database_repo, args.driver_repo]): - source = override or Path('/srv/git') / f'{name}.git' - if override and not source.is_dir(): - parser.error(f'Local repository does not exist: {source}') - if source.is_dir(): - source = source.resolve() - subprocess.run(['git', '-C', str(source), 'rev-parse', '--git-dir'], check=True, capture_output=True) - key = f'url.{source.as_uri()}.insteadOf' - url = f'{PREFIX}{name}.git' - subprocess.run(['git', 'config', '--local', '--replace-all', key, url], cwd=ROOT, check=True) - git_args.extend(['-c', f'{key}={url}']) - print(f'{name}: using local repository {source}', flush=True) - # The file protocol is enabled only for this explicit, fixed set of dependencies. - if git_args: - git_args.extend(['-c', 'protocol.file.allow=always']) - subprocess.run(['git', *git_args, 'submodule', 'update', '--init', '--', *[f'external/{name}' for name in PROJECTS]], cwd=ROOT, check=True) + initialize(name, override) if __name__ == '__main__': diff --git a/scripts/test-init-deps.py b/scripts/test-init-deps.py index b75a20c..796b705 100644 --- a/scripts/test-init-deps.py +++ b/scripts/test-init-deps.py @@ -46,6 +46,39 @@ class InitializeDependencies(unittest.TestCase): for name in ['ChuSQL', 'ChuSQL-RustDriver']: self.assertEqual((project / f'external/{name}/source.txt').read_text(), f'{name} committed source') self.assertTrue(all(line.startswith('160000') for line in git(project, 'ls-files', '--stage', 'external').splitlines())) + # A fresh clone must fall back when localhost SSH fails. Redirect only the test's + # GitHub remotes to local fixtures, so this verifies fallback without network access. + fresh = root / 'fresh' + git(project, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'Pinned dependencies') + git(root, 'clone', '--no-local', str(project), str(fresh)) + (fresh / 'scripts').mkdir() + shutil.copy2(SCRIPT, fresh / 'scripts/init-deps.py') + fallback_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '2'} + for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)): + fallback_env[f'GIT_CONFIG_KEY_{i}'] = f'url.{Path(source).as_uri()}.insteadOf' + fallback_env[f'GIT_CONFIG_VALUE_{i}'] = f'git@github.com:TheBadRoger/{name}.git' + subprocess.run(['python3', 'scripts/init-deps.py'], cwd=fresh, env=fallback_env, check=True, capture_output=True) + for name in ['ChuSQL', 'ChuSQL-RustDriver']: + self.assertEqual((fresh / f'external/{name}/source.txt').read_text(), f'{name} committed source') + self.assertEqual(git(fresh / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'git@github.com:TheBadRoger/{name}.git') + # Also cover an accessible but empty localhost bare repository: the initial + # submodule clone succeeds, fetching the pinned commit fails, then GitHub recovers. + empty = root / 'empty.git' + git(root, 'init', '--bare', str(empty)) + partial = root / 'partial' + git(root, 'clone', '--no-local', str(project), str(partial)) + (partial / 'scripts').mkdir() + shutil.copy2(SCRIPT, partial / 'scripts/init-deps.py') + partial_env = {**fallback_env, 'GIT_CONFIG_COUNT': '4'} + for i, name in enumerate(['ChuSQL', 'ChuSQL-RustDriver'], start=2): + empty_source = root / f'empty-{name}.git' + git(root, 'clone', '--bare', str(empty), str(empty_source)) + partial_env[f'GIT_CONFIG_KEY_{i}'] = f'url.{empty_source.as_uri()}.insteadOf' + partial_env[f'GIT_CONFIG_VALUE_{i}'] = f'ssh://git@localhost/srv/git/{name}.git' + result = subprocess.run(['python3', 'scripts/init-deps.py'], cwd=partial, env=partial_env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for name in ['ChuSQL', 'ChuSQL-RustDriver']: + self.assertEqual((partial / f'external/{name}/source.txt').read_text(), f'{name} committed source') # Running the initialization a second time must preserve the same pins. subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)