From f9545a9d0fd1cb64f0bcbe3b9022262af4ab42ef Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Tue, 4 Aug 2026 12:59:24 +0800 Subject: [PATCH] fix: harden first-run install and clean reinstall --- .gitea/workflows/release.yml | 8 + README.md | 40 ++- distribution/bin/erp-run | 9 +- docs/online-install-and-update.md | 35 ++- install.sh | 2 + oa-backend/build.gradle | 14 +- .../kaidi/oa/install/InstallerService.java | 57 +++++ .../src/installer/resources/static/index.html | 2 +- tests/release-scripts.test.sh | 174 +++++++++++++ uninstall.sh | 242 ++++++++++++++++++ 10 files changed, 566 insertions(+), 17 deletions(-) create mode 100755 uninstall.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1622e95..61fda10 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -58,6 +58,11 @@ jobs: trap cleanup EXIT printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file" export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file" + bash tests/release-scripts.test.sh + ( + cd oa-backend + ./gradlew test installerTest + ) bash scripts/package-release.sh "$version" owner="${repository%%/*}" @@ -97,6 +102,9 @@ jobs: fi release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')" + curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \ + --header "$auth_header" --header 'Accept: application/json' \ + --output release.json "$release_api/$release_id" for asset in "dist/kaidi-erp-$version.tar.gz" "dist/kaidi-erp-installer-$version.jar" dist/SHA256SUMS dist/SHA256SUMS.sig; do name="$(basename "$asset")" encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")" diff --git a/README.md b/README.md index 3eaf571..cb38ce3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Administrator -> System Update UI -> erp-update helper |- README.md |- run.command # macOS 本地预览与 ngrok 启动器 |- install.sh # 非 Docker 一键安装器 +|- uninstall.sh # Linux 完整卸载与数据库 schema 重置 |- distribution/ | |- bin/erp-run # 正式环境应用启动器 | `- bin/erp-update # 下载、校验、切换和回滚助手 @@ -188,6 +189,8 @@ Setup URL: http://192.168.1.20:8091/?token= 首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/` 和 `install.pending`。 +PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。 + ### macOS macOS 需要 Homebrew,并使用已有 PostgreSQL: @@ -204,14 +207,37 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ 当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收: ```bash -curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/branch/main/install.sh \ - | sudo -E bash -s -- \ - --gitea-url http://38.76.196.225:10099 \ - --repository awaioi/ERP \ - --allow-insecure +( + set -e + tmp="$(mktemp)" + trap 'rm -f -- "$tmp"' EXIT + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp" + printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c - + sudo -E bash "$tmp" \ + --gitea-url http://38.76.196.225:10099 \ + --repository awaioi/ERP \ + --allow-insecure +) ``` -只有首个 Release 发布后这条命令才可下载安装包。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为生产方案。 +只有 `v0.3.1` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为生产方案。 + +### 完整卸载后重装 + +以下命令具有破坏性:它会先停止服务,使用现有配置中的 ERP 数据库账号删除并重建目标数据库的 `public` schema,然后删除 systemd unit、程序、配置、状态和日志。脚本只允许数据库所有者执行 schema 清理,并拒绝 `postgres`、`template0`、`template1` 和危险文件路径。 + +```bash +( + set -e + tmp="$(mktemp)" + trap 'rm -f -- "$tmp"' EXIT + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp" + printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - + sudo -E bash "$tmp" --purge-database --yes +) +``` + +卸载器会先核对安装配置、路径和 systemd 停止状态,再清理数据库和文件。卸载成功后,再执行上面的 Linux 一键安装命令。不要对包含其他系统数据的共享数据库运行此命令。 ### 正式安装目录 @@ -263,7 +289,7 @@ ERP_UPDATE_BACKUP_MODE=pg_dump ## Gitea Release 发布 -推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。 +推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会先执行 shell、后端和独立安装器测试,再构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。 ### Actions 前置配置 diff --git a/distribution/bin/erp-run b/distribution/bin/erp-run index 91b489f..f219b0f 100755 --- a/distribution/bin/erp-run +++ b/distribution/bin/erp-run @@ -21,6 +21,7 @@ load_configuration() { INSTALLER_JAR="${ERP_INSTALLER_JAR:-$INSTALL_ROOT/installer/kaidi-erp-installer.jar}" PENDING_FILE="${ERP_INSTALL_PENDING_FILE:-$INSTALL_ROOT/state/install.pending}" INSTALL_LOCK_FILE="${ERP_INSTALL_LOCK_FILE:-$INSTALL_ROOT/state/install.lock}" + INSTALL_LOG_FILE="${ERP_INSTALL_LOG_FILE:-$(dirname "$PENDING_FILE")/install-formal.log}" HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}" HEALTH_TIMEOUT_SECONDS="${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}" HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}" @@ -98,10 +99,14 @@ run_pending_formal_application() { [[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid health poll interval' say 'Starting the formal PostgreSQL application' + mkdir -p "$(dirname "$INSTALL_LOG_FILE")" + : > "$INSTALL_LOG_FILE" + chmod 600 "$INSTALL_LOG_FILE" "$JAVA_BIN" "${JAVA_OPTS[@]}" \ -jar "$JAR_PATH" \ --spring.profiles.active=postgres \ - --server.port="${SERVER_PORT:-8091}" & + --server.port="${SERVER_PORT:-8091}" \ + > >(tee -a "$INSTALL_LOG_FILE") 2>&1 & APP_PID=$! write_pid "$APP_PID" @@ -118,6 +123,7 @@ run_pending_formal_application() { local status=$? set -e clear_pid "$APP_PID" + say "Formal application diagnostics: $INSTALL_LOG_FILE" fail "formal application exited before becoming healthy (status $status)" fi if curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then @@ -137,6 +143,7 @@ run_pending_formal_application() { wait "$APP_PID" set -e clear_pid "$APP_PID" + say "Formal application diagnostics: $INSTALL_LOG_FILE" fail "formal application did not become healthy within ${HEALTH_TIMEOUT_SECONDS} seconds" } diff --git a/docs/online-install-and-update.md b/docs/online-install-and-update.md index e7fab9d..9efc39b 100644 --- a/docs/online-install-and-update.md +++ b/docs/online-install-and-update.md @@ -42,11 +42,42 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ --repository awaioi/ERP ``` -命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+ 和 `pg_trgm`,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。 +命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限和 `pg_trgm` 所有权,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。 + +目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。 Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。 -当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`。HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式。 +当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。 + +当前 `v0.3.1` 安装命令: + +```bash +( + set -e + tmp="$(mktemp)" + trap 'rm -f -- "$tmp"' EXIT + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp" + printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c - + sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \ + --repository awaioi/ERP --allow-insecure +) +``` + +完整卸载并清空本项目数据库 schema 后重装: + +```bash +( + set -e + tmp="$(mktemp)" + trap 'rm -f -- "$tmp"' EXIT + curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp" + printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c - + sudo -E bash "$tmp" --purge-database --yes +) +``` + +该命令会先确认安装路径与 `erp.env` 一致、systemd 服务已经停止,再删除目标数据库 `public` schema 中的全部对象以及 `/opt/kaidi-erp`、`/etc/kaidi-erp`、`/var/lib/kaidi-erp`、`/var/log/kaidi-erp`。只允许对 Kaidi ERP 专用数据库执行。 ## 在线更新 diff --git a/install.sh b/install.sh index 4581464..6d4e030 100755 --- a/install.sh +++ b/install.sh @@ -394,6 +394,8 @@ write_bootstrap_configuration() { shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE" shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE" shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE" + shell_setting ERP_INSTALL_LOG_FILE "$STATE_ROOT/install-formal.log" + shell_setting ERP_INSTALL_HEALTH_TIMEOUT_SECONDS "${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}" shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN" shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar" shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar" diff --git a/oa-backend/build.gradle b/oa-backend/build.gradle index b9b671e..8207a6c 100644 --- a/oa-backend/build.gradle +++ b/oa-backend/build.gradle @@ -8,6 +8,8 @@ group = 'com.kaidi' version = providers.gradleProperty('releaseVersion') .orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0') .get() +def flywayVersion = '11.20.3' +def postgresqlDriverVersion = '42.7.13' def productionBuild = providers.gradleProperty('productionBuild') .map { it.toBoolean() } .orElse(false) @@ -47,9 +49,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' // Production database and versioned schema migrations. - implementation 'org.flywaydb:flyway-core:10.22.0' - runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0' - runtimeOnly 'org.postgresql:postgresql' + implementation "org.flywaydb:flyway-core:$flywayVersion" + runtimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion" + runtimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion" // SQLite remains available to source-tree development and tests, but is // deliberately absent from PostgreSQL-only production release artifacts. @@ -69,9 +71,9 @@ dependencies { installerImplementation 'org.springframework.boot:spring-boot-starter-web' installerImplementation 'org.springframework.boot:spring-boot-starter-validation' - installerImplementation 'org.flywaydb:flyway-core:10.22.0' - installerRuntimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0' - installerRuntimeOnly 'org.postgresql:postgresql' + installerImplementation "org.flywaydb:flyway-core:$flywayVersion" + installerRuntimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion" + installerRuntimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion" } tasks.named('test') { diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java index 0d45726..4b8006a 100644 --- a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java @@ -3,6 +3,8 @@ package com.kaidi.oa.install; import com.fasterxml.jackson.databind.ObjectMapper; import org.flywaydb.core.Flyway; import org.flywaydb.core.api.output.MigrateResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.http.HttpStatus; @@ -36,6 +38,8 @@ import java.util.Set; @Service public class InstallerService { + private static final Logger log = LoggerFactory.getLogger(InstallerService.class); + private static final Set OWNER_ONLY = EnumSet.of( PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); @@ -117,6 +121,11 @@ public class InstallerService { .load() .migrate(); } catch (RuntimeException exception) { + log.error( + "PostgreSQL migration failed for database {} as user {}", + request.database().database().strip(), + request.database().username().strip(), + exception); throw new InstallApiException( HttpStatus.UNPROCESSABLE_ENTITY, 42203, @@ -170,6 +179,7 @@ public class InstallerService { 42201, "PostgreSQL 版本过低,需要 15 或更高版本"); } + requireDatabaseOwnership(connection); try (Statement statement = connection.createStatement()) { statement.execute("SELECT 1"); statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm"); @@ -181,10 +191,16 @@ public class InstallerService { throw new SQLException("pg_trgm is unavailable"); } } + requirePgTrgmOwnership(connection); return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion()); } catch (InstallApiException exception) { throw exception; } catch (SQLException | NumberFormatException exception) { + log.warn( + "PostgreSQL verification failed for database {} as user {}: {}", + database.database().strip(), + database.username().strip(), + exception.getMessage()); throw new InstallApiException( HttpStatus.UNPROCESSABLE_ENTITY, 42202, @@ -192,6 +208,40 @@ public class InstallerService { } } + private void requireDatabaseOwnership(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT " + + "pg_get_userbyid(datdba) = current_user AS owns_database, " + + "has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema, " + + "has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_in_schema " + + "FROM pg_database WHERE datname = current_database()")) { + if (!result.next() + || !result.getBoolean("owns_database") + || !result.getBoolean("can_use_schema") + || !result.getBoolean("can_create_in_schema")) { + throw new InstallApiException( + HttpStatus.UNPROCESSABLE_ENTITY, + 42206, + "数据库账号必须是该空数据库的所有者,并拥有 public schema 建表权限"); + } + } + } + + private void requirePgTrgmOwnership(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT pg_get_userbyid(extowner) = current_user " + + "FROM pg_extension WHERE extname = 'pg_trgm'")) { + if (!result.next() || !result.getBoolean(1)) { + throw new InstallApiException( + HttpStatus.UNPROCESSABLE_ENTITY, + 42207, + "数据库账号必须拥有 pg_trgm 扩展;请由管理员删除预建扩展后重新测试"); + } + } + } + private void rejectExistingInstallation(InstallRequest.Database database) { try (Connection connection = openConnection(database); PreparedStatement tableQuery = connection.prepareStatement( @@ -272,6 +322,11 @@ public class InstallerService { } catch (InstallApiException exception) { throw exception; } catch (Exception exception) { + log.error( + "Administrator initialization failed for database {} as user {}", + database.database().strip(), + database.username().strip(), + exception); throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败"); } } @@ -326,6 +381,8 @@ public class InstallerService { values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString()); values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString()); values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString()); + values.put("ERP_INSTALL_LOG_FILE", stateRoot.resolve("install-formal.log").toString()); + values.put("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", environment("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", "240")); values.put("ERP_SETUP_TOKEN", properties.token()); values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString()); values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString()); diff --git a/oa-backend/src/installer/resources/static/index.html b/oa-backend/src/installer/resources/static/index.html index e429675..abb9a93 100644 --- a/oa-backend/src/installer/resources/static/index.html +++ b/oa-backend/src/installer/resources/static/index.html @@ -440,7 +440,7 @@ } catch { /* Service is changing from installer to the formal application. */ } } $('#install-message').className = 'notice error'; - $('#install-message').textContent = '正式服务尚未就绪。请在服务器查看 kaidi-erp 服务日志,安装文件和安装锁不会被误删。'; + $('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。'; } $('#start-button').addEventListener('click', () => go(2)); diff --git a/tests/release-scripts.test.sh b/tests/release-scripts.test.sh index a66e3c6..5065261 100755 --- a/tests/release-scripts.test.sh +++ b/tests/release-scripts.test.sh @@ -56,6 +56,83 @@ test_erp_run_preserves_java_option_arguments() ( grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output" ) +test_erp_run_finalizes_healthy_pending_install() ( + local tmp + tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-success.XXXXXX")" || return 1 + trap 'rm -rf "$tmp"' EXIT + mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin" + : > "$tmp/current/app/kaidi-erp.jar" + : > "$tmp/installer/kaidi-erp-installer.jar" + printf 'pending\n' > "$tmp/state/install.pending" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \ + 'printf '\''formal application output\n'\''' \ + 'sleep 0.1' \ + 'exit 0' > "$tmp/java" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$tmp/bin/curl" + chmod +x "$tmp/java" "$tmp/bin/curl" + { + printf 'ERP_JAVA_BIN=%q\n' "$tmp/java" + printf 'ERP_RUN_DIR=%q\n' "$tmp/run" + printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar" + printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar" + printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending" + printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock" + printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log" + printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n' + printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n' + } > "$tmp/erp.env" + + PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ + "$PROJECT_ROOT/distribution/bin/erp-run" > "$tmp/run.log" 2>&1 || return 1 + + [[ -f "$tmp/state/install.lock" ]] || return 1 + [[ ! -e "$tmp/state/install.pending" ]] || return 1 + [[ ! -d "$tmp/installer" ]] || return 1 + [[ ! -e "$tmp/run/app.pid" ]] || return 1 + grep -Fq 'formal application output' "$tmp/state/install-formal.log" +) + +test_erp_run_preserves_failed_pending_install() ( + local tmp output status=0 + tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-failure.XXXXXX")" || return 1 + trap 'rm -rf "$tmp"' EXIT + mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin" + : > "$tmp/current/app/kaidi-erp.jar" + : > "$tmp/installer/kaidi-erp-installer.jar" + printf 'pending\n' > "$tmp/state/install.pending" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \ + 'printf '\''formal application exploded\n'\'' >&2' \ + 'sleep 0.1' \ + 'exit 23' > "$tmp/java" + printf '%s\n' '#!/usr/bin/env bash' 'exit 1' > "$tmp/bin/curl" + chmod +x "$tmp/java" "$tmp/bin/curl" + { + printf 'ERP_JAVA_BIN=%q\n' "$tmp/java" + printf 'ERP_RUN_DIR=%q\n' "$tmp/run" + printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar" + printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar" + printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending" + printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock" + printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log" + printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n' + printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n' + } > "$tmp/erp.env" + + output="$(PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ + "$PROJECT_ROOT/distribution/bin/erp-run" 2>&1)" || status=$? + + [[ "$status" -ne 0 ]] || return 1 + [[ -f "$tmp/state/install.pending" ]] || return 1 + [[ ! -e "$tmp/state/install.lock" ]] || return 1 + [[ -d "$tmp/installer" ]] || return 1 + grep -Fq 'formal application exploded' "$tmp/state/install-formal.log" || return 1 + [[ "$output" == *"Formal application diagnostics: $tmp/state/install-formal.log"* ]] +) + prepare_signed_archive() { local tmp="$1" unsafe="${2:-0}" mkdir -p "$tmp/package/kaidi-erp-1.2.3/app" @@ -483,6 +560,98 @@ test_systemd_unit_uses_unquoted_legacy_paths() ( ! grep -Fq 'WorkingDirectory="$INSTALL_ROOT"' <<< "$unit" ) +test_uninstaller_purges_database_before_removing_files() ( + local tmp + tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-test.XXXXXX")" || return 1 + trap 'rm -rf "$tmp"' EXIT + mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs" + : > "$tmp/unit.service" + { + printf 'ERP_PGHOST=127.0.0.1\n' + printf 'ERP_PGPORT=5432\n' + printf 'ERP_PGDATABASE=kaidi_test\n' + printf 'ERP_PGSSLMODE=disable\n' + printf 'OA_DB_USERNAME=kaidi_test\n' + printf 'OA_DB_PASSWORD='\''test-password'\''\n' + printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install" + printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock" + } > "$tmp/config/erp.env" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf '\''%s\n'\'' "$*" >> "$MOCK_SYSTEMCTL_LOG"' \ + 'if [[ "${1:-}" == "is-active" ]]; then exit 3; fi' \ + 'exit 0' > "$tmp/bin/systemctl" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf '\''ARGS %s\n'\'' "$*" >> "$MOCK_PSQL_LOG"' \ + 'if [[ " $* " == *" -c "* ]]; then printf '\''t\n'\''; exit 0; fi' \ + 'cat >> "$MOCK_PSQL_LOG"' \ + 'exit 0' > "$tmp/bin/psql" + chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql" + + PATH="$tmp/bin:$PATH" \ + MOCK_SYSTEMCTL_LOG="$tmp/systemctl.log" \ + MOCK_PSQL_LOG="$tmp/psql.log" \ + ERP_UNINSTALL_TEST_MODE=1 \ + ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \ + "$PROJECT_ROOT/uninstall.sh" --purge-database --yes \ + --config-file "$tmp/config/erp.env" \ + --install-root "$tmp/install" \ + --config-root "$tmp/config" \ + --state-root "$tmp/state" \ + --log-root "$tmp/logs" > "$tmp/uninstall.log" 2>&1 || return 1 + + [[ ! -e "$tmp/install" && ! -e "$tmp/config" && ! -e "$tmp/state" && ! -e "$tmp/logs" ]] || return 1 + [[ ! -e "$tmp/unit.service" ]] || return 1 + grep -Fq 'DROP SCHEMA IF EXISTS public CASCADE;' "$tmp/psql.log" || return 1 + grep -Fq 'stop kaidi-erp.service' "$tmp/systemctl.log" || return 1 + grep -Fq 'disable kaidi-erp.service' "$tmp/systemctl.log" +) + +test_uninstaller_rejects_unsafe_paths() ( + ! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc) >/dev/null 2>&1 || return 1 + ! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc/ssh) >/dev/null 2>&1 || return 1 + ! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /opt/kaidi-erp/../../etc) >/dev/null 2>&1 +) + +test_uninstaller_aborts_when_service_remains_active() ( + local tmp output status=0 + tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-active.XXXXXX")" || return 1 + trap 'rm -rf "$tmp"' EXIT + mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs" + { + printf 'ERP_PGHOST=127.0.0.1\n' + printf 'ERP_PGPORT=5432\n' + printf 'ERP_PGDATABASE=kaidi_test\n' + printf 'ERP_PGSSLMODE=disable\n' + printf 'OA_DB_USERNAME=kaidi_test\n' + printf 'OA_DB_PASSWORD=test-password\n' + printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install" + printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock" + } > "$tmp/config/erp.env" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "stop" ]]; then exit 1; fi' \ + 'if [[ "${1:-}" == "show" ]]; then printf '\''loaded\n'\''; exit 0; fi' \ + 'if [[ "${1:-}" == "is-active" ]]; then exit 0; fi' \ + 'exit 0' > "$tmp/bin/systemctl" + printf '%s\n' '#!/usr/bin/env bash' 'touch "$MOCK_PSQL_CALLED"' 'exit 0' > "$tmp/bin/psql" + chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql" + + output="$(PATH="$tmp/bin:$PATH" MOCK_PSQL_CALLED="$tmp/psql.called" \ + ERP_UNINSTALL_TEST_MODE=1 ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \ + "$PROJECT_ROOT/uninstall.sh" --purge-database --yes \ + --config-file "$tmp/config/erp.env" \ + --install-root "$tmp/install" \ + --config-root "$tmp/config" \ + --state-root "$tmp/state" \ + --log-root "$tmp/logs" 2>&1)" || status=$? + + [[ "$status" -ne 0 && "$output" == *'unable to stop kaidi-erp.service'* ]] || return 1 + [[ ! -e "$tmp/psql.called" ]] || return 1 + [[ -d "$tmp/install" && -d "$tmp/config" && -d "$tmp/state" && -d "$tmp/logs" ]] +) + test_no_service_install_disables_online_update() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1 @@ -524,6 +693,8 @@ test_installer_runs_when_piped_to_bash() ( ) run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments +run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install +run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag @@ -536,6 +707,9 @@ run_test 'installer requires an explicit Gitea URL' test_installer_requires_expl run_test 'Linux production install requires systemd' test_linux_service_preflight_requires_systemd run_test 'systemd unit uses compatible protection' test_systemd_unit_uses_compatible_protection run_test 'systemd unit uses unquoted legacy paths' test_systemd_unit_uses_unquoted_legacy_paths +run_test 'uninstaller purges database before removing files' test_uninstaller_purges_database_before_removing_files +run_test 'uninstaller rejects unsafe paths' test_uninstaller_rejects_unsafe_paths +run_test 'uninstaller aborts while the service remains active' test_uninstaller_aborts_when_service_remains_active run_test 'no-service install disables online update' test_no_service_install_disables_online_update run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..b5904a3 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="${ERP_CONFIG_FILE:-/etc/kaidi-erp/erp.env}" +INSTALL_ROOT="${ERP_INSTALL_ROOT:-/opt/kaidi-erp}" +CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}" +STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}" +LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}" +PURGE_DATABASE=0 +ASSUME_YES=0 + +say() { printf '[ERP Uninstall] %s\n' "$*"; } +fail() { printf '[ERP Uninstall] ERROR: %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'EOF' +Usage: uninstall.sh [options] + --purge-database Drop and recreate the public schema using the configured ERP account + --yes Confirm destructive removal without an interactive prompt + --config-file PATH Installer-generated erp.env file + --install-root PATH Installation root (default: /opt/kaidi-erp) + --config-root PATH Configuration root (default: /etc/kaidi-erp) + --state-root PATH State root (default: /var/lib/kaidi-erp) + --log-root PATH Log root (default: /var/log/kaidi-erp) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --purge-database) PURGE_DATABASE=1; shift ;; + --yes) ASSUME_YES=1; shift ;; + --config-file) [[ $# -ge 2 ]] || fail '--config-file requires a value'; CONFIG_FILE="$2"; shift 2 ;; + --install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;; + --config-root) [[ $# -ge 2 ]] || fail '--config-root requires a value'; CONFIG_ROOT="$2"; shift 2 ;; + --state-root) [[ $# -ge 2 ]] || fail '--state-root requires a value'; STATE_ROOT="$2"; shift 2 ;; + --log-root) [[ $# -ge 2 ]] || fail '--log-root requires a value'; LOG_ROOT="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +require_root() { + if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$(id -u)" != "0" ]]; then + fail 'Linux uninstall requires root' + fi +} + +require_confirmation() { + [[ "$PURGE_DATABASE" == "1" ]] || fail '--purge-database is required for a clean reinstall' + [[ "$ASSUME_YES" == "1" ]] || fail '--yes is required to confirm database and file removal' +} + +validate_remove_path() { + local path="$1" + [[ "$path" == /* && "$path" != "/" && ${#path} -ge 8 ]] \ + || fail "refusing unsafe removal path: $path" + [[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \ + || fail "refusing removal path with unsafe characters: $path" + + local resolved + resolved="$(python3 - "$path" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1]), end="") +PY +)" || fail "unable to resolve removal path: $path" + if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$resolved" != "$path" ]]; then + fail "removal path must be canonical and cannot traverse links: $path" + fi + + case "$resolved" in + /bin|/boot|/dev|/etc|/home|/lib|/lib64|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var) + fail "refusing unsafe removal path: $path" + ;; + esac + + if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" ]]; then + case "$resolved" in + /etc/kaidi-erp|/var/lib/kaidi-erp|/var/log/kaidi-erp) ;; + /bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/root/*|/run/*|/sbin/*|/sys/*|/tmp/*|/usr/*|/var/*) + fail "refusing removal below a protected system directory: $path" + ;; + esac + fi + + printf '%s' "$resolved" +} + +prepare_paths() { + command -v python3 >/dev/null 2>&1 || fail 'python3 is required' + INSTALL_ROOT="$(validate_remove_path "$INSTALL_ROOT")" + CONFIG_ROOT="$(validate_remove_path "$CONFIG_ROOT")" + STATE_ROOT="$(validate_remove_path "$STATE_ROOT")" + LOG_ROOT="$(validate_remove_path "$LOG_ROOT")" + + local config_resolved + config_resolved="$(python3 - "$CONFIG_FILE" <<'PY' +import os +import sys +print(os.path.realpath(sys.argv[1]), end="") +PY +)" || fail "unable to resolve configuration path: $CONFIG_FILE" + [[ "$config_resolved" == "$CONFIG_ROOT/erp.env" ]] \ + || fail "configuration file must be $CONFIG_ROOT/erp.env" + CONFIG_FILE="$config_resolved" +} + +read_config_value() { + local key="$1" + python3 - "$CONFIG_FILE" "$key" <<'PY' +import re +import shlex +import sys + +path, wanted = sys.argv[1:] +pattern = re.compile(r"[A-Z][A-Z0-9_]*") +seen = set() +matches = [] +with open(path, encoding="utf-8") as handle: + for raw in handle: + line = raw.rstrip("\r\n") + if not line or line.startswith("#") or "=" not in line: + continue + key, encoded = line.split("=", 1) + if not pattern.fullmatch(key): + raise SystemExit("invalid configuration key") + if key in seen: + raise SystemExit(f"duplicate configuration key: {key}") + seen.add(key) + if key != wanted: + continue + values = shlex.split(encoded, posix=True) + if len(values) != 1: + raise SystemExit(f"invalid value for {wanted}") + matches.append(values[0]) +if len(matches) != 1: + raise SystemExit(f"missing configuration value: {wanted}") +print(matches[0], end="") +PY +} + +verify_installation_identity() { + [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \ + || fail "configuration file is missing or unsafe: $CONFIG_FILE" + + local configured_root configured_lock configured_state + configured_root="$(read_config_value ERP_INSTALL_ROOT)" \ + || fail 'unable to verify the configured installation root' + configured_lock="$(read_config_value ERP_INSTALL_LOCK_FILE)" \ + || fail 'unable to verify the configured installation state' + configured_root="$(validate_remove_path "$configured_root")" + configured_state="$(validate_remove_path "$(dirname "$configured_lock")")" + [[ "$configured_root" == "$INSTALL_ROOT" ]] \ + || fail 'requested installation root does not match erp.env' + [[ "$configured_state" == "$STATE_ROOT" ]] \ + || fail 'requested state root does not match erp.env' +} + +purge_database() { + command -v psql >/dev/null 2>&1 || fail 'PostgreSQL client psql is required' + + local host port database sslmode username password owner_check + host="$(read_config_value ERP_PGHOST)" + port="$(read_config_value ERP_PGPORT)" + database="$(read_config_value ERP_PGDATABASE)" + sslmode="$(read_config_value ERP_PGSSLMODE)" + username="$(read_config_value OA_DB_USERNAME)" + password="$(read_config_value OA_DB_PASSWORD)" + + [[ "$host" != *[[:space:]]* && "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] \ + || fail 'invalid database endpoint in configuration' + [[ "$database" =~ ^[A-Za-z_][A-Za-z0-9_-]{0,62}$ ]] || fail 'invalid database name in configuration' + [[ "$username" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid database user in configuration' + case "$database" in postgres|template0|template1) fail "refusing to purge protected database: $database" ;; esac + case "$sslmode" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid database SSL mode' ;; esac + + owner_check="$(PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -XAt \ + -h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 \ + -c "SELECT pg_get_userbyid(datdba) = current_user FROM pg_database WHERE datname = current_database()")" \ + || fail 'unable to verify database ownership' + [[ "$owner_check" == "t" ]] \ + || fail 'configured ERP account is not the database owner; database was not changed' + + say "Purging PostgreSQL schema $database/public" + PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -X \ + -h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 <<'SQL' +DROP SCHEMA IF EXISTS public CASCADE; +SELECT format('CREATE SCHEMA public AUTHORIZATION %I', current_user) \gexec +SQL +} + +stop_service() { + if command -v systemctl >/dev/null 2>&1; then + local load_state + if ! systemctl stop kaidi-erp.service >/dev/null 2>&1; then + load_state="$(systemctl show kaidi-erp.service --property=LoadState --value 2>/dev/null || true)" + [[ "$load_state" == "not-found" ]] \ + || fail 'unable to stop kaidi-erp.service; database and files were not changed' + fi + if systemctl is-active --quiet kaidi-erp.service; then + fail 'kaidi-erp.service is still active; database and files were not changed' + fi + fi +} + +remove_service() { + if command -v systemctl >/dev/null 2>&1; then + systemctl disable kaidi-erp.service >/dev/null 2>&1 || true + fi + local unit_file="${ERP_UNINSTALL_SYSTEMD_UNIT_FILE:-/etc/systemd/system/kaidi-erp.service}" + if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$unit_file" != "/etc/systemd/system/kaidi-erp.service" ]]; then + fail 'custom systemd unit path is only available in test mode' + fi + rm -f -- "$unit_file" + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl reset-failed kaidi-erp.service >/dev/null 2>&1 || true + fi +} + +remove_files() { + local path + for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do + [[ ! -e "$path" && ! -L "$path" ]] || rm -rf -- "$path" + done +} + +main() { + require_root + require_confirmation + prepare_paths + verify_installation_identity + stop_service + purge_database + remove_service + remove_files + say 'Kaidi ERP service, files, state, logs, and public database schema were removed' +} + +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + main "$@" +fi