优化部署流程

This commit is contained in:
ChuNa7a committed 2026-10-17 14:21:52 +08:00
1 parent 16e5db3b26
commit 2ecc72d027
20 files changed
+597 -116

No files matched your search

+5 -7
View File
@@ -1,13 +1,11 @@
PUBLIC_ORIGIN=https://blog.example.com PUBLIC_ORIGIN=http://localhost
SITE_ADDRESS=blog.example.com SITE_ADDRESS=http://localhost:80
SITE_TITLE=ChuBlog SITE_TITLE=ChuBlog
ADMIN_USERNAME=admin
# Generate via the temporary API container in docs/deployment.md; keep single quotes around the hash.
ADMIN_PASSWORD_HASH='$argon2id$REPLACE_WITH_GENERATED_HASH'
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
CHUSQL_ROOT_PASSWORD=replace-with-random-root-password-at-least-16-characters
CHUSQL_PASSWORD=replace-with-different-random-index-password-at-least-16-characters
SSH_PORT=2222 SSH_PORT=2222
# Generated automatically by deploy.sh on first deployment.
CHUSQL_ROOT_PASSWORD=
CHUSQL_PASSWORD=
HTTP_PORT=80 HTTP_PORT=80
HTTPS_PORT=443 HTTPS_PORT=443
+1
View File
@@ -10,3 +10,4 @@ dist/
__pycache__/ __pycache__/
*.pyc *.pyc
external external
/docs/decisions/
+8 -1
View File
@@ -4,6 +4,14 @@ Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown
提供 Rust/Axum 后端和 Astro + TypeScript 简单前端。首页为居中的施工说明与导航,支持文章阅读、注册登录和账户设置;文章继续通过 Git 发布。 提供 Rust/Axum 后端和 Astro + TypeScript 简单前端。首页为居中的施工说明与导航,支持文章阅读、注册登录和账户设置;文章继续通过 Git 发布。
在项目根目录一键部署(默认腾讯云内网源):
```sh
scripts/deploy.sh --site https://blog.example.com
```
宿主机只需 Docker Compose、Python 3 和 Git。脚本自动完成依赖获取、容器内编译、数据库初始化和启动,不创建初始管理员。更新使用 `scripts/update.sh`,卸载使用 `scripts/uninstall.sh`,详细配置见[部署文档](docs/deployment.md)。
## 文档 ## 文档
| 文档 | 内容 | | 文档 | 内容 |
@@ -15,7 +23,6 @@ Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown
| [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 | | [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 |
| [内容与隔离](docs/security.md) | 文件白名单、容量限制、SSH 与进程边界 | | [内容与隔离](docs/security.md) | 文件白名单、容量限制、SSH 与进程边界 |
| [运维与迁移](docs/operations.md) | 持久化、备份恢复、旧共享仓库迁移 | | [运维与迁移](docs/operations.md) | 持久化、备份恢复、旧共享仓库迁移 |
| [架构决策](docs/decisions/README.md) | 已记录的设计选择及演进 |
源码:[GitHub](https://github.com/TheBadRoger/ChuBlog.git) · [自建仓库](https://repo.chunana.cn/ChuNa7a/ChuBlog.git) 源码:[GitHub](https://github.com/TheBadRoger/ChuBlog.git) · [自建仓库](https://repo.chunana.cn/ChuNa7a/ChuBlog.git)
+3
View File
@@ -16,6 +16,7 @@
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
- [x] Rust、ChuSQL 与 Astro 全部在 Docker 内构建;默认腾讯云内网镜像,提供独立公网源验证配置,宿主机无需安装编译工具链。 - [x] Rust、ChuSQL 与 Astro 全部在 Docker 内构建;默认腾讯云内网镜像,提供独立公网源验证配置,宿主机无需安装编译工具链。
- [x] 一键部署、更新与卸载;自动初始化数据库与保存数据库口令,无初始管理员前置条件。
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
## 第一优先级:部署环境验收 ## 第一优先级:部署环境验收
@@ -32,6 +33,8 @@
## 第二优先级:账户与权限完善 ## 第二优先级:账户与权限完善
- [ ] 后续确定初始站长建立、发布授权及恢复流程;部署不自动创建管理员,首个注册用户不自动提权。
### 数据库用户系统与角色 ### 数据库用户系统与角色
- [ ] 将用户、密码哈希、角色及发布授权迁入 ChuSQL;当前文件账户和 reader / publisher / admin 角色需提供迁移方案。文章正文仍由 Git 管理,账户数据不能作为可丢弃的派生索引。 - [ ] 将用户、密码哈希、角色及发布授权迁入 ChuSQL;当前文件账户和 reader / publisher / admin 角色需提供迁移方案。文章正文仍由 Git 管理,账户数据不能作为可丢弃的派生索引。
+3 -43
View File
@@ -120,55 +120,15 @@ pub async fn load(
}, },
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
let mut accounts = Accounts { Ok(Accounts {
credentials, credentials,
grants, grants,
sessions: BTreeMap::new(), sessions: BTreeMap::new(),
attempts: VecDeque::new(), attempts: VecDeque::new(),
changes: VecDeque::new(), changes: VecDeque::new(),
}; })
if accounts.credentials.is_empty() {
let name = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".into());
username(&name)?;
let hash =
std::env::var("ADMIN_PASSWORD_HASH").context("首次启动必须设置 ADMIN_PASSWORD_HASH")?;
let parsed = PasswordHash::new(&hash).map_err(|_| anyhow::anyhow!("管理员哈希格式无效"))?;
ensure!(
parsed.algorithm.as_str() == "argon2id",
"管理员哈希必须为 Argon2id"
);
accounts
.credentials
.insert(name.clone(), Credential { hash });
accounts.grants.publishers.insert(
name,
Grant {
enabled: true,
role: Role::Admin,
prefixes: vec![],
ssh_key: None,
},
);
atomic(config, &serde_json::to_vec_pretty(&accounts.grants)?, 0o644).await?;
atomic(
&data.join("users.json"),
&serde_json::to_vec(&accounts.credentials)?,
0o600,
)
.await?;
}
ensure!(
accounts
.grants
.publishers
.iter()
.any(|(name, g)| g.role == Role::Admin
&& g.enabled
&& accounts.credentials.contains_key(name)),
"至少需要一个启用的管理员"
);
Ok(accounts)
} }
fn rate(accounts: &mut Accounts) -> ApiResult<()> { fn rate(accounts: &mut Accounts) -> ApiResult<()> {
let now = Instant::now(); let now = Instant::now();
while accounts while accounts
+60
View File
@@ -602,6 +602,66 @@ async fn request(
) )
} }
#[tokio::test] #[tokio::test]
async fn empty_accounts_start_and_first_user_remains_reader_after_reload() {
let dir = tempfile::tempdir().unwrap();
let app = app(dir.path()).await;
tokio::fs::remove_file(dir.path().join("users.json"))
.await
.unwrap();
tokio::fs::remove_file(&app.ssh_config).await.unwrap();
*app.accounts.lock().await =
accounts::load(dir.path(), &app.ssh_config, dir.path().join("ingress.git"))
.await
.unwrap();
assert!(app.accounts.lock().await.grants.publishers.is_empty());
let (status, _, value) = request(
app.clone(),
"POST",
"/api/auth/register",
json!({"username":"first", "password":"First-user-password-123"}),
None,
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(value["role"], "reader");
*app.accounts.lock().await =
accounts::load(dir.path(), &app.ssh_config, dir.path().join("ingress.git"))
.await
.unwrap();
let (status, headers, value) = request(
app.clone(),
"POST",
"/api/auth/login",
json!({"username":"first", "password":"First-user-password-123"}),
None,
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(app.accounts.lock().await.grants.publishers.len(), 1);
assert_eq!(
app.accounts.lock().await.grants.publishers["first"].role,
Role::Reader
);
let cookie = headers[header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap();
let (status, _, _) = request(
app,
"GET",
"/api/admin/users",
json!(null),
Some(cookie),
value["csrf"].as_str(),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn registration_login_grants_revocation_and_private_content_boundaries() { async fn registration_login_grants_revocation_and_private_content_boundaries() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let app = app(dir.path()).await; let app = app(dir.path()).await;
+4 -4
View File
@@ -20,7 +20,7 @@ COPY apps/api ./apps/api
COPY external/ChuSQL-RustDriver ./external/ChuSQL-RustDriver COPY external/ChuSQL-RustDriver ./external/ChuSQL-RustDriver
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
cargo build --locked --release && mkdir /out && cp target/release/chublog-api /out/ cargo build --locked --release --package chublog-api --bin chublog-api && mkdir /out && cp target/release/chublog-api /out/
FROM rust-toolchain AS database-build FROM rust-toolchain AS database-build
ARG DEBIAN_MIRROR=mirrors.tencentyun.com ARG DEBIAN_MIRROR=mirrors.tencentyun.com
@@ -35,13 +35,13 @@ ENV PATH=/opt/ghc/9.10.3/bin:$PATH
WORKDIR /src/ChuSQL WORKDIR /src/ChuSQL
COPY external/ChuSQL ./ COPY external/ChuSQL ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --manifest-path chusql-core/storage/Cargo.toml --locked --release cargo build --manifest-path chusql-core/storage/Cargo.toml --locked --release --lib
ENV LD_LIBRARY_PATH=/src/ChuSQL/chusql-core/storage/target/release ENV LD_LIBRARY_PATH=/src/ChuSQL/chusql-core/storage/target/release
RUN --mount=type=cache,target=/root/.stack \ RUN --mount=type=cache,target=/root/.stack \
mkdir /out && cd chusql-server \ mkdir /out && cd chusql-server \
&& stack --system-ghc --no-install-ghc --no-terminal --jobs 2 build --fast --copy-bins --local-bin-path /out \ && stack --system-ghc --no-install-ghc --no-terminal --jobs 2 build chusql-server:exe:chusql-server --no-test --no-bench --fast --copy-bins --local-bin-path /out \
&& cd ../chusql-bootstrap \ && cd ../chusql-bootstrap \
&& stack --system-ghc --no-install-ghc --no-terminal --jobs 2 build --fast --copy-bins --local-bin-path /out \ && stack --system-ghc --no-install-ghc --no-terminal --jobs 2 build chusql-bootstrap:exe:csql-bootstrap --no-test --no-bench --fast --copy-bins --local-bin-path /out \
&& cp ../chusql-core/storage/target/release/libchusql_core_storage.so /out/ && cp ../chusql-core/storage/target/release/libchusql_core_storage.so /out/
FROM ${DEBIAN_IMAGE} AS base FROM ${DEBIAN_IMAGE} AS base
+1 -3
View File
@@ -31,13 +31,11 @@ services:
init: { condition: service_completed_successfully } init: { condition: service_completed_successfully }
environment: environment:
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set PUBLIC_ORIGIN} PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set PUBLIC_ORIGIN}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:?Set ADMIN_PASSWORD_HASH}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
CHUSQL_ADDRESS: db:7777 CHUSQL_ADDRESS: db:7777
CHUSQL_USER: chublog_index CHUSQL_USER: chublog_index
CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD} CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD}
CHUSQL_DATABASE: chublog CHUSQL_DATABASE: chublog
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
volumes: volumes:
- content:/data - content:/data
- authorization:/auth - authorization:/auth
+58 -32
View File
@@ -2,15 +2,46 @@
[返回项目首页](../README.md) [返回项目首页](../README.md)
## 构建与启动 ## 一键部署
- 构建与运行镜像基于 Debian 13;宿主机只需满足 Docker 运行要求。 在项目根目录执行,将示例域名替换为自己的域名,并事先配置 DNS:
- `scripts/deploy.sh` 会使用同一个依赖初始化脚本,自动按[依赖源顺序](development.md)进行 HTTPS 回退,再构建外部数据库与 Docker 镜像;沿用运行者现有的 Git 配置,不在项目内覆盖服务器环境。
```sh ```sh
scripts/deploy.sh scripts/deploy.sh --site https://blog.example.com
``` ```
脚本依次生成配置、获取固定版本的外部依赖、在 Docker 内构建镜像、启动并初始化数据库,最后启动博客并等待服务就绪。不需要另行执行 `hash-password`、`db-init` 或 `docker compose up`。
首次运行自动创建权限为 `600` 的 `.env`,生成两份不同的随机数据库口令。重复部署保留已有口令和数据;需要备份 `.env` 及持久卷。不传 `--site` 时首次默认使用 `http://localhost`,已有配置则继续沿用。
部署不创建博客管理员,也不要求填写管理员密码。首页继续显示施工说明和导航;文章阅读、注册登录、账户设置及 SSH 服务照常启动。新注册用户均为 `reader`,第一个用户也不会自动提权。已有账户与发布授权保持有效;空站点的发布授权仍需后续确定的管理员管理流程,注册本身不授予发布权。
## 更新与卸载
更新前先提交或自行保存工作区修改,然后执行:
```sh
scripts/update.sh
```
脚本拒绝覆盖有未提交修改的工作区,以 `git pull --ff-only` 更新当前分支,再重新获取依赖、构建、初始化数据库并启动服务。保留 `.env`、账户、文章和数据库持久卷。构建失败会退出,不先停止已有服务;启动失败时检查日志,脚本不提供自动版本回滚。
卸载当前项目的容器、网络和本地构建镜像,保留数据卷:
```sh
scripts/uninstall.sh
```
**永久删除文章、账户、数据库、授权和 SSH 主机密钥等项目持久数据**时,使用:
```sh
scripts/uninstall.sh --purge
```
卸载保留项目源码、`.env`、共享基础镜像和 Docker 构建缓存,不执行全局 prune。保留数据卷时,重新运行部署脚本即可恢复。删除数据卷后恢复只能依靠备份。
三个脚本均支持 `--env-file PATH`、`--project-name NAME` 和 `--sources-env-file PATH`;同一套部署的更新与卸载必须沿用相同配置路径和项目名。下载源覆盖文件只接受镜像及软件包来源,不能覆盖数据库口令。
## 宿主机要求 ## 宿主机要求
宿主机只需 Docker Engine(含 BuildKit)、Docker Compose 插件、Python 3 和 Git,以及执行 Docker 命令的权限。不需要安装 Cargo、Rust、GHC、Stack、Node.js 或 C 编译工具。 宿主机只需 Docker Engine(含 BuildKit)、Docker Compose 插件、Python 3 和 Git,以及执行 Docker 命令的权限。不需要安装 Cargo、Rust、GHC、Stack、Node.js 或 C 编译工具。
@@ -46,56 +77,51 @@ Rust 工具链直接来自 Rust 镜像;GHC 9.10.3 和 Stack 来自匹配 ChuSQ
### 使用外网源验证 ### 使用外网源验证
项目提供不含密码的 `deploy/sources.public.env`,使用公开 ECR Docker Library 镜像、USTC Debian 源、官方 npm 源及 RsProxy Cargo 源。外网构建与隔离联调时运行: 项目提供不含密码的 `deploy/sources.public.env`,使用公开 ECR Docker Library 镜像、USTC Debian 源、官方 npm 源及 RsProxy Cargo 源。外网部署与隔离联调时运行:
```sh ```sh
python3 scripts/prepare-deploy.py --sources-env-file deploy/sources.public.env scripts/deploy.sh --site https://blog.example.com \
--sources-env-file deploy/sources.public.env
python3 scripts/smoke.py --key-type rsa \ python3 scripts/smoke.py --key-type rsa \
--sources-env-file deploy/sources.public.env --sources-env-file deploy/sources.public.env
``` ```
第二份配置只覆盖下载来源,不改写 `.env` 的域名、口令等生产设置。Docker Compose 会优先使用当前 shell 中已导出的同名环境变量;切换测试配置前须检查这些覆盖。公网源连通性不能证明腾讯云内网镜像可用,内网源仍需在腾讯云服务器验证。 第二份配置只覆盖下载来源,不改写 `.env` 的域名、口令等生产设置。Docker Compose 会优先使用当前 shell 中已导出的同名环境变量;切换测试配置前须检查这些覆盖。公网源连通性不能证明腾讯云内网镜像可用,内网源仍需在腾讯云服务器验证。
## 首次部署
在项目根目录执行。先按[开发与依赖](development.md)初始化子模块,再准备配置和构建镜像:
```sh
cp .env.example .env
chmod 600 .env
# 编辑 .env,配置域名、镜像前缀及两份不同的随机 DB 口令。
python3 scripts/prepare-deploy.py
# 在临时容器内生成管理员密码哈希;不启动其他服务。
# 输入口令后按 Ctrl-D 结束,不要把明文写进命令行历史。
docker compose --env-file .env -f deploy/compose.yaml \
run --rm --no-deps -T api hash-password
# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH。
docker compose --env-file .env -f deploy/compose.yaml up -d --no-build
# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。
docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
```
后续部署可运行 `scripts/deploy.sh`,自动初始化依赖、在 Docker 内构建并启动服务。
部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。
## 构建选项与平台 ## 构建选项与平台
服务器部署固定只构建生产目标,无需额外参数:Rust 只构建 `chublog-api` 可执行文件与 ChuSQL 存储库,Haskell 只构建服务端和 bootstrap,并显式禁用测试与基准目标。前端只执行类型检查与生产构建,不调用测试命令。`deploy.sh` 与 `prepare-deploy.py` 不会自动运行联调脚本;开发测试仍可独立执行。
需要切换默认腾讯云内网 Debian 包源时,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 需要切换默认腾讯云内网 Debian 包源时,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。
- `prepare-deploy.py` 调用 Compose 构建全部应用镜像,不执行宿主机编译、不生成 `deploy/bin/`。 - `prepare-deploy.py` 调用 Compose 构建全部应用镜像,不执行宿主机编译、不生成 `deploy/bin/`。
- `--build-database` 保留为兼容参数;数据库始终在 Docker 中构建。旧的 `--no-build` 和 `--chusql-source` 不再支持。 - `--build-database` 保留为兼容参数;数据库始终在 Docker 中构建。旧的 `--no-build` 和 `--chusql-source` 不再支持。
- `--sources-env-file PATH` 可追加只含下载源配置的 Compose 环境文件;优先级高于第一份环境文件。 - `--sources-env-file PATH` 可追加只含下载源配置的 Compose 环境文件;优先级高于第一份环境文件。
- `--env-file PATH` 可选择配置文件;默认读取项目 `.env`,不存在时读取 `.env.example`(仅用于构建,不用于生产启动)。 - `--env-file PATH` 可选择配置文件;默认读取项目 `.env`,不存在时读取 `.env.example`(仅用于构建,不用于生产启动)。
- 外部依赖仍由宿主机 `init-deps.py` 初始化,沿用现有 Git 配置;构建过程不将 SSH 私钥或 Git 凭据复制到镜像。 - 外部依赖仍由宿主机 `fetch-deps.py` 初始化,沿用现有 Git 配置;构建过程不将 SSH 私钥或 Git 凭据复制到镜像。
- GHC 工具链镜像支持 amd64、arm64;更新 ChuSQL resolver 时,须同步核对 GHC 镜像版本与 Dockerfile 中的复制路径。 - GHC 工具链镜像支持 amd64、arm64;更新 ChuSQL resolver 时,须同步核对 GHC 镜像版本与 Dockerfile 中的复制路径。
- 镜像默认按 Docker 主机架构构建,不复用宿主机已有二进制;构建与运行阶段均使用 Debian 13 系列镜像。 - 镜像默认按 Docker 主机架构构建,不复用宿主机已有二进制;构建与运行阶段均使用 Debian 13 系列镜像。
- Cargo、Stack 软件包缓存保存在 Docker BuildKit 中。镜像前缀控制编译器镜像来源,Cargo 与 Haskell 包下载独立于镜像注册表。 - Cargo、Stack 软件包缓存保存在 Docker BuildKit 中。镜像前缀控制编译器镜像来源,Cargo 与 Haskell 包下载独立于镜像注册表。
- 运行镜像不含编译器、源码、`.env` 或密码文件。 - 运行镜像不含编译器、源码、`.env` 或密码文件。
## 管理员初始化 ## 验证部署
浏览器打开配置的站点即可看到前端。`SITE_TITLE` 控制页眉名称。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 浏览器打开配置的站点,确认首页居中显示施工说明,且可以进入文章列表与账户页面。`SITE_TITLE` 控制页眉名称。检查服务状态与日志:
```sh
docker compose --env-file .env -f deploy/compose.yaml ps
docker compose --env-file .env -f deploy/compose.yaml logs --tail 100 api db
```
`api`、`web`、`ssh`、`db` 和 `caddy` 应运行,`init` 成功退出。`db-init` 是一次性初始化任务。部署脚本对数据库初始化设有重试和超时,失败时退出,不会报告部署完成。
用于开发环境的隔离部署回归检查:
```sh
python3 scripts/smoke-deployment.py --sources-env-file deploy/sources.public.env
```
该检查使用独立项目与本机 18080/18443/12222 端口,验证无管理员启动、普通用户注册登录、保留数据卸载后重新部署及彻底卸载。测试结束删除自己的容器和卷,不操作正式项目。
## 域名与网络 ## 域名与网络
+6 -3
View File
@@ -8,7 +8,7 @@ ChuBlog/
├── external/ ├── external/
│ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针 │ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针
│ └── ChuSQL-RustDriver/ # Git 子模块 │ └── ChuSQL-RustDriver/ # Git 子模块
└── scripts/init-deps.py └── scripts/fetch-deps.py
``` ```
## 获取代码 ## 获取代码
@@ -19,7 +19,7 @@ ChuBlog/
git clone https://github.com/TheBadRoger/ChuBlog.git git clone https://github.com/TheBadRoger/ChuBlog.git
# 或:git clone https://repo.chunana.cn/ChuNa7a/ChuBlog.git # 或:git clone https://repo.chunana.cn/ChuNa7a/ChuBlog.git
cd ChuBlog cd ChuBlog
python3 scripts/init-deps.py python3 scripts/fetch-deps.py
``` ```
## 依赖来源与回退 ## 依赖来源与回退
@@ -40,7 +40,7 @@ python3 scripts/init-deps.py
开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本: 开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本:
```sh ```sh
python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver python3 scripts/fetch-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver
``` ```
- 子模块固定到已提交版本;源仓库必须包含这些提交。 - 子模块固定到已提交版本;源仓库必须包含这些提交。
@@ -63,6 +63,8 @@ cargo clippy --all-targets -- -D warnings
```sh ```sh
python3 scripts/test-prepare-deploy.py python3 scripts/test-prepare-deploy.py
python3 scripts/test-fetch-deps.py
python3 scripts/test-deployment.py
``` ```
```sh ```sh
@@ -77,6 +79,7 @@ python3 scripts/smoke.py --key-type rsa --browser-test \
- 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。 - 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。
- 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、TOML 暂存与修复、多页链接、系统时间、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。 - 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、TOML 暂存与修复、多页链接、系统时间、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。
- 管理员仅由隔离测试脚本写入测试卷作为夹具,生产部署没有管理员环境变量或自动建号步骤。
- 该测试会拉取基础镜像,需要 Docker 可用。 - 该测试会拉取基础镜像,需要 Docker 可用。
## 历史清理后的协作 ## 历史清理后的协作
+2 -10
View File
@@ -1,12 +1,4 @@
#!/bin/sh #!/bin/sh
# Initialize dependencies via GitHub HTTPS with self-hosted HTTPS fallback, then build Docker Compose.
set -eu set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
python3 "$PROJECT_ROOT/scripts/init-deps.py" exec python3 "$SCRIPT_DIR/deployment.py" deploy "$@"
if [ ! -f "$PROJECT_ROOT/.env" ]; then
printf '%s\n' 'Create and configure .env first; see docs/deployment.md.' >&2
exit 1
fi
python3 "$PROJECT_ROOT/scripts/prepare-deploy.py"
docker compose --env-file "$PROJECT_ROOT/.env" -f "$PROJECT_ROOT/deploy/compose.yaml" up -d --no-build
printf '%s\n' 'When the database is ready, run the documented db-init command once.'
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Deploy the blog, update it, or uninstall its Compose project."""
import argparse
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
DB_KEYS = ('CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD')
RETIRED_KEYS = ('ADMIN_USERNAME', 'ADMIN_PASSWORD_HASH')
def read_config(path):
values = {}
if path.is_file():
for line in path.read_text().splitlines():
key, sep, value = line.partition('=')
if sep and key.strip() and not key.lstrip().startswith('#'):
values[key.strip()] = value.strip().strip("'\"")
return values
def configure(path, site):
text = path.read_text() if path.exists() else (ROOT / '.env.example').read_text()
values = read_config(path if path.exists() else ROOT / '.env.example')
changes = {}
for key in DB_KEYS:
value = os.environ.get(key, values.get(key, ''))
if key in os.environ and (len(value) < 16 or value.startswith('replace-with-')):
raise RuntimeError(f'{key} 环境变量无效,请先取消该覆盖或设置至少 16 字节的口令。')
if not value or value.startswith('replace-with-'):
changes[key] = secrets.token_urlsafe(32)
elif len(value) < 16:
raise RuntimeError(f'{key} 至少需要 16 字节;已有口令不会自动轮换。')
elif value != values.get(key, ''):
if values.get(key) and not values[key].startswith('replace-with-'):
raise RuntimeError(f'{key} 环境覆盖与已保存口令不同,已停止以避免破坏现有数据库访问。')
changes[key] = value
if site:
if any(c.isspace() for c in site):
raise RuntimeError('--site 不能包含空白字符。')
parsed = urlsplit(site if '://' in site else 'https://' + site)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
raise RuntimeError('--site 需要站点地址,例如 https://blog.example.com,不带路径。')
changes['PUBLIC_ORIGIN'] = f'{parsed.scheme}://{parsed.netloc}'
host = f'[{parsed.hostname}]' if ':' in parsed.hostname else parsed.hostname
changes['SITE_ADDRESS'] = f'http://{host}:80' if parsed.scheme == 'http' else host
changes['HTTP_PORT' if parsed.scheme == 'http' else 'HTTPS_PORT'] = str(
parsed.port or (80 if parsed.scheme == 'http' else 443))
effective = {**values, **changes}
if os.environ.get(DB_KEYS[0], effective.get(DB_KEYS[0])) == os.environ.get(DB_KEYS[1], effective.get(DB_KEYS[1])):
raise RuntimeError('数据库 root 与博客数据库账号必须使用不同口令。')
seen = set()
lines = []
for line in text.splitlines():
key = line.partition('=')[0].strip()
if key in RETIRED_KEYS:
continue
if key in changes:
line = f'{key}={changes[key]}'
seen.add(key)
lines.append(line)
lines.extend(f'{key}={value}' for key, value in changes.items() if key not in seen)
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}.', dir=path.parent)
try:
with os.fdopen(fd, 'w') as config:
os.fchmod(config.fileno(), 0o600)
config.write('\n'.join(lines) + '\n')
config.flush()
os.fsync(config.fileno())
os.replace(temporary, path)
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(temporary).unlink(missing_ok=True)
print(f'配置已就绪:{path}(权限 600,已有数据库口令保留)', flush=True)
def run(command, **kwargs):
return subprocess.run(command, cwd=ROOT, check=True, **kwargs)
def compose_command(args, envfile):
command = ['docker', 'compose', '--env-file', str(envfile)]
if args.sources_env_file:
command.extend(['--env-file', str(args.sources_env_file.resolve())])
return command + ['-p', args.project_name, '-f', str(ROOT / 'deploy/compose.yaml')]
def deploy(args):
configure(args.env_file, args.site)
print('获取固定版本的外部依赖…', flush=True)
run([sys.executable, str(ROOT / 'scripts/fetch-deps.py')])
print('在 Docker 内构建生产镜像…', flush=True)
build = [sys.executable, str(ROOT / 'scripts/prepare-deploy.py'),
'--env-file', str(args.env_file), '--project-name', args.project_name]
if args.sources_env_file:
build.extend(['--sources-env-file', str(args.sources_env_file.resolve())])
run(build)
compose = compose_command(args, args.env_file)
print('启动数据库并初始化博客数据库与专用账号…', flush=True)
run(compose + ['up', '-d', '--no-build', 'db'])
deadline = time.monotonic() + 120
last_error = ''
while time.monotonic() < deadline:
result = subprocess.run(compose + ['run', '--rm', '--no-deps', '-T', '--entrypoint',
'timeout', 'db-init', '15s', 'chublog-api', 'provision-db'],
cwd=ROOT, text=True, capture_output=True)
if result.returncode == 0:
break
last_error = result.stderr[-3000:]
time.sleep(2)
else:
values = read_config(args.env_file)
for key in DB_KEYS:
for secret in (values.get(key), os.environ.get(key)):
if secret:
last_error = last_error.replace(secret, '***')
raise RuntimeError('数据库初始化失败,部署未完成。检查数据库日志和已有口令。\n' + last_error)
print('启动博客、SSH 与 Caddy…', flush=True)
run(compose + ['up', '-d', '--no-build', '--wait', '--wait-timeout', '90'])
origin = os.environ.get('PUBLIC_ORIGIN', read_config(args.env_file).get('PUBLIC_ORIGIN', ''))
print(f'部署完成:{origin}\n不创建初始管理员;注册账户默认为普通用户。', flush=True)
def update(args):
if run(['git', 'status', '--porcelain'], capture_output=True, text=True).stdout.strip():
raise RuntimeError('工作区有未提交修改,更新已停止;先提交或自行保存修改。')
run(['git', 'pull', '--ff-only'])
# Reload the updated deployment code instead of running an old in-memory copy.
command = [sys.executable, str(ROOT / 'scripts/deployment.py'), 'deploy',
'--env-file', str(args.env_file), '--project-name', args.project_name]
if args.sources_env_file:
command.extend(['--sources-env-file', str(args.sources_env_file.resolve())])
if args.site:
command.extend(['--site', args.site])
os.execv(sys.executable, command)
def uninstall(args):
envfile = args.env_file if args.env_file.is_file() else ROOT / '.env.example'
env = os.environ.copy()
values = read_config(envfile)
# Compose validates these variables even when down does not connect to the DB.
for key in DB_KEYS:
env[key] = env.get(key) or values.get(key) or 'unused-uninstall-placeholder'
command = compose_command(args, envfile) + ['--profile', '*', 'down',
'--rmi', 'local', '--remove-orphans']
if args.purge:
print(f'永久删除项目 {args.project_name} 的数据卷。', flush=True)
command.append('--volumes')
run(command, env=env)
print('卸载完成。' + ('项目数据卷已删除。' if args.purge else '数据卷已保留;彻底删除使用 --purge。'), flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['deploy', 'update', 'uninstall'])
parser.add_argument('--env-file', type=Path, default=ROOT / '.env')
parser.add_argument('--sources-env-file', type=Path, help='optional public/build source overrides')
parser.add_argument('--project-name', default='chublog', help='Compose project name (default: chublog)')
parser.add_argument('--site', help='site URL; a bare hostname uses HTTPS')
parser.add_argument('--purge', action='store_true', help='uninstall only: permanently delete project volumes')
args = parser.parse_args()
if args.purge and args.action != 'uninstall':
parser.error('--purge 只能用于卸载。')
if args.sources_env_file and not args.sources_env_file.is_file():
parser.error('下载源配置文件不存在。')
if args.sources_env_file:
supported = {'REGISTRY_PREFIX', 'DEBIAN_MIRROR', 'NPM_REGISTRY', 'CARGO_MIRROR_INDEX'}
if set(read_config(args.sources_env_file)) - supported:
parser.error('下载源配置只能包含镜像和软件包来源,不能覆盖站点或数据库口令。')
args.env_file = args.env_file.resolve()
if shutil.which('docker') is None:
parser.error('需要安装 Docker Engine 和 Docker Compose 插件。')
try:
{'deploy': deploy, 'update': update, 'uninstall': uninstall}[args.action](args)
except subprocess.CalledProcessError as error:
print(f'操作失败,命令退出码:{error.returncode}', file=sys.stderr)
return error.returncode
except (OSError, ValueError, RuntimeError) as error:
print(f'操作失败:{error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
File renamed without changes.
+10 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build deployment images inside Docker; no host Rust or Haskell tools required.""" """Build deployment images inside Docker; no host Rust or Haskell tools required."""
import argparse import argparse
import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess import subprocess
@@ -10,12 +11,13 @@ parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--env-file', type=Path, help='Compose environment file (default: .env, or .env.example when absent)') parser.add_argument('--env-file', type=Path, help='Compose environment file (default: .env, or .env.example when absent)')
parser.add_argument('--build-database', action='store_true', help='compatibility option; database is always built inside Docker') parser.add_argument('--build-database', action='store_true', help='compatibility option; database is always built inside Docker')
parser.add_argument('--sources-env-file', type=Path, help='optional second Compose env file overriding build download sources') parser.add_argument('--sources-env-file', type=Path, help='optional second Compose env file overriding build download sources')
parser.add_argument('--project-name', help='Compose project name')
args = parser.parse_args() args = parser.parse_args()
if shutil.which('docker') is None: if shutil.which('docker') is None:
parser.error('Docker with the Compose plugin is required; see docs/deployment.md.') parser.error('Docker with the Compose plugin is required; see docs/deployment.md.')
for manifest in ['external/ChuSQL-RustDriver/Cargo.toml', 'external/ChuSQL/chusql-core/storage/Cargo.toml']: for manifest in ['external/ChuSQL-RustDriver/Cargo.toml', 'external/ChuSQL/chusql-core/storage/Cargo.toml']:
if not (root / manifest).is_file(): if not (root / manifest).is_file():
parser.error('Initialize external dependencies with python3 scripts/init-deps.py first.') parser.error('Initialize external dependencies with python3 scripts/fetch-deps.py first.')
envfile = args.env_file or (root / '.env' if (root / '.env').is_file() else root / '.env.example') envfile = args.env_file or (root / '.env' if (root / '.env').is_file() else root / '.env.example')
if not envfile.is_file(): if not envfile.is_file():
parser.error(f'Environment file does not exist: {envfile}') parser.error(f'Environment file does not exist: {envfile}')
@@ -24,12 +26,18 @@ if args.sources_env_file:
if not args.sources_env_file.is_file(): if not args.sources_env_file.is_file():
parser.error(f'Source configuration does not exist: {args.sources_env_file}') parser.error(f'Source configuration does not exist: {args.sources_env_file}')
command.extend(['--env-file', str(args.sources_env_file.resolve())]) command.extend(['--env-file', str(args.sources_env_file.resolve())])
if args.project_name:
command.extend(['-p', args.project_name])
command.extend([ command.extend([
'-f', str(root / 'deploy/compose.yaml'), '-f', str(root / 'deploy/compose.yaml'),
'build', 'init', 'api', 'ssh', 'db', 'db-init', 'web', 'build', 'init', 'api', 'ssh', 'db', 'db-init', 'web',
]) ])
# Build-only parsing needs values even before deploy.sh creates runtime credentials.
build_env = os.environ.copy()
for key in ['CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD']:
build_env[key] = build_env.get(key) or 'unused-build-placeholder'
try: try:
subprocess.run(command, cwd=root, check=True) subprocess.run(command, cwd=root, check=True, env=build_env)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise SystemExit(error.returncode) from None raise SystemExit(error.returncode) from None
print('Deployment images built inside Docker. No host binaries were created.') print('Deployment images built inside Docker. No host binaries were created.')
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Exercise the one-command blog deployment and uninstall on isolated Docker resources."""
import argparse
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sources-env-file', type=Path, default=ROOT / 'deploy/sources.public.env')
args = parser.parse_args()
project = 'chublog-lifecycle-' + secrets.token_hex(4)
origin = 'http://localhost:18080'
env = {key: value for key, value in os.environ.items()
if key not in ['COMPOSE_PROFILES', 'COMPOSE_PROJECT_NAME', 'PUBLIC_ORIGIN', 'SITE_ADDRESS',
'CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD', 'HTTP_PORT', 'HTTPS_PORT',
'REGISTRY_PREFIX', 'DEBIAN_MIRROR', 'NPM_REGISTRY', 'CARGO_MIRROR_INDEX']}
def run(command, **kwargs):
return subprocess.run(command, cwd=ROOT, env=env, check=True, text=True, **kwargs)
def read_page(path):
with urllib.request.urlopen(origin + path, timeout=10) as response:
return response.geturl(), response.read().decode()
with tempfile.TemporaryDirectory(prefix='chublog-lifecycle-') as temporary:
config = Path(temporary) / '.env'
config.write_text('HTTP_BIND=127.0.0.1\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
options = ['--env-file', str(config), '--project-name', project,
'--sources-env-file', str(args.sources_env_file.resolve())]
compose = ['docker', 'compose', '--env-file', str(config), '--env-file',
str(args.sources_env_file.resolve()), '-p', project, '-f', str(ROOT / 'deploy/compose.yaml')]
try:
run([str(ROOT / 'scripts/deploy.sh'), *options, '--site', origin], stdin=subprocess.DEVNULL)
initial = config.read_text()
assert 'ADMIN_PASSWORD_HASH' not in initial
assert config.stat().st_mode & 0o777 == 0o600
_, html = read_page('/')
assert '个人博客正在施工中' in html
assert '阅读文章' in html
assert '文章' in read_page('/articles')[1]
assert '登录' in read_page('/login')[1]
assert json.loads(read_page('/api/health')[1])
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
password = secrets.token_urlsafe(24)
def call(path, data=None):
request = urllib.request.Request(origin + path,
data=json.dumps(data).encode() if data is not None else None,
headers={'Origin': origin, 'Content-Type': 'application/json'})
with client.open(request, timeout=10) as response:
return json.load(response)
assert call('/api/auth/register', {'username': 'first', 'password': password})['role'] == 'reader'
assert call('/api/auth/login', {'username': 'first', 'password': password})['csrf']
try:
call('/api/admin/users')
except urllib.error.HTTPError as error:
assert error.code == 403
else:
raise AssertionError('First user acquired administrator permissions')
running = run(compose + ['ps', '--services', '--status', 'running'], capture_output=True).stdout.split()
assert set(running) == {'db', 'api', 'ssh', 'web', 'caddy'}, running
run(compose + ['run', '--rm', '--no-deps', '-T', 'db-init'], capture_output=True)
run([str(ROOT / 'scripts/uninstall.sh'), *options], stdin=subprocess.DEVNULL)
assert run(['docker', 'volume', 'ls', '-q', '--filter',
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
run([str(ROOT / 'scripts/deploy.sh'), *options], stdin=subprocess.DEVNULL)
assert config.read_text() == initial, 'Redeploy rotated credentials'
assert '个人博客正在施工中' in read_page('/')[1]
assert call('/api/auth/login', {'username': 'first', 'password': password})['csrf']
print('PASS: one-command build, DB initialization and full blog without an initial administrator', flush=True)
print('PASS: reading, registration/login, SSH service and first-user permission boundary', flush=True)
print('PASS: repeat DB provisioning and data-preserving uninstall/redeploy retain credentials', flush=True)
finally:
run([str(ROOT / 'scripts/uninstall.sh'), *options, '--purge'], stdin=subprocess.DEVNULL)
assert not run(['docker', 'ps', '-aq', '--filter',
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
assert not run(['docker', 'volume', 'ls', '-q', '--filter',
f'label=com.docker.compose.project={project}'], capture_output=True).stdout.strip()
print('PASS: purge removes only the isolated project containers and volumes', flush=True)
+11 -3
View File
@@ -67,9 +67,8 @@ class Client:
with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
tmp = Path(temporary) tmp = Path(temporary)
password = secrets.token_urlsafe(24) password = secrets.token_urlsafe(24)
hashed = 'temporary-build-placeholder'
envfile = tmp / '.env' envfile = tmp / '.env'
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n" envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\n"
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n" f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
f"REGISTRY_PREFIX={os.environ.get('REGISTRY_PREFIX', '')}\n" f"REGISTRY_PREFIX={os.environ.get('REGISTRY_PREFIX', '')}\n"
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n') 'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
@@ -105,7 +104,16 @@ printf '%s\n' "$reply"
hashed = run(compose + ['run', '--rm', '--no-deps', '-T', 'api', 'hash-password'], hashed = run(compose + ['run', '--rm', '--no-deps', '-T', 'api', 'hash-password'],
input=password, capture_output=True).stdout.strip() input=password, capture_output=True).stdout.strip()
assert hashed.startswith('$argon2id$'), 'Container did not produce an Argon2id hash' assert hashed.startswith('$argon2id$'), 'Container did not produce an Argon2id hash'
envfile.write_text(envfile.read_text().replace('temporary-build-placeholder', hashed)) # Isolated development fixture; production deployment never creates an administrator.
run(compose + ['run', '--rm', '--no-deps', '-T', 'init'])
for path, data, mode in [
('/data/users.json', {'admin': {'hash': hashed}}, '600'),
('/auth/publishers.json', {'repository': '/srv/ingress/authors', 'publishers': {
'admin': {'enabled': True, 'role': 'admin', 'prefixes': [], 'ssh_key': None}}}, '644'),
]:
run(compose + ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'api', '-c',
f'umask 077; cat > {path} && chmod {mode} {path}'],
input=json.dumps(data), capture_output=True)
run(compose + ['up', '-d', '--no-build']) run(compose + ['up', '-d', '--no-build'])
admin = Client() admin = Client()
wait_for(lambda: admin.call('GET', '/api/health')) wait_for(lambda: admin.call('GET', '/api/health'))
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Check unattended deployment, safe updates, and scoped uninstall without Docker."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class Deployment(unittest.TestCase):
def test_deploy_update_and_uninstall_lifecycle(self):
with tempfile.TemporaryDirectory(prefix='chublog lifecycle ') as temporary:
root = Path(temporary)
(root / 'scripts').mkdir()
for name in ['deployment.py', 'prepare-deploy.py', 'deploy.sh', 'update.sh', 'uninstall.sh']:
shutil.copy2(ROOT / 'scripts' / name, root / 'scripts' / name)
shutil.copy2(ROOT / '.env.example', root / '.env.example')
for name in ['external/ChuSQL-RustDriver/Cargo.toml', 'external/ChuSQL/chusql-core/storage/Cargo.toml']:
manifest = root / name
manifest.parent.mkdir(parents=True)
manifest.touch()
(root / 'scripts/fetch-deps.py').write_text(
'import os\nfrom pathlib import Path\n'
'with Path(os.environ["CALL_LOG"]).open("a") as log: log.write(\'["fetch"]\\n\')\n')
tools = root / 'tools'
tools.mkdir()
program = f'''#!{sys.executable}
import json, os, sys
from pathlib import Path
kind = Path(sys.argv[0]).name
args = sys.argv[1:]
with Path(os.environ['CALL_LOG']).open('a') as log:
log.write(json.dumps([kind, *args]) + '\\n')
if kind == 'git' and args[:2] == ['status', '--porcelain']:
print(os.environ.get('GIT_DIRTY', ''), end='')
'''
for name in ['docker', 'git']:
tool = tools / name
tool.write_text(program)
tool.chmod(0o755)
log = root / 'calls.jsonl'
env = {key: value for key, value in os.environ.items()
if key not in ['CHUSQL_ROOT_PASSWORD', 'CHUSQL_PASSWORD']}
env.update(PATH=str(tools) + os.pathsep + env['PATH'], CALL_LOG=str(log))
def invoke(name, *args, extra_env=None):
return subprocess.run([str(root / 'scripts' / name), *args], cwd=root,
env={**env, **(extra_env or {})}, stdin=subprocess.DEVNULL,
capture_output=True, text=True)
result = invoke('deploy.sh', '--site', 'https://blog.example.com')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
settings = (root / '.env').read_text()
self.assertNotIn('ADMIN_PASSWORD_HASH', settings)
self.assertIn('PUBLIC_ORIGIN=https://blog.example.com', settings)
self.assertEqual((root / '.env').stat().st_mode & 0o777, 0o600)
passwords = [line.split('=', 1)[1] for line in settings.splitlines()
if line.startswith(('CHUSQL_ROOT_PASSWORD=', 'CHUSQL_PASSWORD='))]
self.assertTrue(all(len(value) >= 16 for value in passwords))
self.assertNotEqual(*passwords)
self.assertTrue(all(value not in result.stdout + result.stderr for value in passwords))
calls = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(calls[0], ['fetch'])
actions = [call for call in calls if call[0] == 'docker']
self.assertIn('build', actions[0])
self.assertTrue({'api', 'ssh', 'db', 'web'}.issubset(actions[0]))
self.assertEqual(actions[1][-4:], ['up', '-d', '--no-build', 'db'])
self.assertIn('provision-db', actions[2])
self.assertIn('--wait', actions[3])
self.assertFalse(any('hash-password' in call for call in calls))
result = invoke('deploy.sh')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / '.env').read_text(), settings)
(root / '.env').write_text(settings + 'ADMIN_USERNAME=old-admin\nADMIN_PASSWORD_HASH=old-hash\nALLOW_REGISTRATION=false\n')
result = invoke('deploy.sh')
self.assertEqual(result.returncode, 0, result.stderr)
settings = (root / '.env').read_text()
self.assertNotIn('ADMIN_USERNAME=', settings)
self.assertNotIn('ADMIN_PASSWORD_HASH=', settings)
self.assertIn('ALLOW_REGISTRATION=false', settings)
self.assertTrue(all(password in settings for password in passwords))
log.write_text('')
result = invoke('update.sh', extra_env={'GIT_DIRTY': ' M source.rs\n'})
self.assertNotEqual(result.returncode, 0)
self.assertNotIn('pull', log.read_text())
self.assertNotIn('docker', log.read_text())
log.write_text('')
result = invoke('update.sh')
self.assertEqual(result.returncode, 0, result.stderr)
calls = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(calls[:3], [['git', 'status', '--porcelain'], ['git', 'pull', '--ff-only'], ['fetch']])
for purge in [False, True]:
log.write_text('')
result = invoke('uninstall.sh', *(['--purge'] if purge else []))
self.assertEqual(result.returncode, 0, result.stderr)
calls = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(len(calls), 1)
self.assertIn('down', calls[0])
self.assertEqual('--volumes' in calls[0], purge)
self.assertNotIn('prune', calls[0])
self.assertEqual((root / '.env').read_text(), settings)
(root / '.env').unlink()
result = invoke('uninstall.sh', '--purge')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((root / '.env').exists())
bad_sources = root / 'bad.env'
bad_sources.write_text('CHUSQL_ROOT_PASSWORD=not-a-download-source\n')
result = invoke('deploy.sh', '--sources-env-file', str(bad_sources))
self.assertEqual(result.returncode, 2)
self.assertFalse((root / '.env').exists())
if __name__ == '__main__':
unittest.main()
@@ -7,7 +7,7 @@ import subprocess
import tempfile import tempfile
import unittest import unittest
SCRIPT = Path(__file__).with_name('init-deps.py') SCRIPT = Path(__file__).with_name('fetch-deps.py')
class InitializeDependencies(unittest.TestCase): class InitializeDependencies(unittest.TestCase):
@@ -17,7 +17,7 @@ class InitializeDependencies(unittest.TestCase):
project = root / 'blog' project = root / 'blog'
project.mkdir() project.mkdir()
(project / 'scripts').mkdir() (project / 'scripts').mkdir()
shutil.copy2(SCRIPT, project / 'scripts/init-deps.py') shutil.copy2(SCRIPT, project / 'scripts/fetch-deps.py')
env = {**os.environ, 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1'} env = {**os.environ, 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1'}
def git(cwd, *args): def git(cwd, *args):
@@ -42,7 +42,7 @@ class InitializeDependencies(unittest.TestCase):
sources.append(str(source)) sources.append(str(source))
(project / '.gitmodules').write_text(''.join(modules)) (project / '.gitmodules').write_text(''.join(modules))
git(project, 'add', '.gitmodules') git(project, 'add', '.gitmodules')
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/fetch-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
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()))
@@ -52,12 +52,12 @@ class InitializeDependencies(unittest.TestCase):
git(project, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'Pinned dependencies') 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)) git(root, 'clone', '--no-local', str(project), str(fresh))
(fresh / 'scripts').mkdir() (fresh / 'scripts').mkdir()
shutil.copy2(SCRIPT, fresh / 'scripts/init-deps.py') shutil.copy2(SCRIPT, fresh / 'scripts/fetch-deps.py')
fallback_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '2'} fallback_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '2'}
for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)): 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_KEY_{i}'] = f'url.{Path(source).as_uri()}.insteadOf'
fallback_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git' fallback_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git'
subprocess.run(['python3', 'scripts/init-deps.py'], cwd=fresh, env=fallback_env, check=True, capture_output=True) subprocess.run(['python3', 'scripts/fetch-deps.py'], cwd=fresh, env=fallback_env, check=True, capture_output=True)
for name in ['ChuSQL', 'ChuSQL-RustDriver']: for name in ['ChuSQL', 'ChuSQL-RustDriver']:
self.assertEqual((fresh / f'external/{name}/source.txt').read_text(), f'{name} committed source') 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'https://github.com/TheBadRoger/{name}.git') self.assertEqual(git(fresh / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'https://github.com/TheBadRoger/{name}.git')
@@ -66,7 +66,7 @@ class InitializeDependencies(unittest.TestCase):
partial = root / 'partial' partial = root / 'partial'
git(root, 'clone', '--no-local', str(project), str(partial)) git(root, 'clone', '--no-local', str(project), str(partial))
(partial / 'scripts').mkdir() (partial / 'scripts').mkdir()
shutil.copy2(SCRIPT, partial / 'scripts/init-deps.py') shutil.copy2(SCRIPT, partial / 'scripts/fetch-deps.py')
partial_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '4'} partial_env = {**env, 'GIT_ALLOW_PROTOCOL': 'file', 'GIT_CONFIG_COUNT': '4'}
for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)): for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)):
empty_source = root / f'empty-{name}.git' empty_source = root / f'empty-{name}.git'
@@ -75,13 +75,13 @@ class InitializeDependencies(unittest.TestCase):
partial_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git' partial_env[f'GIT_CONFIG_VALUE_{i}'] = f'https://github.com/TheBadRoger/{name}.git'
partial_env[f'GIT_CONFIG_KEY_{i + 2}'] = f'url.{Path(source).as_uri()}.insteadOf' partial_env[f'GIT_CONFIG_KEY_{i + 2}'] = f'url.{Path(source).as_uri()}.insteadOf'
partial_env[f'GIT_CONFIG_VALUE_{i + 2}'] = f'https://repo.chunana.cn/ChuNa7a/{name}.git' partial_env[f'GIT_CONFIG_VALUE_{i + 2}'] = f'https://repo.chunana.cn/ChuNa7a/{name}.git'
result = subprocess.run(['python3', 'scripts/init-deps.py'], cwd=partial, env=partial_env, text=True, capture_output=True) result = subprocess.run(['python3', 'scripts/fetch-deps.py'], cwd=partial, env=partial_env, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for name in ['ChuSQL', 'ChuSQL-RustDriver']: for name in ['ChuSQL', 'ChuSQL-RustDriver']:
self.assertEqual((partial / f'external/{name}/source.txt').read_text(), f'{name} committed source') self.assertEqual((partial / f'external/{name}/source.txt').read_text(), f'{name} committed source')
self.assertEqual(git(partial / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'https://repo.chunana.cn/ChuNa7a/{name}.git') self.assertEqual(git(partial / f'external/{name}', 'config', '--get', 'remote.origin.url'), f'https://repo.chunana.cn/ChuNa7a/{name}.git')
# 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/fetch-deps.py', '--database-repo', sources[0], '--driver-repo', sources[1]], cwd=project, env=env, check=True, capture_output=True)
if __name__ == '__main__': if __name__ == '__main__':
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec python3 "$SCRIPT_DIR/deployment.py" uninstall "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec python3 "$SCRIPT_DIR/deployment.py" update "$@"