优化部署流程
This commit is contained in:
1 parent
16e5db3b26
commit
2ecc72d027
20 files changed
+597
-116
No files matched your search
+5
-7
@@ -1,13 +1,11 @@
|
||||
PUBLIC_ORIGIN=https://blog.example.com
|
||||
SITE_ADDRESS=blog.example.com
|
||||
PUBLIC_ORIGIN=http://localhost
|
||||
SITE_ADDRESS=http://localhost:80
|
||||
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
|
||||
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
|
||||
# Generated automatically by deploy.sh on first deployment.
|
||||
CHUSQL_ROOT_PASSWORD=
|
||||
CHUSQL_PASSWORD=
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
external
|
||||
/docs/decisions/
|
||||
@@ -4,6 +4,14 @@ Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown
|
||||
|
||||
提供 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 接口、认证、请求与错误响应 |
|
||||
| [内容与隔离](docs/security.md) | 文件白名单、容量限制、SSH 与进程边界 |
|
||||
| [运维与迁移](docs/operations.md) | 持久化、备份恢复、旧共享仓库迁移 |
|
||||
| [架构决策](docs/decisions/README.md) | 已记录的设计选择及演进 |
|
||||
|
||||
源码:[GitHub](https://github.com/TheBadRoger/ChuBlog.git) · [自建仓库](https://repo.chunana.cn/ChuNa7a/ChuBlog.git)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
|
||||
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
|
||||
- [x] Rust、ChuSQL 与 Astro 全部在 Docker 内构建;默认腾讯云内网镜像,提供独立公网源验证配置,宿主机无需安装编译工具链。
|
||||
- [x] 一键部署、更新与卸载;自动初始化数据库与保存数据库口令,无初始管理员前置条件。
|
||||
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
|
||||
|
||||
## 第一优先级:部署环境验收
|
||||
@@ -32,6 +33,8 @@
|
||||
|
||||
## 第二优先级:账户与权限完善
|
||||
|
||||
- [ ] 后续确定初始站长建立、发布授权及恢复流程;部署不自动创建管理员,首个注册用户不自动提权。
|
||||
|
||||
### 数据库用户系统与角色
|
||||
|
||||
- [ ] 将用户、密码哈希、角色及发布授权迁入 ChuSQL;当前文件账户和 reader / publisher / admin 角色需提供迁移方案。文章正文仍由 Git 管理,账户数据不能作为可丢弃的派生索引。
|
||||
|
||||
@@ -120,55 +120,15 @@ pub async fn load(
|
||||
},
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut accounts = Accounts {
|
||||
Ok(Accounts {
|
||||
credentials,
|
||||
grants,
|
||||
sessions: BTreeMap::new(),
|
||||
attempts: 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<()> {
|
||||
let now = Instant::now();
|
||||
while accounts
|
||||
|
||||
@@ -602,6 +602,66 @@ async fn request(
|
||||
)
|
||||
}
|
||||
#[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() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = app(dir.path()).await;
|
||||
|
||||
+4
-4
@@ -20,7 +20,7 @@ COPY apps/api ./apps/api
|
||||
COPY external/ChuSQL-RustDriver ./external/ChuSQL-RustDriver
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--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
|
||||
ARG DEBIAN_MIRROR=mirrors.tencentyun.com
|
||||
@@ -35,13 +35,13 @@ ENV PATH=/opt/ghc/9.10.3/bin:$PATH
|
||||
WORKDIR /src/ChuSQL
|
||||
COPY external/ChuSQL ./
|
||||
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
|
||||
RUN --mount=type=cache,target=/root/.stack \
|
||||
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 \
|
||||
&& 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/
|
||||
|
||||
FROM ${DEBIAN_IMAGE} AS base
|
||||
|
||||
+1
-3
@@ -31,13 +31,11 @@ services:
|
||||
init: { condition: service_completed_successfully }
|
||||
environment:
|
||||
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_USER: chublog_index
|
||||
CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD}
|
||||
CHUSQL_DATABASE: chublog
|
||||
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
||||
volumes:
|
||||
- content:/data
|
||||
- authorization:/auth
|
||||
|
||||
+58
-32
@@ -2,15 +2,46 @@
|
||||
|
||||
[返回项目首页](../README.md)
|
||||
|
||||
## 构建与启动
|
||||
## 一键部署
|
||||
|
||||
- 构建与运行镜像基于 Debian 13;宿主机只需满足 Docker 运行要求。
|
||||
- `scripts/deploy.sh` 会使用同一个依赖初始化脚本,自动按[依赖源顺序](development.md)进行 HTTPS 回退,再构建外部数据库与 Docker 镜像;沿用运行者现有的 Git 配置,不在项目内覆盖服务器环境。
|
||||
在项目根目录执行,将示例域名替换为自己的域名,并事先配置 DNS:
|
||||
|
||||
```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 编译工具。
|
||||
@@ -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
|
||||
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 \
|
||||
--sources-env-file deploy/sources.public.env
|
||||
```
|
||||
|
||||
第二份配置只覆盖下载来源,不改写 `.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 签名校验。
|
||||
|
||||
- `prepare-deploy.py` 调用 Compose 构建全部应用镜像,不执行宿主机编译、不生成 `deploy/bin/`。
|
||||
- `--build-database` 保留为兼容参数;数据库始终在 Docker 中构建。旧的 `--no-build` 和 `--chusql-source` 不再支持。
|
||||
- `--sources-env-file PATH` 可追加只含下载源配置的 Compose 环境文件;优先级高于第一份环境文件。
|
||||
- `--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 中的复制路径。
|
||||
- 镜像默认按 Docker 主机架构构建,不复用宿主机已有二进制;构建与运行阶段均使用 Debian 13 系列镜像。
|
||||
- Cargo、Stack 软件包缓存保存在 Docker BuildKit 中。镜像前缀控制编译器镜像来源,Cargo 与 Haskell 包下载独立于镜像注册表。
|
||||
- 运行镜像不含编译器、源码、`.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
@@ -8,7 +8,7 @@ ChuBlog/
|
||||
├── external/
|
||||
│ ├── ChuSQL/ # 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://repo.chunana.cn/ChuNa7a/ChuBlog.git
|
||||
cd ChuBlog
|
||||
python3 scripts/init-deps.py
|
||||
python3 scripts/fetch-deps.py
|
||||
```
|
||||
|
||||
## 依赖来源与回退
|
||||
@@ -40,7 +40,7 @@ python3 scripts/init-deps.py
|
||||
开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本:
|
||||
|
||||
```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
|
||||
python3 scripts/test-prepare-deploy.py
|
||||
python3 scripts/test-fetch-deps.py
|
||||
python3 scripts/test-deployment.py
|
||||
```
|
||||
|
||||
```sh
|
||||
@@ -77,6 +79,7 @@ python3 scripts/smoke.py --key-type rsa --browser-test \
|
||||
|
||||
- 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。
|
||||
- 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、TOML 暂存与修复、多页链接、系统时间、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。
|
||||
- 管理员仅由隔离测试脚本写入测试卷作为夹具,生产部署没有管理员环境变量或自动建号步骤。
|
||||
- 该测试会拉取基础镜像,需要 Docker 可用。
|
||||
|
||||
## 历史清理后的协作
|
||||
|
||||
+2
-10
@@ -1,12 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Initialize dependencies via GitHub HTTPS with self-hosted HTTPS fallback, then build Docker Compose.
|
||||
set -eu
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
python3 "$PROJECT_ROOT/scripts/init-deps.py"
|
||||
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.'
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" deploy "$@"
|
||||
@@ -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.
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deployment images inside Docker; no host Rust or Haskell tools required."""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
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('--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('--project-name', help='Compose project name')
|
||||
args = parser.parse_args()
|
||||
if shutil.which('docker') is None:
|
||||
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']:
|
||||
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')
|
||||
if not envfile.is_file():
|
||||
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():
|
||||
parser.error(f'Source configuration does not exist: {args.sources_env_file}')
|
||||
command.extend(['--env-file', str(args.sources_env_file.resolve())])
|
||||
if args.project_name:
|
||||
command.extend(['-p', args.project_name])
|
||||
command.extend([
|
||||
'-f', str(root / 'deploy/compose.yaml'),
|
||||
'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:
|
||||
subprocess.run(command, cwd=root, check=True)
|
||||
subprocess.run(command, cwd=root, check=True, env=build_env)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise SystemExit(error.returncode) from None
|
||||
print('Deployment images built inside Docker. No host binaries were created.')
|
||||
@@ -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
@@ -67,9 +67,8 @@ class Client:
|
||||
with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
|
||||
tmp = Path(temporary)
|
||||
password = secrets.token_urlsafe(24)
|
||||
hashed = 'temporary-build-placeholder'
|
||||
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"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')
|
||||
@@ -105,7 +104,16 @@ printf '%s\n' "$reply"
|
||||
hashed = run(compose + ['run', '--rm', '--no-deps', '-T', 'api', 'hash-password'],
|
||||
input=password, capture_output=True).stdout.strip()
|
||||
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'])
|
||||
admin = Client()
|
||||
wait_for(lambda: admin.call('GET', '/api/health'))
|
||||
|
||||
@@ -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 unittest
|
||||
|
||||
SCRIPT = Path(__file__).with_name('init-deps.py')
|
||||
SCRIPT = Path(__file__).with_name('fetch-deps.py')
|
||||
|
||||
|
||||
class InitializeDependencies(unittest.TestCase):
|
||||
@@ -17,7 +17,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
project = root / 'blog'
|
||||
project.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'}
|
||||
|
||||
def git(cwd, *args):
|
||||
@@ -42,7 +42,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
sources.append(str(source))
|
||||
(project / '.gitmodules').write_text(''.join(modules))
|
||||
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']:
|
||||
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()))
|
||||
@@ -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(root, 'clone', '--no-local', str(project), str(fresh))
|
||||
(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'}
|
||||
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'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']:
|
||||
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')
|
||||
@@ -66,7 +66,7 @@ class InitializeDependencies(unittest.TestCase):
|
||||
partial = root / 'partial'
|
||||
git(root, 'clone', '--no-local', str(project), str(partial))
|
||||
(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'}
|
||||
for i, (name, source) in enumerate(zip(['ChuSQL', 'ChuSQL-RustDriver'], sources)):
|
||||
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_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'
|
||||
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)
|
||||
for name in ['ChuSQL', 'ChuSQL-RustDriver']:
|
||||
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')
|
||||
# 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__':
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" uninstall "$@"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$SCRIPT_DIR/deployment.py" update "$@"
|
||||
Reference in new issue
Block a user