feat: clarify deployment update strategies

This commit is contained in:
fawney19
2026-05-23 20:14:26 +08:00
parent 4b66cadf15
commit 18eac2dd7a
15 changed files with 399 additions and 61 deletions

View File

@@ -19,9 +19,9 @@ use crate::handlers::admin::system::shared::settings::{
};
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,
read_update_history, read_update_task_status, start_admin_system_rollback_task,
start_admin_system_update_task,
build_admin_system_update_capability_payload, current_self_update_blocker,
prepare_admin_system_update_task, read_update_history, read_update_task_status,
self_update_supported, start_admin_system_rollback_task, start_admin_system_update_task,
};
use crate::important_notification::build_important_notification_test_payload;
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
@@ -99,6 +99,16 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/prepare-update"
{
if !self_update_supported() {
return Ok(Some(
(
http::StatusCode::PRECONDITION_REQUIRED,
Json(json!({ "detail": current_self_update_blocker() })),
)
.into_response(),
));
}
let target_version = request_body
.filter(|b| !b.is_empty())
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())

View File

@@ -1,5 +1,8 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::build_admin_usage_counter_health_payload;
use crate::handlers::admin::system::shared::update::{
current_self_update_blocker, self_update_supported,
};
use crate::handlers::admin::system::shared::update_client::{
build_direct_update_http_client, build_update_http_client, has_explicit_update_proxy_env,
update_github_token_from_env,
@@ -52,7 +55,7 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
latest_release,
error,
);
apply_source_build_check_update_override(&mut payload, current_build_is_release());
apply_self_update_check_update_override(&mut payload, self_update_supported());
payload
}
@@ -62,7 +65,7 @@ pub(crate) fn build_admin_system_releases_list_payload(
) -> serde_json::Value {
let mut payload =
build_admin_system_releases_payload(current_aether_version(), releases, error);
apply_source_build_releases_override(&mut payload, current_build_is_release());
apply_self_update_releases_override(&mut payload, self_update_supported());
payload
}
@@ -70,8 +73,20 @@ fn current_build_is_release() -> bool {
option_env!("AETHER_BUILD_TYPE").unwrap_or("source") == "release"
}
fn apply_source_build_check_update_override(payload: &mut Value, release_build: bool) {
if release_build {
fn apply_self_update_check_update_override(payload: &mut Value, supported: bool) {
apply_self_update_check_update_override_with_blocker(
payload,
supported,
current_self_update_blocker(),
);
}
fn apply_self_update_check_update_override_with_blocker(
payload: &mut Value,
supported: bool,
blocker: &str,
) {
if supported {
return;
}
if payload.get("has_update").and_then(Value::as_bool) != Some(true) {
@@ -79,11 +94,23 @@ fn apply_source_build_check_update_override(payload: &mut Value, release_build:
}
payload["updatable"] = json!(false);
payload["update_blocker"] = json!(SOURCE_BUILD_UPDATE_BLOCKER);
payload["update_blocker"] = json!(blocker);
}
fn apply_source_build_releases_override(payload: &mut Value, release_build: bool) {
if release_build {
fn apply_self_update_releases_override(payload: &mut Value, supported: bool) {
apply_self_update_releases_override_with_blocker(
payload,
supported,
current_self_update_release_blocker(),
);
}
fn apply_self_update_releases_override_with_blocker(
payload: &mut Value,
supported: bool,
blocker: &str,
) {
if supported {
return;
}
let Some(releases) = payload.get_mut("releases").and_then(Value::as_array_mut) else {
@@ -96,11 +123,23 @@ fn apply_source_build_releases_override(payload: &mut Value, release_build: bool
}
release["updatable"] = json!(false);
if release.get("update_blocker").is_none() || release["update_blocker"].is_null() {
release["update_blocker"] = json!(SOURCE_BUILD_RELEASE_BLOCKER);
release["update_blocker"] = json!(blocker);
}
}
}
fn current_self_update_release_blocker() -> &'static str {
if !current_build_is_release() {
return SOURCE_BUILD_RELEASE_BLOCKER;
}
if self_update_supported() {
""
} else {
current_self_update_blocker()
}
}
#[cfg(not(test))]
struct CachedReleases {
all: Vec<AdminSystemUpdateRelease>,
@@ -719,21 +758,25 @@ mod tests {
}
#[test]
fn source_build_check_update_override_marks_latest_release_non_updatable() {
fn self_update_check_update_override_marks_latest_release_non_updatable() {
let mut payload = json!({
"has_update": true,
"updatable": true,
"update_blocker": serde_json::Value::Null
});
apply_source_build_check_update_override(&mut payload, false);
apply_self_update_check_update_override_with_blocker(
&mut payload,
false,
SOURCE_BUILD_UPDATE_BLOCKER,
);
assert_eq!(payload["updatable"], false);
assert_eq!(payload["update_blocker"], SOURCE_BUILD_UPDATE_BLOCKER);
}
#[test]
fn source_build_releases_override_marks_non_current_entries_non_updatable() {
fn self_update_releases_override_marks_non_current_entries_non_updatable() {
let mut payload = json!({
"releases": [
{
@@ -751,7 +794,11 @@ mod tests {
]
});
apply_source_build_releases_override(&mut payload, false);
apply_self_update_releases_override_with_blocker(
&mut payload,
false,
SOURCE_BUILD_RELEASE_BLOCKER,
);
assert_eq!(payload["releases"][0]["updatable"], false);
assert_eq!(

View File

@@ -113,6 +113,74 @@ const MAX_SHA256SUMS_DOWNLOAD_BYTES: u64 = 1024 * 1024;
const MAX_EXTRACTED_RELEASE_BYTES: u64 = 1024 * 1024 * 1024;
const DEFAULT_UPDATE_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
const DEFAULT_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS: u64 = 30;
const SOURCE_BUILD_UPDATE_BLOCKER: &str = "当前为源码构建,请使用 git pull 后重新编译。";
const DOCKER_UPDATE_BLOCKER: &str =
"Docker 部署请使用镜像更新:进入 docker-compose.yml 所在目录执行 ./update.sh。";
const MANUAL_UPDATE_BLOCKER: &str =
"当前部署策略不支持在线自更新,请手动下载 Release 或使用安装脚本更新。";
const MULTI_NODE_UPDATE_BLOCKER: &str =
"多节点部署不支持在管理后台更新单个节点,请使用镜像滚动更新或外部发布编排。";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateStrategy {
SelfManaged,
Docker,
Manual,
}
impl UpdateStrategy {
fn from_env_value(value: Option<&str>, release_build: bool) -> Self {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return if release_build {
Self::SelfManaged
} else {
Self::Manual
};
};
match value.to_ascii_lowercase().as_str() {
"self" | "self-managed" | "binary" | "systemd" | "launchd" => Self::SelfManaged,
"docker" | "compose" | "docker-compose" | "container" => Self::Docker,
"manual" | "source" | "none" | "off" | "disabled" => Self::Manual,
_ => Self::Manual,
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::SelfManaged => "self",
Self::Docker => "docker",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeploymentTopology {
SingleNode,
MultiNode,
}
impl DeploymentTopology {
fn from_env_value(value: Option<&str>) -> Self {
match value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("multi-node" | "multi" | "cluster") => Self::MultiNode,
_ => Self::SingleNode,
}
}
fn as_str(self) -> &'static str {
match self {
Self::SingleNode => "single-node",
Self::MultiNode => "multi-node",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct UpdateHistoryEntry {
@@ -249,12 +317,84 @@ fn is_release_build() -> bool {
true
}
pub(crate) fn current_update_strategy() -> UpdateStrategy {
UpdateStrategy::from_env_value(
std::env::var("AETHER_UPDATE_STRATEGY").ok().as_deref(),
is_release_build(),
)
}
fn current_deployment_topology() -> DeploymentTopology {
DeploymentTopology::from_env_value(
std::env::var("AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY")
.ok()
.as_deref(),
)
}
fn self_update_supported_for(
release_build: bool,
update_strategy: UpdateStrategy,
deployment_topology: DeploymentTopology,
) -> bool {
release_build
&& update_strategy == UpdateStrategy::SelfManaged
&& deployment_topology == DeploymentTopology::SingleNode
}
pub(crate) fn self_update_supported() -> bool {
self_update_supported_for(
is_release_build(),
current_update_strategy(),
current_deployment_topology(),
)
}
pub(crate) fn current_self_update_blocker() -> &'static str {
if !is_release_build() {
return SOURCE_BUILD_UPDATE_BLOCKER;
}
if current_deployment_topology() == DeploymentTopology::MultiNode {
return MULTI_NODE_UPDATE_BLOCKER;
}
match current_update_strategy() {
UpdateStrategy::SelfManaged => "一键更新可用",
UpdateStrategy::Docker => DOCKER_UPDATE_BLOCKER,
UpdateStrategy::Manual => MANUAL_UPDATE_BLOCKER,
}
}
fn update_logs_dir() -> PathBuf {
std::env::var("AETHER_LOG_DIR")
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| aether_base_dir().join("logs"))
}
fn docker_update_command() -> String {
std::env::var("AETHER_DOCKER_UPDATE_COMMAND")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "./update.sh".to_string())
}
pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Value {
let supported = is_release_build();
let build_type = current_build_type();
let rollback_available = find_rollback_target().is_some();
let update_strategy = current_update_strategy();
let deployment_topology = current_deployment_topology();
let supported =
self_update_supported_for(is_release_build(), update_strategy, deployment_topology);
let rollback_available = supported && find_rollback_target().is_some();
let task_status = read_update_task_status();
let base_dir = aether_base_dir();
let docker_command = if update_strategy == UpdateStrategy::Docker {
Some(docker_update_command())
} else {
None
};
let data_dir = base_dir.join("data");
json!({
"supported": supported,
"enabled": supported,
@@ -262,11 +402,19 @@ pub(crate) fn build_admin_system_update_capability_payload() -> serde_json::Valu
"task_status": task_status.phase,
"task_error": task_status.error,
"build_type": build_type,
"install_root": base_dir,
"update_strategy": update_strategy.as_str(),
"strategy": update_strategy.as_str(),
"deployment_topology": deployment_topology.as_str(),
"topology": deployment_topology.as_str(),
"install_root": base_dir.clone(),
"base_dir": base_dir,
"data_dir": data_dir,
"logs_dir": update_logs_dir(),
"docker_update_command": docker_command,
"message": if supported {
"一键更新可用"
} else {
"源码构建不支持在线更新"
current_self_update_blocker()
},
})
}
@@ -291,8 +439,8 @@ pub(crate) async fn prepare_admin_system_update_task(
tarball_url: String,
sha256sums_url: Option<String>,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
if !is_release_build() {
return Ok(Err(source_build_rejection_response()));
if !self_update_supported() {
return Ok(Err(self_update_rejection_response()));
}
let Some(sha256sums_url) = sha256sums_url.filter(|url| !url.trim().is_empty()) else {
return Ok(Err((
@@ -692,8 +840,8 @@ fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
pub(crate) async fn start_admin_system_update_task(
version: Option<String>,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
if !is_release_build() {
return Ok(Err(source_build_rejection_response()));
if !self_update_supported() {
return Ok(Err(self_update_rejection_response()));
}
let version = match version.or_else(get_prepared_version) {
@@ -800,8 +948,8 @@ fn switch_current_symlink(version: &str) -> Result<(), String> {
pub(crate) async fn start_admin_system_rollback_task(
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
if !is_release_build() {
return Ok(Err(source_build_rejection_response()));
if !self_update_supported() {
return Ok(Err(self_update_rejection_response()));
}
let Some(previous) = find_rollback_target() else {
@@ -861,10 +1009,10 @@ fn update_already_running_response() -> (http::StatusCode, serde_json::Value) {
)
}
fn source_build_rejection_response() -> (http::StatusCode, serde_json::Value) {
fn self_update_rejection_response() -> (http::StatusCode, serde_json::Value) {
(
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": "\u{6e90}\u{7801}\u{6784}\u{5efa}\u{4e0d}\u{652f}\u{6301}\u{5728}\u{7ebf}\u{66f4}\u{65b0}\u{ff0c}\u{8bf7}\u{4f7f}\u{7528}\u{6b63}\u{5f0f}\u{53d1}\u{5e03}\u{7248}" }),
json!({ "detail": current_self_update_blocker() }),
)
}
@@ -893,6 +1041,60 @@ mod tests {
.expect("frontend index should be written");
}
#[test]
fn update_strategy_defaults_to_self_only_for_release_builds() {
assert_eq!(
UpdateStrategy::from_env_value(None, true),
UpdateStrategy::SelfManaged
);
assert_eq!(
UpdateStrategy::from_env_value(None, false),
UpdateStrategy::Manual
);
}
#[test]
fn update_strategy_parses_docker_as_non_self_update() {
assert_eq!(
UpdateStrategy::from_env_value(Some("docker"), true),
UpdateStrategy::Docker
);
assert_eq!(
UpdateStrategy::from_env_value(Some("compose"), true),
UpdateStrategy::Docker
);
assert_eq!(
UpdateStrategy::from_env_value(Some("unknown"), true),
UpdateStrategy::Manual
);
}
#[test]
fn deployment_topology_defaults_to_single_node() {
assert_eq!(
DeploymentTopology::from_env_value(None),
DeploymentTopology::SingleNode
);
assert_eq!(
DeploymentTopology::from_env_value(Some("multi-node")),
DeploymentTopology::MultiNode
);
}
#[test]
fn multi_node_topology_disables_self_update() {
assert!(self_update_supported_for(
true,
UpdateStrategy::SelfManaged,
DeploymentTopology::SingleNode,
));
assert!(!self_update_supported_for(
true,
UpdateStrategy::SelfManaged,
DeploymentTopology::MultiNode,
));
}
#[test]
fn update_finds_nested_release_payload_dir() {
let staging = temp_test_dir("nested");