feat: 添加在线更新功能

This commit is contained in:
zhiqicloud
2026-05-20 22:29:11 +08:00
parent 754f672ee2
commit 9562295d8b
18 changed files with 1196 additions and 13 deletions
+5
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
+30 -1
View File
@@ -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));
+14
View File
@@ -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
+40
View File
@@ -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>
+111
View File
@@ -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
View File
@@ -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
Executable
+273
View File
@@ -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