Fetch deployment dependencies over localhost SSH with GitHub fallback

This commit is contained in:
TheBadRoger committed 2026-10-15 20:55:25 +08:00
1 parent d4caf05370
commit 959dba988c
7 files changed
+130 -39

No files matched your search

+29 -4
View File
@@ -22,9 +22,14 @@ ChuBlog/
python3 scripts/init-deps.py 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 ```sh
python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver
@@ -41,7 +46,25 @@ cargo fmt --check
cargo clippy --all-targets -- -D warnings 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 ```sh
# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。 # 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 docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
``` ```
部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。
构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 构建时若 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` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 `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 缓存和旧提交直链的回收由平台管理。
+1 -1
View File
@@ -13,7 +13,7 @@
- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 - [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。
- [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 - [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。
- [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 - [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置与本地路径优先映射;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、本机 SSH 优先拉取与 GitHub 回退;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
+3 -7
View File
@@ -1,13 +1,9 @@
# Optional on the self-hosted server; loaded by scripts/init-deps.py when these repos exist. # Optional project-local aliases. GitHub URLs deliberately remain untouched for fallback.
[url "file:///srv/git/ChuSQL.git"] [url "ssh://git@localhost/srv/git/ChuSQL.git"]
insteadOf = ChuGit:/srv/git/ChuSQL.git insteadOf = ChuGit:/srv/git/ChuSQL.git
insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL.git insteadOf = ssh://git@chunana.cn/srv/git/ChuSQL.git
insteadOf = git@chunana.cn:/srv/git/ChuSQL.git insteadOf = git@chunana.cn:/srv/git/ChuSQL.git
insteadOf = git@github.com:TheBadRoger/ChuSQL.git [url "ssh://git@localhost/srv/git/ChuSQL-RustDriver.git"]
insteadOf = https://github.com/TheBadRoger/ChuSQL.git
[url "file:///srv/git/ChuSQL-RustDriver.git"]
insteadOf = ChuGit:/srv/git/ChuSQL-RustDriver.git insteadOf = ChuGit:/srv/git/ChuSQL-RustDriver.git
insteadOf = ssh://git@chunana.cn/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@chunana.cn:/srv/git/ChuSQL-RustDriver.git
insteadOf = git@github.com:TheBadRoger/ChuSQL-RustDriver.git
insteadOf = https://github.com/TheBadRoger/ChuSQL-RustDriver.git
+3 -1
View File
@@ -8,6 +8,8 @@
数据库服务与 Rust 驱动作为两个独立 Git 子模块维护。主仓库只提交远程地址、固定版本指针、初始化/构建配置,不提交依赖源码或编译产物。Cargo 引用初始化后的驱动路径,数据库从外部 checkout 构建并暂存到被忽略的 Docker 构建目录。 数据库服务与 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 密钥不进入构建上下文。
已有驱动副本从主仓库的历史树中删除。外部项目仍各自保留完整历史;主仓库不能替代依赖仓库分发其对象,源仓库必须先具有固定指针所引用的提交。 已有驱动副本从主仓库的历史树中删除。外部项目仍各自保留完整历史;主仓库不能替代依赖仓库分发其对象,源仓库必须先具有固定指针所引用的提交。
+12
View File
@@ -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.'
+49 -26
View File
@@ -1,42 +1,65 @@
#!/usr/bin/env python3 #!/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 import argparse
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
PROJECTS = ['ChuSQL', 'ChuSQL-RustDriver'] 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(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database-repo', type=Path, help='local clone/bare repository of ChuSQL') parser.add_argument('--database-repo', type=Path, help='explicit local development source for ChuSQL')
parser.add_argument('--driver-repo', type=Path, help='local clone/bare repository of ChuSQL-RustDriver') parser.add_argument('--driver-repo', type=Path, help='explicit local development source for the driver')
args = parser.parse_args() 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]): for name, override in zip(PROJECTS, [args.database_repo, args.driver_repo]):
source = override or Path('/srv/git') / f'{name}.git' initialize(name, override)
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)
if __name__ == '__main__': if __name__ == '__main__':
+33
View File
@@ -46,6 +46,39 @@ class InitializeDependencies(unittest.TestCase):
for name in ['ChuSQL', 'ChuSQL-RustDriver']: for name in ['ChuSQL', 'ChuSQL-RustDriver']:
self.assertEqual((project / f'external/{name}/source.txt').read_text(), f'{name} committed source') 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())) 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. # 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) subprocess.run(['python3', 'scripts/init-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)