mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat: 添加在线更新功能
This commit is contained in:
@@ -57,6 +57,11 @@ ADMIN_USERNAME=admin123456
|
|||||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=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 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
||||||
# 推荐计算方式(单实例):
|
# 推荐计算方式(单实例):
|
||||||
# MAX = CPU 核数 × 10(AI 网关偏 IO 等待,可激进些;纯 OLTP 用 × 4)
|
# MAX = CPU 核数 × 10(AI 网关偏 IO 等待,可激进些;纯 OLTP 用 × 4)
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ jobs:
|
|||||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||||
install.sh > "${root}/install.sh"
|
install.sh > "${root}/install.sh"
|
||||||
chmod 0755 "${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.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 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-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"
|
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/aether-gateway-arm64 (aarch64-unknown-linux-musl 交叉编译产物)
|
||||||
# dist/frontend/ (npm run build 产物)
|
# dist/frontend/ (npm run build 产物)
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12
|
FROM docker:27-cli
|
||||||
|
|
||||||
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
|
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
|
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
|
||||||
COPY dist/frontend/ /srv/frontend
|
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 -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)
|
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd Aether && cd Aether
|
cd Aether
|
||||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
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",
|
"admin:system",
|
||||||
false,
|
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" {
|
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
|
|||||||
@@ -234,6 +234,43 @@ fn classifies_admin_system_check_update_as_admin_proxy_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
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]
|
#[test]
|
||||||
fn classifies_admin_system_aws_regions_as_admin_proxy_route() {
|
fn classifies_admin_system_aws_regions_as_admin_proxy_route() {
|
||||||
let headers = headers(&[]);
|
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,
|
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::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::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_data_contracts::repository::usage::UsageCleanupTargets;
|
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")
|
if decision.route_kind.as_deref() == Some("aws_regions")
|
||||||
&& request_method == http::Method::GET
|
&& request_method == http::Method::GET
|
||||||
&& request_path == "/api/admin/system/aws-regions"
|
&& request_path == "/api/admin/system/aws-regions"
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub(crate) mod modules;
|
|||||||
pub(crate) mod paths;
|
pub(crate) mod paths;
|
||||||
pub(crate) mod settings;
|
pub(crate) mod settings;
|
||||||
pub(crate) mod smtp;
|
pub(crate) mod smtp;
|
||||||
|
pub(crate) mod update;
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ use std::time::Duration;
|
|||||||
const AETHER_RELEASES_API_URL: &str =
|
const AETHER_RELEASES_API_URL: &str =
|
||||||
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
"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 {
|
pub(crate) fn current_aether_version() -> String {
|
||||||
option_env!("AETHER_BUILD_VERSION")
|
option_env!("AETHER_BUILD_VERSION")
|
||||||
.filter(|version| !version.is_empty())
|
.filter(|version| !version.is_empty())
|
||||||
@@ -47,10 +52,40 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
pub(crate) async fn fetch_latest_admin_system_release(
|
pub(crate) async fn fetch_latest_admin_system_release(
|
||||||
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
) -> (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),
|
Ok(release) => (release, None),
|
||||||
Err(err) => (None, Some(err)),
|
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)]
|
#[cfg(test)]
|
||||||
@@ -80,7 +115,7 @@ async fn fetch_latest_admin_system_release_inner(
|
|||||||
|
|
||||||
Ok(releases
|
Ok(releases
|
||||||
.into_iter()
|
.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 {
|
.map(|release| AdminSystemUpdateRelease {
|
||||||
version: release.tag_name,
|
version: release.tag_name,
|
||||||
release_url: Some(release.html_url),
|
release_url: Some(release.html_url),
|
||||||
@@ -100,6 +135,8 @@ struct GitHubRelease {
|
|||||||
published_at: Option<String>,
|
published_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
draft: bool,
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn build_admin_system_stats_payload(
|
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;
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
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();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_system_aws_regions_locally_with_trusted_admin_principal() {
|
async fn gateway_handles_admin_system_aws_regions_locally_with_trusted_admin_principal() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
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
|
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 配置响应
|
// LDAP 配置响应
|
||||||
export interface LdapConfigResponse {
|
export interface LdapConfigResponse {
|
||||||
server_url: string | null
|
server_url: string | null
|
||||||
@@ -981,6 +997,30 @@ export const adminApi = {
|
|||||||
return response.data
|
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 配置相关
|
||||||
// 获取 LDAP 配置
|
// 获取 LDAP 配置
|
||||||
async getLdapConfig(): Promise<LdapConfigResponse> {
|
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||||
|
|||||||
@@ -55,6 +55,13 @@
|
|||||||
>
|
>
|
||||||
新版本已发布,建议更新以获得最新功能和安全修复
|
新版本已发布,建议更新以获得最新功能和安全修复
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="updatePhase === 'restart'"
|
||||||
|
class="mt-1 text-xs text-primary"
|
||||||
|
>
|
||||||
|
更新包已下载,点击“立即重启”完成安装
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -62,16 +69,26 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
:disabled="updating"
|
||||||
@click="handleLater"
|
@click="handleLater"
|
||||||
>
|
>
|
||||||
稍后提醒
|
稍后提醒
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
:disabled="updating"
|
||||||
@click="handleViewRelease"
|
@click="handleViewRelease"
|
||||||
>
|
>
|
||||||
查看更新
|
查看更新
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="updating"
|
||||||
|
@click="handleApplyUpdate"
|
||||||
|
>
|
||||||
|
{{ actionButtonLabel }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -93,13 +110,24 @@ const props = defineProps<{
|
|||||||
releaseUrl: string | null
|
releaseUrl: string | null
|
||||||
releaseNotes: string | null
|
releaseNotes: string | null
|
||||||
publishedAt: string | null
|
publishedAt: string | null
|
||||||
|
updatePhase?: 'download' | 'restart'
|
||||||
|
updating?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
|
applyUpdate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = ref(props.modelValue)
|
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) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
isOpen.value = val
|
isOpen.value = val
|
||||||
@@ -157,4 +185,8 @@ function handleViewRelease() {
|
|||||||
}
|
}
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleApplyUpdate() {
|
||||||
|
emit('applyUpdate')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -92,6 +92,19 @@
|
|||||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||||
查看更新
|
查看更新
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,16 +123,21 @@ import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: CheckUpdateResponse | null
|
status: CheckUpdateResponse | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
updating?: boolean
|
||||||
|
updatePhase?: 'download' | 'restart'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
refresh: []
|
||||||
openRelease: []
|
openRelease: []
|
||||||
|
applyUpdate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
|
||||||
const loading = computed(() => props.loading ?? false)
|
const loading = computed(() => props.loading ?? false)
|
||||||
|
const updating = computed(() => props.updating ?? false)
|
||||||
|
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||||
const buttonClass = computed(() => {
|
const buttonClass = computed(() => {
|
||||||
const classes = []
|
const classes = []
|
||||||
|
|
||||||
@@ -160,6 +178,12 @@ const buttonTitle = computed(() => {
|
|||||||
if (!props.status) return '版本信息'
|
if (!props.status) return '版本信息'
|
||||||
return `版本信息:${statusLabel.value}`
|
return `版本信息:${statusLabel.value}`
|
||||||
})
|
})
|
||||||
|
const actionButtonLabel = computed(() => {
|
||||||
|
if (updating.value) {
|
||||||
|
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||||
|
}
|
||||||
|
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||||
|
})
|
||||||
|
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -169,4 +193,8 @@ function handleOpenRelease() {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
emit('openRelease')
|
emit('openRelease')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleApplyUpdate() {
|
||||||
|
emit('applyUpdate')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -118,8 +118,11 @@
|
|||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
:status="versionStatus"
|
:status="versionStatus"
|
||||||
:loading="loadingVersionStatus"
|
:loading="loadingVersionStatus"
|
||||||
|
:updating="applyingSystemUpdate"
|
||||||
|
:update-phase="systemUpdatePhase"
|
||||||
@refresh="handleVersionRefresh"
|
@refresh="handleVersionRefresh"
|
||||||
@open-release="openVersionReleasePage"
|
@open-release="openVersionReleasePage"
|
||||||
|
@apply-update="handleApplySystemUpdate"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
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"
|
v-if="isAdmin"
|
||||||
:status="versionStatus"
|
:status="versionStatus"
|
||||||
:loading="loadingVersionStatus"
|
:loading="loadingVersionStatus"
|
||||||
|
:updating="applyingSystemUpdate"
|
||||||
|
:update-phase="systemUpdatePhase"
|
||||||
@refresh="handleVersionRefresh"
|
@refresh="handleVersionRefresh"
|
||||||
@open-release="openVersionReleasePage"
|
@open-release="openVersionReleasePage"
|
||||||
|
@apply-update="handleApplySystemUpdate"
|
||||||
/>
|
/>
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<button
|
<button
|
||||||
@@ -385,6 +391,9 @@
|
|||||||
:release-url="updateInfo.release_url"
|
:release-url="updateInfo.release_url"
|
||||||
:release-notes="updateInfo.release_notes"
|
:release-notes="updateInfo.release_notes"
|
||||||
:published-at="updateInfo.published_at"
|
:published-at="updateInfo.published_at"
|
||||||
|
:updating="applyingSystemUpdate"
|
||||||
|
:update-phase="systemUpdatePhase"
|
||||||
|
@apply-update="handleApplySystemUpdate"
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
@@ -397,9 +406,11 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useModuleStore } from '@/stores/modules'
|
import { useModuleStore } from '@/stores/modules'
|
||||||
import { useDarkMode } from '@/composables/useDarkMode'
|
import { useDarkMode } from '@/composables/useDarkMode'
|
||||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import { isDemoMode } from '@/config/demo'
|
import { isDemoMode } from '@/config/demo'
|
||||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||||
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { Dialog } from '@/components/ui'
|
import { Dialog } from '@/components/ui'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
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 { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||||
|
|
||||||
|
type SystemUpdatePhase = 'download' | 'restart'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle } = useSiteInfo()
|
||||||
|
const { success, error: showError } = useToast()
|
||||||
const isDemo = computed(() => isDemoMode())
|
const isDemo = computed(() => isDemoMode())
|
||||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||||
|
|
||||||
@@ -475,8 +489,52 @@ const showUpdateDialog = ref(false)
|
|||||||
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
||||||
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
||||||
const loadingVersionStatus = ref(false)
|
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
|
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, () => {
|
watch(() => route.path, () => {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
@@ -508,6 +566,7 @@ async function loadVersionStatus() {
|
|||||||
versionStatusLoadPromise = (async () => {
|
versionStatusLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
versionStatus.value = await adminApi.checkUpdate()
|
versionStatus.value = await adminApi.checkUpdate()
|
||||||
|
syncSystemUpdatePhase(versionStatus.value)
|
||||||
return versionStatus.value
|
return versionStatus.value
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
|
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
|
||||||
@@ -521,6 +580,22 @@ async function loadVersionStatus() {
|
|||||||
return versionStatusLoadPromise
|
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() {
|
function handleVersionRefresh() {
|
||||||
void loadVersionStatus()
|
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() {
|
function showDebugUpdateDialog() {
|
||||||
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
||||||
updateInfo.value = {
|
updateInfo.value = {
|
||||||
@@ -547,6 +654,8 @@ function showDebugUpdateDialog() {
|
|||||||
published_at: new Date().toISOString(),
|
published_at: new Date().toISOString(),
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
preparedUpdateVersion.value = null
|
||||||
showUpdateDialog.value = true
|
showUpdateDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +677,8 @@ function showDebugVersionStatus(hasUpdate = true) {
|
|||||||
published_at: hasUpdate ? new Date().toISOString() : null,
|
published_at: hasUpdate ? new Date().toISOString() : null,
|
||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
systemUpdatePhase.value = 'download'
|
||||||
|
preparedUpdateVersion.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
|
|||||||
+26
-9
@@ -940,8 +940,9 @@ Install complete.
|
|||||||
|
|
||||||
Docker Compose service:
|
Docker Compose service:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
${compose_cmd} ps
|
./update.sh
|
||||||
${compose_cmd} logs -f app
|
${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:
|
Health checks:
|
||||||
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
|
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
|
||||||
@@ -961,9 +962,13 @@ compose_manual_start_steps() {
|
|||||||
|
|
||||||
Next steps:
|
Next steps:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
${compose_cmd} pull
|
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml pull
|
||||||
${compose_cmd} up -d
|
${compose_cmd} -f docker-compose.yml -f docker-compose.update.yml up -d
|
||||||
${compose_cmd} logs -f app
|
${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:
|
Generate a fresh key set any time:
|
||||||
cd ${COMPOSE_DIR}
|
cd ${COMPOSE_DIR}
|
||||||
@@ -977,13 +982,13 @@ start_compose_deployment() {
|
|||||||
|
|
||||||
info "pulling Docker Compose images"
|
info "pulling Docker Compose images"
|
||||||
if [[ "${compose_cmd}" == "docker compose" ]]; then
|
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"
|
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
|
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"
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1780,6 +1785,8 @@ generate_compose_env() {
|
|||||||
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "pretty"
|
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_LOG_DIR" "/app/logs"
|
||||||
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
|
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() {
|
generate_compose_single_node_env() {
|
||||||
@@ -1805,6 +1812,8 @@ APP_PORT=${APP_PORT:-8084}
|
|||||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||||
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
||||||
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||||
|
AETHER_SYSTEM_UPDATE_COMMAND=${COMPOSE_DIR}/update.sh
|
||||||
|
AETHER_SYSTEM_UPDATE_WORKDIR=${COMPOSE_DIR}
|
||||||
AETHER_RUNTIME_BACKEND=memory
|
AETHER_RUNTIME_BACKEND=memory
|
||||||
API_KEY_PREFIX=sk
|
API_KEY_PREFIX=sk
|
||||||
|
|
||||||
@@ -2185,7 +2194,9 @@ install_compose_mode() {
|
|||||||
ensure_directory "${COMPOSE_DIR}/logs"
|
ensure_directory "${COMPOSE_DIR}/logs"
|
||||||
|
|
||||||
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
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 ".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"
|
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||||
|
|
||||||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||||||
@@ -2200,8 +2211,10 @@ install_compose_mode() {
|
|||||||
|
|
||||||
Docker Compose files are ready:
|
Docker Compose files are ready:
|
||||||
${COMPOSE_DIR}/docker-compose.yml
|
${COMPOSE_DIR}/docker-compose.yml
|
||||||
|
${COMPOSE_DIR}/docker-compose.update.yml
|
||||||
${COMPOSE_DIR}/.env
|
${COMPOSE_DIR}/.env
|
||||||
${COMPOSE_DIR}/.env.example
|
${COMPOSE_DIR}/.env.example
|
||||||
|
${COMPOSE_DIR}/update.sh
|
||||||
${COMPOSE_DIR}/generate_keys.sh
|
${COMPOSE_DIR}/generate_keys.sh
|
||||||
${COMPOSE_DIR}/logs
|
${COMPOSE_DIR}/logs
|
||||||
EOF
|
EOF
|
||||||
@@ -2224,7 +2237,9 @@ install_compose_single_node_mode() {
|
|||||||
ensure_directory "${COMPOSE_DIR}/data"
|
ensure_directory "${COMPOSE_DIR}/data"
|
||||||
|
|
||||||
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
|
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 ".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"
|
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||||
|
|
||||||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||||||
@@ -2239,8 +2254,10 @@ install_compose_single_node_mode() {
|
|||||||
|
|
||||||
Docker Compose single-node files are ready:
|
Docker Compose single-node files are ready:
|
||||||
${COMPOSE_DIR}/docker-compose.yml
|
${COMPOSE_DIR}/docker-compose.yml
|
||||||
|
${COMPOSE_DIR}/docker-compose.update.yml
|
||||||
${COMPOSE_DIR}/.env
|
${COMPOSE_DIR}/.env
|
||||||
${COMPOSE_DIR}/.env.example
|
${COMPOSE_DIR}/.env.example
|
||||||
|
${COMPOSE_DIR}/update.sh
|
||||||
${COMPOSE_DIR}/generate_keys.sh
|
${COMPOSE_DIR}/generate_keys.sh
|
||||||
${COMPOSE_DIR}/data
|
${COMPOSE_DIR}/data
|
||||||
${COMPOSE_DIR}/logs
|
${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