From fa4517c2637459ba329a2c6e47ea714862124e9e Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Tue, 18 Aug 2026 01:13:05 +0800 Subject: [PATCH] feat: add verified clean reinstall workflow --- .gitea/workflows/release.yml | 4 +- README.md | 65 +++++++-- deploy/install-from-git.sh | 2 +- deploy/purge.sh | 199 ++++++++++++++++++++++++++ scripts/package-release.sh | 13 +- scripts/publish-gitea-release.sh | 5 +- scripts/test-baota-start-fixture.sh | 4 +- scripts/test-gitea-publish-fixture.sh | 9 +- scripts/test-purge-fixture.sh | 87 +++++++++++ scripts/verify-release.sh | 17 ++- 10 files changed, 374 insertions(+), 31 deletions(-) create mode 100755 deploy/purge.sh create mode 100755 scripts/test-purge-fixture.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b2ad17e..94c11f9 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -66,14 +66,16 @@ jobs: ./scripts/test-git-install-fixture.sh ./scripts/test-update-fixture.sh ./scripts/test-baota-start-fixture.sh + ./scripts/test-purge-fixture.sh ./scripts/test-gitea-publish-fixture.sh shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh \ + deploy/purge.sh \ scripts/check-semver.sh \ scripts/package-release.sh scripts/publish-gitea-release.sh \ scripts/generate-release-key.sh scripts/test-install-fixture.sh \ scripts/test-git-install-fixture.sh \ scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \ - scripts/test-baota-start-fixture.sh \ + scripts/test-baota-start-fixture.sh scripts/test-purge-fixture.sh \ scripts/verify-release.sh - name: Build and sign release assets diff --git a/README.md b/README.md index dee1b4c..0b95699 100644 --- a/README.md +++ b/README.md @@ -56,17 +56,37 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发 外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、 `/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。 +### 彻底清理旧安装 + +重新安装前先在宝塔面板停止并删除名为 `kaidi-finance` 的 Java 项目,避免面板守护进程重新拉起旧 Java。 +随后执行一键清理: + +```bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/purge.sh \ + -o /tmp/kaidi-purge.sh \ + && printf '%s %s\n' c49c5460260d2c67d88aced99463e3d98a67f8ef0309f5c8d5d8a8f5e4addf36 /tmp/kaidi-purge.sh \ + | sha256sum -c - \ + && sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \ + && rm -f /tmp/kaidi-purge.sh +``` + +清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到 +`/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、 +`/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为 +`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; +新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 + ### 直接 curl 安装 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash ``` 这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup` 安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ | sudo env KAIDI_APP_PORT=19090 bash ``` @@ -74,8 +94,8 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh -o /tmp/kaidi-install.sh -printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c - +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh -o /tmp/kaidi-install.sh +printf '%s %s\n' 6256b05025aa314d4c02e7c0f5d2ca530f28ec5d10f833504b34c09ada6e02a5 /tmp/kaidi-install.sh | sha256sum -c - sudo bash /tmp/kaidi-install.sh rm -f /tmp/kaidi-install.sh ``` @@ -86,7 +106,7 @@ rm -f /tmp/kaidi-install.sh 包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 ```bash -git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview +git clone --branch v1.0.0-preview.24 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview cd kaidi-preview ./deploy/install-from-git.sh ``` @@ -106,6 +126,21 @@ cd kaidi-preview - 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 - 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。 +### 环境变量与密钥安全 + +首次安装不需要在命令行或宝塔项目中填写数据库环境变量。数据库地址、账号和密码只在 `/setup` 向导中提交, +完成后写入 `/var/lib/kaidi/setup/application.env`;字段加密密钥由安装器生成并写入 +`/etc/kaidi/kaidi.env`。两个文件不会把秘密放进 Java 启动参数或 `ps` 输出,并由 Linux 文件权限限制为 +root 或专用 `kaidi` 服务账号读取。运行中的 Java 仍必须持有这些值,因此 root 和该服务账号始终属于受信边界。 + +不要通过公网 HTTP 提交数据库密码。应先为域名配置有效 HTTPS,或通过 SSH 端口转发访问本机回环地址完成向导: + +```bash +ssh -L 18080:127.0.0.1:18080 root@SERVER_IP +``` + +然后在本机浏览器打开 `http://127.0.0.1:18080/setup`。向导完成后自动锁定,安装码不能再次使用。 + 安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后, 通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置 `SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为 @@ -141,7 +176,7 @@ sudo cat /root/kaidi-first-login.txt 32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh | sudo bash ``` 无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 @@ -163,7 +198,7 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP'; 管理员数据和运维人员新增的环境变量: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash ``` @@ -174,7 +209,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.23/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.24/install.sh \ | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=true bash ``` @@ -185,7 +220,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 推荐采用版本目录加 `current` 软链接: ```bash -VERSION=1.0.0-preview.23 +VERSION=1.0.0-preview.24 RELEASE_ROOT=/www/wwwroot/kaidi/releases/$VERSION sudo install -d -o root -g kaidi -m 0750 "$RELEASE_ROOT" curl -fL "https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz" \ @@ -263,13 +298,13 @@ base64 < release-signing-private.pem | tr -d '\n' Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求 act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。 -工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 +工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.19 -git push origin v1.0.0-preview.19 +git tag v1.0.0-preview.24 +git push origin v1.0.0-preview.24 ``` 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 @@ -321,13 +356,13 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.19 + ./scripts/package-release.sh 1.0.0-preview.24 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` -输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、 -首次安装指纹文件和完整应用压缩包。 +输出位于 `dist/release/`,包含安装脚本、彻底清理脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX +SBOM、首次安装指纹文件和完整应用压缩包。 打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件; JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。 签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录 diff --git a/deploy/install-from-git.sh b/deploy/install-from-git.sh index f518a51..1db14fe 100755 --- a/deploy/install-from-git.sh +++ b/deploy/install-from-git.sh @@ -5,7 +5,7 @@ umask 077 ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) INSTALLER="$ROOT/deploy/install.sh" -INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7} +INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-6256b05025aa314d4c02e7c0f5d2ca530f28ec5d10f833504b34c09ada6e02a5} RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} diff --git a/deploy/purge.sh b/deploy/purge.sh new file mode 100755 index 0000000..c4c61b4 --- /dev/null +++ b/deploy/purge.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +APP_ROOT=/opt/kaidi +BAOTA_ROOT=/www/wwwroot/kaidi +CONFIG_ROOT=/etc/kaidi +STATE_ROOT=/var/lib/kaidi +UPDATE_STATE_ROOT=/var/lib/kaidi-update +LOG_ROOT=/var/log/kaidi +FIRST_LOGIN_FILE=/root/kaidi-first-login.txt +BACKUP_ROOT=${KAIDI_PURGE_BACKUP_ROOT:-/root/kaidi-reinstall-backups} +CONFIRMATION=${KAIDI_PURGE_CONFIRM:-} +REQUIRED_CONFIRMATION=DELETE_LOCAL_KAIDI_INSTALLATION +SERVICE_USER=kaidi +SERVICE_GROUP=kaidi +BACKUP_ARCHIVE= + +log() { printf '[kaidi-purge] %s\n' "$*"; } +die() { printf '[kaidi-purge] ERROR: %s\n' "$*" >&2; exit 1; } + +systemd_available() { + command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] +} + +validate_inputs() { + [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" + [ "$(uname -s)" = Linux ] || die "The purge script only supports Linux" + [ "$CONFIRMATION" = "$REQUIRED_CONFIRMATION" ] \ + || die "Set KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION to confirm local removal" + case "$BACKUP_ROOT" in + /*) ;; + *) die "KAIDI_PURGE_BACKUP_ROOT must be an absolute path" ;; + esac + case "$BACKUP_ROOT/" in + "$APP_ROOT/"*|"$BAOTA_ROOT/"*|"$CONFIG_ROOT/"*|"$STATE_ROOT/"*|\ + "$UPDATE_STATE_ROOT/"*|"$LOG_ROOT/"*) + die "The recovery backup must be outside every managed Kaidi directory" + ;; + esac + [ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link" +} + +stop_systemd_units() { + local unit + systemd_available || return 0 + for unit in kaidi-update.path kaidi-update.service kaidi-finance.service; do + if systemctl cat "$unit" >/dev/null 2>&1; then + systemctl stop "$unit" >/dev/null 2>&1 \ + || die "Failed to stop $unit" + systemctl disable "$unit" >/dev/null 2>&1 || true + fi + done +} + +related_pids() { + local proc pid command_line + for proc in /proc/[0-9]*; do + [ -r "$proc/cmdline" ] || continue + pid=${proc##*/} + [ "$pid" != "$$" ] && [ "$pid" != "$PPID" ] || continue + command_line=$(tr '\000' ' ' < "$proc/cmdline" 2>/dev/null || true) + case "$command_line" in + *"$APP_ROOT/"*|*"$BAOTA_ROOT/"*) printf '%s\n' "$pid" ;; + esac + done +} + +stop_managed_processes() { + local deadline + local -a pids=() + mapfile -t pids < <(related_pids) + if [ "${#pids[@]}" -gt 0 ]; then + log "Stopping remaining Kaidi processes: ${pids[*]}" + kill -TERM "${pids[@]}" 2>/dev/null || true + fi + deadline=$((SECONDS + 5)) + while [ "$SECONDS" -lt "$deadline" ]; do + mapfile -t pids < <(related_pids) + [ "${#pids[@]}" -gt 0 ] || return 0 + sleep 1 + done + kill -KILL "${pids[@]}" 2>/dev/null || true + sleep 1 + mapfile -t pids < <(related_pids) + [ "${#pids[@]}" -eq 0 ] \ + || die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again" +} + +create_recovery_backup() { + local path relative temporary timestamp + local -a paths=() + for path in \ + "$CONFIG_ROOT" \ + "$STATE_ROOT" \ + "$UPDATE_STATE_ROOT/backups" \ + "$UPDATE_STATE_ROOT/failed" \ + "$UPDATE_STATE_ROOT/transactions" \ + "$FIRST_LOGIN_FILE"; do + if [ -e "$path" ] || [ -L "$path" ]; then + relative=${path#/} + paths+=("$relative") + fi + done + if [ "${#paths[@]}" -eq 0 ]; then + log "No local configuration or data needs a recovery backup" + return 0 + fi + install -d -o root -g root -m 0700 "$BACKUP_ROOT" + timestamp=$(date -u +%Y%m%dT%H%M%SZ) + BACKUP_ARCHIVE="$BACKUP_ROOT/kaidi-local-state-$timestamp.tar.gz" + temporary="$BACKUP_ARCHIVE.next.$$" + log "Creating a root-only recovery backup at $BACKUP_ARCHIVE" + tar -czf "$temporary" -C / -- "${paths[@]}" + tar -tzf "$temporary" >/dev/null + chmod 0600 "$temporary" + mv -f "$temporary" "$BACKUP_ARCHIVE" +} + +remove_systemd_units() { + local unit + rm -f \ + /etc/systemd/system/kaidi-finance.service \ + /etc/systemd/system/kaidi-update.service \ + /etc/systemd/system/kaidi-update.path \ + /etc/systemd/system/multi-user.target.wants/kaidi-finance.service \ + /etc/systemd/system/multi-user.target.wants/kaidi-update.path + systemd_available || return 0 + systemctl daemon-reload + for unit in kaidi-finance.service kaidi-update.service kaidi-update.path; do + systemctl reset-failed "$unit" >/dev/null 2>&1 || true + done +} + +remove_managed_paths() { + rm -rf -- \ + "$APP_ROOT" \ + "$BAOTA_ROOT" \ + "$CONFIG_ROOT" \ + "$STATE_ROOT" \ + "$UPDATE_STATE_ROOT" \ + "$LOG_ROOT" + rm -f -- "$FIRST_LOGIN_FILE" +} + +remove_download_archives() { + rm -f -- /tmp/kaidi-finance-*.tar.gz +} + +remove_service_identity() { + local entry home shell group_entry gid members primary_users + entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true) + if [ -n "$entry" ]; then + home=$(printf '%s\n' "$entry" | awk -F: '{print $6}') + shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}') + case "$home:$shell" in + "$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) + userdel "$SERVICE_USER" \ + || die "Failed to remove the dedicated $SERVICE_USER service account" + ;; + *) + log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed" + ;; + esac + fi + + group_entry=$(getent group "$SERVICE_GROUP" 2>/dev/null || true) + [ -n "$group_entry" ] || return 0 + gid=$(printf '%s\n' "$group_entry" | awk -F: '{print $3}') + members=$(printf '%s\n' "$group_entry" | awk -F: '{print $4}') + primary_users=$(getent passwd | awk -F: -v gid="$gid" '$4 == gid { print $1 }') + if [ -z "$members" ] && [ -z "$primary_users" ]; then + groupdel "$SERVICE_GROUP" \ + || die "Failed to remove the dedicated $SERVICE_GROUP service group" + else + log "Keeping group $SERVICE_GROUP because another account still uses it" + fi +} + +main() { + validate_inputs + log "External MySQL data and reverse-proxy configuration will not be modified" + stop_systemd_units + stop_managed_processes + create_recovery_backup + remove_systemd_units + remove_managed_paths + remove_download_archives + remove_service_identity + stop_managed_processes + log "Local Kaidi installation state has been removed" + if [ -n "$BACKUP_ARCHIVE" ]; then + log "Recovery backup: $BACKUP_ARCHIVE" + fi + log "Use a new empty MySQL database for the next installation" +} + +main "$@" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 458d38c..2d89970 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -143,8 +143,10 @@ else fi install -m 0755 "$ROOT/deploy/install.sh" "$OUTPUT_DIR/install.sh" +install -m 0755 "$ROOT/deploy/purge.sh" "$OUTPUT_DIR/purge.sh" PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem") INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh") +PURGER_SHA256=$(sha256_file "$OUTPUT_DIR/purge.sh") if [ -n "$TRUSTED_PUBLIC_KEY_SHA256" ]; then NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') [[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \ @@ -158,6 +160,7 @@ fi cat > "$OUTPUT_DIR/bootstrap-checksums.txt" < "$OUTPUT_DIR/release-manifest.json" @@ -192,6 +196,8 @@ const manifest = { bootstrap: { installer: 'install.sh', installerSha256: process.env.INSTALLER_SHA256, + purger: 'purge.sh', + purgerSha256: process.env.PURGER_SHA256, publicKey: 'release-public.pem', publicKeySha256: process.env.PUBLIC_KEY_SHA256, checksums: 'bootstrap-checksums.txt', @@ -211,14 +217,15 @@ fi cd "$OUTPUT_DIR" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ - release-manifest.json release-manifest.sig release-public.pem install.sh \ + release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \ bootstrap-checksums.txt > SHA256SUMS else shasum -a 256 "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ - release-manifest.json release-manifest.sig release-public.pem install.sh \ + release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \ bootstrap-checksums.txt > SHA256SUMS fi ) printf 'Release assets created in %s\n' "$OUTPUT_DIR" printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256" printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256" +printf 'Purger SHA-256: %s\n' "$PURGER_SHA256" diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 7a45374..3e8a4dc 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -87,6 +87,7 @@ ASSETS=( release-manifest.sig release-public.pem install.sh + purge.sh bootstrap-checksums.txt SHA256SUMS ) @@ -124,7 +125,7 @@ jq -n \ > "$WORK/publish.json" publish_status=$(request PATCH "$API/releases/$RELEASE_ID" "$WORK/published.json" "$WORK/publish.json") [ "$publish_status" = 200 ] || fail "release publication returned HTTP $publish_status" -jq -e '.draft == false and .prerelease == false and (.assets | length) == 9' \ +jq -e '.draft == false and .prerelease == false and (.assets | length) == 10' \ "$WORK/published.json" >/dev/null || fail 'published release state is invalid' -printf 'Published Gitea release %s with nine verified assets\n' "$TAG" +printf 'Published Gitea release %s with ten verified assets\n' "$TAG" diff --git a/scripts/test-baota-start-fixture.sh b/scripts/test-baota-start-fixture.sh index a838e0b..a046aa7 100755 --- a/scripts/test-baota-start-fixture.sh +++ b/scripts/test-baota-start-fixture.sh @@ -14,7 +14,7 @@ RELEASE="$WORK/release" mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp" printf 'fixture jar\n' > "$RELEASE/app.jar" printf '\n' > "$RELEASE/public/index.html" -printf '1.0.0-preview.23\n' > "$RELEASE/VERSION" +printf '1.0.0-preview.24\n' > "$RELEASE/VERSION" cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh" chmod 0755 "$RELEASE/ops/baota-start.sh" @@ -46,7 +46,7 @@ FILE_STORAGE_TEMP="$WORK/tmp" \ grep -Fqx "cwd=$RELEASE" "$WORK/java.log" || fail 'launcher did not use the release working directory' grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar $RELEASE/app.jar" "$WORK/java.log" \ || fail 'launcher did not execute the packaged Spring Boot jar' -grep -Fqx 'version=1.0.0-preview.23' "$WORK/java.log" || fail 'launcher did not read VERSION' +grep -Fqx 'version=1.0.0-preview.24' "$WORK/java.log" || fail 'launcher did not read VERSION' grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback' grep -Fqx 'port=18080' "$WORK/java.log" || fail 'launcher did not default to port 18080' grep -Fqx 'update=false' "$WORK/java.log" || fail 'launcher did not disable the systemd updater' diff --git a/scripts/test-gitea-publish-fixture.sh b/scripts/test-gitea-publish-fixture.sh index f20d052..c047ac0 100755 --- a/scripts/test-gitea-publish-fixture.sh +++ b/scripts/test-gitea-publish-fixture.sh @@ -24,6 +24,7 @@ for name in \ release-manifest.sig \ release-public.pem \ install.sh \ + purge.sh \ bootstrap-checksums.txt \ SHA256SUMS; do printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name" @@ -102,11 +103,11 @@ GITEA_TOKEN="$TOKEN" \ KAIDI_RELEASE_DIR="$RELEASE_DIR" \ "$ROOT/scripts/publish-gitea-release.sh" > "$WORK/publish.log" -grep -Fqx "Published Gitea release $TAG with nine verified assets" "$WORK/publish.log" \ +grep -Fqx "Published Gitea release $TAG with ten verified assets" "$WORK/publish.log" \ || fail 'publish script did not report success' -[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 9 ] \ - || fail 'publish script did not upload exactly nine assets' +[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 10 ] \ + || fail 'publish script did not upload exactly ten assets' ! grep -Fq "$TOKEN" "$WORK/curl.log" \ || fail 'Gitea write token leaked into curl process arguments' -printf 'Gitea draft, nine-asset verification, and publication fixture passed\n' +printf 'Gitea draft, ten-asset verification, and publication fixture passed\n' diff --git a/scripts/test-purge-fixture.sh b/scripts/test-purge-fixture.sh new file mode 100755 index 0000000..4fef012 --- /dev/null +++ b/scripts/test-purge-fixture.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +fail() { + printf 'Purge fixture failed: %s\n' "$1" >&2 + exit 1 +} + +sed '$d' "$ROOT/deploy/purge.sh" > "$WORK/purge-functions.sh" +# shellcheck disable=SC1090,SC1091 +source "$WORK/purge-functions.sh" + +install() { + local -a arguments=() + while [ "$#" -gt 0 ]; do + case "$1" in + -o|-g) shift 2 ;; + *) arguments+=("$1"); shift ;; + esac + done + command install "${arguments[@]}" +} + +APP_ROOT="$WORK/opt/kaidi" +BAOTA_ROOT="$WORK/www/kaidi" +CONFIG_ROOT="$WORK/etc/kaidi" +STATE_ROOT="$WORK/var/lib/kaidi" +UPDATE_STATE_ROOT="$WORK/var/lib/kaidi-update" +LOG_ROOT="$WORK/var/log/kaidi" +FIRST_LOGIN_FILE="$WORK/root/kaidi-first-login.txt" +# shellcheck disable=SC2034 # Referenced by the sourced purge helper. +BACKUP_ROOT="$WORK/recovery" +BACKUP_ARCHIVE= + +mkdir -p \ + "$APP_ROOT" \ + "$BAOTA_ROOT" \ + "$CONFIG_ROOT" \ + "$STATE_ROOT/files" \ + "$UPDATE_STATE_ROOT/backups" \ + "$UPDATE_STATE_ROOT/failed" \ + "$UPDATE_STATE_ROOT/transactions" \ + "$LOG_ROOT" \ + "$(dirname "$FIRST_LOGIN_FILE")" \ + "$WORK/external-mysql" \ + "$WORK/reverse-proxy" +printf 'DB_PASSWORD="secret"\n' > "$CONFIG_ROOT/kaidi.env" +printf 'document\n' > "$STATE_ROOT/files/document.txt" +printf 'dump\n' > "$UPDATE_STATE_ROOT/backups/mysql.sql" +printf 'setup code\n' > "$FIRST_LOGIN_FILE" +printf 'database sentinel\n' > "$WORK/external-mysql/keep" +printf 'proxy sentinel\n' > "$WORK/reverse-proxy/keep" + +create_recovery_backup +[ -s "$BACKUP_ARCHIVE" ] || fail 'root-only recovery archive was not created' +[ "$(stat -c '%a' "$BACKUP_ARCHIVE" 2>/dev/null || stat -f '%Lp' "$BACKUP_ARCHIVE")" = 600 ] \ + || fail 'recovery archive mode is not 0600' +archive_list=$(tar -tzf "$BACKUP_ARCHIVE") +grep -Fq "${CONFIG_ROOT#/}/kaidi.env" <<< "$archive_list" \ + || fail 'configuration was omitted from the recovery archive' +grep -Fq "${STATE_ROOT#/}/files/document.txt" <<< "$archive_list" \ + || fail 'file storage was omitted from the recovery archive' +grep -Fq "${UPDATE_STATE_ROOT#/}/backups/mysql.sql" <<< "$archive_list" \ + || fail 'database backup was omitted from the recovery archive' + +remove_managed_paths +for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$UPDATE_STATE_ROOT" "$LOG_ROOT"; do + [ ! -e "$path" ] || fail "managed path was not removed: $path" +done +[ ! -e "$FIRST_LOGIN_FILE" ] || fail 'first-login file was not removed' +[ -f "$WORK/external-mysql/keep" ] || fail 'external database state was removed' +[ -f "$WORK/reverse-proxy/keep" ] || fail 'reverse-proxy state was removed' +[ -s "$BACKUP_ARCHIVE" ] || fail 'recovery archive was removed with the installation' + +[ "$(tail -n 1 "$ROOT/deploy/purge.sh")" = 'main "$@"' ] \ + || fail 'purge actions can execute before a complete curl stream is parsed' +# shellcheck disable=SC2016 # Match the literal guard in the purge source. +grep -Fq 'KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION' "$ROOT/deploy/purge.sh" \ + || fail 'destructive removal no longer requires explicit confirmation' +grep -Fq 'External MySQL data and reverse-proxy configuration will not be modified' "$ROOT/deploy/purge.sh" \ + || fail 'purge boundary is no longer explicit' + +printf 'Local purge and recovery fixture passed\n' diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index d7003db..426e210 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -40,6 +40,7 @@ printf '%s\n' \ bootstrap-checksums.txt \ frontend-sbom.cdx.json \ install.sh \ + purge.sh \ "$ARTIFACT" \ release-manifest.json \ release-manifest.sig \ @@ -49,7 +50,7 @@ for path in ./* ./.[!.]* ./..?*; do basename "$path" done | LC_ALL=C sort > "$WORK/actual-assets.txt" diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \ - || { printf 'Release directory must contain exactly the nine published assets\n' >&2; exit 1; } + || { printf 'Release directory must contain exactly the ten published assets\n' >&2; exit 1; } EXPECTED_ARTIFACT_SHA256=$(jq -er \ '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ release-manifest.json) @@ -109,16 +110,21 @@ done PUBLIC_KEY_SHA256=$(sha256_file release-public.pem) INSTALLER_SHA256=$(sha256_file install.sh) +PURGER_SHA256=$(sha256_file purge.sh) BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt) SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json) +SIGNED_PURGER=$(jq -er '.bootstrap.purger' release-manifest.json) SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json) SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json) -[ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \ +[ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PURGER" = purge.sh ] \ + && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \ && [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \ || { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; } -jq -e --arg installer "$INSTALLER_SHA256" --arg publicKey "$PUBLIC_KEY_SHA256" \ +jq -e --arg installer "$INSTALLER_SHA256" --arg purger "$PURGER_SHA256" \ + --arg publicKey "$PUBLIC_KEY_SHA256" \ --arg bootstrap "$BOOTSTRAP_SHA256" \ '.bootstrap.installerSha256 == $installer + and .bootstrap.purgerSha256 == $purger and .bootstrap.publicKeySha256 == $publicKey and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \ || { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; } @@ -131,6 +137,8 @@ NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" || { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; } [ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \ || { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; } +[ "$(sed -n 's/^KAIDI_PURGER_SHA256=//p' bootstrap-checksums.txt)" = "$PURGER_SHA256" ] \ + || { printf 'Bootstrap purger digest does not match\n' >&2; exit 1; } ARCHIVE_LIST=$(tar -tzf "$ARTIFACT") if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then @@ -171,5 +179,8 @@ tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \ bash -n "$ROOT/deploy/baota-start.sh" cmp install.sh "$ROOT/deploy/install.sh" \ || { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; } +bash -n "$ROOT/deploy/purge.sh" +cmp purge.sh "$ROOT/deploy/purge.sh" \ + || { printf 'Release purger does not match deploy/purge.sh\n' >&2; exit 1; } printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256"