mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 19:59:50 +08:00
feat: 添加在线更新功能
This commit is contained in:
@@ -57,6 +57,11 @@ ADMIN_USERNAME=admin123456
|
||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
|
||||
# 管理后台一键更新(默认开启)
|
||||
# install.sh 会自动安装 docker-compose.update.yml,并写入:
|
||||
AETHER_SYSTEM_UPDATE_COMMAND=/opt/aether/compose/update.sh
|
||||
AETHER_SYSTEM_UPDATE_WORKDIR=/opt/aether/compose
|
||||
|
||||
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
||||
# 推荐计算方式(单实例):
|
||||
# MAX = CPU 核数 × 10(AI 网关偏 IO 等待,可激进些;纯 OLTP 用 × 4)
|
||||
|
||||
@@ -270,7 +270,9 @@ jobs:
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.update.yml "${root}/docker-compose.update.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0755 scripts/migrate-pg-compose-to-single-node.sh "${root}/scripts/migrate-pg-compose-to-single-node.sh"
|
||||
install -m 0755 scripts/migrate-pg-to-single-node.sh "${root}/scripts/migrate-pg-to-single-node.sh"
|
||||
|
||||
+3
-1
@@ -8,11 +8,13 @@
|
||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl 交叉编译产物)
|
||||
# dist/frontend/ (npm run build 产物)
|
||||
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
FROM docker:27-cli
|
||||
|
||||
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
|
||||
COPY dist/frontend/ /srv/frontend
|
||||
|
||||
|
||||
@@ -55,10 +55,39 @@ docker compose pull && docker compose up -d
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
```
|
||||
|
||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,数据卷、Postgres、Redis 不会被删除。Single Node 部署也可显式指定:
|
||||
|
||||
```bash
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
管理后台右上角“版本信息”会在检测到新版本时显示“立即更新”。通过 `install.sh` 初始化的 Docker Compose 部署会自动安装 `docker-compose.update.yml`,把部署目录和 Docker socket 挂给 `app` 容器,并默认写入:
|
||||
|
||||
```bash
|
||||
AETHER_SYSTEM_UPDATE_COMMAND=/opt/aether/compose/update.sh
|
||||
AETHER_SYSTEM_UPDATE_WORKDIR=/opt/aether/compose
|
||||
```
|
||||
|
||||
如果你是手动部署或用了自定义路径,可以把这两个变量改成你自己的 `update.sh` 所在位置,并确保运行时容器能访问该路径和 `/var/run/docker.sock`。启用后点击“立即更新”会先拉取最新 `app` 镜像,下载完成后按钮会切换为“立即重启”;点击“立即重启”会重建 `app` 容器并应用新版本。这不是运行时热补丁,更新过程中服务会短暂重启。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||
|
||||
```bash
|
||||
cd Aether && cd Aether
|
||||
cd Aether
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
|
||||
@@ -23,6 +23,33 @@ pub(super) fn classify_admin_system_family_route(
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path == "/api/admin/system/update-capability"
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"update_capability",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/prepare-update"
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"prepare_update",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/apply-update" {
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"apply_update",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
|
||||
@@ -234,6 +234,43 @@ fn classifies_admin_system_check_update_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_system_update_routes_as_admin_proxy_routes() {
|
||||
let headers = headers(&[]);
|
||||
let cases = [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/admin/system/update-capability",
|
||||
"update_capability",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/system/prepare-update",
|
||||
"prepare_update",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/system/apply-update",
|
||||
"apply_update",
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, expected_kind) in cases {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("system_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(expected_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:system")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_system_aws_regions_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -17,6 +17,10 @@ use crate::handlers::admin::system::shared::settings::{
|
||||
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
|
||||
use crate::handlers::admin::system::shared::update::{
|
||||
build_admin_system_update_capability_payload, prepare_admin_system_update_task,
|
||||
start_admin_system_update_task,
|
||||
};
|
||||
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::usage::UsageCleanupTargets;
|
||||
@@ -67,6 +71,47 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("update_capability")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/update-capability"
|
||||
{
|
||||
return Ok(Some(
|
||||
Json(build_admin_system_update_capability_payload()).into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("prepare_update")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/prepare-update"
|
||||
{
|
||||
return Ok(Some(match prepare_admin_system_update_task().await? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_update_prepared",
|
||||
"prepare_system_update",
|
||||
"system_update",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("apply_update")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/apply-update"
|
||||
{
|
||||
return Ok(Some(match start_admin_system_update_task().await? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_update_started",
|
||||
"apply_system_update",
|
||||
"system_update",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("aws_regions")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/aws-regions"
|
||||
|
||||
@@ -4,3 +4,4 @@ pub(crate) mod modules;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod settings;
|
||||
pub(crate) mod smtp;
|
||||
pub(crate) mod update;
|
||||
|
||||
@@ -22,6 +22,11 @@ use std::time::Duration;
|
||||
const AETHER_RELEASES_API_URL: &str =
|
||||
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
||||
|
||||
/// Minimum interval between actual GitHub API requests. Within this window
|
||||
/// the cached result is reused.
|
||||
#[cfg(not(test))]
|
||||
const RELEASE_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
pub(crate) fn current_aether_version() -> String {
|
||||
option_env!("AETHER_BUILD_VERSION")
|
||||
.filter(|version| !version.is_empty())
|
||||
@@ -47,10 +52,40 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
|
||||
#[cfg(not(test))]
|
||||
pub(crate) async fn fetch_latest_admin_system_release(
|
||||
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
||||
match fetch_latest_admin_system_release_inner().await {
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
struct CachedRelease {
|
||||
result: (Option<AdminSystemUpdateRelease>, Option<String>),
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: std::sync::OnceLock<Mutex<Option<CachedRelease>>> = std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(None));
|
||||
|
||||
{
|
||||
if let Ok(guard) = cache.lock() {
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
if cached.fetched_at.elapsed() < RELEASE_CACHE_TTL {
|
||||
return cached.result.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = match fetch_latest_admin_system_release_inner().await {
|
||||
Ok(release) => (release, None),
|
||||
Err(err) => (None, Some(err)),
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = cache.lock() {
|
||||
*guard = Some(CachedRelease {
|
||||
result: result.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -80,7 +115,7 @@ async fn fetch_latest_admin_system_release_inner(
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.find(|release| !release.draft && release.tag_name.starts_with('v'))
|
||||
.find(|release| !release.draft && !release.prerelease && release.tag_name.starts_with('v'))
|
||||
.map(|release| AdminSystemUpdateRelease {
|
||||
version: release.tag_name,
|
||||
release_url: Some(release.html_url),
|
||||
@@ -100,6 +135,8 @@ struct GitHubRelease {
|
||||
published_at: Option<String>,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_system_stats_payload(
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
use crate::GatewayError;
|
||||
use axum::http;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
const SYSTEM_UPDATE_COMMAND_ENV: &str = "AETHER_SYSTEM_UPDATE_COMMAND";
|
||||
const SYSTEM_UPDATE_WORKDIR_ENV: &str = "AETHER_SYSTEM_UPDATE_WORKDIR";
|
||||
|
||||
static SYSTEM_UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// RAII guard that resets [`SYSTEM_UPDATE_RUNNING`] on drop.
|
||||
struct SystemUpdateGuard;
|
||||
|
||||
impl SystemUpdateGuard {
|
||||
fn try_acquire() -> Option<Self> {
|
||||
if SYSTEM_UPDATE_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
{
|
||||
Some(Self)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemUpdateGuard {
|
||||
fn drop(&mut self) {
|
||||
SYSTEM_UPDATE_RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Value {
|
||||
let status = system_update_status();
|
||||
json!({
|
||||
"enabled": status.enabled,
|
||||
"command_env": SYSTEM_UPDATE_COMMAND_ENV,
|
||||
"workdir_env": SYSTEM_UPDATE_WORKDIR_ENV,
|
||||
"command": status.command,
|
||||
"workdir": status.workdir,
|
||||
"detail": status.detail,
|
||||
"message": if status.enabled {
|
||||
"一键更新已启用"
|
||||
} else {
|
||||
status.detail.as_deref().unwrap_or("未配置一键更新命令")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_admin_system_update_task(
|
||||
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
||||
let (command, workdir) = match prepare_system_update_command(&["--prepare"]) {
|
||||
Ok(command) => command,
|
||||
Err(response) => return Ok(Err(response)),
|
||||
};
|
||||
let Some(guard) = SystemUpdateGuard::try_acquire() else {
|
||||
return Ok(Err(update_already_running_response()));
|
||||
};
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || run_system_update_command(&command, workdir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|inner| inner);
|
||||
drop(guard);
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok(Ok(json!({
|
||||
"message": "更新包已下载完成,点击“立即重启”完成安装",
|
||||
"started": true,
|
||||
"need_restart": true,
|
||||
}))),
|
||||
Err(err) => Ok(Err((
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "detail": err }),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_admin_system_update_task(
|
||||
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
||||
let (command, workdir) = match prepare_system_update_command(&["--no-pull", "--force-recreate"])
|
||||
{
|
||||
Ok(command) => command,
|
||||
Err(response) => return Ok(Err(response)),
|
||||
};
|
||||
|
||||
let Some(guard) = SystemUpdateGuard::try_acquire() else {
|
||||
return Ok(Err(update_already_running_response()));
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || run_system_update_command(&command, workdir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|inner| inner);
|
||||
if let Err(err) = result {
|
||||
tracing::error!(error = %err, "admin system one-click update failed");
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
Ok(Ok(json!({
|
||||
"message": "一键重启已启动,服务会在重建 app 容器后短暂不可用",
|
||||
"started": true,
|
||||
"need_restart": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn prepare_system_update_command(
|
||||
args: &[&str],
|
||||
) -> Result<(String, Option<String>), (http::StatusCode, serde_json::Value)> {
|
||||
let status = system_update_status();
|
||||
let Some(command) = status.command else {
|
||||
return Err(missing_update_command_response());
|
||||
};
|
||||
if !status.enabled {
|
||||
return Err((
|
||||
http::StatusCode::PRECONDITION_REQUIRED,
|
||||
json!({
|
||||
"detail": status.detail.unwrap_or_else(|| "一键更新运行时不可用".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Ok((append_command_args(&command, args), status.workdir))
|
||||
}
|
||||
|
||||
fn missing_update_command_response() -> (http::StatusCode, serde_json::Value) {
|
||||
(
|
||||
http::StatusCode::PRECONDITION_REQUIRED,
|
||||
json!({
|
||||
"detail": format!(
|
||||
"未配置一键更新命令。请在部署环境中设置 {SYSTEM_UPDATE_COMMAND_ENV},例如 /opt/aether/compose/update.sh"
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_already_running_response() -> (http::StatusCode, serde_json::Value) {
|
||||
(
|
||||
http::StatusCode::CONFLICT,
|
||||
json!({ "detail": "已有一键更新任务正在执行" }),
|
||||
)
|
||||
}
|
||||
|
||||
fn append_command_args(command: &str, args: &[&str]) -> String {
|
||||
if args.is_empty() {
|
||||
return command.to_string();
|
||||
}
|
||||
format!("{} {}", command, args.join(" "))
|
||||
}
|
||||
|
||||
fn system_update_command() -> Option<String> {
|
||||
std::env::var(SYSTEM_UPDATE_COMMAND_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn system_update_workdir() -> Option<String> {
|
||||
std::env::var(SYSTEM_UPDATE_WORKDIR_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SystemUpdateStatus {
|
||||
enabled: bool,
|
||||
command: Option<String>,
|
||||
workdir: Option<String>,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
fn system_update_status() -> SystemUpdateStatus {
|
||||
let command = system_update_command();
|
||||
let workdir = system_update_workdir();
|
||||
let detail = validate_system_update_runtime(command.as_deref(), workdir.as_deref()).err();
|
||||
SystemUpdateStatus {
|
||||
enabled: command.is_some() && detail.is_none(),
|
||||
command,
|
||||
workdir,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_system_update_runtime(
|
||||
command: Option<&str>,
|
||||
workdir: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let Some(command) = command else {
|
||||
return Err(format!(
|
||||
"未配置一键更新命令。请设置 {SYSTEM_UPDATE_COMMAND_ENV}"
|
||||
));
|
||||
};
|
||||
let command_path = first_command_token(command);
|
||||
let path = Path::new(&command_path);
|
||||
if !path.is_file() {
|
||||
return Err(format!("一键更新命令路径不可访问: {command_path}"));
|
||||
}
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
let path = Path::new(workdir);
|
||||
if !path.is_dir() {
|
||||
return Err(format!("一键更新工作目录不可访问: {workdir}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn first_command_token(command: &str) -> String {
|
||||
command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or(command)
|
||||
.trim_matches(['"', '\''])
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn run_system_update_command(command: &str, workdir: Option<String>) -> Result<(), String> {
|
||||
validate_system_update_runtime(Some(command), workdir.as_deref())?;
|
||||
|
||||
let mut process = if cfg!(windows) {
|
||||
let mut process = Command::new("cmd");
|
||||
process.arg("/C").arg(command);
|
||||
process
|
||||
} else {
|
||||
let mut process = Command::new("sh");
|
||||
process.arg("-c").arg(command);
|
||||
process
|
||||
};
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
process.current_dir(workdir);
|
||||
}
|
||||
|
||||
let output = process
|
||||
.output()
|
||||
.map_err(|err| format!("启动一键更新命令失败: {err}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = stderr
|
||||
.trim()
|
||||
.split('\n')
|
||||
.next()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.or_else(|| stdout.trim().split('\n').next())
|
||||
.unwrap_or("更新命令执行失败");
|
||||
Err(format!("一键更新命令退出状态 {}: {detail}", output.status))
|
||||
}
|
||||
@@ -36,6 +36,70 @@ use crate::constants::{
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
struct TestEnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
struct TestUpdateCommand {
|
||||
path: std::path::PathBuf,
|
||||
log_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for TestUpdateCommand {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
let _ = std::fs::remove_file(&self.log_path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.previous.as_deref() {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_test_env_var(key: &'static str, value: &str) -> TestEnvVarGuard {
|
||||
let previous = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
TestEnvVarGuard { key, previous }
|
||||
}
|
||||
|
||||
fn create_test_update_command() -> TestUpdateCommand {
|
||||
let suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be valid")
|
||||
.as_nanos();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
let path = temp_dir.join(format!("aether-update-test-{suffix}.{extension}"));
|
||||
let log_path = temp_dir.join(format!("aether-update-test-{suffix}.log"));
|
||||
let log_path_text = log_path.to_string_lossy();
|
||||
let content = if cfg!(windows) {
|
||||
format!("@echo off\r\necho %*>>\"{log_path_text}\"\r\nexit /b 0\r\n")
|
||||
} else {
|
||||
let escaped_log_path = log_path_text.replace('"', "\\\"");
|
||||
format!("#!/usr/bin/env sh\nprintf '%s\\n' \"$*\" >> \"{escaped_log_path}\"\n")
|
||||
};
|
||||
std::fs::write(&path, content).expect("test update command should be written");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = std::fs::metadata(&path)
|
||||
.expect("test update command metadata should be readable")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions)
|
||||
.expect("test update command should be executable");
|
||||
}
|
||||
|
||||
TestUpdateCommand { path, log_path }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -158,6 +222,166 @@ async fn gateway_handles_admin_system_check_update_locally_with_bearer_admin_ses
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_update_capability_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/update-capability",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/admin/system/update-capability"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["command_env"], "AETHER_SYSTEM_UPDATE_COMMAND");
|
||||
assert!(payload["enabled"].is_boolean());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_prepares_admin_system_update_locally() {
|
||||
let command = create_test_update_command();
|
||||
let _command_guard = set_test_env_var(
|
||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
||||
command
|
||||
.path
|
||||
.to_str()
|
||||
.expect("test command path should be utf-8"),
|
||||
);
|
||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/prepare-update",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/prepare-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["need_restart"], json!(true));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("立即重启")));
|
||||
let command_log = std::fs::read_to_string(&command.log_path).expect("test command should run");
|
||||
assert!(command_log.contains("--prepare"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_without_config_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/apply-update",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("AETHER_SYSTEM_UPDATE_COMMAND")));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_when_command_path_is_inaccessible() {
|
||||
let _command_guard = set_test_env_var(
|
||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
||||
"/definitely/missing/aether-update.sh",
|
||||
);
|
||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("路径不可访问")));
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_aws_regions_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Optional one-click update wiring for Docker Compose deployments.
|
||||
#
|
||||
# This file is installed by install.sh and included by update.sh when present.
|
||||
# It gives the app container access to the deployment directory and Docker
|
||||
# socket so the admin "立即更新" button can execute update.sh.
|
||||
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
AETHER_SYSTEM_UPDATE_COMMAND: ${AETHER_SYSTEM_UPDATE_COMMAND:-/opt/aether/compose/update.sh}
|
||||
AETHER_SYSTEM_UPDATE_WORKDIR: ${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}
|
||||
volumes:
|
||||
- ${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}:${AETHER_SYSTEM_UPDATE_WORKDIR:-/opt/aether/compose}
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
@@ -373,6 +373,22 @@ export interface CheckUpdateResponse {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SystemUpdateCapabilityResponse {
|
||||
enabled: boolean
|
||||
command_env: string
|
||||
workdir_env: string
|
||||
command?: string | null
|
||||
workdir?: string | null
|
||||
detail?: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApplySystemUpdateResponse {
|
||||
message: string
|
||||
started: boolean
|
||||
need_restart: boolean
|
||||
}
|
||||
|
||||
// LDAP 配置响应
|
||||
export interface LdapConfigResponse {
|
||||
server_url: string | null
|
||||
@@ -981,6 +997,30 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取一键更新能力
|
||||
async getSystemUpdateCapability(): Promise<SystemUpdateCapabilityResponse> {
|
||||
const response = await apiClient.get<SystemUpdateCapabilityResponse>(
|
||||
'/api/admin/system/update-capability'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 准备系统一键更新(拉取最新镜像)
|
||||
async prepareSystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||
'/api/admin/system/prepare-update'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 触发系统一键重启(重建 app 容器)
|
||||
async applySystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||
'/api/admin/system/apply-update'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// LDAP 配置相关
|
||||
// 获取 LDAP 配置
|
||||
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
>
|
||||
新版本已发布,建议更新以获得最新功能和安全修复
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="updatePhase === 'restart'"
|
||||
class="mt-1 text-xs text-primary"
|
||||
>
|
||||
更新包已下载,点击“立即重启”完成安装
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -62,16 +69,26 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updating"
|
||||
@click="handleLater"
|
||||
>
|
||||
稍后提醒
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updating"
|
||||
@click="handleViewRelease"
|
||||
>
|
||||
查看更新
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1"
|
||||
:disabled="updating"
|
||||
@click="handleApplyUpdate"
|
||||
>
|
||||
{{ actionButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -93,13 +110,24 @@ const props = defineProps<{
|
||||
releaseUrl: string | null
|
||||
releaseNotes: string | null
|
||||
publishedAt: string | null
|
||||
updatePhase?: 'download' | 'restart'
|
||||
updating?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
applyUpdate: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const updating = computed(() => props.updating ?? false)
|
||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
@@ -157,4 +185,8 @@ function handleViewRelease() {
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleApplyUpdate() {
|
||||
emit('applyUpdate')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,6 +92,19 @@
|
||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||
查看更新
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status?.has_update"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="updating"
|
||||
@click="handleApplyUpdate"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-3.5 w-3.5"
|
||||
:class="updating ? 'animate-spin' : ''"
|
||||
/>
|
||||
{{ actionButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,16 +123,21 @@ import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||
const props = defineProps<{
|
||||
status: CheckUpdateResponse | null
|
||||
loading?: boolean
|
||||
updating?: boolean
|
||||
updatePhase?: 'download' | 'restart'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
openRelease: []
|
||||
applyUpdate: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const loading = computed(() => props.loading ?? false)
|
||||
const updating = computed(() => props.updating ?? false)
|
||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||
const buttonClass = computed(() => {
|
||||
const classes = []
|
||||
|
||||
@@ -160,6 +178,12 @@ const buttonTitle = computed(() => {
|
||||
if (!props.status) return '版本信息'
|
||||
return `版本信息:${statusLabel.value}`
|
||||
})
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
})
|
||||
|
||||
function handleRefresh() {
|
||||
emit('refresh')
|
||||
@@ -169,4 +193,8 @@ function handleOpenRelease() {
|
||||
isOpen.value = false
|
||||
emit('openRelease')
|
||||
}
|
||||
|
||||
function handleApplyUpdate() {
|
||||
emit('applyUpdate')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,8 +118,11 @@
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
/>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
@@ -301,8 +304,11 @@
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
/>
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@@ -385,6 +391,9 @@
|
||||
:release-url="updateInfo.release_url"
|
||||
:release-notes="updateInfo.release_notes"
|
||||
:published-at="updateInfo.published_at"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -397,9 +406,11 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
@@ -449,12 +460,15 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
type SystemUpdatePhase = 'download' | 'restart'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||
const { siteName, siteSubtitle } = useSiteInfo()
|
||||
const { success, error: showError } = useToast()
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
@@ -475,8 +489,52 @@ const showUpdateDialog = ref(false)
|
||||
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
||||
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
||||
const loadingVersionStatus = ref(false)
|
||||
const applyingSystemUpdate = ref(false)
|
||||
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
||||
const preparedUpdateVersion = ref<string | null>(
|
||||
readSessionStorageItem('aether_prepared_update_version')
|
||||
)
|
||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||
|
||||
watch(systemUpdatePhase, (val) => {
|
||||
setSessionStorageItem('aether_update_phase', val)
|
||||
})
|
||||
watch(preparedUpdateVersion, (val) => {
|
||||
if (val) {
|
||||
setSessionStorageItem('aether_prepared_update_version', val)
|
||||
} else {
|
||||
removeSessionStorageItem('aether_prepared_update_version')
|
||||
}
|
||||
})
|
||||
|
||||
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
|
||||
return readSessionStorageItem('aether_update_phase') === 'restart' ? 'restart' : 'download'
|
||||
}
|
||||
|
||||
function readSessionStorageItem(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionStorageItem(key: string, value: string) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage failures; update state still lives in memory for this page.
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionStorageItem(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage failures; update state still lives in memory for this page.
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化时自动关闭移动端菜单
|
||||
watch(() => route.path, () => {
|
||||
mobileMenuOpen.value = false
|
||||
@@ -508,6 +566,7 @@ async function loadVersionStatus() {
|
||||
versionStatusLoadPromise = (async () => {
|
||||
try {
|
||||
versionStatus.value = await adminApi.checkUpdate()
|
||||
syncSystemUpdatePhase(versionStatus.value)
|
||||
return versionStatus.value
|
||||
} catch (error) {
|
||||
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
|
||||
@@ -521,6 +580,22 @@ async function loadVersionStatus() {
|
||||
return versionStatusLoadPromise
|
||||
}
|
||||
|
||||
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
||||
if (!status?.has_update) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
systemUpdatePhase.value === 'restart' &&
|
||||
(!preparedUpdateVersion.value || preparedUpdateVersion.value !== status.latest_version)
|
||||
) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionRefresh() {
|
||||
void loadVersionStatus()
|
||||
}
|
||||
@@ -531,6 +606,38 @@ function openVersionReleasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplySystemUpdate() {
|
||||
if (applyingSystemUpdate.value) return
|
||||
applyingSystemUpdate.value = true
|
||||
try {
|
||||
const capability = await adminApi.getSystemUpdateCapability()
|
||||
if (!capability.enabled) {
|
||||
showError(
|
||||
capability.detail || `一键更新未启用,请先在部署环境配置 ${capability.command_env}`,
|
||||
'无法启动更新'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (systemUpdatePhase.value === 'download') {
|
||||
const result = await adminApi.prepareSystemUpdate()
|
||||
preparedUpdateVersion.value = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
|
||||
systemUpdatePhase.value = 'restart'
|
||||
success(result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminApi.applySystemUpdate()
|
||||
success(result.message || '一键重启已启动')
|
||||
showUpdateDialog.value = false
|
||||
} catch (err) {
|
||||
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
||||
showError(parseApiError(err, fallback))
|
||||
} finally {
|
||||
applyingSystemUpdate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showDebugUpdateDialog() {
|
||||
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
||||
updateInfo.value = {
|
||||
@@ -547,6 +654,8 @@ function showDebugUpdateDialog() {
|
||||
published_at: new Date().toISOString(),
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
showUpdateDialog.value = true
|
||||
}
|
||||
|
||||
@@ -568,6 +677,8 @@ function showDebugVersionStatus(hasUpdate = true) {
|
||||
published_at: hasUpdate ? new Date().toISOString() : null,
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
|
||||
+26
-9
@@ -940,8 +940,9 @@ Install complete.
|
||||
|
||||
Docker Compose service:
|
||||
cd ${COMPOSE_DIR}
|
||||
${compose_cmd} ps
|
||||
${compose_cmd} logs -f app
|
||||
./update.sh
|
||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml ps
|
||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml logs -f app
|
||||
|
||||
Health checks:
|
||||
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
|
||||
@@ -961,9 +962,13 @@ compose_manual_start_steps() {
|
||||
|
||||
Next steps:
|
||||
cd ${COMPOSE_DIR}
|
||||
${compose_cmd} pull
|
||||
${compose_cmd} up -d
|
||||
${compose_cmd} logs -f app
|
||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml pull
|
||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml up -d
|
||||
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml logs -f app
|
||||
|
||||
Later updates:
|
||||
cd ${COMPOSE_DIR}
|
||||
./update.sh
|
||||
|
||||
Generate a fresh key set any time:
|
||||
cd ${COMPOSE_DIR}
|
||||
@@ -977,13 +982,13 @@ start_compose_deployment() {
|
||||
|
||||
info "pulling Docker Compose images"
|
||||
if [[ "${compose_cmd}" == "docker compose" ]]; then
|
||||
docker compose pull
|
||||
docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" pull
|
||||
info "starting Docker Compose services"
|
||||
docker compose up -d
|
||||
docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" up -d
|
||||
else
|
||||
docker-compose pull
|
||||
docker-compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" pull
|
||||
info "starting Docker Compose services"
|
||||
docker-compose up -d
|
||||
docker-compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" -f "${COMPOSE_DIR}/docker-compose.update.yml" up -d
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1780,6 +1785,8 @@ generate_compose_env() {
|
||||
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "pretty"
|
||||
replace_or_append_env "${output}" "AETHER_LOG_DIR" "/app/logs"
|
||||
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
|
||||
replace_or_append_env "${output}" "AETHER_SYSTEM_UPDATE_COMMAND" "${COMPOSE_DIR}/update.sh"
|
||||
replace_or_append_env "${output}" "AETHER_SYSTEM_UPDATE_WORKDIR" "${COMPOSE_DIR}"
|
||||
}
|
||||
|
||||
generate_compose_single_node_env() {
|
||||
@@ -1805,6 +1812,8 @@ APP_PORT=${APP_PORT:-8084}
|
||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
||||
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
AETHER_SYSTEM_UPDATE_COMMAND=${COMPOSE_DIR}/update.sh
|
||||
AETHER_SYSTEM_UPDATE_WORKDIR=${COMPOSE_DIR}
|
||||
AETHER_RUNTIME_BACKEND=memory
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
@@ -2185,7 +2194,9 @@ install_compose_mode() {
|
||||
ensure_directory "${COMPOSE_DIR}/logs"
|
||||
|
||||
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
||||
install_project_file "docker-compose.update.yml" "${COMPOSE_DIR}/docker-compose.update.yml" "0644"
|
||||
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
||||
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
||||
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||
|
||||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||||
@@ -2200,8 +2211,10 @@ install_compose_mode() {
|
||||
|
||||
Docker Compose files are ready:
|
||||
${COMPOSE_DIR}/docker-compose.yml
|
||||
${COMPOSE_DIR}/docker-compose.update.yml
|
||||
${COMPOSE_DIR}/.env
|
||||
${COMPOSE_DIR}/.env.example
|
||||
${COMPOSE_DIR}/update.sh
|
||||
${COMPOSE_DIR}/generate_keys.sh
|
||||
${COMPOSE_DIR}/logs
|
||||
EOF
|
||||
@@ -2224,7 +2237,9 @@ install_compose_single_node_mode() {
|
||||
ensure_directory "${COMPOSE_DIR}/data"
|
||||
|
||||
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
||||
install_project_file "docker-compose.update.yml" "${COMPOSE_DIR}/docker-compose.update.yml" "0644"
|
||||
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
|
||||
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
|
||||
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||
|
||||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||||
@@ -2239,8 +2254,10 @@ install_compose_single_node_mode() {
|
||||
|
||||
Docker Compose single-node files are ready:
|
||||
${COMPOSE_DIR}/docker-compose.yml
|
||||
${COMPOSE_DIR}/docker-compose.update.yml
|
||||
${COMPOSE_DIR}/.env
|
||||
${COMPOSE_DIR}/.env.example
|
||||
${COMPOSE_DIR}/update.sh
|
||||
${COMPOSE_DIR}/generate_keys.sh
|
||||
${COMPOSE_DIR}/data
|
||||
${COMPOSE_DIR}/logs
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-click updater for Docker Compose deployments.
|
||||
#
|
||||
# This updates the app container image and recreates only the app service. It is
|
||||
# intentionally not a hot patch of the running Rust process.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
|
||||
MODE="auto"
|
||||
COMPOSE_DIR=""
|
||||
APP_SERVICE="app"
|
||||
NO_PULL=false
|
||||
FORCE_RECREATE=false
|
||||
SHOW_LOGS=false
|
||||
LOCAL_BUILD=false
|
||||
PREPARE_ONLY=false
|
||||
COMPOSE_FILES=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./update.sh [options]
|
||||
|
||||
Update Aether Docker Compose deployment in one command.
|
||||
|
||||
Options:
|
||||
--mode MODE auto, compose, single-node, or local-build
|
||||
auto uses docker-compose.yml in the current directory
|
||||
--compose-dir DIR deployment directory, default: current directory
|
||||
-f, --compose-file FILE compose file path; can be provided multiple times
|
||||
--service NAME app service name, default: app
|
||||
--no-pull skip docker compose pull
|
||||
--prepare pull the latest app image only, do not recreate app
|
||||
--force-recreate force recreate the app container
|
||||
--logs follow app logs after update
|
||||
-h, --help show help
|
||||
|
||||
Examples:
|
||||
./update.sh
|
||||
./update.sh --mode single-node
|
||||
./update.sh --compose-dir /opt/aether/compose
|
||||
./update.sh --mode local-build
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
[[ $# -ge 2 ]] || die "--mode requires a value"
|
||||
MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--compose-dir)
|
||||
[[ $# -ge 2 ]] || die "--compose-dir requires a value"
|
||||
COMPOSE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f|--compose-file)
|
||||
[[ $# -ge 2 ]] || die "--compose-file requires a value"
|
||||
COMPOSE_FILES+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--service)
|
||||
[[ $# -ge 2 ]] || die "--service requires a value"
|
||||
APP_SERVICE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-pull)
|
||||
NO_PULL=true
|
||||
shift
|
||||
;;
|
||||
--prepare)
|
||||
PREPARE_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--force-recreate)
|
||||
FORCE_RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--logs)
|
||||
SHOW_LOGS=true
|
||||
shift
|
||||
;;
|
||||
--local-build)
|
||||
MODE="local-build"
|
||||
LOCAL_BUILD=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$MODE" in
|
||||
auto|compose|single-node|local-build)
|
||||
;;
|
||||
*)
|
||||
die "unsupported mode: ${MODE}; expected auto, compose, single-node, or local-build"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${MODE}" == "local-build" || "${LOCAL_BUILD}" == "true" ]]; then
|
||||
[[ "${PREPARE_ONLY}" != "true" ]] || die "--prepare is only supported for Docker Compose deployments"
|
||||
deploy_script="${SCRIPT_DIR}/deploy.sh"
|
||||
[[ -f "${deploy_script}" ]] || die "local-build mode requires deploy.sh next to update.sh"
|
||||
args=()
|
||||
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
||||
args+=(--force)
|
||||
fi
|
||||
exec bash "${deploy_script}" "${args[@]}"
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE=(docker-compose)
|
||||
else
|
||||
die "docker compose or docker-compose is required"
|
||||
fi
|
||||
|
||||
docker info >/dev/null 2>&1 || die "Docker is not running"
|
||||
|
||||
if [[ -z "${COMPOSE_DIR}" ]]; then
|
||||
COMPOSE_DIR="$(pwd -P)"
|
||||
fi
|
||||
COMPOSE_DIR="$(cd -- "${COMPOSE_DIR}" && pwd -P)"
|
||||
|
||||
resolve_compose_file() {
|
||||
local filename="$1"
|
||||
if [[ "${filename}" = /* ]]; then
|
||||
printf '%s\n' "${filename}"
|
||||
else
|
||||
printf '%s\n' "${COMPOSE_DIR}/${filename}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${#COMPOSE_FILES[@]}" -eq 0 ]]; then
|
||||
case "${MODE}" in
|
||||
compose)
|
||||
COMPOSE_FILES=("docker-compose.yml")
|
||||
;;
|
||||
single-node)
|
||||
if [[ -f "${COMPOSE_DIR}/docker-compose.single-node.yml" ]]; then
|
||||
COMPOSE_FILES=("docker-compose.single-node.yml")
|
||||
else
|
||||
COMPOSE_FILES=("docker-compose.yml")
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||
COMPOSE_FILES=("docker-compose.yml")
|
||||
elif [[ -f "${COMPOSE_DIR}/docker-compose.single-node.yml" ]]; then
|
||||
COMPOSE_FILES=("docker-compose.single-node.yml")
|
||||
else
|
||||
die "no docker-compose.yml or docker-compose.single-node.yml found in ${COMPOSE_DIR}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "${COMPOSE_DIR}/docker-compose.update.yml" ]]; then
|
||||
COMPOSE_FILES+=("docker-compose.update.yml")
|
||||
fi
|
||||
fi
|
||||
|
||||
COMPOSE_ARGS=()
|
||||
COMPOSE_ARGS+=(--project-directory "${COMPOSE_DIR}")
|
||||
for file in "${COMPOSE_FILES[@]}"; do
|
||||
resolved_file="$(resolve_compose_file "${file}")"
|
||||
[[ -f "${resolved_file}" ]] || die "compose file not found: ${resolved_file}"
|
||||
COMPOSE_ARGS+=(-f "${resolved_file}")
|
||||
done
|
||||
|
||||
services="$("${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" config --services)"
|
||||
if ! grep -qx "${APP_SERVICE}" <<< "${services}"; then
|
||||
die "service '${APP_SERVICE}' not found in compose config"
|
||||
fi
|
||||
|
||||
echo ">>> Compose directory: ${COMPOSE_DIR}"
|
||||
echo ">>> App service: ${APP_SERVICE}"
|
||||
|
||||
if [[ "${PREPARE_ONLY}" == "true" ]]; then
|
||||
echo ">>> Preparing update by pulling latest image for ${APP_SERVICE}..."
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" pull "${APP_SERVICE}"
|
||||
echo ">>> Done."
|
||||
echo ">>> Note: image is downloaded. Recreate ${APP_SERVICE} to apply the update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${NO_PULL}" != "true" ]]; then
|
||||
echo ">>> Pulling latest image for ${APP_SERVICE}..."
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" pull "${APP_SERVICE}"
|
||||
fi
|
||||
|
||||
has_healthcheck() {
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" config 2>/dev/null \
|
||||
| grep -q "healthcheck:" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local timeout="${1:-120}"
|
||||
local elapsed=0
|
||||
echo ">>> Waiting for ${APP_SERVICE} to become healthy (timeout ${timeout}s)..."
|
||||
while (( elapsed < timeout )); do
|
||||
local container_id
|
||||
local state
|
||||
container_id="$("${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" ps -q "${APP_SERVICE}" 2>/dev/null | head -n 1)"
|
||||
if [[ -z "${container_id}" ]]; then
|
||||
sleep 2
|
||||
elapsed=$(( elapsed + 2 ))
|
||||
continue
|
||||
fi
|
||||
state="$(docker inspect --format='{{.State.Health.Status}}' \
|
||||
"${container_id}" 2>/dev/null || true)"
|
||||
if [[ "${state}" == "healthy" ]]; then
|
||||
echo ">>> Container is healthy."
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$(( elapsed + 2 ))
|
||||
done
|
||||
echo ">>> WARNING: health check timed out after ${timeout}s."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Update execution.
|
||||
# When a healthcheck is defined we use --wait so compose blocks until
|
||||
# the new container passes health, reducing observable downtime.
|
||||
|
||||
up_args=(up -d)
|
||||
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
||||
up_args+=(--force-recreate)
|
||||
fi
|
||||
|
||||
# Compose v2.20+ supports --wait; older versions may reject it.
|
||||
if has_healthcheck; then
|
||||
up_args+=(--wait --wait-timeout 120)
|
||||
fi
|
||||
up_args+=("${APP_SERVICE}")
|
||||
|
||||
echo ">>> Recreating ${APP_SERVICE}..."
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "${up_args[@]}" || {
|
||||
echo ">>> Compose up with --wait failed; falling back to simple recreate..."
|
||||
fallback_up_args=(up -d)
|
||||
if [[ "${FORCE_RECREATE}" == "true" ]]; then
|
||||
fallback_up_args+=(--force-recreate)
|
||||
fi
|
||||
fallback_up_args+=("${APP_SERVICE}")
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "${fallback_up_args[@]}"
|
||||
if has_healthcheck; then
|
||||
wait_healthy 120 || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo ">>> Current services:"
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" ps
|
||||
|
||||
echo ">>> Done."
|
||||
echo ">>> Note: this is a one-click app container update, not a no-restart hot patch."
|
||||
|
||||
if [[ "${SHOW_LOGS}" == "true" ]]; then
|
||||
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" logs -f "${APP_SERVICE}"
|
||||
fi
|
||||
Reference in New Issue
Block a user