From 6589ad10a621758eabe97ab30e9c019b10964df9 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:55:36 +0800 Subject: [PATCH] feat(epctl): add upgrade restart controls and rollback-safe deploy --- epctl | 265 +++++++++++++++++++++++++++++++++++++++---- epctl-docker-test.sh | 230 ++++++++++++++++++++++++------------- wiki/EPCTL.en.md | 28 ++++- wiki/EPCTL.md | 28 ++++- 4 files changed, 445 insertions(+), 106 deletions(-) diff --git a/epctl b/epctl index 2d0c0bb..d2d0bdf 100755 --- a/epctl +++ b/epctl @@ -127,6 +127,24 @@ trf() { zh:service_failed_active) template='服务 %s 未能成功进入 active 状态' ;; en:service_installed) template='installed %s to %s from %s' ;; zh:service_installed) template='已将 %s 安装到 %s,来源版本:%s' ;; + en:service_upgraded_restarted) template='updated %s in %s from %s and restarted the service' ;; + zh:service_upgraded_restarted) template='已将 %s 在 %s 升级到 %s,并完成服务重启' ;; + en:upgrade_existing_config_required) template='upgrade requires an existing config file at %s; run install first' ;; + zh:upgrade_existing_config_required) template='升级要求 %s 已存在;请先执行 install' ;; + en:upgrade_restart_flag_conflict) template='cannot combine --prompt-restart and --no-restart' ;; + zh:upgrade_restart_flag_conflict) template='--prompt-restart 和 --no-restart 不能同时使用' ;; + en:upgrade_deploy_failed_rollback_start) template='upgrading %s failed before restart; attempting to roll back the release files' ;; + zh:upgrade_deploy_failed_rollback_start) template='升级 %s 在重启前失败,正在尝试回滚 release 文件' ;; + en:upgrade_deploy_failed_rolled_back) template='upgrading %s to %s failed before restart; previous release files were restored' ;; + zh:upgrade_deploy_failed_rolled_back) template='升级 %s 到 %s 在重启前失败,已恢复到之前的 release 文件' ;; + en:upgrade_deploy_failed_rollback_failed) template='upgrading %s to %s failed before restart, and rollback also failed' ;; + zh:upgrade_deploy_failed_rollback_failed) template='升级 %s 到 %s 在重启前失败,且回滚也失败了' ;; + en:upgrade_restart_failed_rollback_start) template='restart failed after upgrading %s; attempting to roll back the release files' ;; + zh:upgrade_restart_failed_rollback_start) template='升级 %s 后重启失败,正在尝试回滚 release 文件' ;; + en:upgrade_restart_failed_rolled_back) template='restart failed after upgrading %s to %s; previous release files were restored' ;; + zh:upgrade_restart_failed_rolled_back) template='升级 %s 到 %s 后重启失败,已恢复到之前的 release 文件' ;; + en:upgrade_restart_failed_rollback_failed) template='restart failed after upgrading %s to %s, and rollback also failed' ;; + zh:upgrade_restart_failed_rollback_failed) template='升级 %s 到 %s 后重启失败,且回滚也失败了' ;; en:self_install_current) template='%s already points to the current script' ;; zh:self_install_current) template='%s 已经指向当前脚本,无需重复安装' ;; en:self_install_done) template='installed %s into /usr/local/bin' ;; @@ -215,6 +233,8 @@ trf() { zh:menu_language_input) template='语言选择(Language selection)' ;; en:menu_confirm_prompt) template='Continue? / 是否继续? [y/N] ' ;; zh:menu_confirm_prompt) template='是否继续?/ Continue? [y/N] ' ;; + en:upgrade_restart_prompt) template='Release files are updated. Restart %s now? [Y/n] ' ;; + zh:upgrade_restart_prompt) template='Release 文件已更新,是否立即重启 %s?[Y/n] ' ;; en:menu_cancelled_back) template='Cancelled and returned to the menu / 已取消,返回菜单' ;; zh:menu_cancelled_back) template='已取消,返回菜单 / Cancelled and returned to the menu' ;; en:language_switched) template='Language switched to %s / 语言已切换为 %s' ;; @@ -239,8 +259,14 @@ trf() { zh:action_install_desc) template='此操作会把 epusdt 安装到 %s,创建或复用 %s,写入 %s,并通过 systemd 启用 %s。若已有 .env,则会保留。' ;; en:action_upgrade_title) template='Upgrade the service' ;; zh:action_upgrade_title) template='升级服务' ;; - en:action_upgrade_desc) template='This will download and install a newer release into %s, then restart %s. The existing .env configuration will be preserved.' ;; - zh:action_upgrade_desc) template='此操作会下载并安装较新的 release 到 %s,然后重启 %s。现有 .env 配置会被保留。' ;; + en:action_upgrade_desc) template='This will download and install a newer release into %s, keep the existing .env configuration, and then ask whether to restart %s. The direct CLI command restarts by default unless you pass --no-restart.' ;; + zh:action_upgrade_desc) template='此操作会下载并安装较新的 release 到 %s,保留现有 .env 配置,然后再询问是否重启 %s。直接执行 CLI 时默认会重启,除非显式传入 --no-restart。' ;; + en:action_upgrade_restart_notice) template='Restarting %s will briefly interrupt the service and switch the running process to the new binary.' ;; + zh:action_upgrade_restart_notice) template='重启 %s 会带来短暂服务中断,并让运行中的进程切换到新的二进制文件。' ;; + en:action_upgrade_restart_skipped) template='Release files for %s were updated, but the service was not restarted. Run `systemctl restart %s` manually to apply the new binary.' ;; + zh:action_upgrade_restart_skipped) template='已更新 %s 的 release 文件,但没有重启服务。请手动执行 `systemctl restart %s` 以应用新的二进制文件。' ;; + en:action_upgrade_restart_no_tty) template='--prompt-restart requires an interactive terminal. Re-run in a TTY, use plain `upgrade` to restart immediately, or pass `--no-restart` to skip the restart.' ;; + zh:action_upgrade_restart_no_tty) template='--prompt-restart 需要交互终端。请在 TTY 中重新执行,或直接使用默认 `upgrade` 立即重启,或传入 `--no-restart` 跳过重启。' ;; en:action_self_install_title) template='Install epctl into PATH' ;; zh:action_self_install_title) template='安装 epctl 到 PATH' ;; en:action_self_install_desc) template='This will install %s into /usr/local/bin so it can be called from anywhere. Use %s to force Chinese or %s to force English.' ;; @@ -321,7 +347,7 @@ usage() { epctl [--lang zh|en] menu epctl [--lang zh|en] download [--tag ] epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT] - epctl [--lang zh|en] upgrade [--tag ] + epctl [--lang zh|en] upgrade [--tag ] [--prompt-restart|--no-restart] epctl [--lang zh|en] self-install epctl [--lang zh|en] show-config epctl [--lang zh|en] init-password @@ -337,7 +363,7 @@ usage() { menu 进入交互式菜单;直接执行 `epctl` 也会进入 download 下载 GitHub Release 包到 /tmp/epusdt// install 安装或刷新 /opt/epusdt,并注册 epusdt.service - upgrade 升级到新的 release,保留现有 .env + upgrade 升级到新的 release,保留现有 .env;默认直接重启,菜单会改为先询问 self-install 安装 epctl 到 /usr/local/bin show-config 输出当前生效的 /opt/epusdt/.env init-password 通过本地 HTTP 路由请求初始化管理员密码 @@ -351,6 +377,10 @@ usage() { - 需要提权的命令会自动通过 sudo 重新执行 - tag 需要是真实的 GitHub release tag,例如 v1.0.6 - 未提供 --tag 时,会自动解析 latest,并要求二次确认 + - upgrade 只接受已有安装;不会创建新的 .env + - upgrade 默认会在文件替换后立即重启服务 + - upgrade --prompt-restart 会在交互终端下询问是否重启 + - upgrade --no-restart 只替换文件,不重启服务 EOF else cat <<'EOF' @@ -361,7 +391,7 @@ Usage: epctl [--lang zh|en] menu epctl [--lang zh|en] download [--tag ] epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT] - epctl [--lang zh|en] upgrade [--tag ] + epctl [--lang zh|en] upgrade [--tag ] [--prompt-restart|--no-restart] epctl [--lang zh|en] self-install epctl [--lang zh|en] show-config epctl [--lang zh|en] init-password @@ -377,7 +407,7 @@ Command summary: menu Open the interactive menu; `epctl` with no arguments does this too download Download a GitHub release archive into /tmp/epusdt// install Install or refresh /opt/epusdt and register epusdt.service - upgrade Upgrade to a newer release while keeping the existing .env + upgrade Upgrade to a newer release while keeping the existing .env; direct CLI restarts by default, menu mode asks first self-install Install epctl into /usr/local/bin show-config Print the active /opt/epusdt/.env file init-password Request the initial admin password from the local HTTP route @@ -391,6 +421,10 @@ Notes: - privileged commands automatically re-exec through sudo - tags must match real GitHub release tags such as v1.0.6 - if --tag is omitted, epctl resolves the latest release and asks for confirmation + - upgrade requires an existing install and does not create a new .env + - upgrade restarts the service by default after files are replaced + - upgrade --prompt-restart asks whether to restart in an interactive terminal + - upgrade --no-restart replaces files only and prints a manual restart warning EOF fi } @@ -701,6 +735,152 @@ render_service_debug_info() { journalctl -u "${SERVICE_NAME}" --no-pager -n 100 || true } +prepare_release_extract() { + local tag="$1" + local archive_path + + archive_path="$(download_release "${tag}")" + extract_release_archive "${tag}" "${archive_path}" +} + +deploy_release_payload() { + local extract_dir="$1" + + ensure_system_user + install -d -m 0755 "${INSTALL_DIR}" + install -m 0755 "${extract_dir}/epusdt" "${BIN_PATH}" + install -m 0644 "${extract_dir}/.env.example" "${CONFIG_EXAMPLE_PATH}" +} + +finalize_install_dir_ownership() { + chown -R "${SYSTEM_USER}:${SYSTEM_USER}" "${INSTALL_DIR}" +} + +refresh_service_unit() { + write_systemd_unit + systemctl daemon-reload +} + +ensure_existing_upgrade_config() { + [[ -f "${CONFIG_PATH}" ]] || die "$(trf upgrade_existing_config_required "${CONFIG_PATH}")" +} + +create_upgrade_backup_dir() { + local tag="$1" + local backup_dir + + backup_dir="$(cache_dir_for_tag "${tag}")/backup-current" + rm -rf "${backup_dir}" + mkdir -p "${backup_dir}" + printf '%s\n' "${backup_dir}" +} + +backup_release_files() { + local backup_dir="$1" + + [[ -f "${BIN_PATH}" ]] && cp -p "${BIN_PATH}" "${backup_dir}/epusdt" + [[ -f "${CONFIG_EXAMPLE_PATH}" ]] && cp -p "${CONFIG_EXAMPLE_PATH}" "${backup_dir}/.env.example" + [[ -f "${SYSTEMD_UNIT_PATH}" ]] && cp -p "${SYSTEMD_UNIT_PATH}" "${backup_dir}/${SERVICE_NAME}.service" +} + +restore_release_files() { + local backup_dir="$1" + + install -d -m 0755 "${INSTALL_DIR}" + + if [[ -f "${backup_dir}/epusdt" ]]; then + install -m 0755 "${backup_dir}/epusdt" "${BIN_PATH}" + else + rm -f "${BIN_PATH}" + fi + + if [[ -f "${backup_dir}/.env.example" ]]; then + install -m 0644 "${backup_dir}/.env.example" "${CONFIG_EXAMPLE_PATH}" + else + rm -f "${CONFIG_EXAMPLE_PATH}" + fi + + if [[ -f "${backup_dir}/${SERVICE_NAME}.service" ]]; then + install -m 0644 "${backup_dir}/${SERVICE_NAME}.service" "${SYSTEMD_UNIT_PATH}" + else + rm -f "${SYSTEMD_UNIT_PATH}" + fi + + finalize_install_dir_ownership + systemctl daemon-reload +} + +apply_upgrade_release() { + local extract_dir="$1" + + deploy_release_payload "${extract_dir}" + finalize_install_dir_ownership + refresh_service_unit +} + +restart_service_and_verify() { + local tag="$1" + local backup_dir="${2:-}" + + if systemctl restart "${SERVICE_NAME}" && systemctl is-active --quiet "${SERVICE_NAME}"; then + log "$(trf service_upgraded_restarted "${SERVICE_NAME}" "${INSTALL_DIR}" "${tag}")" + return 0 + fi + + render_service_debug_info + + if [[ -n "${backup_dir}" ]]; then + warn "$(trf upgrade_restart_failed_rollback_start "${SERVICE_NAME}")" + if ! restore_release_files "${backup_dir}"; then + die "$(trf upgrade_restart_failed_rollback_failed "${SERVICE_NAME}" "${tag}")" + fi + if systemctl restart "${SERVICE_NAME}" && systemctl is-active --quiet "${SERVICE_NAME}"; then + die "$(trf upgrade_restart_failed_rolled_back "${SERVICE_NAME}" "${tag}")" + fi + render_service_debug_info + die "$(trf upgrade_restart_failed_rollback_failed "${SERVICE_NAME}" "${tag}")" + fi + + die "$(trf service_failed_active "${SERVICE_NAME}")" +} + +handle_upgrade_restart() { + local tag="$1" + local restart_mode="$2" + local backup_dir="$3" + local reply="" + + case "${restart_mode}" in + restart) + restart_service_and_verify "${tag}" "${backup_dir}" + ;; + prompt) + if ! is_interactive_terminal; then + die "$(trf action_upgrade_restart_no_tty)" + fi + + warn "$(trf action_upgrade_restart_notice "${SERVICE_NAME}")" + printf '%b[%s]%b %s' "${COLOR_BOLD}${COLOR_CYAN}" "${EPCTL_NAME}" "${COLOR_RESET}" "$(trf upgrade_restart_prompt "${SERVICE_NAME}")" >&2 + if ! read -r reply; then + reply="n" + fi + + if [[ -z "${reply}" ]] || is_affirmative_reply "${reply}"; then + restart_service_and_verify "${tag}" "${backup_dir}" + return 0 + fi + + warn "$(trf action_upgrade_restart_skipped "${SERVICE_NAME}" "${SERVICE_NAME}")" + ;; + skip) + warn "$(trf action_upgrade_restart_skipped "${SERVICE_NAME}" "${SERVICE_NAME}")" + ;; + *) + die "$(trf upgrade_restart_flag_conflict)" + ;; + esac +} + command_download() { local tag="" local effective_tag archive_path @@ -736,7 +916,8 @@ command_install() { local app_uri="${DEFAULT_APP_URI}" local listen="${DEFAULT_LISTEN}" local original_args=("$@") - local effective_tag archive_path extract_dir + local effective_tag + local extract_dir while [[ $# -gt 0 ]]; do case "$1" in @@ -784,6 +965,7 @@ command_install() { require_command tar require_command install require_command systemctl + require_command cp require_command grep require_command sed require_command getent @@ -792,18 +974,11 @@ command_install() { require_command chown effective_tag="$(resolve_effective_tag "${tag}")" - archive_path="$(download_release "${effective_tag}")" - extract_dir="$(extract_release_archive "${effective_tag}" "${archive_path}")" - - ensure_system_user - install -d -m 0755 "${INSTALL_DIR}" - install -m 0755 "${extract_dir}/epusdt" "${BIN_PATH}" - install -m 0644 "${extract_dir}/.env.example" "${CONFIG_EXAMPLE_PATH}" + extract_dir="$(prepare_release_extract "${effective_tag}")" + deploy_release_payload "${extract_dir}" create_default_config_if_missing "${app_uri}" "${listen}" - chown -R "${SYSTEM_USER}:${SYSTEM_USER}" "${INSTALL_DIR}" - - write_systemd_unit - systemctl daemon-reload + finalize_install_dir_ownership + refresh_service_unit systemctl enable --now "${SERVICE_NAME}" if ! systemctl is-active --quiet "${SERVICE_NAME}"; then @@ -845,15 +1020,31 @@ command_self_install() { } command_upgrade() { + local tag="" + local restart_mode="restart" local original_args=("$@") + local effective_tag + local extract_dir backup_dir while [[ $# -gt 0 ]]; do case "$1" in - --tag|--app-uri|--listen) - [[ $# -ge 2 ]] || die "$(trf arg_requires_value "$1")" + --tag) + [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--tag")" + tag="$2" shift 2 ;; - --tag=*|--app-uri=*|--listen=*) + --tag=*) + tag="${1#*=}" + shift + ;; + --prompt-restart) + [[ "${restart_mode}" == "restart" ]] || die "$(trf upgrade_restart_flag_conflict)" + restart_mode="prompt" + shift + ;; + --no-restart) + [[ "${restart_mode}" == "restart" ]] || die "$(trf upgrade_restart_flag_conflict)" + restart_mode="skip" shift ;; -h|--help) @@ -867,7 +1058,33 @@ command_upgrade() { done ensure_root_via_sudo upgrade "${original_args[@]}" - command_install "${original_args[@]}" + + require_root + require_linux + require_command tar + require_command install + require_command systemctl + require_command cp + require_command grep + require_command sed + require_command getent + require_command groupadd + require_command useradd + require_command chown + + effective_tag="$(resolve_effective_tag "${tag}")" + ensure_existing_upgrade_config + extract_dir="$(prepare_release_extract "${effective_tag}")" + backup_dir="$(create_upgrade_backup_dir "${effective_tag}")" + backup_release_files "${backup_dir}" + if ! apply_upgrade_release "${extract_dir}"; then + warn "$(trf upgrade_deploy_failed_rollback_start "${SERVICE_NAME}")" + if restore_release_files "${backup_dir}"; then + die "$(trf upgrade_deploy_failed_rolled_back "${SERVICE_NAME}" "${effective_tag}")" + fi + die "$(trf upgrade_deploy_failed_rollback_failed "${SERVICE_NAME}" "${effective_tag}")" + fi + handle_upgrade_restart "${effective_tag}" "${restart_mode}" "${backup_dir}" } command_show_config() { @@ -1280,9 +1497,9 @@ interactive_upgrade() { tag="$(prompt_input "$(trf input_upgrade_tag)")" || return 0 if [[ -n "${tag}" ]]; then - run_menu_command upgrade --tag "${tag}" + run_menu_command upgrade --tag "${tag}" --prompt-restart else - run_menu_command upgrade + run_menu_command upgrade --prompt-restart fi } diff --git a/epctl-docker-test.sh b/epctl-docker-test.sh index 2a663fb..128ac7d 100755 --- a/epctl-docker-test.sh +++ b/epctl-docker-test.sh @@ -2,9 +2,8 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly IMAGE_TAG="epctl-ubuntu-systemd-test:$(date +%s)-$$" readonly CONTAINER_NAME="epctl-systemd-test-$$" -readonly BASE_IMAGE="ubuntu:24.04" +readonly BASE_IMAGE="${EPCTL_DOCKER_IMAGE:-ubuntu:24.04}" ACTIVE_LANG="" POSITIONAL_ARGS=() @@ -80,10 +79,8 @@ trf() { zh:upgrade_tag_invalid) template='升级 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;; en:base_image) template='base image: %s' ;; zh:base_image) template='基础镜像:%s' ;; - en:building_image) template='building the Ubuntu systemd test image' ;; - zh:building_image) template='正在构建 Ubuntu systemd 测试镜像' ;; - en:starting_container) template='starting the test container' ;; - zh:starting_container) template='正在启动测试容器' ;; + en:starting_container) template='starting the Ubuntu systemd test container directly from %s' ;; + zh:starting_container) template='正在直接从 %s 启动 Ubuntu systemd 测试容器' ;; en:systemd_not_ready) template='systemd did not become ready inside the container' ;; zh:systemd_not_ready) template='容器内的 systemd 未能及时就绪' ;; en:self_installing) template='installing epctl into PATH' ;; @@ -96,6 +93,14 @@ trf() { zh:waiting_assets) template='正在等待 epusdt 启动并释放 www 静态文件' ;; en:upgrading_release) template='upgrading epusdt from %s to %s' ;; zh:upgrading_release) template='正在将 epusdt 从 %s 升级到 %s' ;; + en:verifying_upgrade_skip_restart) template='verifying that `upgrade --no-restart` skips the restart and keeps the current process running' ;; + zh:verifying_upgrade_skip_restart) template='正在验证 `upgrade --no-restart` 会跳过重启,并保持当前进程继续运行' ;; + en:verifying_upgrade_default_restart) template='verifying that a non-interactive upgrade restarts the service by default' ;; + zh:verifying_upgrade_default_restart) template='正在验证非交互升级默认会重启服务' ;; + en:verifying_upgrade_prompt_skip) template='verifying that `upgrade --prompt-restart` can skip the restart when `n` is entered' ;; + zh:verifying_upgrade_prompt_skip) template='正在验证 `upgrade --prompt-restart` 在输入 `n` 时会跳过重启' ;; + en:verifying_upgrade_prompt_restart) template='verifying that `upgrade --prompt-restart` restarts the service by default when Enter is pressed' ;; + zh:verifying_upgrade_prompt_restart) template='正在验证 `upgrade --prompt-restart` 在直接回车时默认会重启服务' ;; en:checking_init_password) template='checking init-password behavior' ;; zh:checking_init_password) template='正在检查 init-password 行为' ;; en:expected_second_failure) template='expected init-password to fail after the password was changed' ;; @@ -181,25 +186,24 @@ map_arch() { } docker_bash() { - docker exec "${CONTAINER_NAME}" bash -lc "$1" + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "$1" } cleanup() { local exit_code="$?" if [[ "${exit_code}" -ne 0 ]]; then log "$(trf test_failed_collecting)" - docker exec "${CONTAINER_NAME}" bash -lc 'systemctl status epusdt --no-pager || true' || true - docker exec "${CONTAINER_NAME}" bash -lc 'journalctl -u epusdt --no-pager -n 100 || true' || true + docker logs "${CONTAINER_NAME}" >/dev/null 2>&1 && docker logs "${CONTAINER_NAME}" >&2 || true + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl status epusdt --no-pager || true' || true + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'journalctl -u epusdt --no-pager -n 100 || true' || true fi docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true - docker rmi -f "${IMAGE_TAG}" >/dev/null 2>&1 || true - rm -rf "${TMPDIR_PATH:-}" } wait_for_systemd() { local attempt - for attempt in $(seq 1 30); do - if docker exec "${CONTAINER_NAME}" bash -lc 'systemctl show --property=Version --value >/dev/null 2>&1'; then + for attempt in $(seq 1 120); do + if docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=Version --value >/dev/null 2>&1'; then return 0 fi sleep 1 @@ -207,6 +211,24 @@ wait_for_systemd() { return 1 } +wait_for_service_assets() { + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c ' +for _ in $(seq 1 30); do + if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then + exit 0 + fi + sleep 1 +done +systemctl status epusdt --no-pager || true +journalctl -u epusdt --no-pager -n 100 || true +exit 1 +' +} + +service_main_pid() { + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'systemctl show --property=MainPID --value epusdt' | tr -d '\r\n' +} + set_language "" parse_global_options "$@" @@ -235,39 +257,10 @@ if [[ -n "${UPGRADE_TAG}" ]]; then curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/SHA256SUMS" >/dev/null fi -TMPDIR_PATH="$(mktemp -d)" trap cleanup EXIT -cat > "${TMPDIR_PATH}/Dockerfile" < /etc/sudoers.d/tester \ - && chmod 0440 /etc/sudoers.d/tester - -STOPSIGNAL SIGRTMIN+3 - -CMD ["/sbin/init"] -EOF - log "$(trf base_image "${BASE_IMAGE}")" -log "$(trf building_image)" -docker build -t "${IMAGE_TAG}" "${TMPDIR_PATH}" >/dev/null - -log "$(trf starting_container)" +log "$(trf starting_container "${BASE_IMAGE}")" docker run -d \ --name "${CONTAINER_NAME}" \ --privileged \ @@ -278,75 +271,152 @@ docker run -d \ -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ -v "${SCRIPT_DIR}:/work" \ -w /work \ - "${IMAGE_TAG}" \ - /sbin/init >/dev/null + "${BASE_IMAGE}" \ + bash -euo pipefail -c ' + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y \ + ca-certificates \ + curl \ + expect \ + jq \ + sudo \ + systemd \ + systemd-sysv \ + tar + apt-get clean + rm -rf /var/lib/apt/lists/* + if ! id -u tester >/dev/null 2>&1; then + useradd -m -s /bin/bash tester + fi + mkdir -p /etc/sudoers.d + echo "tester ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/tester + chmod 0440 /etc/sudoers.d/tester + exec /sbin/init + ' >/dev/null wait_for_systemd || die "$(trf systemd_not_ready)" log "$(trf self_installing)" -docker exec "${CONTAINER_NAME}" bash -lc "su - tester -c 'cd /work && ./epctl self-install'" +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c "su - tester -c 'cd /work && ./epctl self-install'" docker_bash '[[ -x /usr/local/bin/epctl ]]' -docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""' -docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""' -docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""' log "$(trf downloading_release "${INSTALL_TAG}")" -docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl download --tag ${TAG}"' +docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl download --tag ${TAG}"' log "$(trf installing_release "${INSTALL_TAG}")" -docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"' +docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -euo pipefail -c 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"' log "$(trf waiting_assets)" -docker exec "${CONTAINER_NAME}" bash -lc ' -for _ in $(seq 1 30); do - if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then - exit 0 - fi - sleep 1 -done -systemctl status epusdt --no-pager || true -journalctl -u epusdt --no-pager -n 100 || true -exit 1 -' +wait_for_service_assets docker_bash '[[ -x /opt/epusdt/epusdt ]]' docker_bash '[[ -f /opt/epusdt/.env ]]' docker_bash 'systemctl is-active --quiet epusdt' docker_bash '[[ -f /opt/epusdt/www/index.html ]]' -docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' -docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""' -docker exec "${CONTAINER_NAME}" bash -lc ' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c ' su - tester -c "epctl status >/tmp/epctl-status.out 2>/tmp/epctl-status.err" ! grep -q "systemd-journal" /tmp/epctl-status.err ' -docker exec "${CONTAINER_NAME}" bash -lc ' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c ' su - tester -c "epctl logs --lines 50 >/tmp/epctl-logs.out 2>/tmp/epctl-logs.err" ! grep -q "systemd-journal" /tmp/epctl-logs.err ' if [[ -n "${UPGRADE_TAG}" ]]; then log "$(trf upgrading_release "${INSTALL_TAG}" "${UPGRADE_TAG}")" - docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -lc 'su - tester -c "epctl upgrade --tag ${TAG}"' - docker exec "${CONTAINER_NAME}" bash -lc ' -for _ in $(seq 1 30); do - if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then - exit 0 - fi - sleep 1 -done -systemctl status epusdt --no-pager || true -journalctl -u epusdt --no-pager -n 100 || true -exit 1 + env_checksum_before="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')" + pid_before_skip_restart="$(service_main_pid)" + + log "$(trf verifying_upgrade_skip_restart)" + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c ' +output_file="$(mktemp)" +su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --no-restart" >"${output_file}" 2>&1 +grep -q "systemctl restart epusdt" "${output_file}" +! grep -q "Restart .*\[Y/n\]" "${output_file}" +rm -f "${output_file}" ' - docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' - docker exec "${CONTAINER_NAME}" bash -lc ' + pid_after_skip_restart="$(service_main_pid)" + [[ "${pid_before_skip_restart}" == "${pid_after_skip_restart}" ]] + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c '/opt/epusdt/epusdt version | grep -q "^version: ${TAG}$"' + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c 'cmp -s /opt/epusdt/.env.example "/tmp/epusdt/${TAG}/extract/.env.example"' + env_checksum_after_skip="$(docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'sha256sum /opt/epusdt/.env | cut -d" " -f1' | tr -d '\r\n')" + [[ "${env_checksum_before}" == "${env_checksum_after_skip}" ]] + + log "$(trf verifying_upgrade_default_restart)" + pid_before_default_restart="$(service_main_pid)" + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c ' +output_file="$(mktemp)" +su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG}" >"${output_file}" 2>&1 +! grep -q "Restart .*\[Y/n\]" "${output_file}" +rm -f "${output_file}" +' + wait_for_service_assets + pid_after_default_restart="$(service_main_pid)" + [[ "${pid_before_default_restart}" != "${pid_after_default_restart}" ]] + + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c ' +output_file="$(mktemp)" +if su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag ${TAG} --prompt-restart" "${output_file}" 2>&1; then + cat "${output_file}" >&2 + rm -f "${output_file}" + exit 1 +fi +grep -q "interactive terminal" "${output_file}" +rm -f "${output_file}" +' + + log "$(trf verifying_upgrade_prompt_skip)" + pid_before_prompt_skip="$(service_main_pid)" + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c ' +output_file="$(mktemp)" +OUTPUT_FILE="${output_file}" expect <<'"'"'EOF'"'"' +set timeout 120 +log_file -noappend $env(OUTPUT_FILE) +spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart" +expect { + -re {Restart .*\[Y/n\]} { send "n\r" } + timeout { exit 1 } +} +expect eof +EOF +grep -q "systemctl restart epusdt" "${output_file}" +rm -f "${output_file}" +' + pid_after_prompt_skip="$(service_main_pid)" + [[ "${pid_before_prompt_skip}" == "${pid_after_prompt_skip}" ]] + + log "$(trf verifying_upgrade_prompt_restart)" + pid_before_prompt_restart="$(service_main_pid)" + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -euo pipefail -c ' +expect <<'"'"'EOF'"'"' +set timeout 120 +spawn su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en upgrade --tag $env(TAG) --prompt-restart" +expect { + -re {Restart .*\[Y/n\]} { send "\r" } + timeout { exit 1 } +} +expect eof +EOF +' + wait_for_service_assets + pid_after_prompt_restart="$(service_main_pid)" + [[ "${pid_before_prompt_restart}" != "${pid_after_prompt_restart}" ]] + + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' + docker exec "${CONTAINER_NAME}" bash -euo pipefail -c ' su - tester -c "epctl status >/tmp/epctl-status-upgrade.out 2>/tmp/epctl-status-upgrade.err" ! grep -q "systemd-journal" /tmp/epctl-status-upgrade.err ' fi log "$(trf checking_init_password)" -docker exec "${CONTAINER_NAME}" bash -lc ' +docker exec "${CONTAINER_NAME}" bash -euo pipefail -c ' response="$(su - tester -c "epctl init-password")" password="$(printf "%s\n" "${response}" | jq -r ".data.password")" printf "%s\n" "${response}" | jq -e ".status_code == 200 and (.data.password | type == \"string\" and length > 0)" >/dev/null diff --git a/wiki/EPCTL.en.md b/wiki/EPCTL.en.md index 3e1ab9b..34c1c39 100644 --- a/wiki/EPCTL.en.md +++ b/wiki/EPCTL.en.md @@ -86,6 +86,10 @@ Upgrade to a newer release: ./epctl upgrade --tag v1.0.9 ``` +Running `./epctl upgrade --tag ...` directly restarts `epusdt` immediately after the files are replaced. +If you want to replace files only, pass `--no-restart`. +If you want an explicit confirmation step, pass `--prompt-restart`; in an interactive terminal the prompt is `[Y/n]`, so pressing Enter restarts by default. + Inspect config, status, and logs: ```bash @@ -136,6 +140,27 @@ When `.env` is auto-created, the script applies only the minimum bootstrap chang - `http_listen=<--listen, default 127.0.0.1:8000>` If `/opt/epusdt/.env` already exists, install and upgrade keep it unchanged. +`/opt/epusdt/.env.example` is refreshed from the current release on every install / upgrade. + +## What Happens During Upgrade + +`upgrade` performs these steps: + +1. Detect the current CPU architecture and download the target GitHub Release archive +2. Extract into `/tmp/epusdt//extract/` +3. Require the existing `/opt/epusdt/.env`; if it is missing, the command fails and tells you to run `install` first +4. Replace `/opt/epusdt/epusdt` +5. Replace `/opt/epusdt/.env.example` +6. Keep the existing `/opt/epusdt/.env` +7. Refresh `epusdt.service` and run `systemctl daemon-reload` +8. Restart `epusdt` immediately by default + +Additional behavior: + +- `upgrade` no longer creates `.env` and no longer runs `systemctl enable` +- `upgrade --no-restart` replaces files only and prints a manual restart warning +- `upgrade --prompt-restart` asks whether to restart when an interactive terminal is available +- if the restart fails after an upgrade, the script attempts to roll back the previous binary, `.env.example`, and unit file ## systemd Service Details @@ -191,11 +216,12 @@ Examples: It performs these checks on the local machine: -- builds an `ubuntu:24.04` + systemd test image +- starts directly from `ubuntu:24.04` and installs systemd plus the test dependencies during container bootstrap - starts a privileged container - runs `epctl self-install` inside the container - downloads real GitHub Release artifacts - installs `epusdt` +- when `upgrade-tag` is provided, verifies `upgrade --no-restart`, the default non-interactive `upgrade`, and both the `n` / Enter branches of `upgrade --prompt-restart` - verifies the systemd service, `www/index.html`, config output, logs, and status - verifies that `init-password` succeeds once and later returns `10040` after the admin password is changed diff --git a/wiki/EPCTL.md b/wiki/EPCTL.md index e58dcb8..d650c26 100644 --- a/wiki/EPCTL.md +++ b/wiki/EPCTL.md @@ -86,6 +86,10 @@ epctl ./epctl upgrade --tag v1.0.9 ``` +直接执行 `./epctl upgrade --tag ...` 时,脚本会在文件替换完成后默认立即执行 `systemctl restart epusdt`。 +如果你只想替换文件而不重启,请显式传入 `--no-restart`。 +如果你希望人工确认,再传 `--prompt-restart`;交互终端下提示为 `[Y/n]`,直接回车默认重启。 + 查看配置、状态、日志: ```bash @@ -136,6 +140,27 @@ EPCTL_ASSUME_YES=1 ./epctl download - `http_listen=<--listen,默认 127.0.0.1:8000>` 如果 `/opt/epusdt/.env` 已存在,则安装和升级都会保留它,不会覆盖。 +`/opt/epusdt/.env.example` 则会在每次 install / upgrade 时按当前 release 重新刷新。 + +## 升级时会发生什么 + +执行 `upgrade` 时,脚本会: + +1. 按当前机器架构下载目标 GitHub Release 压缩包 +2. 解压到 `/tmp/epusdt//extract/` +3. 要求现有 `/opt/epusdt/.env` 已存在;若不存在会直接失败,并提示先执行 `install` +4. 覆盖 `/opt/epusdt/epusdt` +5. 覆盖 `/opt/epusdt/.env.example` +6. 保留现有 `/opt/epusdt/.env` +7. 刷新 `epusdt.service` 并执行 `systemctl daemon-reload` +8. 默认立即执行 `systemctl restart epusdt` + +补充行为: + +- `upgrade` 不会再补写 `.env`,也不会再执行 `systemctl enable` +- `upgrade --no-restart` 只替换文件,不重启服务,并输出手动 restart 提示 +- `upgrade --prompt-restart` 会在交互终端下询问是否重启 +- 如果升级后的重启失败,脚本会尝试回滚旧的二进制、`.env.example` 和 unit 文件 ## systemd 服务说明 @@ -191,11 +216,12 @@ GET /admin/api/v1/auth/init-password 它会在本机: -- 构建 `ubuntu:24.04` + systemd 测试镜像 +- 直接从 `ubuntu:24.04` 启动容器,并在容器启动阶段安装 systemd 与测试依赖 - 启动一个特权容器 - 在容器内执行 `epctl self-install` - 下载真实 GitHub Release - 安装 `epusdt` +- 若传入 `upgrade-tag`,验证 `upgrade --no-restart`、默认 `upgrade`、以及 `upgrade --prompt-restart` 的 `n/回车` 分支 - 检查 `systemd` 服务、`www/index.html`、配置文件、日志、状态输出 - 验证 `init-password` 首次成功、修改管理员密码后再次请求返回 `10040`