mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 添加在线更新功能
This commit is contained in:
@@ -23,6 +23,33 @@ pub(super) fn classify_admin_system_family_route(
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path == "/api/admin/system/update-capability"
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"update_capability",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/prepare-update"
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"prepare_update",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST && normalized_path == "/api/admin/system/apply-update" {
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"system_manage",
|
||||
"apply_update",
|
||||
"admin:system",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
|
||||
@@ -234,6 +234,43 @@ fn classifies_admin_system_check_update_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_system_update_routes_as_admin_proxy_routes() {
|
||||
let headers = headers(&[]);
|
||||
let cases = [
|
||||
(
|
||||
http::Method::GET,
|
||||
"/api/admin/system/update-capability",
|
||||
"update_capability",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/system/prepare-update",
|
||||
"prepare_update",
|
||||
),
|
||||
(
|
||||
http::Method::POST,
|
||||
"/api/admin/system/apply-update",
|
||||
"apply_update",
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, expected_kind) in cases {
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision =
|
||||
classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("system_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(expected_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:system")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_system_aws_regions_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -17,6 +17,10 @@ use crate::handlers::admin::system::shared::settings::{
|
||||
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
|
||||
};
|
||||
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
|
||||
use crate::handlers::admin::system::shared::update::{
|
||||
build_admin_system_update_capability_payload, prepare_admin_system_update_task,
|
||||
start_admin_system_update_task,
|
||||
};
|
||||
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::usage::UsageCleanupTargets;
|
||||
@@ -67,6 +71,47 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("update_capability")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/update-capability"
|
||||
{
|
||||
return Ok(Some(
|
||||
Json(build_admin_system_update_capability_payload()).into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("prepare_update")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/prepare-update"
|
||||
{
|
||||
return Ok(Some(match prepare_admin_system_update_task().await? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_update_prepared",
|
||||
"prepare_system_update",
|
||||
"system_update",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("apply_update")
|
||||
&& request_method == http::Method::POST
|
||||
&& request_path == "/api/admin/system/apply-update"
|
||||
{
|
||||
return Ok(Some(match start_admin_system_update_task().await? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_update_started",
|
||||
"apply_system_update",
|
||||
"system_update",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("aws_regions")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/aws-regions"
|
||||
|
||||
@@ -4,3 +4,4 @@ pub(crate) mod modules;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod settings;
|
||||
pub(crate) mod smtp;
|
||||
pub(crate) mod update;
|
||||
|
||||
@@ -22,6 +22,11 @@ use std::time::Duration;
|
||||
const AETHER_RELEASES_API_URL: &str =
|
||||
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
||||
|
||||
/// Minimum interval between actual GitHub API requests. Within this window
|
||||
/// the cached result is reused.
|
||||
#[cfg(not(test))]
|
||||
const RELEASE_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
pub(crate) fn current_aether_version() -> String {
|
||||
option_env!("AETHER_BUILD_VERSION")
|
||||
.filter(|version| !version.is_empty())
|
||||
@@ -47,10 +52,40 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
|
||||
#[cfg(not(test))]
|
||||
pub(crate) async fn fetch_latest_admin_system_release(
|
||||
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
|
||||
match fetch_latest_admin_system_release_inner().await {
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
struct CachedRelease {
|
||||
result: (Option<AdminSystemUpdateRelease>, Option<String>),
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
static CACHE: std::sync::OnceLock<Mutex<Option<CachedRelease>>> = std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(None));
|
||||
|
||||
{
|
||||
if let Ok(guard) = cache.lock() {
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
if cached.fetched_at.elapsed() < RELEASE_CACHE_TTL {
|
||||
return cached.result.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = match fetch_latest_admin_system_release_inner().await {
|
||||
Ok(release) => (release, None),
|
||||
Err(err) => (None, Some(err)),
|
||||
};
|
||||
|
||||
if let Ok(mut guard) = cache.lock() {
|
||||
*guard = Some(CachedRelease {
|
||||
result: result.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -80,7 +115,7 @@ async fn fetch_latest_admin_system_release_inner(
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.find(|release| !release.draft && release.tag_name.starts_with('v'))
|
||||
.find(|release| !release.draft && !release.prerelease && release.tag_name.starts_with('v'))
|
||||
.map(|release| AdminSystemUpdateRelease {
|
||||
version: release.tag_name,
|
||||
release_url: Some(release.html_url),
|
||||
@@ -100,6 +135,8 @@ struct GitHubRelease {
|
||||
published_at: Option<String>,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_system_stats_payload(
|
||||
|
||||
259
apps/aether-gateway/src/handlers/admin/system/shared/update.rs
Normal file
259
apps/aether-gateway/src/handlers/admin/system/shared/update.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use crate::GatewayError;
|
||||
use axum::http;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
const SYSTEM_UPDATE_COMMAND_ENV: &str = "AETHER_SYSTEM_UPDATE_COMMAND";
|
||||
const SYSTEM_UPDATE_WORKDIR_ENV: &str = "AETHER_SYSTEM_UPDATE_WORKDIR";
|
||||
|
||||
static SYSTEM_UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// RAII guard that resets [`SYSTEM_UPDATE_RUNNING`] on drop.
|
||||
struct SystemUpdateGuard;
|
||||
|
||||
impl SystemUpdateGuard {
|
||||
fn try_acquire() -> Option<Self> {
|
||||
if SYSTEM_UPDATE_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
{
|
||||
Some(Self)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemUpdateGuard {
|
||||
fn drop(&mut self) {
|
||||
SYSTEM_UPDATE_RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Value {
|
||||
let status = system_update_status();
|
||||
json!({
|
||||
"enabled": status.enabled,
|
||||
"command_env": SYSTEM_UPDATE_COMMAND_ENV,
|
||||
"workdir_env": SYSTEM_UPDATE_WORKDIR_ENV,
|
||||
"command": status.command,
|
||||
"workdir": status.workdir,
|
||||
"detail": status.detail,
|
||||
"message": if status.enabled {
|
||||
"一键更新已启用"
|
||||
} else {
|
||||
status.detail.as_deref().unwrap_or("未配置一键更新命令")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_admin_system_update_task(
|
||||
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
||||
let (command, workdir) = match prepare_system_update_command(&["--prepare"]) {
|
||||
Ok(command) => command,
|
||||
Err(response) => return Ok(Err(response)),
|
||||
};
|
||||
let Some(guard) = SystemUpdateGuard::try_acquire() else {
|
||||
return Ok(Err(update_already_running_response()));
|
||||
};
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || run_system_update_command(&command, workdir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|inner| inner);
|
||||
drop(guard);
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok(Ok(json!({
|
||||
"message": "更新包已下载完成,点击“立即重启”完成安装",
|
||||
"started": true,
|
||||
"need_restart": true,
|
||||
}))),
|
||||
Err(err) => Ok(Err((
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "detail": err }),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_admin_system_update_task(
|
||||
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
||||
let (command, workdir) = match prepare_system_update_command(&["--no-pull", "--force-recreate"])
|
||||
{
|
||||
Ok(command) => command,
|
||||
Err(response) => return Ok(Err(response)),
|
||||
};
|
||||
|
||||
let Some(guard) = SystemUpdateGuard::try_acquire() else {
|
||||
return Ok(Err(update_already_running_response()));
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || run_system_update_command(&command, workdir))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|inner| inner);
|
||||
if let Err(err) = result {
|
||||
tracing::error!(error = %err, "admin system one-click update failed");
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
Ok(Ok(json!({
|
||||
"message": "一键重启已启动,服务会在重建 app 容器后短暂不可用",
|
||||
"started": true,
|
||||
"need_restart": true,
|
||||
})))
|
||||
}
|
||||
|
||||
fn prepare_system_update_command(
|
||||
args: &[&str],
|
||||
) -> Result<(String, Option<String>), (http::StatusCode, serde_json::Value)> {
|
||||
let status = system_update_status();
|
||||
let Some(command) = status.command else {
|
||||
return Err(missing_update_command_response());
|
||||
};
|
||||
if !status.enabled {
|
||||
return Err((
|
||||
http::StatusCode::PRECONDITION_REQUIRED,
|
||||
json!({
|
||||
"detail": status.detail.unwrap_or_else(|| "一键更新运行时不可用".to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
Ok((append_command_args(&command, args), status.workdir))
|
||||
}
|
||||
|
||||
fn missing_update_command_response() -> (http::StatusCode, serde_json::Value) {
|
||||
(
|
||||
http::StatusCode::PRECONDITION_REQUIRED,
|
||||
json!({
|
||||
"detail": format!(
|
||||
"未配置一键更新命令。请在部署环境中设置 {SYSTEM_UPDATE_COMMAND_ENV},例如 /opt/aether/compose/update.sh"
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_already_running_response() -> (http::StatusCode, serde_json::Value) {
|
||||
(
|
||||
http::StatusCode::CONFLICT,
|
||||
json!({ "detail": "已有一键更新任务正在执行" }),
|
||||
)
|
||||
}
|
||||
|
||||
fn append_command_args(command: &str, args: &[&str]) -> String {
|
||||
if args.is_empty() {
|
||||
return command.to_string();
|
||||
}
|
||||
format!("{} {}", command, args.join(" "))
|
||||
}
|
||||
|
||||
fn system_update_command() -> Option<String> {
|
||||
std::env::var(SYSTEM_UPDATE_COMMAND_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn system_update_workdir() -> Option<String> {
|
||||
std::env::var(SYSTEM_UPDATE_WORKDIR_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SystemUpdateStatus {
|
||||
enabled: bool,
|
||||
command: Option<String>,
|
||||
workdir: Option<String>,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
fn system_update_status() -> SystemUpdateStatus {
|
||||
let command = system_update_command();
|
||||
let workdir = system_update_workdir();
|
||||
let detail = validate_system_update_runtime(command.as_deref(), workdir.as_deref()).err();
|
||||
SystemUpdateStatus {
|
||||
enabled: command.is_some() && detail.is_none(),
|
||||
command,
|
||||
workdir,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_system_update_runtime(
|
||||
command: Option<&str>,
|
||||
workdir: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let Some(command) = command else {
|
||||
return Err(format!(
|
||||
"未配置一键更新命令。请设置 {SYSTEM_UPDATE_COMMAND_ENV}"
|
||||
));
|
||||
};
|
||||
let command_path = first_command_token(command);
|
||||
let path = Path::new(&command_path);
|
||||
if !path.is_file() {
|
||||
return Err(format!("一键更新命令路径不可访问: {command_path}"));
|
||||
}
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
let path = Path::new(workdir);
|
||||
if !path.is_dir() {
|
||||
return Err(format!("一键更新工作目录不可访问: {workdir}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn first_command_token(command: &str) -> String {
|
||||
command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or(command)
|
||||
.trim_matches(['"', '\''])
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn run_system_update_command(command: &str, workdir: Option<String>) -> Result<(), String> {
|
||||
validate_system_update_runtime(Some(command), workdir.as_deref())?;
|
||||
|
||||
let mut process = if cfg!(windows) {
|
||||
let mut process = Command::new("cmd");
|
||||
process.arg("/C").arg(command);
|
||||
process
|
||||
} else {
|
||||
let mut process = Command::new("sh");
|
||||
process.arg("-c").arg(command);
|
||||
process
|
||||
};
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
process.current_dir(workdir);
|
||||
}
|
||||
|
||||
let output = process
|
||||
.output()
|
||||
.map_err(|err| format!("启动一键更新命令失败: {err}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = stderr
|
||||
.trim()
|
||||
.split('\n')
|
||||
.next()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.or_else(|| stdout.trim().split('\n').next())
|
||||
.unwrap_or("更新命令执行失败");
|
||||
Err(format!("一键更新命令退出状态 {}: {detail}", output.status))
|
||||
}
|
||||
@@ -36,6 +36,70 @@ use crate::constants::{
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
struct TestEnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
struct TestUpdateCommand {
|
||||
path: std::path::PathBuf,
|
||||
log_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for TestUpdateCommand {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
let _ = std::fs::remove_file(&self.log_path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.previous.as_deref() {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_test_env_var(key: &'static str, value: &str) -> TestEnvVarGuard {
|
||||
let previous = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
TestEnvVarGuard { key, previous }
|
||||
}
|
||||
|
||||
fn create_test_update_command() -> TestUpdateCommand {
|
||||
let suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be valid")
|
||||
.as_nanos();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
let path = temp_dir.join(format!("aether-update-test-{suffix}.{extension}"));
|
||||
let log_path = temp_dir.join(format!("aether-update-test-{suffix}.log"));
|
||||
let log_path_text = log_path.to_string_lossy();
|
||||
let content = if cfg!(windows) {
|
||||
format!("@echo off\r\necho %*>>\"{log_path_text}\"\r\nexit /b 0\r\n")
|
||||
} else {
|
||||
let escaped_log_path = log_path_text.replace('"', "\\\"");
|
||||
format!("#!/usr/bin/env sh\nprintf '%s\\n' \"$*\" >> \"{escaped_log_path}\"\n")
|
||||
};
|
||||
std::fs::write(&path, content).expect("test update command should be written");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = std::fs::metadata(&path)
|
||||
.expect("test update command metadata should be readable")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions)
|
||||
.expect("test update command should be executable");
|
||||
}
|
||||
|
||||
TestUpdateCommand { path, log_path }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -158,6 +222,166 @@ async fn gateway_handles_admin_system_check_update_locally_with_bearer_admin_ses
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_update_capability_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/update-capability",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/admin/system/update-capability"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["command_env"], "AETHER_SYSTEM_UPDATE_COMMAND");
|
||||
assert!(payload["enabled"].is_boolean());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_prepares_admin_system_update_locally() {
|
||||
let command = create_test_update_command();
|
||||
let _command_guard = set_test_env_var(
|
||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
||||
command
|
||||
.path
|
||||
.to_str()
|
||||
.expect("test command path should be utf-8"),
|
||||
);
|
||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/prepare-update",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/prepare-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["need_restart"], json!(true));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("立即重启")));
|
||||
let command_log = std::fs::read_to_string(&command.log_path).expect("test command should run");
|
||||
assert!(command_log.contains("--prepare"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_without_config_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/apply-update",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("AETHER_SYSTEM_UPDATE_COMMAND")));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_when_command_path_is_inaccessible() {
|
||||
let _command_guard = set_test_env_var(
|
||||
"AETHER_SYSTEM_UPDATE_COMMAND",
|
||||
"/definitely/missing/aether-update.sh",
|
||||
);
|
||||
let _workdir_guard = set_test_env_var("AETHER_SYSTEM_UPDATE_WORKDIR", ".");
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains("路径不可访问")));
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_aws_regions_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user