diff --git a/backend/src/admin/system/updater/mod.rs b/backend/src/admin/system/updater/mod.rs index e69ef66db..ad448e217 100644 --- a/backend/src/admin/system/updater/mod.rs +++ b/backend/src/admin/system/updater/mod.rs @@ -10,7 +10,7 @@ use std::{ }; use axum::{ - extract::Query, + extract::{Json, Query}, http::StatusCode, response::{ sse::{Event, KeepAlive, Sse}, @@ -25,7 +25,7 @@ use tokio::sync::{broadcast, Mutex}; use crate::{ admin::auth::session::AdminAuth, admin::response::{AdminEnvelope, AdminError, AdminResponse}, - runtime::shutdown::request_shutdown, + runtime::shutdown::{request_process_restart, request_shutdown}, }; use super::state::{ @@ -42,8 +42,8 @@ mod archive; use archive::{extract_release_archive, replace_release_files, rollback_release_update}; mod release; use release::{ - check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release, - GitHubRelease, UpdateInfoData, + check_latest_release, fetch_latest_release, normalize_version_tag, select_release_archive, + update_info_from_release, GitHubRelease, UpdateInfoData, }; const APP_BINARY_NAME: &str = "codex-proxy-rs"; @@ -62,6 +62,12 @@ pub(crate) struct UpdateDetailQuery { refresh: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateRequest { + target_version: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct VersionData { @@ -168,7 +174,10 @@ pub(crate) async fn update_event_stream( } /// `POST /api/admin/system/update` -pub(crate) async fn perform_update(_auth: AdminAuth) -> Result { +pub(crate) async fn perform_update( + _auth: AdminAuth, + payload: Option>, +) -> Result { let _guard = SYSTEM_OPERATION_LOCK .try_lock() .map_err(|_| AdminError::conflict("System update already running"))?; @@ -182,6 +191,7 @@ pub(crate) async fn perform_update(_auth: AdminAuth) -> Result Result Result>) -> Result { + let Some(Json(payload)) = payload else { + return Err(AdminError::conflict("更新前需要确认目标版本")); + }; + let target_version = normalize_version_tag(&payload.target_version); + if target_version.is_empty() { + return Err(AdminError::bad_request("目标版本不能为空")); + } + Ok(target_version) +} + /// `GET /api/admin/system/update-status` pub(crate) async fn update_status(_auth: AdminAuth) -> Result { let config = SystemUpdateConfig::from_env(); @@ -323,11 +357,15 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result request_process_restart(executable_path), + RestartAction::Shutdown => request_shutdown(), + } }); Ok(AdminResponse::new( @@ -339,13 +377,29 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result Result<&'static str, AdminError> { +struct RestartPlan { + message: &'static str, + action: RestartAction, +} + +enum RestartAction { + Exec(PathBuf), + Shutdown, +} + +fn schedule_restart(config: &SystemUpdateConfig) -> Result { if config.deployment_mode == "docker" { - return Ok("已安排重启"); + return Ok(RestartPlan { + message: "已安排进程内重启", + action: RestartAction::Exec(config.executable_path()?), + }); } spawn_replacement_process(config)?; - Ok("已安排自重启") + Ok(RestartPlan { + message: "已安排自重启", + action: RestartAction::Shutdown, + }) } fn spawn_replacement_process(config: &SystemUpdateConfig) -> Result<(), AdminError> { @@ -362,6 +416,14 @@ fn spawn_replacement_process(config: &SystemUpdateConfig) -> Result<(), AdminErr impl SystemUpdateConfig { fn from_env() -> Self { let update_repository = env_string("CPR_UPDATE_REPOSITORY"); + let deployment_mode = + env_string("CPR_DEPLOYMENT_MODE").unwrap_or_else(|| "source".to_string()); + let executable_path = env_string("CPR_UPDATE_EXE_PATH") + .map(PathBuf::from) + .or_else(|| { + (deployment_mode == "docker") + .then(|| PathBuf::from("/app/bin").join(APP_BINARY_NAME)) + }); let state_file = env_string("CPR_UPDATE_STATE_FILE") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/app/data/update-state.json")); @@ -375,15 +437,14 @@ impl SystemUpdateConfig { version: build_version(), git_sha: build_git_sha(), build_time: build_time(), - deployment_mode: env_string("CPR_DEPLOYMENT_MODE") - .unwrap_or_else(|| "source".to_string()), + deployment_mode, build_type: build_type(), update_channel: env_string("CPR_UPDATE_CHANNEL") .unwrap_or_else(|| "stable".to_string()), update_repository, github_api_base: env_string("CPR_GITHUB_API_BASE") .unwrap_or_else(|| GITHUB_API_BASE.to_string()), - executable_path: env_string("CPR_UPDATE_EXE_PATH").map(PathBuf::from), + executable_path, web_dist_dir: env_string("CPR_WEB_DIST_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)), diff --git a/backend/src/admin/system/updater/release.rs b/backend/src/admin/system/updater/release.rs index 32f711c09..c0235c77c 100644 --- a/backend/src/admin/system/updater/release.rs +++ b/backend/src/admin/system/updater/release.rs @@ -276,6 +276,6 @@ fn asset_matches_current_platform(name: &str, os_aliases: &[&str], arch_aliases: && arch_aliases.iter().any(|arch| name.contains(arch)) } -fn normalize_version_tag(version: &str) -> String { +pub(super) fn normalize_version_tag(version: &str) -> String { version.trim().trim_start_matches('v').to_string() } diff --git a/backend/src/runtime/bootstrap.rs b/backend/src/runtime/bootstrap.rs index eda54c8ab..992b0e81f 100644 --- a/backend/src/runtime/bootstrap.rs +++ b/backend/src/runtime/bootstrap.rs @@ -1,4 +1,4 @@ -use std::{env, error::Error, time::Duration}; +use std::{env, error::Error, process::Command, time::Duration}; use axum::Router; use chrono::Utc; @@ -10,7 +10,7 @@ use crate::infra::database::connect_sqlite; use crate::infra::logging::{init_tracing, LogGuard, RotationConfig}; use crate::infra::paths::{ensure_data_dir, load_or_create_installation_id}; use crate::runtime::services::{BackgroundTaskStores, Services}; -use crate::runtime::shutdown::shutdown_signal; +use crate::runtime::shutdown::{restart_executable_path, shutdown_signal}; use crate::runtime::state::{AppState, RuntimeConfig}; use crate::runtime::tasks::coordinator::TaskCoordinator; use crate::upstream::fingerprint::{Fingerprint, FingerprintRepository}; @@ -23,7 +23,7 @@ pub async fn run() -> Result<(), Box> { let config = AppConfig::load()?; let host = config.server.host.clone(); let port = config.server.port; - let _log_guard = init_logging(&config)?; + let log_guard = init_logging(&config)?; let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?; let (app, task_coordinator) = build_application(config).await?; @@ -37,6 +37,15 @@ pub async fn run() -> Result<(), Box> { task_coordinator.shutdown().await; serve_result?; + if let Some(executable_path) = restart_executable_path() { + tracing::info!( + executable = %executable_path.display(), + "正在以更新后的二进制替换当前进程" + ); + drop(log_guard); + exec_replacement_process(executable_path)?; + } + Ok(()) } @@ -132,3 +141,25 @@ async fn wait_for_scheduled_restart() { tokio::time::sleep(Duration::from_millis(delay_ms)).await; } + +#[cfg(unix)] +fn exec_replacement_process( + executable_path: std::path::PathBuf, +) -> Result<(), Box> { + use std::os::unix::process::CommandExt; + + let error = Command::new(executable_path) + .args(env::args_os().skip(1)) + .exec(); + Err(Box::new(error)) +} + +#[cfg(not(unix))] +fn exec_replacement_process( + _executable_path: std::path::PathBuf, +) -> Result<(), Box> { + Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "process exec restart is only supported on Unix", + ))) +} diff --git a/backend/src/runtime/shutdown.rs b/backend/src/runtime/shutdown.rs index 5aa99e8bd..91f5ada1d 100644 --- a/backend/src/runtime/shutdown.rs +++ b/backend/src/runtime/shutdown.rs @@ -1,11 +1,19 @@ //! 优雅关闭信号处理。 -use std::sync::OnceLock; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, + }, +}; use tokio::signal; use tokio::sync::broadcast; static SHUTDOWN_REQUESTS: OnceLock> = OnceLock::new(); +static RESTART_REQUESTED: AtomicBool = AtomicBool::new(false); +static RESTART_EXECUTABLE_PATH: OnceLock = OnceLock::new(); /// 等待进程关闭信号(Ctrl+C 或 SIGTERM)。 pub async fn shutdown_signal() { @@ -39,6 +47,21 @@ pub fn request_shutdown() { let _ = shutdown_sender().send(()); } +/// 请求进程完成优雅关闭后以新二进制替换当前进程。 +pub fn request_process_restart(executable_path: PathBuf) { + let _ = RESTART_EXECUTABLE_PATH.set(executable_path); + RESTART_REQUESTED.store(true, Ordering::SeqCst); + request_shutdown(); +} + +/// 获取待 exec 的重启目标。 +pub fn restart_executable_path() -> Option { + RESTART_REQUESTED + .load(Ordering::SeqCst) + .then(|| RESTART_EXECUTABLE_PATH.get().cloned()) + .flatten() +} + fn shutdown_sender() -> &'static broadcast::Sender<()> { SHUTDOWN_REQUESTS.get_or_init(|| { let (sender, _receiver) = broadcast::channel(16); diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs index 5feb0eb80..dd77353cc 100644 --- a/backend/tests/admin/system/mod.rs +++ b/backend/tests/admin/system/mod.rs @@ -172,7 +172,7 @@ async fn update_detail_should_reject_untrusted_github_api_base() { } #[tokio::test] -async fn restart_should_request_graceful_shutdown() { +async fn restart_should_request_process_restart_inside_docker() { let _guard = SYSTEM_ENV_LOCK.lock().await; set_system_update_env("zyycn/codex-proxy-rs-restart-route", "http://127.0.0.1:9"); std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); @@ -201,6 +201,7 @@ async fn restart_should_request_graceful_shutdown() { body["data"]["operationId"] .as_str() .is_some_and(|id| id.starts_with("sysop-restart-")) + && body["data"]["message"] == "已安排进程内重启" && shutdown.is_ok() ); } @@ -310,7 +311,8 @@ async fn update_should_replace_local_release_files_with_latest_asset() { .uri("/api/admin/system/update") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_system_update_latest") - .body(Body::empty()) + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) .unwrap(), ) .await @@ -335,6 +337,52 @@ async fn update_should_replace_local_release_files_with_latest_asset() { ); } +#[tokio::test] +async fn update_should_reject_when_confirmed_target_differs_from_remote_latest() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-update-target-changed-route"; + let initial_github = MockServer::start().await; + let latest_github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release(&initial_github, repository, "0.4.0").await; + mount_latest_release(&latest_github, repository, "0.5.0").await; + set_system_update_env(repository, &initial_github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-target-changed.sqlite").await; + let initial = get_update_detail(&app, true, "req_system_update_target_initial").await; + set_system_update_env(repository, &latest_github.uri()); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/update") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_update_target_changed") + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert!( + initial["latestVersion"] == "0.4.0" + && status == StatusCode::CONFLICT + && body["message"] == "远端最新版本已变更为 v0.5.0,请重新检查并确认" + && std::fs::read_to_string(&exe_path).unwrap() == "old-binary" + && std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "old-web" + ); +} + #[tokio::test] async fn update_should_reject_untrusted_github_api_base() { let _guard = SYSTEM_ENV_LOCK.lock().await; @@ -564,7 +612,8 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() { .uri("/api/admin/system/update") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_system_update_binary_backup_failure") - .body(Body::empty()) + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) .unwrap(), ) .await @@ -622,7 +671,8 @@ async fn update_should_remove_stale_file_lock_and_continue() { .uri("/api/admin/system/update") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_system_update_stale_lock") - .body(Body::empty()) + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) .unwrap(), ) .await @@ -666,7 +716,8 @@ async fn update_should_replace_web_assets_across_filesystems() { .uri("/api/admin/system/update") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", "req_system_cross_device_update") - .body(Body::empty()) + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) .unwrap(), ) .await @@ -788,6 +839,10 @@ async fn get_update_detail(app: &axum::Router, refresh: bool, request_id: &str) response_json(response).await["data"].clone() } +fn confirmed_update_body(version: &str) -> Body { + Body::from(json!({ "targetVersion": version }).to_string()) +} + async fn assert_update_failure_preserves_files( app: &axum::Router, request_id: &str, @@ -804,7 +859,8 @@ async fn assert_update_failure_preserves_files( .uri("/api/admin/system/update") .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", request_id) - .body(Body::empty()) + .header(CONTENT_TYPE, "application/json") + .body(confirmed_update_body("0.4.0")) .unwrap(), ) .await diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5bef2219c..ee19fde8e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,3 +1,5 @@ +name: codex-proxy-rs + services: codex-proxy-rs: image: ${CPR_IMAGE:-ghcr.io/zyycn/codex-proxy-rs:latest} diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 999664c8c..3b8e6d849 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -2,6 +2,7 @@ import request from '../request' import { API_BASE_URL } from '../constants' export const SYSTEM_UPDATE_EVENTS_URL = `${API_BASE_URL}/api/admin/system/update-events` +const SYSTEM_HEALTH_URL = `${API_BASE_URL}/healthz` export interface SystemVersion { version: string @@ -53,6 +54,13 @@ export function getSystemVersion() { }) } +export async function checkSystemHealth() { + const response = await fetch(SYSTEM_HEALTH_URL) + if (!response.ok) { + throw new Error('service is not ready') + } +} + export function getSystemUpdateDetail(refresh = false) { return request({ url: '/api/admin/system/update-detail', @@ -61,10 +69,11 @@ export function getSystemUpdateDetail(refresh = false) { }) } -export function performSystemUpdate() { +export function performSystemUpdate(targetVersion: string) { return request({ url: '/api/admin/system/update', method: 'POST', + data: { targetVersion }, }) } diff --git a/frontend/src/composables/useSystemUpdate.ts b/frontend/src/composables/useSystemUpdate.ts index 8737ebfc2..5e42b3715 100644 --- a/frontend/src/composables/useSystemUpdate.ts +++ b/frontend/src/composables/useSystemUpdate.ts @@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue' import { SYSTEM_UPDATE_EVENTS_URL, + checkSystemHealth, getSystemVersion, getSystemUpdateDetail, performSystemUpdate, @@ -41,6 +42,9 @@ let restartTimer: number | undefined let loadVersionPromise: Promise | undefined let loadSystemPromise: Promise | undefined const maxUpdateLogs = 200 +const restartCountdownSeconds = 8 +const restartReadyPollAttempts = 60 +const restartReadyPollIntervalMs = 1000 const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate)) const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release') @@ -174,9 +178,10 @@ async function checkUpdates(refresh = true) { } } -async function updateNow() { +async function updateNow(targetVersion: string) { const currentInfo = updateInfo.value - if (!canUpdate.value || !currentInfo || updating.value) return null + const confirmedTargetVersion = targetVersion.trim() + if (!canUpdate.value || !currentInfo || updating.value || !confirmedTargetVersion) return null clearUpdateLogs() connectUpdateEvents() @@ -184,7 +189,7 @@ async function updateNow() { updateError.value = '' updateSuccess.value = false try { - const result = await performSystemUpdate() + const result = await performSystemUpdate(confirmedTargetVersion) updateSuccess.value = true needRestart.value = result.needRestart updateInfo.value = { @@ -214,18 +219,28 @@ function clearRestartTimer() { } } +function delay(ms: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) +} + async function waitForServiceAndReload() { - for (let attempt = 0; attempt < 5; attempt += 1) { + restartCountdown.value = 0 + + for (let attempt = 0; attempt < restartReadyPollAttempts; attempt += 1) { try { - await getSystemVersion() + await checkSystemHealth() window.location.reload() return } catch { - await new Promise((resolve) => window.setTimeout(resolve, 1000)) + if (attempt < restartReadyPollAttempts - 1) { + await delay(restartReadyPollIntervalMs) + } } } - updateError.value = '服务重启后暂不可用,请检查进程是否已被自重启或外部守护进程拉起' + updateError.value = '服务重启恢复超时,请检查进程是否已被自重启或外部守护进程拉起' restarting.value = false restartCountdown.value = 0 } @@ -243,7 +258,7 @@ async function restartNow() { if (restarting.value) return restarting.value = true - restartCountdown.value = 8 + restartCountdown.value = restartCountdownSeconds updateError.value = '' clearRestartTimer() diff --git a/frontend/src/layout/components/SystemUpdateModal.vue b/frontend/src/layout/components/SystemUpdateModal.vue index defce2229..9d1d95a94 100644 --- a/frontend/src/layout/components/SystemUpdateModal.vue +++ b/frontend/src/layout/components/SystemUpdateModal.vue @@ -1,5 +1,5 @@