From 2ecc72d0277c6be2419f5373bab757fcddc72715 Mon Sep 17 00:00:00 2001 From: ChuNa7a Date: Sat, 17 Oct 2026 14:21:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=83=A8=E7=BD=B2=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 +- .gitignore | 1 + README.md | 9 +- TODO.md | 3 + apps/api/src/accounts.rs | 46 +--- apps/api/src/tests.rs | 60 ++++++ deploy/Dockerfile | 8 +- deploy/compose.yaml | 4 +- docs/deployment.md | 90 +++++--- docs/development.md | 9 +- scripts/deploy.sh | 12 +- scripts/deployment.py | 200 ++++++++++++++++++ scripts/{init-deps.py => fetch-deps.py} | 0 scripts/prepare-deploy.py | 12 +- scripts/smoke-deployment.py | 90 ++++++++ scripts/smoke.py | 14 +- scripts/test-deployment.py | 119 +++++++++++ .../{test-init-deps.py => test-fetch-deps.py} | 16 +- scripts/uninstall.sh | 4 + scripts/update.sh | 4 + 20 files changed, 597 insertions(+), 116 deletions(-) create mode 100644 scripts/deployment.py rename scripts/{init-deps.py => fetch-deps.py} (100%) create mode 100644 scripts/smoke-deployment.py create mode 100644 scripts/test-deployment.py rename scripts/{test-init-deps.py => test-fetch-deps.py} (85%) create mode 100755 scripts/uninstall.sh create mode 100755 scripts/update.sh diff --git a/.env.example b/.env.example index 092f80a..19b0119 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 3678756..35ba1f0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ __pycache__/ *.pyc external +/docs/decisions/ diff --git a/README.md b/README.md index e6881d1..16331b1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/TODO.md b/TODO.md index 816fab7..a8d8368 100644 --- a/TODO.md +++ b/TODO.md @@ -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 管理,账户数据不能作为可丢弃的派生索引。 diff --git a/apps/api/src/accounts.rs b/apps/api/src/accounts.rs index 32fe456..c4b3229 100644 --- a/apps/api/src/accounts.rs +++ b/apps/api/src/accounts.rs @@ -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 diff --git a/apps/api/src/tests.rs b/apps/api/src/tests.rs index c461b3a..3bf5c9d 100644 --- a/apps/api/src/tests.rs +++ b/apps/api/src/tests.rs @@ -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; diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2752cc4..0fa53f8 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 8520547..c4d66a0 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 9b36854..ffd3cf6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 端口,验证无管理员启动、普通用户注册登录、保留数据卸载后重新部署及彻底卸载。测试结束删除自己的容器和卷,不操作正式项目。 ## 域名与网络 diff --git a/docs/development.md b/docs/development.md index 953f858..b1e9b78 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 可用。 ## 历史清理后的协作 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9c1a82d..d9dfa49 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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 "$@" diff --git a/scripts/deployment.py b/scripts/deployment.py new file mode 100644 index 0000000..daf9f8f --- /dev/null +++ b/scripts/deployment.py @@ -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()) diff --git a/scripts/init-deps.py b/scripts/fetch-deps.py similarity index 100% rename from scripts/init-deps.py rename to scripts/fetch-deps.py diff --git a/scripts/prepare-deploy.py b/scripts/prepare-deploy.py index 1ce1bef..5dc3a39 100755 --- a/scripts/prepare-deploy.py +++ b/scripts/prepare-deploy.py @@ -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.') diff --git a/scripts/smoke-deployment.py b/scripts/smoke-deployment.py new file mode 100644 index 0000000..d4e6f4e --- /dev/null +++ b/scripts/smoke-deployment.py @@ -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) diff --git a/scripts/smoke.py b/scripts/smoke.py index 713aed2..9db5279 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -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')) diff --git a/scripts/test-deployment.py b/scripts/test-deployment.py new file mode 100644 index 0000000..9d42885 --- /dev/null +++ b/scripts/test-deployment.py @@ -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() diff --git a/scripts/test-init-deps.py b/scripts/test-fetch-deps.py similarity index 85% rename from scripts/test-init-deps.py rename to scripts/test-fetch-deps.py index f7c0183..b980e5b 100644 --- a/scripts/test-init-deps.py +++ b/scripts/test-fetch-deps.py @@ -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__': diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..78470ab --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$SCRIPT_DIR/deployment.py" uninstall "$@" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..b1ddf9e --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$SCRIPT_DIR/deployment.py" update "$@"