Merge remote-tracking branch 'origin/pr/544'

This commit is contained in:
fawney19
2026-05-23 18:41:52 +08:00
36 changed files with 4329 additions and 191 deletions

View File

@@ -57,6 +57,29 @@ ADMIN_USERNAME=admin123456
# docker compose 下 app 启动前自动执行 pending migration/backfill默认 true
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
# 管理后台一键更新(正式发布版默认可用)
# 正式发布版会下载 GitHub Release 包,校验 SHA256 后切换 /opt/aether/current 并重启。
# 源码/本地构建不支持后台在线更新。
# AETHER_BASE_DIR=/opt/aether
# Docker Compose 默认把应用日志输出到 stdout/stderr。
# 如需文件日志,可改成 file 或 both并把可写目录挂载到 /opt/aether/logs。
# AETHER_LOG_DESTINATION=stdout
# AETHER_LOG_FORMAT=pretty
# AETHER_LOG_DIR=/opt/aether/logs
# 服务器访问 GitHub 需要代理时可配置;也兼容 UPDATE_PROXY_URL / HTTPS_PROXY / ALL_PROXY / HTTP_PROXY。
# 如果 Aether 跑在 Docker 容器里,想走宿主机代理时请写 host.docker.internal不要写 127.0.0.1。
# AETHER_UPDATE_PROXY_URL=http://host.docker.internal:7890
# 共享出口触发 GitHub API 限流时可配置只读 token也兼容 GITHUB_TOKEN / GH_TOKEN。
# AETHER_UPDATE_GITHUB_TOKEN=
# 下载超时控制:总超时默认 600 秒;连续无响应/无数据默认 30 秒。
# AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS=600
# AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS=30
# 本地联调后台在线更新(配合 docker-compose.release-local.yml
# 会用当前源码构建 release-layout 测试镜像,并伪装成较旧版本以触发升级入口。
# AETHER_RELEASE_LOCAL_VERSION=v0.7.0
# AETHER_RELEASE_LOCAL_PORT=18085
# LOCAL_RELEASE_APP_IMAGE=aether-app:release-local
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
# 推荐计算方式(单实例):
# MAX = CPU 核数 × 10AI 网关偏 IO 等待,可激进些;纯 OLTP 用 × 4

View File

@@ -146,6 +146,7 @@ jobs:
- name: Build
env:
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
AETHER_BUILD_TYPE: release
CARGO_TERM_COLOR: always
shell: bash
run: |
@@ -270,6 +271,7 @@ jobs:
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
install.sh > "${root}/install.sh"
chmod 0755 "${root}/install.sh"
install -m 0755 update.sh "${root}/update.sh"
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
install -m 0644 docker-compose.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"

1
Cargo.lock generated
View File

@@ -270,6 +270,7 @@ dependencies = [
"sha1",
"sha2",
"sqlx",
"tar",
"thiserror 2.0.18",
"tikv-jemallocator",
"tokio",

View File

@@ -91,6 +91,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_path_to_error = "0.1"
sha2 = "0.10"
tar = "0.4"
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }

View File

@@ -1,31 +1,42 @@
# syntax=docker/dockerfile:1
# Aether Gateway 运行时镜像(交叉编译方案)
# 二进制和前端产物均由 CI 预先构建,此 Dockerfile 仅做打包
# 用法: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
# Aether Gateway runtime image (cross-compilation)
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
#
# 构建上下文中须包含:
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl 交叉编译产物)
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl 交叉编译产物)
# dist/frontend/ (npm run build 产物)
# Build context must contain:
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
# dist/frontend/ (npm run build output)
FROM gcr.io/distroless/static-debian12
# --- layout stage: create /opt/aether directory structure with symlink ---
# distroless has no shell, so we use busybox to set up the symlink.
FROM busybox:1.37-musl AS layout
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
ARG TARGETARCH
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
COPY dist/frontend/ /srv/frontend
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
WORKDIR /app
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
COPY dist/frontend/ /opt/aether/releases/image/frontend/
RUN ln -s /opt/aether/releases/image /opt/aether/current
# --- final stage: distroless runtime ---
FROM gcr.io/distroless/static-debian12
COPY --from=layout --chown=65532:65532 /opt/aether /opt/aether
WORKDIR /opt/aether
ENV RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER root
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
USER 65532:65532
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]

View File

@@ -1,11 +1,13 @@
# syntax=docker/dockerfile:1
# syntax=docker.m.daocloud.io/docker/dockerfile:1
# Aether 运行镜像Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
ARG RUST_VERSION=1.95.0
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
# ==================== 前端构建 ====================
FROM node:22-slim AS frontend-builder
FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
@@ -18,7 +20,7 @@ COPY frontend/ ./
RUN npm run build
# ==================== Rust gateway 构建 ====================
FROM rust:${RUST_VERSION}-slim AS gateway-base
FROM ${RUST_BASE_IMAGE} AS gateway-base
WORKDIR /build
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。

View File

@@ -0,0 +1,149 @@
# syntax=docker.m.daocloud.io/docker/dockerfile:1
# Aether 本地发布版联调镜像
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
ARG RUST_VERSION=1.95.0
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
# ==================== 前端构建 ====================
FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
npm config set registry https://registry.npmmirror.com && \
npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ==================== Rust gateway 构建 ====================
FROM ${RUST_BASE_IMAGE} AS gateway-base
WORKDIR /build
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_PROFILE_RELEASE_LTO=thin \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
git \
libclang-dev \
libssl-dev \
pkg-config \
perl
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
cargo install cargo-chef --locked
FROM gateway-base AS gateway-planner
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN cargo chef prepare --recipe-path recipe.json
FROM gateway-base AS gateway-builder
ARG AETHER_BUILD_VERSION
ARG AETHER_BUILD_TYPE=release
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION} \
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
cargo build --release --locked -p aether-gateway && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
FROM gateway-builder AS runtime-prep
RUN set -eux; \
mkdir -p \
/runtime-root/app/data \
/runtime-root/etc \
/runtime-root/etc/ssl \
/runtime-root/lib \
/runtime-root/lib64 \
/runtime-root/usr/lib \
/runtime-root/opt/aether/logs \
/runtime-root/opt/aether/releases/image/bin \
/runtime-root/opt/aether/releases/image/frontend; \
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
: > /tmp/runtime-libs.txt; \
: > /tmp/runtime-scan-queue.txt; \
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
while [ -s /tmp/runtime-scan-queue.txt ]; do \
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
sed -i '1d' /tmp/runtime-scan-queue.txt; \
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
[ -n "$lib" ]; \
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
fi; \
done; \
done; \
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
while read -r lib; do \
[ -n "$lib" ]; \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
done < /tmp/runtime-libs.txt; \
for lib in \
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
/lib/x86_64-linux-gnu/libnss_files.so.2 \
/lib/x86_64-linux-gnu/libresolv.so.2; do \
if [ -f "$lib" ]; then \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
fi; \
done; \
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
if [ -f /etc/ssl/openssl.cnf ]; then \
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
fi; \
if [ -f /etc/nsswitch.conf ]; then \
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
fi
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
# ==================== 运行时镜像 ====================
FROM scratch
COPY --from=runtime-prep /runtime-root/ /
WORKDIR /app
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_BASE_DIR=/opt/aether \
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]

View File

@@ -55,10 +55,59 @@ docker compose pull && docker compose up -d
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
```
### 一键更新
Docker Compose 部署后,可在部署目录直接执行:
```bash
./update.sh
```
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器数据卷、Postgres、Redis 不会被删除。Single Node 部署也可显式指定:
```bash
./update.sh --mode single-node
```
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,再显式设置 `AETHER_LOG_DESTINATION=file|both`,并把一个可写目录挂载到 `/opt/aether/logs`(或同步覆盖 `AETHER_LOG_DIR`)。
管理后台右上角“版本信息”会在检测到新版本时显示“立即更新”。正式发布版支持后台内置更新:点击后会下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 Docker restart policy / systemd / launchd 拉起新版本。整个过程不需要挂载 Docker socket也不需要额外 helper 容器。
源码或本地构建版本不会启用后台在线更新请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL``HTTPS_PROXY``ALL_PROXY``HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS``AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
如果是本地源码构建镜像的部署,继续使用:
```bash
./deploy.sh
```
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
```bash
docker compose -f docker-compose.release-local.yml up -d --build
```
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
```bash
docker compose -f docker-compose.release-local.yml down -v
```
可选变量:
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
### 一键安装(默认 Single NodeLinux systemd / macOS launchd + SQLite
```bash
cd Aether && cd Aether
git clone https://github.com/fawney19/Aether.git
cd Aether
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
```

View File

@@ -57,6 +57,7 @@ serde.workspace = true
serde_json.workspace = true
sha1 = "0.10"
sha2 = { workspace = true, features = ["oid"] }
tar.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true

View File

@@ -3,6 +3,7 @@ use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=AETHER_BUILD_VERSION");
println!("cargo:rerun-if-env-changed=AETHER_BUILD_TYPE");
println!("cargo:rerun-if-env-changed=AETHER_VERSION");
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
println!("cargo:rerun-if-changed=../../.git/HEAD");
@@ -27,6 +28,12 @@ fn main() {
.unwrap_or(package_version);
println!("cargo:rustc-env=AETHER_BUILD_VERSION={version}");
let build_type = env::var("AETHER_BUILD_TYPE")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "source".to_string());
println!("cargo:rustc-env=AETHER_BUILD_TYPE={build_type}");
}
fn git_describe_version() -> Option<String> {

View File

@@ -23,6 +23,65 @@ pub(super) fn classify_admin_system_family_route(
"admin:system",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/system/releases" {
Some(classified(
"admin_proxy",
"system_manage",
"releases",
"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::POST && normalized_path == "/api/admin/system/rollback" {
Some(classified(
"admin_proxy",
"system_manage",
"rollback",
"admin:system",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/system/update-status" {
Some(classified(
"admin_proxy",
"system_manage",
"update_status",
"admin:system",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/system/update-history" {
Some(classified(
"admin_proxy",
"system_manage",
"update_history",
"admin:system",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/system/aws-regions" {
Some(classified(
"admin_proxy",

View File

@@ -238,6 +238,55 @@ 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",
),
(http::Method::POST, "/api/admin/system/rollback", "rollback"),
(http::Method::GET, "/api/admin/system/releases", "releases"),
(
http::Method::GET,
"/api/admin/system/update-history",
"update_history",
),
(
http::Method::GET,
"/api/admin/system/update-status",
"update_status",
),
];
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(&[]);

View File

@@ -13,10 +13,16 @@ use crate::handlers::admin::system::shared::paths::{
};
use crate::handlers::admin::system::shared::settings::{
apply_admin_system_settings_update, build_admin_api_formats_payload,
build_admin_system_check_update_payload_from_release, build_admin_system_settings_payload,
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
build_admin_system_check_update_payload_from_release, build_admin_system_releases_list_payload,
build_admin_system_settings_payload, build_admin_system_stats_payload, current_aether_version,
fetch_admin_system_releases, fetch_latest_admin_system_release, resolve_update_target,
};
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,
};
use crate::important_notification::build_important_notification_test_payload;
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
use crate::GatewayError;
@@ -58,7 +64,8 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/check-update"
{
let (latest_release, error) = fetch_latest_admin_system_release().await;
let force = query_flag(request_context.query_string(), "force");
let (latest_release, error) = fetch_latest_admin_system_release(force).await;
return Ok(Some(
Json(build_admin_system_check_update_payload_from_release(
latest_release,
@@ -68,6 +75,123 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
));
}
if decision.route_kind.as_deref() == Some("releases")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/releases"
{
let force = query_flag(request_context.query_string(), "force");
let (releases, error) = fetch_admin_system_releases(force).await;
return Ok(Some(
Json(build_admin_system_releases_list_payload(releases, error)).into_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"
{
let target_version = request_body
.filter(|b| !b.is_empty())
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
let (version, tarball_url, sha256sums_url) =
match resolve_update_target(target_version).await {
Ok(result) => result,
Err((status, payload)) => {
return Ok(Some((status, Json(payload)).into_response()));
}
};
return Ok(Some(
match prepare_admin_system_update_task(version, tarball_url, sha256sums_url).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"
{
let version = request_body
.filter(|b| !b.is_empty())
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
return Ok(Some(
match start_admin_system_update_task(version).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("rollback")
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/rollback"
{
return Ok(Some(match start_admin_system_rollback_task().await? {
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_system_rollback_started",
"rollback_system_update",
"system_rollback",
"global",
),
Err((status, payload)) => (status, Json(payload)).into_response(),
}));
}
if decision.route_kind.as_deref() == Some("update_status")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/update-status"
{
let status = read_update_task_status();
return Ok(Some(
Json(json!({
"phase": status.phase,
"error": status.error,
"output": status.output,
"progress_label": status.progress_label,
"downloaded_bytes": status.downloaded_bytes,
"total_bytes": status.total_bytes,
"progress_percent": status.progress_percent,
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("update_history")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/update-history"
{
let entries = read_update_history();
return Ok(Some(Json(json!({ "entries": entries })).into_response()));
}
if decision.route_kind.as_deref() == Some("aws_regions")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/aws-regions"
@@ -942,6 +1066,15 @@ fn query_param(query_string: Option<&str>, name: &str) -> Option<String> {
.find_map(|(key, value)| (key == name && !value.is_empty()).then(|| value.into_owned()))
}
fn query_flag(query_string: Option<&str>, name: &str) -> bool {
query_param(query_string, name).is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>, Response<Body>> {
let Some(value) = query_param(query_string, "older_than_days") else {
return Ok(None);

View File

@@ -4,3 +4,5 @@ pub(crate) mod modules;
pub(crate) mod paths;
pub(crate) mod settings;
pub(crate) mod smtp;
pub(crate) mod update;
pub(crate) mod update_client;

View File

@@ -1,11 +1,15 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::build_admin_usage_counter_health_payload;
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,
};
use crate::handlers::shared::{system_config_bool, system_config_string};
use crate::GatewayError;
use aether_admin::system::{
build_admin_api_formats_payload as build_admin_api_formats_payload_pure,
build_admin_system_check_update_payload as build_admin_system_check_update_payload_pure,
build_admin_system_check_update_payload_with_release,
build_admin_system_check_update_payload_with_release, build_admin_system_releases_payload,
build_admin_system_settings_payload as build_admin_system_settings_payload_pure,
build_admin_system_settings_updated_payload,
build_admin_system_stats_payload as build_admin_system_stats_payload_pure,
@@ -15,12 +19,18 @@ use axum::body::Bytes;
use axum::http;
#[cfg(not(test))]
use serde::Deserialize;
use serde_json::json;
#[cfg(not(test))]
use serde_json::{json, Value};
use std::time::Duration;
const AETHER_RELEASES_API_URL: &str =
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
const AETHER_RELEASE_TAG_URL_BASE: &str = "https://github.com/fawney19/Aether/releases/tag";
const SOURCE_BUILD_UPDATE_BLOCKER: &str = "当前为源码构建,请使用 git pull 后重新编译。";
const SOURCE_BUILD_RELEASE_BLOCKER: &str = "当前为源码构建,请手动切换到对应标签后重新编译。";
/// Minimum interval between actual GitHub API requests. Within this window
/// the cached result is reused.
const RELEASE_CACHE_TTL: Duration = Duration::from_secs(1200);
pub(crate) fn current_aether_version() -> String {
option_env!("AETHER_BUILD_VERSION")
@@ -37,56 +47,388 @@ pub(crate) fn build_admin_system_check_update_payload_from_release(
latest_release: Option<AdminSystemUpdateRelease>,
error: Option<String>,
) -> serde_json::Value {
build_admin_system_check_update_payload_with_release(
let mut payload = build_admin_system_check_update_payload_with_release(
current_aether_version(),
latest_release,
error,
)
);
apply_source_build_check_update_override(&mut payload, current_build_is_release());
payload
}
pub(crate) fn build_admin_system_releases_list_payload(
releases: Vec<AdminSystemUpdateRelease>,
error: Option<String>,
) -> 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());
payload
}
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 {
return;
}
if payload.get("has_update").and_then(Value::as_bool) != Some(true) {
return;
}
payload["updatable"] = json!(false);
payload["update_blocker"] = json!(SOURCE_BUILD_UPDATE_BLOCKER);
}
fn apply_source_build_releases_override(payload: &mut Value, release_build: bool) {
if release_build {
return;
}
let Some(releases) = payload.get_mut("releases").and_then(Value::as_array_mut) else {
return;
};
for release in releases {
if release.get("is_current").and_then(Value::as_bool) == Some(true) {
continue;
}
release["updatable"] = json!(false);
if release.get("update_blocker").is_none() || release["update_blocker"].is_null() {
release["update_blocker"] = json!(SOURCE_BUILD_RELEASE_BLOCKER);
}
}
}
#[cfg(not(test))]
struct CachedReleases {
all: Vec<AdminSystemUpdateRelease>,
latest: Option<AdminSystemUpdateRelease>,
error: Option<String>,
fetched_at: std::time::Instant,
}
#[cfg(not(test))]
fn releases_cache() -> &'static std::sync::Mutex<Option<CachedReleases>> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<CachedReleases>>> =
std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(None))
}
#[cfg(not(test))]
async fn ensure_releases_cached(force: bool) {
{
if let Ok(guard) = releases_cache().lock() {
if let Some(cached) = guard.as_ref() {
if should_reuse_releases_cache(
force,
cached.error.is_some(),
cached.fetched_at.elapsed(),
) {
return;
}
}
}
}
let (all, latest, error) = match fetch_admin_system_releases_inner().await {
Ok(releases) => {
let latest = releases.first().cloned();
(releases, latest, None)
}
Err(err) => (Vec::new(), None, Some(err)),
};
if let Ok(mut guard) = releases_cache().lock() {
*guard = Some(CachedReleases {
all,
latest,
error,
fetched_at: std::time::Instant::now(),
});
}
}
fn should_reuse_releases_cache(force: bool, has_error: bool, age: Duration) -> bool {
!force && !has_error && age < RELEASE_CACHE_TTL
}
#[cfg(not(test))]
pub(crate) async fn fetch_latest_admin_system_release(
force: bool,
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
match fetch_latest_admin_system_release_inner().await {
Ok(release) => (release, None),
Err(err) => (None, Some(err)),
ensure_releases_cached(force).await;
if let Ok(guard) = releases_cache().lock() {
if let Some(cached) = guard.as_ref() {
return (cached.latest.clone(), cached.error.clone());
}
}
(None, None)
}
#[cfg(not(test))]
pub(crate) async fn fetch_admin_system_releases(
force: bool,
) -> (Vec<AdminSystemUpdateRelease>, Option<String>) {
ensure_releases_cached(force).await;
if let Ok(guard) = releases_cache().lock() {
if let Some(cached) = guard.as_ref() {
return (cached.all.clone(), cached.error.clone());
}
}
(Vec::new(), None)
}
#[cfg(test)]
pub(crate) async fn fetch_admin_system_releases(
_force: bool,
) -> (Vec<AdminSystemUpdateRelease>, Option<String>) {
(
Vec::new(),
Some("测试环境未请求 GitHub Releases".to_string()),
)
}
#[cfg(test)]
pub(crate) async fn fetch_latest_admin_system_release(
_force: bool,
) -> (Option<AdminSystemUpdateRelease>, Option<String>) {
(None, Some("测试环境未请求 GitHub Releases".to_string()))
}
#[cfg(not(test))]
async fn fetch_latest_admin_system_release_inner(
) -> Result<Option<AdminSystemUpdateRelease>, String> {
let releases: Vec<GitHubRelease> = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.map_err(|err| format!("创建更新检查客户端失败: {err}"))?
.get(AETHER_RELEASES_API_URL)
.header(reqwest::header::USER_AGENT, "Aether-Gateway update-check")
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|err| format!("请求 GitHub Releases 失败: {err}"))?
.error_for_status()
.map_err(|err| format!("GitHub Releases 返回错误: {err}"))?
.json()
.await
.map_err(|err| format!("解析 GitHub Releases 失败: {err}"))?;
pub(crate) async fn resolve_update_target(
version: Option<String>,
) -> Result<(String, String, Option<String>), (http::StatusCode, serde_json::Value)> {
let (releases, error) = fetch_admin_system_releases(false).await;
if releases.is_empty() {
return Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": error.unwrap_or_else(|| "无法获取版本信息".to_string()) }),
));
}
let release = match version {
Some(ref v) => releases.into_iter().find(|r| r.version == *v),
None => releases.into_iter().next(),
};
let release = release.ok_or_else(|| {
(
http::StatusCode::NOT_FOUND,
json!({ "detail": "未找到指定版本" }),
)
})?;
let tarball_url = release.tarball_url.ok_or_else(|| {
(
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": format!("版本 {} 没有适用于当前平台的安装包", release.version) }),
)
})?;
let sha256sums_url = release.sha256sums_url.ok_or_else(|| {
(
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": format!("版本 {} 缺少 SHA256SUMS 校验文件,已拒绝在线更新", release.version) }),
)
})?;
Ok((release.version, tarball_url, Some(sha256sums_url)))
}
#[cfg(test)]
pub(crate) async fn resolve_update_target(
_version: Option<String>,
) -> Result<(String, String, Option<String>), (http::StatusCode, serde_json::Value)> {
Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "测试环境不支持更新" }),
))
}
#[cfg(not(test))]
async fn fetch_admin_system_releases_inner() -> Result<Vec<AdminSystemUpdateRelease>, String> {
let current_channel = update_channel_for_version(&current_aether_version());
let timeout = Duration::from_secs(8);
let github_token = update_github_token_from_env();
let client = build_update_http_client(timeout, "更新检查")?;
let releases = match fetch_github_releases_with_client(&client, github_token.as_deref()).await {
Ok(releases) => releases,
Err(err) if err.rate_limited && !has_explicit_update_proxy_env() => {
let direct_client = build_direct_update_http_client(timeout, "更新检查直连重试")?;
fetch_github_releases_with_client(&direct_client, github_token.as_deref())
.await
.map_err(|retry_err| retry_err.message)?
}
Err(err) => return Err(err.message),
};
Ok(releases
.into_iter()
.find(|release| !release.draft && release.tag_name.starts_with('v'))
.map(|release| AdminSystemUpdateRelease {
version: release.tag_name,
release_url: Some(release.html_url),
release_notes: release.body.filter(|body| !body.trim().is_empty()),
published_at: release.published_at,
}))
.filter(|release| should_include_release_for_channel(release, current_channel))
.map(|release| {
let (tarball_url, sha256sums_url) = select_release_tarball_urls(&release);
AdminSystemUpdateRelease {
version: release.tag_name.clone(),
release_url: Some(github_release_tag_url(&release.tag_name)),
release_notes: release.body.filter(|body| !body.trim().is_empty()),
published_at: release.published_at,
tarball_url,
sha256sums_url,
}
})
.collect())
}
#[derive(Debug)]
struct GitHubReleaseFetchError {
message: String,
rate_limited: bool,
}
#[cfg(not(test))]
async fn fetch_github_releases_with_client(
client: &reqwest::Client,
github_token: Option<&str>,
) -> Result<Vec<GitHubRelease>, GitHubReleaseFetchError> {
let mut request = client
.get(AETHER_RELEASES_API_URL)
.header(reqwest::header::USER_AGENT, "Aether-Gateway update-check")
.header(reqwest::header::ACCEPT, "application/vnd.github+json");
if let Some(token) = github_token {
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|err| GitHubReleaseFetchError {
message: format!("请求 GitHub Releases 失败: {err}"),
rate_limited: false,
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(github_release_response_error(status, &body));
}
response
.json()
.await
.map_err(|err| GitHubReleaseFetchError {
message: format!("解析 GitHub Releases 失败: {err}"),
rate_limited: false,
})
}
fn github_release_response_error(
status: reqwest::StatusCode,
response_body: &str,
) -> GitHubReleaseFetchError {
if is_github_rate_limit_error(status, response_body) {
return GitHubReleaseFetchError {
message: "GitHub Releases API 已触发限流;当前共享代理出口的匿名额度已用尽,更新检查将自动尝试直连。若仍失败,请配置 AETHER_UPDATE_GITHUB_TOKEN / GITHUB_TOKEN / GH_TOKEN或为 GitHub 更新检查单独设置可用代理。".to_string(),
rate_limited: true,
};
}
let detail = parse_github_error_message(response_body);
let message = if detail.is_empty() {
format!("GitHub Releases 返回错误: HTTP {status}")
} else {
format!("GitHub Releases 返回错误: HTTP {status}; {detail}")
};
GitHubReleaseFetchError {
message,
rate_limited: false,
}
}
fn is_github_rate_limit_error(status: reqwest::StatusCode, response_body: &str) -> bool {
status == reqwest::StatusCode::FORBIDDEN
&& response_body
.to_ascii_lowercase()
.contains("rate limit exceeded")
}
fn parse_github_error_message(response_body: &str) -> String {
serde_json::from_str::<serde_json::Value>(response_body)
.ok()
.and_then(|value| {
value
.get("message")
.and_then(|message| message.as_str())
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToString::to_string)
})
.unwrap_or_default()
}
#[cfg(not(test))]
fn should_include_release_for_channel(
release: &GitHubRelease,
current_channel: UpdateChannel,
) -> bool {
if release.draft || !release.tag_name.starts_with('v') {
return false;
}
if !release.prerelease {
return true;
}
current_channel.allows_prerelease(&release.tag_name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateChannel {
Stable,
Rc,
Beta,
OtherPrerelease,
}
impl UpdateChannel {
fn allows_prerelease(self, release_version: &str) -> bool {
match self {
Self::Stable => false,
Self::Rc => update_channel_for_version(release_version) == Self::Rc,
Self::Beta => update_channel_for_version(release_version) == Self::Beta,
Self::OtherPrerelease => {
matches!(
update_channel_for_version(release_version),
Self::Rc | Self::Beta | Self::OtherPrerelease
)
}
}
}
}
fn update_channel_for_version(version: &str) -> UpdateChannel {
let normalized = version
.trim()
.strip_prefix('v')
.or_else(|| version.trim().strip_prefix('V'))
.unwrap_or(version.trim());
let Some((_, prerelease)) = normalized.split_once('-') else {
return UpdateChannel::Stable;
};
let prerelease = prerelease.to_ascii_lowercase();
if prerelease.starts_with("rc") {
UpdateChannel::Rc
} else if prerelease.starts_with("beta") {
UpdateChannel::Beta
} else {
UpdateChannel::OtherPrerelease
}
}
#[cfg(not(test))]
#[derive(Debug, Deserialize)]
struct GitHubReleaseAsset {
name: String,
browser_download_url: String,
}
#[cfg(not(test))]
@@ -100,6 +442,43 @@ struct GitHubRelease {
published_at: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GitHubReleaseAsset>,
}
fn github_release_tag_url(tag_name: &str) -> String {
format!("{AETHER_RELEASE_TAG_URL_BASE}/{tag_name}")
}
#[cfg(not(test))]
fn select_release_tarball_urls(release: &GitHubRelease) -> (Option<String>, Option<String>) {
let platform = if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
};
let expected_name = format!("aether-{}-{}-{}.tar.gz", release.tag_name, platform, arch);
let tarball_url = release
.assets
.iter()
.find(|a| a.name == expected_name)
.map(|a| a.browser_download_url.clone());
let sha256sums_url = release
.assets
.iter()
.find(|a| a.name == "SHA256SUMS")
.map(|a| a.browser_download_url.clone());
(tarball_url, sha256sums_url)
}
pub(crate) async fn build_admin_system_stats_payload(
@@ -249,3 +628,137 @@ pub(crate) async fn apply_admin_system_settings_update(
pub(crate) fn build_admin_api_formats_payload() -> serde_json::Value {
build_admin_api_formats_payload_pure()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn update_channel_detects_stable_rc_beta_and_other_prerelease() {
assert_eq!(update_channel_for_version("v1.2.3"), UpdateChannel::Stable);
assert_eq!(update_channel_for_version("1.2.3-rc1"), UpdateChannel::Rc);
assert_eq!(
update_channel_for_version("1.2.3-beta.2"),
UpdateChannel::Beta
);
assert_eq!(
update_channel_for_version("1.2.3-alpha.1"),
UpdateChannel::OtherPrerelease
);
}
#[test]
fn stable_channel_does_not_allow_prereleases() {
assert!(!UpdateChannel::Stable.allows_prerelease("v1.2.3-rc1"));
}
#[test]
fn prerelease_channels_only_follow_matching_channel() {
assert!(UpdateChannel::Rc.allows_prerelease("v1.2.3-rc2"));
assert!(!UpdateChannel::Rc.allows_prerelease("v1.2.3-beta.1"));
assert!(UpdateChannel::Beta.allows_prerelease("v1.2.3-beta.2"));
assert!(!UpdateChannel::Beta.allows_prerelease("v1.2.3-rc1"));
assert!(UpdateChannel::OtherPrerelease.allows_prerelease("v1.2.3-alpha.1"));
assert!(UpdateChannel::OtherPrerelease.allows_prerelease("v1.2.3-rc1"));
}
#[test]
fn github_release_tag_url_points_to_explicit_tag_page() {
assert_eq!(
github_release_tag_url("v0.7.3"),
"https://github.com/fawney19/Aether/releases/tag/v0.7.3"
);
}
#[test]
fn successful_release_cache_is_reused_within_ttl() {
assert!(should_reuse_releases_cache(
false,
false,
Duration::from_secs(60)
));
}
#[test]
fn failed_release_cache_is_not_reused() {
assert!(!should_reuse_releases_cache(
false,
true,
Duration::from_secs(60)
));
}
#[test]
fn force_refresh_bypasses_release_cache() {
assert!(!should_reuse_releases_cache(
true,
false,
Duration::from_secs(60)
));
}
#[test]
fn github_rate_limit_response_is_marked_retryable() {
let err = github_release_response_error(
reqwest::StatusCode::FORBIDDEN,
r#"{"message":"API rate limit exceeded for 1.2.3.4."}"#,
);
assert!(err.rate_limited);
assert!(err.message.contains("GitHub Releases API 已触发限流"));
}
#[test]
fn non_rate_limit_github_response_keeps_http_status_message() {
let err = github_release_response_error(
reqwest::StatusCode::FORBIDDEN,
r#"{"message":"Resource not accessible"}"#,
);
assert!(!err.rate_limited);
assert!(err.message.contains("HTTP 403 Forbidden"));
assert!(err.message.contains("Resource not accessible"));
}
#[test]
fn source_build_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);
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() {
let mut payload = json!({
"releases": [
{
"version": "v0.7.3",
"is_current": false,
"updatable": true,
"update_blocker": serde_json::Value::Null
},
{
"version": "v0.7.2",
"is_current": true,
"updatable": true,
"update_blocker": "当前版本"
}
]
});
apply_source_build_releases_override(&mut payload, false);
assert_eq!(payload["releases"][0]["updatable"], false);
assert_eq!(
payload["releases"][0]["update_blocker"],
SOURCE_BUILD_RELEASE_BLOCKER
);
assert_eq!(payload["releases"][1]["updatable"], true);
assert_eq!(payload["releases"][1]["update_blocker"], "当前版本");
}
}

View File

@@ -0,0 +1,998 @@
use crate::handlers::admin::system::shared::update_client::build_update_http_client;
use crate::GatewayError;
use axum::http;
use futures_util::StreamExt;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::path::Component;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct SystemUpdateTaskStatus {
pub phase: &'static str,
pub error: Option<String>,
pub output: Option<String>,
pub progress_label: Option<String>,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub progress_percent: Option<u8>,
}
static UPDATE_TASK_STATUS: std::sync::OnceLock<Mutex<SystemUpdateTaskStatus>> =
std::sync::OnceLock::new();
fn update_task_status_lock() -> &'static Mutex<SystemUpdateTaskStatus> {
UPDATE_TASK_STATUS.get_or_init(|| {
Mutex::new(SystemUpdateTaskStatus {
phase: "idle",
error: None,
output: None,
progress_label: None,
downloaded_bytes: None,
total_bytes: None,
progress_percent: None,
})
})
}
fn set_update_task_phase(phase: &'static str) {
if let Ok(mut guard) = update_task_status_lock().lock() {
guard.phase = phase;
guard.error = None;
guard.output = None;
guard.progress_label = None;
guard.downloaded_bytes = None;
guard.total_bytes = None;
guard.progress_percent = None;
}
}
fn set_update_task_download_progress(label: &str, downloaded_bytes: u64, total_bytes: Option<u64>) {
if let Ok(mut guard) = update_task_status_lock().lock() {
guard.progress_label = Some(label.to_string());
guard.downloaded_bytes = Some(downloaded_bytes);
guard.total_bytes = total_bytes;
guard.progress_percent = total_bytes
.filter(|total| *total > 0)
.map(|total| ((downloaded_bytes.saturating_mul(100) / total).min(100)) as u8);
}
}
fn set_update_task_failed(error: String) {
if let Ok(mut guard) = update_task_status_lock().lock() {
guard.phase = "failed";
guard.error = Some(error);
}
}
fn set_update_task_output(output: String) {
if let Ok(mut guard) = update_task_status_lock().lock() {
guard.output = Some(output);
}
}
pub(crate) fn read_update_task_status() -> SystemUpdateTaskStatus {
update_task_status_lock()
.lock()
.map(|guard| guard.clone())
.unwrap_or(SystemUpdateTaskStatus {
phase: "idle",
error: None,
output: None,
progress_label: None,
downloaded_bytes: None,
total_bytes: None,
progress_percent: None,
})
}
static PREPARED_VERSION: std::sync::OnceLock<Mutex<Option<String>>> = std::sync::OnceLock::new();
fn prepared_version_lock() -> &'static Mutex<Option<String>> {
PREPARED_VERSION.get_or_init(|| Mutex::new(None))
}
fn set_prepared_version(version: String) {
if let Ok(mut guard) = prepared_version_lock().lock() {
*guard = Some(version);
}
}
pub(crate) fn get_prepared_version() -> Option<String> {
prepared_version_lock().lock().ok()?.clone()
}
const UPDATE_HISTORY_FILENAME: &str = ".aether-update-history.json";
const PREVIOUS_RELEASE_FILENAME: &str = ".aether-previous-release";
const MAX_HISTORY_ENTRIES: usize = 50;
const RESTART_EXIT_CODE: i32 = 75;
const MAX_RELEASE_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
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;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct UpdateHistoryEntry {
pub timestamp: String,
pub operation: String,
pub success: bool,
pub error: Option<String>,
pub output_tail: Option<String>,
}
fn aether_base_dir() -> PathBuf {
std::env::var("AETHER_BASE_DIR")
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/opt/aether"))
}
fn releases_base_dir() -> PathBuf {
aether_base_dir().join("releases")
}
fn safe_release_name(version: &str) -> Result<String, String> {
let value = version.trim();
if value.is_empty() || value == "." || value == ".." {
return Err("版本号为空或非法".to_string());
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+'))
{
return Err(format!("版本号包含非法字符: {version}"));
}
Ok(value.to_string())
}
fn release_dir_for_version(version: &str) -> Result<PathBuf, String> {
Ok(releases_base_dir().join(safe_release_name(version)?))
}
fn current_symlink_path() -> PathBuf {
aether_base_dir().join("current")
}
fn current_release_name() -> Option<String> {
std::fs::read_link(current_symlink_path())
.ok()?
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
}
fn update_history_path() -> PathBuf {
aether_base_dir().join(UPDATE_HISTORY_FILENAME)
}
fn append_update_history(
operation: &str,
success: bool,
error: Option<&str>,
output: Option<&str>,
) {
let path = update_history_path();
let entry = UpdateHistoryEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
operation: operation.to_string(),
success,
error: error.map(|s| s.to_string()),
output_tail: output.map(|s| {
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(20);
lines[start..].join("\n")
}),
};
let mut entries: Vec<UpdateHistoryEntry> = std::fs::read_to_string(&path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default();
entries.push(entry);
if entries.len() > MAX_HISTORY_ENTRIES {
entries.drain(..entries.len() - MAX_HISTORY_ENTRIES);
}
if let Ok(json) = serde_json::to_string_pretty(&entries) {
let _ = std::fs::write(&path, json);
}
}
pub(crate) fn read_update_history() -> Vec<UpdateHistoryEntry> {
let path = update_history_path();
std::fs::read_to_string(&path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default()
}
static SYSTEM_UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
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);
}
}
fn current_build_type() -> &'static str {
option_env!("AETHER_BUILD_TYPE").unwrap_or("source")
}
#[cfg(not(test))]
fn is_release_build() -> bool {
current_build_type() == "release"
}
#[cfg(test)]
fn is_release_build() -> bool {
true
}
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 task_status = read_update_task_status();
let base_dir = aether_base_dir();
json!({
"supported": supported,
"enabled": supported,
"rollback_available": rollback_available,
"task_status": task_status.phase,
"task_error": task_status.error,
"build_type": build_type,
"install_root": base_dir,
"message": if supported {
"一键更新可用"
} else {
"源码构建不支持在线更新"
},
})
}
fn find_rollback_target() -> Option<String> {
let previous_path = aether_base_dir().join(PREVIOUS_RELEASE_FILENAME);
let previous = std::fs::read_to_string(previous_path).ok()?;
let previous = previous.trim().to_string();
if previous.is_empty() {
return None;
}
let target_dir = release_dir_for_version(&previous).ok()?;
if target_dir.is_dir() {
Some(previous)
} else {
None
}
}
pub(crate) async fn prepare_admin_system_update_task(
version: String,
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()));
}
let Some(sha256sums_url) = sha256sums_url.filter(|url| !url.trim().is_empty()) else {
return Ok(Err((
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": "缺少 SHA256SUMS 校验文件,已拒绝在线更新" }),
)));
};
let Some(guard) = SystemUpdateGuard::try_acquire() else {
return Ok(Err(update_already_running_response()));
};
set_update_task_phase("preparing");
tokio::spawn(async move {
let _guard = guard;
let total_timeout = update_download_total_timeout();
let result = match tokio::time::timeout(
total_timeout,
download_and_extract_release(&version, &tarball_url, &sha256sums_url),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"下载更新包超时: 超过 {}",
total_timeout.as_secs()
)),
};
match result {
Ok(output) => {
set_update_task_phase("prepared");
set_update_task_output(output.clone());
set_prepared_version(version);
append_update_history("prepare", true, None, Some(&output));
}
Err(err) => {
set_update_task_failed(err.clone());
append_update_history("prepare", false, Some(&err), None);
}
}
});
Ok(Ok(json!({
"message": "更新包开始下载,请等待准备完成",
"started": true,
"need_restart": false,
})))
}
async fn download_and_extract_release(
version: &str,
tarball_url: &str,
sha256sums_url: &str,
) -> Result<String, String> {
let client = build_update_http_client(update_download_total_timeout(), "更新下载")?;
set_update_task_phase("downloading");
let tarball_bytes =
download_update_bytes(&client, tarball_url, MAX_RELEASE_DOWNLOAD_BYTES, "更新包").await?;
set_update_task_phase("downloading_checksum");
let sha256_text = String::from_utf8(
download_update_bytes(
&client,
sha256sums_url,
MAX_SHA256SUMS_DOWNLOAD_BYTES,
"校验文件",
)
.await?,
)
.map_err(|err| format!("校验文件不是有效 UTF-8: {err}"))?;
let tarball_url_owned = tarball_url.to_string();
let version_owned = version.to_string();
tokio::task::spawn_blocking(move || {
set_update_task_phase("verifying");
verify_sha256(&tarball_bytes, &sha256_text, &tarball_url_owned)?;
set_update_task_phase("extracting");
extract_release(&version_owned, &tarball_bytes)
})
.await
.map_err(|err| format!("\u{89e3}\u{538b}\u{4efb}\u{52a1}\u{5f02}\u{5e38}: {err}"))?
}
async fn download_update_bytes(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
label: &str,
) -> Result<Vec<u8>, String> {
validate_update_download_url(url)?;
let idle_timeout = update_download_idle_timeout();
let response = tokio::time::timeout(
idle_timeout,
client
.get(url)
.header(reqwest::header::USER_AGENT, "Aether-Gateway update")
.send(),
)
.await
.map_err(|_| {
format!(
"下载{label}超时: {} 秒内没有收到响应",
idle_timeout.as_secs()
)
})?
.map_err(|err| format!("下载{label}失败: {err}"))?
.error_for_status()
.map_err(|err| format!("下载{label}返回错误: {err}"))?;
if let Some(content_length) = response.content_length() {
if content_length > max_bytes {
return Err(format!(
"{label}过大: {content_length} bytes最大允许 {max_bytes} bytes"
));
}
}
let total_bytes = response.content_length();
let mut stream = response.bytes_stream();
let mut data = Vec::new();
set_update_task_download_progress(label, 0, total_bytes);
while let Some(chunk) = tokio::time::timeout(idle_timeout, stream.next())
.await
.map_err(|_| {
format!(
"下载{label}超时: {} 秒内没有收到数据",
idle_timeout.as_secs()
)
})?
{
let chunk = chunk.map_err(|err| format!("读取{label}数据失败: {err}"))?;
let next_len = data.len() as u64 + chunk.len() as u64;
if next_len > max_bytes {
return Err(format!("{label}超过大小限制: 最大允许 {max_bytes} bytes"));
}
data.extend_from_slice(&chunk);
set_update_task_download_progress(label, data.len() as u64, total_bytes);
}
Ok(data)
}
fn update_download_total_timeout() -> std::time::Duration {
update_timeout_from_env(
"AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS",
DEFAULT_UPDATE_DOWNLOAD_TIMEOUT_SECS,
)
}
fn update_download_idle_timeout() -> std::time::Duration {
update_timeout_from_env(
"AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS",
DEFAULT_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS,
)
}
fn update_timeout_from_env(key: &str, default_secs: u64) -> std::time::Duration {
let secs = std::env::var(key)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(default_secs);
std::time::Duration::from_secs(secs)
}
fn validate_update_download_url(raw_url: &str) -> Result<(), String> {
let parsed = url::Url::parse(raw_url).map_err(|err| format!("下载 URL 无效: {err}"))?;
if parsed.scheme() != "https" {
return Err("下载 URL 必须使用 HTTPS".to_string());
}
let Some(host) = parsed.host_str() else {
return Err("下载 URL 缺少主机名".to_string());
};
if host == "github.com"
|| host.ends_with(".github.com")
|| host == "objects.githubusercontent.com"
|| host.ends_with(".objects.githubusercontent.com")
{
return Ok(());
}
Err(format!("下载 URL 主机不受信任: {host}"))
}
fn verify_sha256(data: &[u8], sums_text: &str, tarball_url: &str) -> Result<(), String> {
let tarball_filename = tarball_url.rsplit('/').next().ok_or_else(|| {
"\u{65e0}\u{6cd5}\u{4ece} URL \u{63d0}\u{53d6}\u{6587}\u{4ef6}\u{540d}".to_string()
})?;
let expected_hash = sums_text
.lines()
.find_map(|line| {
let (hash, name) = line.split_once(char::is_whitespace)?;
let name = name.trim().trim_start_matches('*');
if name == tarball_filename {
Some(hash.to_string())
} else {
None
}
})
.ok_or_else(|| {
format!("SHA256SUMS \u{4e2d}\u{672a}\u{627e}\u{5230} {tarball_filename} \u{7684}\u{6821}\u{9a8c}\u{503c}")
})?;
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
let actual_hash: String = hash.iter().map(|b| format!("{b:02x}")).collect();
if actual_hash != expected_hash {
return Err(format!(
"SHA256 \u{6821}\u{9a8c}\u{5931}\u{8d25}: \u{671f}\u{671b} {expected_hash}, \u{5b9e}\u{9645} {actual_hash}"
));
}
Ok(())
}
fn extract_release(version: &str, tarball_bytes: &[u8]) -> Result<String, String> {
let safe_version = safe_release_name(version)?;
if current_release_name().as_deref() == Some(safe_version.as_str()) {
return Err(format!("版本 {version} 已经是当前运行版本"));
}
let base_dir = releases_base_dir();
std::fs::create_dir_all(&base_dir).map_err(|err| {
format!("\u{521b}\u{5efa} releases \u{76ee}\u{5f55}\u{5931}\u{8d25}: {err}")
})?;
let release_dir = base_dir.join(&safe_version);
let staging_dir = base_dir.join(format!(".prepare-{}-{}", safe_version, std::process::id()));
remove_path_if_exists(&staging_dir).map_err(|err| format!("清理临时版本目录失败: {err}"))?;
std::fs::create_dir_all(&staging_dir).map_err(|err| format!("创建临时版本目录失败: {err}"))?;
if let Err(err) = unpack_release_archive(tarball_bytes, &staging_dir) {
let _ = remove_path_if_exists(&staging_dir);
return Err(err);
}
let bundle_dir = match find_release_payload_dir(&staging_dir) {
Ok(dir) => dir,
Err(err) => {
let _ = remove_path_if_exists(&staging_dir);
return Err(err);
}
};
if let Err(err) = validate_release_payload_dir(&bundle_dir) {
let _ = remove_path_if_exists(&staging_dir);
return Err(err);
}
remove_path_if_exists(&release_dir).map_err(|err| {
format!("\u{6e05}\u{7406}\u{65e7}\u{7248}\u{672c}\u{76ee}\u{5f55}\u{5931}\u{8d25}: {err}")
})?;
if bundle_dir == staging_dir {
std::fs::rename(&staging_dir, &release_dir)
.map_err(|err| format!("安装版本目录失败: {err}"))?;
} else {
std::fs::rename(&bundle_dir, &release_dir)
.map_err(|err| format!("安装版本目录失败: {err}"))?;
let _ = remove_path_if_exists(&staging_dir);
}
ensure_release_binary_permissions(&release_dir.join("bin/aether-gateway"));
Ok(format!(
"\u{7248}\u{672c} {} \u{5df2}\u{51c6}\u{5907}\u{5c31}\u{7eea}",
version
))
}
fn unpack_release_archive(tarball_bytes: &[u8], staging_dir: &Path) -> Result<(), String> {
let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(tarball_bytes));
let mut archive = tar::Archive::new(decoder);
let entries = archive
.entries()
.map_err(|err| format!("读取更新包失败: {err}"))?;
let mut extracted_bytes = 0u64;
for entry in entries {
let mut entry = entry.map_err(|err| format!("读取更新包条目失败: {err}"))?;
let path = entry
.path()
.map_err(|err| format!("读取更新包路径失败: {err}"))?
.to_path_buf();
validate_archive_entry_path(&path)?;
let entry_type = entry.header().entry_type();
if entry_type.is_file() {
let size = entry
.header()
.size()
.map_err(|err| format!("读取更新包文件大小失败: {err}"))?;
extracted_bytes = extracted_bytes.saturating_add(size);
if extracted_bytes > MAX_EXTRACTED_RELEASE_BYTES {
return Err(format!(
"更新包解压后过大: 最大允许 {MAX_EXTRACTED_RELEASE_BYTES} bytes"
));
}
} else if !entry_type.is_dir() {
return Err(format!("更新包包含不支持的条目: {}", path.display()));
}
let unpacked = entry
.unpack_in(staging_dir)
.map_err(|err| format!("解压更新包失败: {err}"))?;
if !unpacked {
return Err(format!("更新包包含非法路径: {}", path.display()));
}
}
Ok(())
}
fn validate_archive_entry_path(path: &Path) -> Result<(), String> {
let mut has_normal_component = false;
for component in path.components() {
match component {
Component::Normal(_) => has_normal_component = true,
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(format!("更新包包含非法路径: {}", path.display()));
}
}
}
if has_normal_component {
Ok(())
} else {
Err("更新包包含空路径".to_string())
}
}
fn find_release_payload_dir(staging_dir: &Path) -> Result<PathBuf, String> {
if looks_like_release_payload(staging_dir) {
return Ok(staging_dir.to_path_buf());
}
let mut candidates = Vec::new();
let entries =
std::fs::read_dir(staging_dir).map_err(|err| format!("读取更新包目录失败: {err}"))?;
for entry in entries {
let entry = entry.map_err(|err| format!("读取更新包条目失败: {err}"))?;
let path = entry.path();
if path.is_dir() && looks_like_release_payload(&path) {
candidates.push(path);
}
}
match candidates.len() {
1 => Ok(candidates.remove(0)),
0 => Err(
"\u{66f4}\u{65b0}\u{5305}\u{4e2d}\u{672a}\u{627e}\u{5230} bin/aether-gateway"
.to_string(),
),
_ => Err("更新包中包含多个可安装目录,无法确定目标版本".to_string()),
}
}
fn looks_like_release_payload(path: &Path) -> bool {
path.join("bin/aether-gateway").is_file() && path.join("frontend").is_dir()
}
fn validate_release_payload_dir(path: &Path) -> Result<(), String> {
if !path.join("bin/aether-gateway").is_file() {
return Err(
"\u{66f4}\u{65b0}\u{5305}\u{4e2d}\u{672a}\u{627e}\u{5230} bin/aether-gateway"
.to_string(),
);
}
if !path.join("frontend/index.html").is_file() {
return Err("更新包中未找到 frontend/index.html".to_string());
}
Ok(())
}
fn ensure_release_binary_permissions(binary_path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(binary_path, std::fs::Permissions::from_mode(0o755));
}
}
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => {
std::fs::remove_dir_all(path)
}
Ok(_) => std::fs::remove_file(path),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
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()));
}
let version = match version.or_else(get_prepared_version) {
Some(v) => v,
None => {
return Ok(Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": "\u{672a}\u{6307}\u{5b9a}\u{7248}\u{672c}\u{4e14}\u{6ca1}\u{6709}\u{5df2}\u{51c6}\u{5907}\u{7684}\u{66f4}\u{65b0}" }),
)));
}
};
let release_dir = match release_dir_for_version(&version) {
Ok(dir) => dir,
Err(err) => {
return Ok(Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": err }),
)));
}
};
if !release_dir.join("bin/aether-gateway").is_file() {
return Ok(Err((
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": format!("\u{7248}\u{672c} {version} \u{5c1a}\u{672a}\u{51c6}\u{5907}\u{597d}\u{ff0c}\u{8bf7}\u{5148}\u{6267}\u{884c} prepare-update") }),
)));
}
let Some(guard) = SystemUpdateGuard::try_acquire() else {
return Ok(Err(update_already_running_response()));
};
save_previous_release();
set_update_task_phase("restarting");
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
match switch_current_symlink(&version) {
Ok(_) => {
append_update_history(
"apply",
true,
None,
Some(&format!(
"\u{5df2}\u{5207}\u{6362}\u{5230}\u{7248}\u{672c} {version}"
)),
);
tracing::info!(version = %version, "update applied, exiting for restart");
request_process_restart();
}
Err(err) => {
tracing::error!(error = %err, "admin system update apply failed");
append_update_history("apply", false, Some(&err), None);
set_update_task_failed(err);
}
}
drop(guard);
});
Ok(Ok(json!({
"message": "\u{6b63}\u{5728}\u{5207}\u{6362}\u{7248}\u{672c}\u{5e76}\u{91cd}\u{542f}\u{ff0c}\u{670d}\u{52a1}\u{4f1a}\u{77ed}\u{6682}\u{4e0d}\u{53ef}\u{7528}",
"started": true,
"need_restart": true,
})))
}
fn save_previous_release() {
let current = current_symlink_path();
if let Ok(target) = std::fs::read_link(&current) {
if let Some(name) = target.file_name().and_then(|n| n.to_str()) {
let prev_path = aether_base_dir().join(PREVIOUS_RELEASE_FILENAME);
let _ = std::fs::write(prev_path, name);
}
}
}
fn switch_current_symlink(version: &str) -> Result<(), String> {
let target = release_dir_for_version(version)?;
if !target.is_dir() {
return Err(format!(
"\u{7248}\u{672c}\u{76ee}\u{5f55}\u{4e0d}\u{5b58}\u{5728}: {}",
target.display()
));
}
validate_release_payload_dir(&target)?;
let current = current_symlink_path();
let current_new = current.with_file_name("current.new");
let _ = remove_path_if_exists(&current_new);
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &current_new)
.map_err(|err| format!("\u{521b}\u{5efa}\u{4e34}\u{65f6}\u{7b26}\u{53f7}\u{94fe}\u{63a5}\u{5931}\u{8d25}: {err}"))?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target, &current_new)
.map_err(|err| format!("\u{521b}\u{5efa}\u{4e34}\u{65f6}\u{7b26}\u{53f7}\u{94fe}\u{63a5}\u{5931}\u{8d25}: {err}"))?;
std::fs::rename(&current_new, &current)
.map_err(|err| format!("\u{539f}\u{5b50}\u{5207}\u{6362}\u{7b26}\u{53f7}\u{94fe}\u{63a5}\u{5931}\u{8d25}: {err}"))?;
Ok(())
}
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()));
}
let Some(previous) = find_rollback_target() else {
return Ok(Err((
http::StatusCode::PRECONDITION_REQUIRED,
json!({ "detail": "\u{6ca1}\u{6709}\u{53ef}\u{56de}\u{6eda}\u{7684}\u{7248}\u{672c}" }),
)));
};
let Some(guard) = SystemUpdateGuard::try_acquire() else {
return Ok(Err(update_already_running_response()));
};
set_update_task_phase("rolling_back");
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
match switch_current_symlink(&previous) {
Ok(_) => {
let prev_path = aether_base_dir().join(PREVIOUS_RELEASE_FILENAME);
let _ = std::fs::remove_file(prev_path);
append_update_history(
"rollback",
true,
None,
Some(&format!(
"\u{5df2}\u{56de}\u{6eda}\u{5230}\u{7248}\u{672c} {previous}"
)),
);
tracing::info!(version = %previous, "rollback applied, exiting for restart");
request_process_restart();
}
Err(err) => {
tracing::error!(error = %err, "admin system rollback failed");
append_update_history("rollback", false, Some(&err), None);
set_update_task_failed(err);
}
}
drop(guard);
});
Ok(Ok(json!({
"message": "\u{56de}\u{6eda}\u{5df2}\u{542f}\u{52a8}\u{ff0c}\u{670d}\u{52a1}\u{4f1a}\u{77ed}\u{6682}\u{4e0d}\u{53ef}\u{7528}",
"started": true,
"need_restart": true,
})))
}
fn request_process_restart() -> ! {
std::process::exit(RESTART_EXIT_CODE);
}
fn update_already_running_response() -> (http::StatusCode, serde_json::Value) {
(
http::StatusCode::CONFLICT,
json!({ "detail": "\u{5df2}\u{6709}\u{4e00}\u{952e}\u{66f4}\u{65b0}\u{4efb}\u{52a1}\u{6b63}\u{5728}\u{6267}\u{884c}" }),
)
}
fn source_build_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}" }),
)
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
fn temp_test_dir(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"aether-update-{name}-{}-{nanos}",
std::process::id()
))
}
fn write_release_payload(root: &Path) {
std::fs::create_dir_all(root.join("bin")).expect("bin dir should be created");
std::fs::create_dir_all(root.join("frontend")).expect("frontend dir should be created");
std::fs::write(root.join("bin/aether-gateway"), b"test-binary")
.expect("binary should be written");
std::fs::write(root.join("frontend/index.html"), b"<html></html>")
.expect("frontend index should be written");
}
#[test]
fn update_finds_nested_release_payload_dir() {
let staging = temp_test_dir("nested");
let bundle = staging.join("aether-v1.2.3-linux-amd64");
write_release_payload(&bundle);
let found = find_release_payload_dir(&staging).expect("payload dir should be found");
assert_eq!(found, bundle);
std::fs::remove_dir_all(staging).ok();
}
#[test]
fn update_finds_flat_release_payload_dir() {
let staging = temp_test_dir("flat");
write_release_payload(&staging);
let found = find_release_payload_dir(&staging).expect("payload dir should be found");
assert_eq!(found, staging);
std::fs::remove_dir_all(found).ok();
}
#[test]
fn update_rejects_unsafe_release_names() {
assert!(safe_release_name("v1.2.3").is_ok());
assert!(safe_release_name("../v1.2.3").is_err());
assert!(safe_release_name("v1.2.3/linux").is_err());
assert!(safe_release_name("").is_err());
}
#[test]
fn update_validates_download_urls() {
assert!(validate_update_download_url(
"https://github.com/fawney19/Aether/releases/download/v1/aether.tar.gz"
)
.is_ok());
assert!(validate_update_download_url(
"https://objects.githubusercontent.com/github-production-release-asset/test"
)
.is_ok());
assert!(validate_update_download_url(
"http://github.com/fawney19/Aether/releases/download/v1/aether.tar.gz"
)
.is_err());
assert!(validate_update_download_url("https://example.com/aether.tar.gz").is_err());
}
#[test]
fn update_rejects_archive_path_traversal() {
assert!(validate_archive_entry_path(Path::new("bundle/bin/aether-gateway")).is_ok());
assert!(validate_archive_entry_path(Path::new("../escape")).is_err());
assert!(validate_archive_entry_path(Path::new("/tmp/escape")).is_err());
}
#[test]
fn update_rejects_archive_symlinks() {
let staging = temp_test_dir("symlink");
std::fs::create_dir_all(&staging).expect("staging dir should be created");
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = tar::Builder::new(&mut encoder);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
header
.set_link_name("/bin/sh")
.expect("link name should be set");
header.set_cksum();
builder
.append_data(&mut header, "bundle/bin/aether-gateway", std::io::empty())
.expect("symlink entry should be appended");
builder.finish().expect("tar builder should finish");
}
let tarball = encoder.finish().expect("gzip encoder should finish");
let err = unpack_release_archive(&tarball, &staging).expect_err("archive should fail");
assert!(err.contains("不支持的条目"));
std::fs::remove_dir_all(staging).ok();
}
#[test]
fn update_verifies_sha256sum_for_asset_name() {
let data = b"release-bytes";
let mut hasher = Sha256::new();
hasher.update(data);
let expected: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let sums = format!("{expected} aether-v1.2.3-linux-amd64.tar.gz\n");
verify_sha256(
data,
&sums,
"https://example.test/aether-v1.2.3-linux-amd64.tar.gz",
)
.expect("sha256 should match");
}
}

View File

@@ -0,0 +1,68 @@
use std::time::Duration;
const EXPLICIT_UPDATE_PROXY_ENV_KEYS: &[&str] = &["AETHER_UPDATE_PROXY_URL", "UPDATE_PROXY_URL"];
const UPDATE_PROXY_ENV_KEYS: &[&str] = &[
"AETHER_UPDATE_PROXY_URL",
"UPDATE_PROXY_URL",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
];
const UPDATE_GITHUB_TOKEN_ENV_KEYS: &[&str] =
&["AETHER_UPDATE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"];
pub(crate) fn build_update_http_client(
timeout: Duration,
label: &str,
) -> Result<reqwest::Client, String> {
let mut builder = base_update_http_client_builder(timeout);
if let Some(proxy_url) = update_proxy_url_from_env() {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|_| format!("创建{label}代理失败,请检查更新代理环境变量"))?
.no_proxy(reqwest::NoProxy::from_env());
builder = builder.proxy(proxy);
}
builder
.build()
.map_err(|err| format!("创建{label}客户端失败: {err}"))
}
pub(crate) fn build_direct_update_http_client(
timeout: Duration,
label: &str,
) -> Result<reqwest::Client, String> {
base_update_http_client_builder(timeout)
.no_proxy()
.build()
.map_err(|err| format!("创建{label}客户端失败: {err}"))
}
pub(crate) fn has_explicit_update_proxy_env() -> bool {
read_nonempty_env_value(EXPLICIT_UPDATE_PROXY_ENV_KEYS).is_some()
}
fn base_update_http_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
reqwest::Client::builder().timeout(timeout)
}
fn update_proxy_url_from_env() -> Option<String> {
read_nonempty_env_value(UPDATE_PROXY_ENV_KEYS)
}
pub(crate) fn update_github_token_from_env() -> Option<String> {
read_nonempty_env_value(UPDATE_GITHUB_TOKEN_ENV_KEYS)
}
fn read_nonempty_env_value(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}

View File

@@ -1,7 +1,8 @@
use std::path::PathBuf;
use axum::extract::Request;
use axum::http::Method;
use axum::http::header::{CACHE_CONTROL, EXPIRES, PRAGMA};
use axum::http::{HeaderValue, Method};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use axum::Router;
@@ -111,7 +112,16 @@ async fn serve_static_asset(static_dir: &PathBuf, request: Request) -> Response
async fn serve_frontend_index(index_html: &PathBuf, request: Request) -> Response {
match ServeFile::new(index_html).oneshot(request).await {
Ok(response) => response.into_response(),
Ok(mut response) => {
let headers = response.headers_mut();
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("no-store, no-cache, must-revalidate"),
);
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(EXPIRES, HeaderValue::from_static("0"));
response.into_response()
}
Err(err) => {
warn!(error = %err, "failed to serve frontend index");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()

View File

@@ -36,6 +36,8 @@ use crate::constants::{
};
use crate::data::GatewayDataState;
static SYSTEM_UPDATE_TEST_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
@@ -158,6 +160,269 @@ 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 _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
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!(payload["supported"].is_boolean());
assert!(payload["build_type"].is_string());
assert!(payload["task_status"].is_string());
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 _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
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::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert!(payload["detail"].is_string());
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_prepared_version() {
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
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::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert!(payload["detail"].is_string());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_admin_system_rollback_without_previous_release() {
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/system/rollback",
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/rollback"))
.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"].is_string());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_system_releases_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/releases",
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/releases"))
.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!(payload["current_version"]
.as_str()
.is_some_and(|value| !value.is_empty()));
assert!(payload["releases"].is_array());
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_with_nonexistent_version() {
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
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")
.header("content-type", "application/json")
.body(r#"{"version":"v99.99.99"}"#)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert!(payload["detail"].is_string());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_system_update_status_locally() {
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().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-status"))
.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!(payload["phase"].is_string());
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));

View File

@@ -145,6 +145,13 @@ async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api(
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store, no-cache, must-revalidate")
);
let content_type = response
.headers()
.get("content-type")
@@ -161,6 +168,13 @@ async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api(
.await
.expect("spa request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store, no-cache, must-revalidate")
);
let body = response.text().await.expect("spa body should be readable");
assert!(body.contains("Aether Frontend"));

View File

@@ -67,6 +67,8 @@ pub struct AdminSystemUpdateRelease {
pub release_url: Option<String>,
pub release_notes: Option<String>,
pub published_at: Option<String>,
pub tarball_url: Option<String>,
pub sha256sums_url: Option<String>,
}
fn default_true() -> bool {
@@ -826,11 +828,19 @@ pub fn build_admin_system_check_update_payload_with_release(
let has_update = latest_release
.as_ref()
.is_some_and(|release| admin_system_update_available(&current_version, &release.version));
let update_blocker = latest_release
.as_ref()
.and_then(admin_system_update_blocker);
let updatable = latest_release
.as_ref()
.is_some_and(|release| admin_system_update_blocker(release).is_none());
json!({
"current_version": current_version,
"latest_version": latest_release.as_ref().map(|release| release.version.clone()),
"has_update": has_update,
"updatable": updatable,
"update_blocker": update_blocker,
"release_url": latest_release.as_ref().and_then(|release| release.release_url.clone()),
"release_notes": latest_release.as_ref().and_then(|release| release.release_notes.clone()),
"published_at": latest_release.as_ref().and_then(|release| release.published_at.clone()),
@@ -838,6 +848,53 @@ pub fn build_admin_system_check_update_payload_with_release(
})
}
pub fn build_admin_system_releases_payload(
current_version: String,
releases: Vec<AdminSystemUpdateRelease>,
error: Option<String>,
) -> serde_json::Value {
let entries: Vec<serde_json::Value> = releases
.iter()
.map(|release| {
let is_current = {
let norm_current = normalized_admin_system_version(&current_version);
let norm_release = normalized_admin_system_version(&release.version);
norm_current == norm_release
};
let is_newer = admin_system_update_available(&current_version, &release.version);
let update_blocker = admin_system_update_blocker(release);
json!({
"version": release.version,
"release_url": release.release_url,
"release_notes": release.release_notes,
"published_at": release.published_at,
"tarball_url": release.tarball_url,
"sha256sums_url": release.sha256sums_url,
"is_current": is_current,
"is_newer": is_newer,
"updatable": update_blocker.is_none(),
"update_blocker": update_blocker,
})
})
.collect();
json!({
"current_version": current_version,
"releases": entries,
"error": error,
})
}
fn admin_system_update_blocker(release: &AdminSystemUpdateRelease) -> Option<&'static str> {
if release.tarball_url.is_none() {
Some("当前平台暂无安装包")
} else if release.sha256sums_url.is_none() {
Some("缺少 SHA256SUMS 校验文件")
} else {
None
}
}
fn normalized_admin_system_version(version: &str) -> String {
let trimmed = version.trim();
trimmed
@@ -2950,6 +3007,8 @@ mod tests {
),
release_notes: Some("release notes".to_string()),
published_at: Some("2026-05-13T00:00:00Z".to_string()),
tarball_url: None,
sha256sums_url: None,
}),
None,
);
@@ -2963,9 +3022,31 @@ mod tests {
);
assert_eq!(payload["release_notes"], "release notes");
assert_eq!(payload["published_at"], "2026-05-13T00:00:00Z");
assert_eq!(payload["updatable"], false);
assert_eq!(payload["update_blocker"], "当前平台暂无安装包");
assert_eq!(payload["error"], serde_json::Value::Null);
}
#[test]
fn build_admin_system_check_update_payload_reports_updatable_release() {
let payload = build_admin_system_check_update_payload_with_release(
"0.7.0-rc27".to_string(),
Some(AdminSystemUpdateRelease {
version: "v0.7.0-rc28".to_string(),
release_url: None,
release_notes: None,
published_at: None,
tarball_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/aether.tar.gz".to_string()),
sha256sums_url: Some("https://github.com/fawney19/Aether/releases/download/v0.7.0-rc28/SHA256SUMS".to_string()),
}),
None,
);
assert_eq!(payload["has_update"], true);
assert_eq!(payload["updatable"], true);
assert_eq!(payload["update_blocker"], serde_json::Value::Null);
}
#[test]
fn build_admin_system_check_update_payload_normalizes_v_prefix() {
let payload = build_admin_system_check_update_payload_with_release(
@@ -2975,6 +3056,8 @@ mod tests {
release_url: None,
release_notes: None,
published_at: None,
tarball_url: None,
sha256sums_url: None,
}),
None,
);
@@ -2992,6 +3075,8 @@ mod tests {
release_url: None,
release_notes: None,
published_at: None,
tarball_url: None,
sha256sums_url: None,
}),
None,
);
@@ -3009,6 +3094,8 @@ mod tests {
release_url: None,
release_notes: None,
published_at: None,
tarball_url: None,
sha256sums_url: None,
}),
None,
);
@@ -3026,6 +3113,8 @@ mod tests {
release_url: None,
release_notes: None,
published_at: None,
tarball_url: None,
sha256sums_url: None,
}),
None,
);

View File

@@ -0,0 +1,50 @@
# Aether 本地在线更新联调环境
# 使用当前源码构建一个 release-layout 测试镜像,专门验证管理后台一键更新。
#
# 启动:
# docker compose -f docker-compose.release-local.yml up -d --build
#
# 重置(包含 /opt/aether/current 与历史版本目录):
# docker compose -f docker-compose.release-local.yml down -v
name: aether-release-local
services:
release-local-app:
build:
context: .
dockerfile: Dockerfile.app.release-local
args:
AETHER_BUILD_VERSION: ${AETHER_RELEASE_LOCAL_VERSION:-v0.7.0}
AETHER_BUILD_TYPE: release
image: ${LOCAL_RELEASE_APP_IMAGE:-aether-app:release-local}
container_name: aether-release-local-app
env_file:
- ${AETHER_ENV_FILE:-.env}
environment:
TZ: Asia/Shanghai
AETHER_DATABASE_DRIVER: sqlite
AETHER_DATABASE_URL: sqlite://./data/aether.db
AETHER_RUNTIME_BACKEND: memory
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY: single-node
AETHER_GATEWAY_NODE_ROLE: all
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
APP_PORT: ${AETHER_RELEASE_LOCAL_PORT:-18085}
AETHER_UPDATE_PROXY_URL: ${AETHER_UPDATE_PROXY_URL:-}
UPDATE_PROXY_URL: ${UPDATE_PROXY_URL:-}
AETHER_BASE_DIR: /opt/aether
AETHER_GATEWAY_AUTO_PREPARE_DATABASE: ${AETHER_GATEWAY_AUTO_PREPARE_DATABASE:-true}
ports:
- "${AETHER_RELEASE_LOCAL_PORT:-18085}:${AETHER_RELEASE_LOCAL_PORT:-18085}"
volumes:
- ./data-release-local:/app/data
- aether_release_local_root:/opt/aether
restart: unless-stopped
volumes:
aether_release_local_root:

View File

@@ -6,14 +6,15 @@ services:
- ${AETHER_ENV_FILE:-.env}
environment:
TZ: Asia/Shanghai
AETHER_BASE_DIR: /opt/aether
AETHER_DATABASE_DRIVER: sqlite
AETHER_DATABASE_URL: sqlite://./data/aether.db
AETHER_DATABASE_URL: sqlite:///opt/aether/data/aether.db
AETHER_RUNTIME_BACKEND: memory
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY: single-node
AETHER_GATEWAY_NODE_ROLE: all
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-both}
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/app/logs}
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
@@ -22,12 +23,5 @@ services:
ports:
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
volumes:
- ./data:/app/data
- ./logs:/app/logs
healthcheck:
test: ["CMD", "aether-gateway", "--healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
- ./data:/opt/aether/data
restart: unless-stopped

View File

@@ -82,9 +82,10 @@ services:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
TZ: Asia/Shanghai
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-both}
AETHER_BASE_DIR: /opt/aether
AETHER_LOG_DESTINATION: ${AETHER_LOG_DESTINATION:-stdout}
AETHER_LOG_FORMAT: ${AETHER_LOG_FORMAT:-pretty}
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/app/logs}
AETHER_LOG_DIR: ${AETHER_LOG_DIR:-/opt/aether/logs}
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
@@ -97,14 +98,6 @@ services:
condition: service_healthy
ports:
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "aether-gateway", "--healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
restart: unless-stopped
volumes:

View File

@@ -8,9 +8,9 @@
"build": "vite build",
"build:with-typecheck": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest",
"test:ui": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest --ui",
"test:run": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest run",
"test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs",
"test:ui": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs --ui",
"test:run": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
"lint": "eslint . --fix",
"type-check": "vue-tsc --noEmit",
"version": "git describe --tags --always"

View File

@@ -371,12 +371,72 @@ export interface CheckUpdateResponse {
current_version: string
latest_version: string | null
has_update: boolean
updatable: boolean
update_blocker: string | null
release_url: string | null
release_notes: string | null
published_at: string | null
error: string | null
}
export interface SystemUpdateCapabilityResponse {
supported: boolean
build_type: string
enabled: boolean
rollback_available: boolean
task_status: string
task_error: string | null
install_root?: string
message: string
}
export interface UpdateTaskStatusResponse {
phase: string
error: string | null
output: string | null
progress_label?: string | null
downloaded_bytes?: number | null
total_bytes?: number | null
progress_percent?: number | null
}
export interface UpdateHistoryEntry {
timestamp: string
operation: string
success: boolean
error: string | null
output_tail: string | null
}
export interface UpdateHistoryResponse {
entries: UpdateHistoryEntry[]
}
export interface ApplySystemUpdateResponse {
message: string
started: boolean
need_restart: boolean
}
export interface ReleaseEntry {
version: string
release_url: string | null
release_notes: string | null
published_at: string | null
tarball_url?: string | null
sha256sums_url?: string | null
is_current: boolean
is_newer: boolean
updatable: boolean
update_blocker: string | null
}
export interface ReleasesListResponse {
current_version: string
releases: ReleaseEntry[]
error: string | null
}
// LDAP 配置响应
export interface LdapConfigResponse {
server_url: string | null
@@ -995,9 +1055,67 @@ export const adminApi = {
},
// 检查系统更新
async checkUpdate(): Promise<CheckUpdateResponse> {
async checkUpdate(force = false): Promise<CheckUpdateResponse> {
const response = await apiClient.get<CheckUpdateResponse>(
'/api/admin/system/check-update'
'/api/admin/system/check-update',
force ? { params: { force: 'true' } } : undefined
)
return response.data
},
async getSystemReleases(force = false): Promise<ReleasesListResponse> {
const response = await apiClient.get<ReleasesListResponse>(
'/api/admin/system/releases',
force ? { params: { force: 'true' } } : undefined
)
return response.data
},
// 获取一键更新能力
async getSystemUpdateCapability(): Promise<SystemUpdateCapabilityResponse> {
const response = await apiClient.get<SystemUpdateCapabilityResponse>(
'/api/admin/system/update-capability'
)
return response.data
},
// 准备系统一键更新(下载并校验 release 包)
async prepareSystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
const response = await apiClient.post<ApplySystemUpdateResponse>(
'/api/admin/system/prepare-update',
version ? { version } : undefined
)
return response.data
},
// 触发系统一键重启(切换 release 并退出等待进程管理器拉起)
async applySystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
const response = await apiClient.post<ApplySystemUpdateResponse>(
'/api/admin/system/apply-update',
version ? { version } : undefined
)
return response.data
},
// 回滚到上一个版本
async rollbackSystemUpdate(): Promise<ApplySystemUpdateResponse> {
const response = await apiClient.post<ApplySystemUpdateResponse>(
'/api/admin/system/rollback'
)
return response.data
},
// 查询更新任务状态
async getUpdateStatus(): Promise<UpdateTaskStatusResponse> {
const response = await apiClient.get<UpdateTaskStatusResponse>(
'/api/admin/system/update-status'
)
return response.data
},
async getUpdateHistory(): Promise<UpdateHistoryResponse> {
const response = await apiClient.get<UpdateHistoryResponse>(
'/api/admin/system/update-history'
)
return response.data
},

View File

@@ -1,7 +1,7 @@
<template>
<Dialog
v-model="isOpen"
size="md"
size="lg"
title=""
>
<div class="flex flex-col items-center text-center py-2">
@@ -11,66 +11,163 @@
class-name="text-primary"
/>
<!-- Title -->
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
发现新版本
</h2>
<!-- Version Info -->
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
<p class="text-xs text-muted-foreground">
最新版本
<!-- Reconnecting State -->
<template v-if="updatePhase === 'reconnecting'">
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
正在重启服务
</h2>
<p class="text-sm text-muted-foreground max-w-xs mt-2 mb-2">
服务正在切换版本并重启请稍候...
</p>
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
{{ formatDisplayVersion(latestVersion) }}
</p>
</div>
<div class="flex items-center gap-2 text-primary mt-2 mb-4">
<svg
class="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span class="text-sm font-medium">
{{ reconnectMessage }}
</span>
</div>
</template>
<!-- Release Notes -->
<div
v-if="releaseNotes"
class="w-full mt-3 mb-4"
>
<!-- Normal Update State -->
<template v-else>
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
{{ dialogTitleText }}
</h2>
<!-- Version Info -->
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
<p class="text-xs text-muted-foreground">
{{ versionLabelText }}
</p>
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
{{ formatDisplayVersion(latestVersion) }}
</p>
</div>
<!-- Release Notes -->
<div
v-if="publishedAt"
class="text-left text-xs text-muted-foreground mb-2"
v-if="displayReleaseNotes"
class="w-full mt-3 mb-4"
>
发布于 {{ formattedPublishedAt }}
<div
v-if="publishedAt"
class="mb-2 text-left text-xs text-muted-foreground"
>
发布于 {{ formattedPublishedAt }}
</div>
<div class="mb-2 text-left text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/80">
更新内容
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="max-h-64 w-full overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-left text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
v-html="renderedReleaseNotes"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<div class="text-left text-xs font-medium text-muted-foreground mb-2">
更新内容
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="w-full max-h-48 overflow-y-auto rounded-lg bg-muted/50 p-3 text-left text-sm text-foreground/80 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0"
v-html="renderedReleaseNotes"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<!-- Description (fallback when no release notes) -->
<p
v-else
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
>
新版本已发布建议更新以获得最新功能和安全修复
</p>
<!-- Description (fallback when no release notes) -->
<p
v-else
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
>
{{ fallbackDescriptionText }}
</p>
<p
v-if="updatePhase === 'restart'"
class="mt-1 text-xs text-primary"
>
更新包已下载点击"立即重启"完成安装
</p>
<div
v-if="updating && updatePhase === 'download'"
class="mt-3 w-full max-w-sm"
>
<div class="mb-1.5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span class="truncate">{{ downloadProgressText }}</span>
<span
v-if="downloadProgressPercent !== null"
class="shrink-0 font-mono text-primary"
>
{{ downloadProgressPercent }}%
</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: progressBarWidth }"
/>
</div>
</div>
<!-- Source Build Hint -->
<p
v-if="!canApplyUpdate"
class="mt-1 text-xs text-muted-foreground"
>
{{ updateBlockerText }}
</p>
</template>
</div>
<template #footer>
<div class="flex w-full gap-3">
<div
v-if="updatePhase !== 'reconnecting'"
class="flex w-full gap-3"
>
<Button
variant="outline"
class="flex-1"
:disabled="updating || rollingBack"
@click="handleLater"
>
稍后提醒
</Button>
<Button
v-if="rollbackAvailable"
variant="outline"
class="flex-1"
:disabled="updating || rollingBack"
@click="handleRollback"
>
{{ rollingBack ? '回滚中...' : '回滚上一版本' }}
</Button>
<Button
v-else
variant="outline"
class="flex-1"
:disabled="updating || rollingBack"
@click="handleViewRelease"
>
查看更新
{{ releaseLinkLabelText }}
</Button>
<Button
v-if="updateSupported"
class="flex-1"
:disabled="updating || rollingBack || !canApplyUpdate"
@click="handleApplyUpdate"
>
{{ actionButtonLabel }}
</Button>
</div>
</template>
@@ -83,8 +180,11 @@ import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import HeaderLogo from '@/components/HeaderLogo.vue'
import { formatDisplayVersion } from '@/utils/version'
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
import { sanitizeMarkdown } from '@/utils/sanitize'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
const props = defineProps<{
modelValue: boolean
@@ -93,13 +193,63 @@ const props = defineProps<{
releaseUrl: string | null
releaseNotes: string | null
publishedAt: string | null
dialogTitle?: string
versionLabel?: string
releaseLinkLabel?: string
updatePhase?: 'download' | 'restart' | 'reconnecting'
updating?: boolean
updateSupported?: boolean
updatable?: boolean
updateBlocker?: string | null
reconnectMessage?: string
rollbackAvailable?: boolean
rollingBack?: boolean
downloadProgressText?: string | null
downloadProgressPercent?: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
applyUpdate: []
rollback: []
}>()
const isOpen = ref(props.modelValue)
const updating = computed(() => props.updating ?? false)
const updatePhase = computed(() => props.updatePhase ?? 'download')
const updateSupported = computed(() => props.updateSupported ?? true)
const updatable = computed(() => props.updatable ?? true)
const canApplyUpdate = computed(() => updateSupported.value && updatable.value)
const updateBlockerText = computed(() => {
if (!updateSupported.value) return props.updateBlocker || SOURCE_BUILD_UPDATE_HINT
return props.updateBlocker || '当前版本暂不支持在线更新'
})
const reconnectMessage = computed(() => props.reconnectMessage ?? '等待服务恢复...')
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
const rollingBack = computed(() => props.rollingBack ?? false)
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
const dialogTitleText = computed(() => props.dialogTitle ?? '发现新版本')
const versionLabelText = computed(() => props.versionLabel ?? '最新版本')
const releaseLinkLabelText = computed(() => props.releaseLinkLabel ?? '查看发布')
const fallbackDescriptionText = computed(() => {
if (!canApplyUpdate.value) return updateBlockerText.value
return '新版本已发布,建议更新以获得最新功能和安全修复'
})
const downloadProgressPercent = computed(() => {
const value = props.downloadProgressPercent
return typeof value === 'number' && Number.isFinite(value)
? Math.max(0, Math.min(100, Math.round(value)))
: null
})
const progressBarWidth = computed(() => {
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
})
const actionButtonLabel = computed(() => {
if (updating.value) {
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
}
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
})
watch(() => props.modelValue, (val) => {
isOpen.value = val
@@ -109,7 +259,6 @@ watch(isOpen, (val) => {
emit('update:modelValue', val)
})
// 格式化发布时间
const formattedPublishedAt = computed(() => {
if (!props.publishedAt) return ''
try {
@@ -124,15 +273,20 @@ const formattedPublishedAt = computed(() => {
}
})
// 渲染 Markdown 格式的 Release Notes(使用 DOMPurify 防止 XSS
const displayReleaseNotes = computed(() => {
return normalizeReleaseNotesForDisplay(props.releaseNotes)
})
const renderedReleaseNotes = computed(() => {
if (!props.releaseNotes) return ''
if (!displayReleaseNotes.value) return ''
try {
const html = marked.parse(props.releaseNotes, { async: false }) as string
return DOMPurify.sanitize(html)
const html = marked.parse(displayReleaseNotes.value, {
async: false,
breaks: true
}) as string
return sanitizeMarkdown(html)
} catch {
// 如果 markdown 解析失败,返回原始文本(转义 HTML
return props.releaseNotes
return displayReleaseNotes.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -141,11 +295,10 @@ const renderedReleaseNotes = computed(() => {
})
function handleLater() {
// 记录忽略的版本24小时内不再提醒
const ignoreKey = 'aether_update_ignore'
const ignoreData = {
version: props.latestVersion,
until: Date.now() + 24 * 60 * 60 * 1000 // 24小时
until: Date.now() + 24 * 60 * 60 * 1000
}
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
isOpen.value = false
@@ -157,4 +310,13 @@ function handleViewRelease() {
}
isOpen.value = false
}
function handleApplyUpdate() {
if (!canApplyUpdate.value) return
emit('applyUpdate')
}
function handleRollback() {
emit('rollback')
}
</script>

View File

@@ -9,9 +9,14 @@
aria-label="版本信息"
>
<Info
v-if="!isReconnecting"
class="h-4 w-4"
:class="loading ? 'animate-pulse' : ''"
/>
<RefreshCw
v-else
class="h-4 w-4 animate-spin"
/>
</button>
</PopoverTrigger>
@@ -69,12 +74,52 @@
检查更新失败{{ status.error }}
</p>
<div class="flex items-center gap-2">
<p
v-if="status?.has_update && !canApplyUpdate"
class="text-xs text-muted-foreground"
>
{{ updateBlockerText }}
</p>
<!-- Reconnecting / busy banner -->
<div
v-if="isReconnecting"
class="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-primary"
>
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
<span class="text-xs font-medium">服务重启中请稍候...</span>
</div>
<div
v-else-if="isDownloadingUpdate"
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2"
>
<div class="flex items-center justify-between gap-3 text-xs text-primary">
<span class="truncate">{{ downloadProgressText }}</span>
<span
v-if="downloadProgressPercent !== null"
class="shrink-0 font-mono"
>
{{ downloadProgressPercent }}%
</span>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/15">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: progressBarWidth }"
/>
</div>
</div>
<div
v-else
class="flex items-center gap-2"
>
<Button
variant="outline"
size="sm"
class="flex-1"
:disabled="loading"
:disabled="isBusy || loading"
@click="handleRefresh"
>
<RefreshCw
@@ -84,45 +129,289 @@
重新检查
</Button>
<Button
v-if="status?.has_update && status.release_url"
v-if="rollbackAvailable"
variant="outline"
size="sm"
class="flex-1"
:disabled="isBusy"
@click="handleRollback"
>
{{ rollingBack ? '回滚中...' : '回滚' }}
</Button>
<Button
v-if="status?.has_update && status.release_url && !rollbackAvailable"
size="sm"
class="flex-1"
@click="handleOpenRelease"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" />
查看更新
{{ releaseButtonLabel }}
</Button>
<Button
v-if="status?.has_update && canApplyUpdate"
size="sm"
class="flex-1"
:disabled="isBusy"
@click="handleApplyUpdate"
>
<RefreshCw
class="mr-2 h-3.5 w-3.5"
:class="updating ? 'animate-spin' : ''"
/>
{{ actionButtonLabel }}
</Button>
</div>
<!-- Releases List -->
<div v-if="!isReconnecting">
<button
type="button"
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
@click="toggleReleases"
>
<ChevronRight
class="h-3 w-3 transition-transform"
:class="showReleases ? 'rotate-90' : ''"
/>
历史版本
<span
v-if="releases.length > 0"
class="text-[10px] text-muted-foreground/60"
>({{ releases.length }})</span>
<RefreshCw
v-if="loadingReleases"
class="ml-auto h-3 w-3 animate-spin text-muted-foreground"
/>
</button>
<div
v-if="showReleases"
class="mt-2 max-h-48 space-y-1 overflow-y-auto"
>
<p
v-if="releasesError"
class="text-xs text-muted-foreground"
>
{{ releasesError }}
</p>
<button
v-for="release in releases"
:key="release.version"
type="button"
class="flex w-full items-center justify-between rounded-md border border-border/40 px-2.5 py-1.5 text-left text-xs transition hover:border-primary/30 hover:bg-primary/5"
:class="release.is_current ? 'bg-primary/5 border-primary/20' : 'bg-muted/10'"
@click="openReleaseDetails(release)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="break-all font-mono font-medium text-foreground">
{{ formatDisplayVersion(release.version) }}
</span>
<span
v-if="release.is_current"
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>当前</span>
<span
v-else-if="release.is_newer"
class="shrink-0 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
></span>
<span
v-if="release.is_newer && release.updatable === false"
class="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
>不可在线更新</span>
</div>
<div
v-if="release.published_at"
class="mt-0.5 text-[10px] text-muted-foreground"
>
{{ formatDate(release.published_at) }}
</div>
<div
v-if="release.update_blocker"
class="mt-0.5 text-[10px] text-muted-foreground"
>
{{ release.update_blocker }}
</div>
</div>
<span class="ml-2 shrink-0 text-[10px] font-medium text-muted-foreground">
详情
</span>
</button>
<p
v-if="!loadingReleases && releases.length === 0 && !releasesError"
class="py-2 text-center text-xs text-muted-foreground"
>
暂无版本信息
</p>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
<Dialog
v-model="showReleaseDetails"
size="xl"
:title="selectedReleaseTitle"
:description="selectedReleaseDescription"
>
<div
v-if="selectedRelease"
class="space-y-4"
>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border/60 bg-muted/20 px-2 py-0.5 font-mono text-[11px] text-foreground">
{{ formatDisplayVersion(selectedRelease.version) }}
</span>
<span
v-if="selectedRelease.is_current"
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
当前运行版本
</span>
<span
v-else-if="selectedRelease.is_newer"
class="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
>
可升级版本
</span>
<span
v-else
class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>
历史版本
</span>
</div>
<p
v-if="selectedReleaseHelpText"
class="text-xs text-muted-foreground"
>
{{ selectedReleaseHelpText }}
</p>
<div
v-if="selectedReleaseDisplayNotes"
class="max-h-[26rem] overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
v-html="selectedReleaseNotesHtml"
/>
<p
v-else
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
>
这个版本没有附带更新说明
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="showReleaseDetails = false"
>
关闭
</Button>
<Button
v-if="selectedRelease?.release_url"
variant="outline"
@click="handleOpenSelectedReleasePage"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" />
查看标签页
</Button>
<Button
v-if="canUseSelectedRelease"
:disabled="isBusy"
@click="handleUseSelectedRelease"
>
<RefreshCw
class="mr-2 h-3.5 w-3.5"
:class="isBusy ? 'animate-spin' : ''"
/>
{{ selectedReleaseActionLabel }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { CheckUpdateResponse } from '@/api/admin'
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
import type { CheckUpdateResponse, ReleaseEntry } from '@/api/admin'
import { adminApi } from '@/api/admin'
import { Button, Dialog, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
import { formatDisplayVersion } from '@/utils/version'
import { describeUpdateStatus } from '@/utils/updateStatus'
import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
import { sanitizeMarkdown } from '@/utils/sanitize'
import { marked } from 'marked'
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
const props = defineProps<{
status: CheckUpdateResponse | null
loading?: boolean
updating?: boolean
updatePhase?: 'download' | 'restart' | 'reconnecting'
updateSupported?: boolean
rollbackAvailable?: boolean
rollingBack?: boolean
downloadProgressText?: string | null
downloadProgressPercent?: number | null
}>()
const emit = defineEmits<{
refresh: []
openRelease: []
applyUpdate: []
previewRelease: [release: ReleaseEntry]
rollback: []
}>()
const isOpen = ref(false)
const showReleases = ref(false)
const showReleaseDetails = ref(false)
const loadingReleases = ref(false)
const releases = ref<ReleaseEntry[]>([])
const releasesError = ref<string | null>(null)
const selectedRelease = ref<ReleaseEntry | null>(null)
let releasesFetched = false
const loading = computed(() => props.loading ?? false)
const updating = computed(() => props.updating ?? false)
const updatePhase = computed(() => props.updatePhase ?? 'download')
const updateSupported = computed(() => props.updateSupported ?? true)
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
const rollingBack = computed(() => props.rollingBack ?? false)
const isReconnecting = computed(() => updatePhase.value === 'reconnecting')
const isDownloadingUpdate = computed(() => updating.value && updatePhase.value === 'download')
const isBusy = computed(() => updating.value || rollingBack.value || isReconnecting.value)
const canApplyUpdate = computed(() => updateSupported.value && props.status?.updatable !== false)
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
const downloadProgressPercent = computed(() => {
const value = props.downloadProgressPercent
return typeof value === 'number' && Number.isFinite(value)
? Math.max(0, Math.min(100, Math.round(value)))
: null
})
const progressBarWidth = computed(() => {
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
})
const updateBlockerText = computed(() => {
if (!updateSupported.value) {
return props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT
}
return props.status?.update_blocker || '当前版本暂不支持在线更新'
})
const releaseButtonLabel = computed(() => updateSupported.value ? '查看更新' : '查看发布')
const buttonClass = computed(() => {
const classes = []
if (isReconnecting.value) {
classes.push('bg-primary/10 text-primary animate-pulse')
return classes
}
if (isOpen.value) {
classes.push('bg-muted/50')
} else {
@@ -139,7 +428,12 @@ const buttonClass = computed(() => {
return classes
})
const statusLabel = computed(() => describeUpdateStatus(props.status))
const statusLabel = computed(() => {
if (isReconnecting.value) return '重启中'
if (rollingBack.value) return '回滚中'
if (updating.value) return '更新中'
return describeUpdateStatus(props.status)
})
const currentVersionLabel = computed(() => {
return props.status?.current_version
? formatDisplayVersion(props.status.current_version)
@@ -151,17 +445,123 @@ const latestVersionLabel = computed(() => {
: ''
})
const statusPillClass = computed(() => {
if (isReconnecting.value || updating.value || rollingBack.value) {
return 'border-primary/20 bg-primary/10 text-primary'
}
if (!props.status) return 'border-border/60 bg-background/70 text-muted-foreground'
if (props.status.has_update) return 'border-primary/20 bg-primary/10 text-primary'
if (props.status.error) return 'border-destructive/20 bg-destructive/10 text-destructive'
return 'border-border/60 bg-background/70 text-muted-foreground'
})
const buttonTitle = computed(() => {
if (isReconnecting.value) return '服务重启中...'
if (!props.status) return '版本信息'
return `版本信息:${statusLabel.value}`
})
const actionButtonLabel = computed(() => {
if (updating.value) {
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
}
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
})
const selectedReleaseTitle = computed(() => {
return selectedRelease.value
? `版本详情 · ${formatDisplayVersion(selectedRelease.value.version)}`
: '版本详情'
})
const selectedReleaseDescription = computed(() => {
return selectedRelease.value?.published_at
? `发布于 ${formatDate(selectedRelease.value.published_at)}`
: '查看该版本的发布说明'
})
const canUseSelectedRelease = computed(() => {
return !!selectedRelease.value &&
!selectedRelease.value.is_current &&
selectedRelease.value.updatable !== false &&
updateSupported.value
})
const selectedReleaseActionLabel = computed(() => {
if (!selectedRelease.value) return '切换到此版本'
return selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本'
})
const selectedReleaseHelpText = computed(() => {
if (!selectedRelease.value) return ''
if (selectedRelease.value.is_current) return '当前正在运行这个版本。'
if (!updateSupported.value) {
return selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT
}
if (selectedRelease.value.update_blocker) return selectedRelease.value.update_blocker
return selectedRelease.value.is_newer
? '将这个版本作为在线更新目标。'
: '将切换到这个历史版本。'
})
const selectedReleaseDisplayNotes = computed(() => {
return normalizeReleaseNotesForDisplay(selectedRelease.value?.release_notes)
})
const selectedReleaseNotesHtml = computed(() => {
if (!selectedReleaseDisplayNotes.value) return ''
try {
const html = marked.parse(selectedReleaseDisplayNotes.value, {
async: false,
breaks: true
}) as string
return sanitizeMarkdown(html)
} catch {
return selectedReleaseDisplayNotes.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
})
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
} catch {
return dateStr
}
}
async function fetchReleases(force = false) {
if (releasesFetched && !force) return
loadingReleases.value = true
releasesError.value = null
try {
const data = await adminApi.getSystemReleases(force)
releases.value = data.releases
releasesError.value = data.error
releasesFetched = true
} catch (err) {
releasesError.value = err instanceof Error ? err.message : '获取版本列表失败'
} finally {
loadingReleases.value = false
}
}
function toggleReleases() {
showReleases.value = !showReleases.value
if (showReleases.value) {
fetchReleases()
}
}
function openReleaseDetails(release: ReleaseEntry) {
selectedRelease.value = release
isOpen.value = false
showReleaseDetails.value = true
}
function handleRefresh() {
releasesFetched = false
if (showReleases.value) {
fetchReleases(true)
}
emit('refresh')
}
@@ -169,4 +569,25 @@ function handleOpenRelease() {
isOpen.value = false
emit('openRelease')
}
function handleOpenSelectedReleasePage() {
if (selectedRelease.value?.release_url) {
window.open(selectedRelease.value.release_url, '_blank', 'noopener,noreferrer')
}
}
function handleUseSelectedRelease() {
if (!selectedRelease.value || !canUseSelectedRelease.value) return
showReleaseDetails.value = false
isOpen.value = false
emit('previewRelease', selectedRelease.value)
}
function handleApplyUpdate() {
emit('applyUpdate')
}
function handleRollback() {
emit('rollback')
}
</script>

View File

@@ -118,8 +118,18 @@
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@preview-release="openReleaseUpdateDialog"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
@@ -301,8 +311,18 @@
v-if="isAdmin"
:status="versionStatus"
:loading="loadingVersionStatus"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@refresh="handleVersionRefresh"
@open-release="openVersionReleasePage"
@preview-release="openReleaseUpdateDialog"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
<!-- Theme Toggle -->
<button
@@ -385,6 +405,21 @@
:release-url="updateInfo.release_url"
:release-notes="updateInfo.release_notes"
:published-at="updateInfo.published_at"
:dialog-title="updateDialogTitle"
:version-label="updateDialogVersionLabel"
:release-link-label="updateDialogReleaseLinkLabel"
:updating="applyingSystemUpdate"
:update-phase="systemUpdatePhase"
:update-supported="updateSupported"
:updatable="updateInfo.updatable"
:update-blocker="updateInfo.update_blocker"
:reconnect-message="reconnectMessage"
:rollback-available="rollbackAvailable"
:rolling-back="rollingBack"
:download-progress-text="updateProgressText"
:download-progress-percent="updateProgressPercent"
@apply-update="handleApplySystemUpdate"
@rollback="handleRollback"
/>
</AppShell>
</template>
@@ -397,9 +432,11 @@ import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules'
import { useDarkMode } from '@/composables/useDarkMode'
import { useSiteInfo } from '@/composables/useSiteInfo'
import { useToast } from '@/composables/useToast'
import { isDemoMode } from '@/config/demo'
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type UpdateTaskStatusResponse } from '@/api/admin'
import { announcementApi, type Announcement } from '@/api/announcements'
import { parseApiError } from '@/utils/errorParser'
import Button from '@/components/ui/button.vue'
import { Dialog } from '@/components/ui'
import AppShell from '@/components/layout/AppShell.vue'
@@ -450,12 +487,15 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
import { sanitizeMarkdown } from '@/utils/sanitize'
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const moduleStore = useModuleStore()
const { themeMode, toggleDarkMode } = useDarkMode()
const { siteName, siteSubtitle } = useSiteInfo()
const { success, error: showError } = useToast()
const isDemo = computed(() => isDemoMode())
const isAdmin = computed(() => authStore.user?.role === 'admin')
@@ -476,7 +516,154 @@ const showUpdateDialog = ref(false)
const updateInfo = ref<CheckUpdateResponse | null>(null)
const versionStatus = ref<CheckUpdateResponse | null>(null)
const loadingVersionStatus = ref(false)
const applyingSystemUpdate = ref(false)
const updateSupported = ref(true)
const reconnectMessage = ref('等待服务恢复...')
const rollbackAvailable = ref(false)
const rollingBack = ref(false)
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
const updateDialogMode = ref<'latest' | 'selected'>('latest')
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
const preparedUpdateVersion = ref<string | null>(
readSessionStorageItem('aether_prepared_update_version')
)
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
let updateStatusPollTimer: number | null = null
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
const updateDialogTitle = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '切换版本' : '版本详情'
}
return '发现新版本'
})
const updateDialogVersionLabel = computed(() => {
if (updateDialogMode.value === 'selected') {
return updateSupported.value ? '目标版本' : '版本标签'
}
return '最新版本'
})
const updateDialogReleaseLinkLabel = computed(() => {
if (updateDialogMode.value === 'selected') return '查看标签页'
return updateSupported.value ? '查看更新' : '查看发布'
})
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 {
const stored = readSessionStorageItem('aether_update_phase')
if (stored === 'restart' || stored === 'reconnecting') return stored
return '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.
}
}
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
if (!status) return '正在下载更新包...'
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
const downloaded = status.downloaded_bytes
const total = status.total_bytes
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
return `${label} ${formatFileSize(downloaded)} / ${formatFileSize(total)}`
}
if (typeof downloaded === 'number' && downloaded > 0) {
return `${label} ${formatFileSize(downloaded)}`
}
return label
}
function formatUpdateTaskPhase(phase: string): string {
switch (phase) {
case 'downloading':
return '正在下载更新包'
case 'downloading_checksum':
return '正在下载校验文件'
case 'verifying':
return '正在校验更新包'
case 'extracting':
return '正在解压更新包'
case 'prepared':
return '更新包已准备完成'
default:
return '正在准备更新'
}
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${bytes} B`
}
async function refreshUpdateTaskStatus() {
try {
updateTaskStatus.value = await adminApi.getUpdateStatus()
} catch {
// Keep the last progress snapshot while the request is in flight or the service restarts.
}
}
async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
const deadline = Date.now() + 10 * 60 * 1000
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1000))
await refreshUpdateTaskStatus()
const status = updateTaskStatus.value
if (status?.phase === 'prepared') return status
if (status?.phase === 'failed') {
throw new Error(status.error || '下载更新失败')
}
}
throw new Error('下载更新超时')
}
function startUpdateStatusPolling() {
stopUpdateStatusPolling()
void refreshUpdateTaskStatus()
updateStatusPollTimer = window.setInterval(() => {
void refreshUpdateTaskStatus()
}, 1000)
}
function stopUpdateStatusPolling() {
if (updateStatusPollTimer !== null) {
window.clearInterval(updateStatusPollTimer)
updateStatusPollTimer = null
}
}
// 路由变化时自动关闭移动端菜单
watch(() => route.path, () => {
@@ -501,14 +688,29 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
return true
}
async function loadVersionStatus() {
async function loadVersionStatus(force = false) {
if (!isAdmin.value) return null
if (versionStatusLoadPromise) return versionStatusLoadPromise
loadingVersionStatus.value = true
versionStatusLoadPromise = (async () => {
try {
versionStatus.value = await adminApi.checkUpdate()
const [status, capability] = await Promise.all([
adminApi.checkUpdate(force),
adminApi.getSystemUpdateCapability().catch(() => null),
])
if (capability) {
rollbackAvailable.value = capability.rollback_available
updateSupported.value = capability.supported
}
versionStatus.value = capability?.supported === false && status.has_update
? {
...status,
updatable: false,
update_blocker: SOURCE_BUILD_UPDATE_HINT,
}
: status
syncSystemUpdatePhase(versionStatus.value)
return versionStatus.value
} catch (error) {
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
@@ -522,8 +724,22 @@ async function loadVersionStatus() {
return versionStatusLoadPromise
}
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
if (systemUpdatePhase.value === 'reconnecting') return
if (systemUpdatePhase.value === 'restart') {
if (!preparedUpdateVersion.value) {
systemUpdatePhase.value = 'download'
}
return
}
if (!status?.has_update) {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
}
function handleVersionRefresh() {
void loadVersionStatus()
void loadVersionStatus(true)
}
function openVersionReleasePage() {
@@ -532,8 +748,172 @@ function openVersionReleasePage() {
}
}
function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse {
const currentVersion =
versionStatus.value?.current_version ||
updateInfo.value?.current_version ||
__APP_VERSION__ ||
''
const sourceBuild = !updateSupported.value
return {
current_version: currentVersion,
latest_version: release.version,
has_update: !release.is_current,
updatable: !sourceBuild && !release.is_current && release.updatable,
update_blocker: release.is_current
? '当前已是这个版本'
: sourceBuild
? SOURCE_BUILD_RELEASE_HINT
: release.update_blocker,
release_url: release.release_url,
release_notes: release.release_notes,
published_at: release.published_at,
error: null,
}
}
function openReleaseUpdateDialog(release: ReleaseEntry) {
updateDialogMode.value = 'selected'
updateInfo.value = buildUpdateInfoFromRelease(release)
if (systemUpdatePhase.value !== 'reconnecting') {
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
showUpdateDialog.value = true
}
async function handleApplySystemUpdate() {
if (applyingSystemUpdate.value) return
applyingSystemUpdate.value = true
try {
const capability = await adminApi.getSystemUpdateCapability()
rollbackAvailable.value = capability.rollback_available
if (!capability.supported) {
updateSupported.value = false
showError(
SOURCE_BUILD_UPDATE_HINT,
'不支持在线更新'
)
return
}
updateSupported.value = true
if (systemUpdatePhase.value === 'download') {
const targetStatus = updateInfo.value || versionStatus.value
if (targetStatus?.has_update && targetStatus.updatable === false) {
showError(
targetStatus.update_blocker || '当前版本暂不支持在线更新',
'无法在线更新'
)
return
}
const targetVersion = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
updateTaskStatus.value = null
startUpdateStatusPolling()
try {
const result = await adminApi.prepareSystemUpdate(targetVersion)
const finalStatus = await waitForPreparedUpdate()
preparedUpdateVersion.value = targetVersion
systemUpdatePhase.value = 'restart'
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
} finally {
stopUpdateStatusPolling()
void refreshUpdateTaskStatus()
}
return
}
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
success(result.message || '一键重启已启动')
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '服务正在重启...'
showUpdateDialog.value = true
applyingSystemUpdate.value = false
await pollHealthUntilReady()
} catch (err) {
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
showError(parseApiError(err, fallback))
} finally {
applyingSystemUpdate.value = false
}
}
async function handleRollback() {
if (rollingBack.value) return
rollingBack.value = true
try {
const result = await adminApi.rollbackSystemUpdate()
success(result.message || '回滚已启动')
systemUpdatePhase.value = 'reconnecting'
reconnectMessage.value = '正在回滚到上一版本...'
showUpdateDialog.value = true
rollingBack.value = false
await pollHealthUntilReady()
} catch (err) {
showError(parseApiError(err, '回滚失败'))
} finally {
rollingBack.value = false
}
}
async function pollHealthUntilReady() {
const maxAttempts = 60
const intervalMs = 2000
for (let i = 0; i < maxAttempts; i++) {
if (i < 3) {
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
await new Promise(r => setTimeout(r, intervalMs))
continue
}
const elapsed = i * 2
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
try {
const resp = await fetch('/_gateway/health', {
method: 'GET',
signal: AbortSignal.timeout(3000),
})
if (resp.ok) {
reconnectMessage.value = '服务已恢复,正在刷新...'
await new Promise(r => setTimeout(r, 500))
window.location.replace(buildFreshReloadUrl())
return
}
} catch {
// expected while service is down
}
// After 30 seconds, start checking if the task actually failed
if (i > 15) {
try {
const status = await adminApi.getUpdateStatus()
if (status.phase === 'failed' && status.error) {
reconnectMessage.value = `更新失败: ${status.error}`
systemUpdatePhase.value = 'download'
return
}
} catch {
// service still down, continue polling
}
}
await new Promise(r => setTimeout(r, intervalMs))
}
reconnectMessage.value = '等待超时,请手动刷新页面'
systemUpdatePhase.value = 'download'
}
function buildFreshReloadUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set('__aether_reload', Date.now().toString())
return url.toString()
}
function showDebugUpdateDialog() {
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
updateDialogMode.value = 'latest'
updateInfo.value = {
current_version: currentVersion,
latest_version: 'v0.7.0-rc99',
@@ -546,8 +926,12 @@ function showDebugUpdateDialog() {
'- 统一版本号显示格式',
].join('\n'),
published_at: new Date().toISOString(),
updatable: true,
update_blocker: null,
error: null,
}
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
showUpdateDialog.value = true
}
@@ -567,8 +951,12 @@ function showDebugVersionStatus(hasUpdate = true) {
].join('\n')
: null,
published_at: hasUpdate ? new Date().toISOString() : null,
updatable: hasUpdate,
update_blocker: null,
error: null,
}
systemUpdatePhase.value = 'download'
preparedUpdateVersion.value = null
}
// 检查更新
@@ -584,6 +972,7 @@ async function checkForUpdate() {
const result = versionStatus.value ?? await loadVersionStatus()
if (result?.has_update && result.latest_version) {
if (shouldShowUpdatePrompt(result.latest_version)) {
updateDialogMode.value = 'latest'
updateInfo.value = result
showUpdateDialog.value = true
}
@@ -676,6 +1065,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange)
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopUpdateStatusPolling()
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
delete window.__aetherShowUpdateDialog
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { normalizeReleaseNotesForDisplay, trimReleaseNotesForDisplay } from '../releaseNotes'
describe('trimReleaseNotesForDisplay', () => {
it('keeps manual notes and removes GitHub auto-generated sections', () => {
const input = [
'### Features',
'- 新增在线更新',
'',
'### Bug Fixes',
'- 修复版本检查',
'',
'## What\'s Changed',
'* fix: something by @someone in https://github.com/example/repo/pull/1',
'',
'## New Contributors',
'* @someone made their first contribution',
'',
'**Full Changelog**: https://github.com/example/repo/compare/v1...v2',
].join('\n')
expect(trimReleaseNotesForDisplay(input)).toBe([
'### Features',
'- 新增在线更新',
'',
'### Bug Fixes',
'- 修复版本检查',
].join('\n'))
})
it('removes standalone full changelog lines', () => {
const input = '**Full Changelog**: https://github.com/example/repo/compare/v1...v2'
expect(trimReleaseNotesForDisplay(input)).toBe('')
})
it('returns original notes when there is no auto-generated footer', () => {
const input = [
'### Features',
'- 支持历史版本切换',
].join('\n')
expect(trimReleaseNotesForDisplay(input)).toBe(input)
})
})
describe('normalizeReleaseNotesForDisplay', () => {
it('keeps existing markdown structure intact', () => {
const input = [
'### Features',
'- 新增在线更新',
'',
'### Fixes',
'- 修复版本检查',
].join('\n')
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
})
it('converts plain multi-section notes into markdown sections', () => {
const input = [
'Features',
'新增在线更新弹窗',
'支持选择历史版本',
'',
'问题修复',
'修复下载进度显示不准',
'修复版本详情跳转错误',
].join('\n')
expect(normalizeReleaseNotesForDisplay(input)).toBe([
'### Features',
'- 新增在线更新弹窗',
'- 支持选择历史版本',
'',
'### 问题修复',
'- 修复下载进度显示不准',
'- 修复版本详情跳转错误',
].join('\n'))
})
it('does not over-normalize plain paragraph text', () => {
const input = '这次更新主要修复了在线更新流程中的代理问题,并优化了历史版本切换体验。'
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
})
})

View File

@@ -10,6 +10,8 @@ function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdate
current_version: '0.7.0-rc27',
latest_version: null,
has_update: false,
updatable: false,
update_blocker: null,
release_url: null,
release_notes: null,
published_at: null,

View File

@@ -0,0 +1,144 @@
const AUTO_GENERATED_RELEASE_SECTION_PATTERNS = [
/^#{1,6}\s+what'?s changed\s*$/i,
/^#{1,6}\s+new contributors\s*$/i,
/^\*\*full changelog\*\*:/i,
]
const MARKDOWN_STRUCTURE_PATTERNS = [
/^#{1,6}\s+\S/,
/^\s*[-*+]\s+\S/,
/^\s*\d+\.\s+\S/,
/^\s*>\s+\S/,
/^\s*```/,
/^\s*---+\s*$/,
/^\s*\|(?:[^|]+\|)+\s*$/,
/`[^`]+`/,
/\[[^\]]+\]\([^)]+\)/,
]
const SENTENCE_END_PUNCTUATION = /[,。,.!?;]$/
const SECTION_HEADING_SUFFIX = /[:]\s*$/
const URL_PATTERN = /https?:\/\/|www\./i
const BRACKET_PREFIX_PATTERN = /^[\[((【<]/
const SECTION_HEADING_TEXT_PATTERN = /[\u3400-\u9FFFA-Za-z]/
function isStructuredMarkdownLine(line: string): boolean {
return MARKDOWN_STRUCTURE_PATTERNS.some((pattern) => pattern.test(line))
}
function normalizeSectionHeading(line: string): string {
return line.replace(SECTION_HEADING_SUFFIX, '').trim()
}
function looksLikeSectionHeading(line: string): boolean {
const trimmed = normalizeSectionHeading(line)
if (!trimmed) return false
if (trimmed.length > 24) return false
if (URL_PATTERN.test(trimmed)) return false
if (BRACKET_PREFIX_PATTERN.test(trimmed)) return false
if (isStructuredMarkdownLine(trimmed)) return false
if (SENTENCE_END_PUNCTUATION.test(trimmed)) return false
if (!SECTION_HEADING_TEXT_PATTERN.test(trimmed)) return false
return true
}
function collectConfirmedHeadingIndexes(lines: string[]): Set<number> {
const candidates = lines
.map((line, index) => {
if (!looksLikeSectionHeading(line.trim())) return -1
if (index === 0) return index
return lines[index - 1].trim() === '' ? index : -1
})
.filter((index) => index !== -1)
const confirmed = new Set<number>()
for (let i = 0; i < candidates.length; i += 1) {
const current = candidates[i]
const next = i + 1 < candidates.length ? candidates[i + 1] : lines.length
for (let cursor = current + 1; cursor < next; cursor += 1) {
const content = lines[cursor].trim()
if (!content) continue
confirmed.add(current)
break
}
}
return confirmed
}
function collapseBlankLines(lines: string[]): string {
return lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function trimReleaseNotesForDisplay(notes: string | null | undefined): string {
if (!notes) return ''
const normalized = notes.replace(/\r\n?/g, '\n').trim()
if (!normalized) return ''
const lines = normalized.split('\n')
const cutoff = lines.findIndex((line) => {
const trimmed = line.trim()
return AUTO_GENERATED_RELEASE_SECTION_PATTERNS.some((pattern) => pattern.test(trimmed))
})
if (cutoff === -1) {
return normalized
}
return lines.slice(0, cutoff).join('\n').trim()
}
export function normalizeReleaseNotesForDisplay(notes: string | null | undefined): string {
const trimmed = trimReleaseNotesForDisplay(notes)
if (!trimmed) return ''
const lines = trimmed.split('\n')
if (lines.some((line) => isStructuredMarkdownLine(line.trim()))) {
return trimmed
}
const headingIndexes = collectConfirmedHeadingIndexes(lines)
if (headingIndexes.size < 2) {
return trimmed
}
const normalized: string[] = []
let insideSection = false
for (let index = 0; index < lines.length; index += 1) {
const trimmedLine = lines[index].trim()
if (!trimmedLine) {
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
normalized.push('')
}
continue
}
if (headingIndexes.has(index)) {
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
normalized.push('')
}
normalized.push(`### ${normalizeSectionHeading(trimmedLine)}`)
insideSection = true
continue
}
if (insideSection) {
normalized.push(`- ${trimmedLine.replace(/^[-*+•]\s*/, '')}`)
continue
}
normalized.push(trimmedLine)
}
return collapseBlankLines(normalized)
}

View File

@@ -15,6 +15,8 @@ export function buildUpdateErrorStatus(
current_version: previousStatus?.current_version || '',
latest_version: null,
has_update: false,
updatable: false,
update_blocker: null,
release_url: null,
release_notes: null,
published_at: null,

View File

@@ -34,6 +34,18 @@ fi
SERVICE_USER="${SERVICE_USER:-aether}"
SERVICE_GROUP="${SERVICE_GROUP:-aether}"
SERVICE_NAME="aether-gateway"
COMPOSE_RELEASE_BASE_DIR="/opt/aether"
COMPOSE_RELEASE_CURRENT_DIR="${COMPOSE_RELEASE_BASE_DIR}/current"
COMPOSE_RELEASE_FRONTEND_DIR="${COMPOSE_RELEASE_CURRENT_DIR}/frontend"
COMPOSE_RELEASE_LOG_DIR="${COMPOSE_RELEASE_BASE_DIR}/logs"
COMPOSE_RELEASE_SQLITE_DATABASE_URL="sqlite://${COMPOSE_RELEASE_BASE_DIR}/data/aether.db"
COMPOSE_LOG_DESTINATION_DEFAULT="stdout"
COMPOSE_LOG_FORMAT_DEFAULT="pretty"
COMPOSE_LOG_ROTATION_DEFAULT="daily"
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
COMPOSE_APP_PORT_DEFAULT="8084"
COMPOSE_CLI=()
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
@@ -598,6 +610,7 @@ EOF
fi
;;
esac
if [[ -z "${MIGRATE_FROM_COMPOSE}" && "${MODE}" != "compose" ]]; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
@@ -904,11 +917,21 @@ ensure_directory() {
}
require_compose_runtime() {
resolve_compose_cli
}
resolve_compose_cli() {
if [[ "${#COMPOSE_CLI[@]}" -gt 0 ]]; then
return
fi
if docker compose version >/dev/null 2>&1; then
COMPOSE_CLI=(docker compose)
return
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CLI=(docker-compose)
return
fi
@@ -920,11 +943,13 @@ require_compose_runtime() {
}
compose_command() {
if docker compose version >/dev/null 2>&1; then
printf '%s\n' "docker compose"
else
printf '%s\n' "docker-compose"
fi
resolve_compose_cli
printf '%s\n' "${COMPOSE_CLI[*]}"
}
run_compose() {
resolve_compose_cli
"${COMPOSE_CLI[@]}" "$@"
}
compose_next_steps() {
@@ -940,8 +965,9 @@ Install complete.
Docker Compose service:
cd ${COMPOSE_DIR}
${compose_cmd} ps
${compose_cmd} logs -f app
./update.sh
${compose_cmd} -f docker-compose.yml ps
${compose_cmd} -f docker-compose.yml logs -f app
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
@@ -961,9 +987,13 @@ compose_manual_start_steps() {
Next steps:
cd ${COMPOSE_DIR}
${compose_cmd} pull
${compose_cmd} up -d
${compose_cmd} logs -f app
${compose_cmd} -f docker-compose.yml pull
${compose_cmd} -f docker-compose.yml up -d
${compose_cmd} -f docker-compose.yml logs -f app
Later updates:
cd ${COMPOSE_DIR}
./update.sh
Generate a fresh key set any time:
cd ${COMPOSE_DIR}
@@ -972,19 +1002,12 @@ EOF
}
start_compose_deployment() {
local compose_cmd
compose_cmd="$(compose_command)"
local -a compose_args=(--project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml")
info "pulling Docker Compose images"
if [[ "${compose_cmd}" == "docker compose" ]]; then
docker compose pull
info "starting Docker Compose services"
docker compose up -d
else
docker-compose pull
info "starting Docker Compose services"
docker-compose up -d
fi
run_compose "${compose_args[@]}" pull
info "starting Docker Compose services"
run_compose "${compose_args[@]}" up -d
}
migration_options_requested() {
@@ -1759,6 +1782,31 @@ compose_image() {
printf '%s:%s\n' "${IMAGE_REPO}" "${tag}"
}
compose_app_port() {
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
}
append_compose_log_env_defaults() {
local output="$1"
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "${COMPOSE_LOG_FORMAT_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_DIR" "${COMPOSE_RELEASE_LOG_DIR}"
replace_or_append_env "${output}" "AETHER_LOG_ROTATION" "${COMPOSE_LOG_ROTATION_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_RETENTION_DAYS" "${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}"
replace_or_append_env "${output}" "AETHER_LOG_MAX_FILES" "${COMPOSE_LOG_MAX_FILES_DEFAULT}"
}
compose_log_env_block() {
cat <<EOF
AETHER_LOG_DESTINATION=${COMPOSE_LOG_DESTINATION_DEFAULT}
AETHER_LOG_FORMAT=${COMPOSE_LOG_FORMAT_DEFAULT}
AETHER_LOG_DIR=${COMPOSE_RELEASE_LOG_DIR}
AETHER_LOG_ROTATION=${COMPOSE_LOG_ROTATION_DEFAULT}
AETHER_LOG_RETENTION_DAYS=${COMPOSE_LOG_RETENTION_DAYS_DEFAULT}
AETHER_LOG_MAX_FILES=${COMPOSE_LOG_MAX_FILES_DEFAULT}
EOF
}
generate_compose_env() {
local output="$1"
local jwt_key encryption_key
@@ -1768,7 +1816,7 @@ generate_compose_env() {
cp "${COMPOSE_DIR}/.env.example" "${output}"
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
replace_or_append_env "${output}" "APP_PORT" "${APP_PORT:-8084}"
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
replace_or_append_env "${output}" "DB_PASSWORD" "aether"
replace_or_append_env "${output}" "REDIS_PASSWORD" "aether"
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
@@ -1776,9 +1824,7 @@ generate_compose_env() {
replace_or_append_env "${output}" "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
replace_or_append_env "${output}" "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
replace_or_append_env "${output}" "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "both"
replace_or_append_env "${output}" "AETHER_LOG_FORMAT" "pretty"
replace_or_append_env "${output}" "AETHER_LOG_DIR" "/app/logs"
append_compose_log_env_defaults "${output}"
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
}
@@ -1793,24 +1839,19 @@ generate_compose_single_node_env() {
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=/app/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
$(compose_log_env_block)
APP_IMAGE=$(compose_image)
APP_PORT=${APP_PORT:-8084}
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
APP_PORT=$(compose_app_port)
AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR}
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
AETHER_DATABASE_DRIVER=sqlite
AETHER_DATABASE_URL=sqlite:///app/data/aether.db
DATABASE_URL=sqlite:///app/data/aether.db
AETHER_DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
DATABASE_URL=${COMPOSE_RELEASE_SQLITE_DATABASE_URL}
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}}
ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}}
@@ -2182,10 +2223,9 @@ install_compose_mode() {
resolve_compose_dir
info "preparing Docker Compose deployment in ${COMPOSE_DIR}"
ensure_directory "${COMPOSE_DIR}"
ensure_directory "${COMPOSE_DIR}/logs"
install_project_file "docker-compose.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
@@ -2202,8 +2242,8 @@ Docker Compose files are ready:
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
${COMPOSE_DIR}/update.sh
${COMPOSE_DIR}/generate_keys.sh
${COMPOSE_DIR}/logs
EOF
if [[ "${SKIP_START}" == "true" ]]; then
@@ -2220,11 +2260,11 @@ install_compose_single_node_mode() {
resolve_compose_dir
info "preparing Docker Compose single-node deployment in ${COMPOSE_DIR}"
ensure_directory "${COMPOSE_DIR}"
ensure_directory "${COMPOSE_DIR}/logs"
ensure_directory "${COMPOSE_DIR}/data"
install_project_file "docker-compose.single-node.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
install_project_file "update.sh" "${COMPOSE_DIR}/update.sh" "0755"
install_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
@@ -2241,9 +2281,9 @@ Docker Compose single-node files are ready:
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
${COMPOSE_DIR}/update.sh
${COMPOSE_DIR}/generate_keys.sh
${COMPOSE_DIR}/data
${COMPOSE_DIR}/logs
EOF
if [[ "${SKIP_START}" == "true" ]]; then
@@ -2277,7 +2317,10 @@ install_release() {
[[ -d "${bundle}/frontend" ]] || die "frontend directory not found: ${bundle}/frontend"
info "installing release ${VERSION} into ${release_dir}"
install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases" "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
chown root:"${SERVICE_GROUP}" "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
chmod 2775 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
install -d -m 0755 "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
if is_darwin; then
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
"${INSTALL_ROOT}/data" \
@@ -2291,12 +2334,13 @@ install_release() {
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
install -m 0755 "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
cp -R "${bundle}/frontend/." "${release_dir}/frontend/"
chmod -R u=rwX,go=rX "${release_dir}"
chmod -R u=rwX,g=rwX,o=rX "${release_dir}"
if is_darwin; then
chown -R root:wheel "${release_dir}"
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
else
chown -R root:root "${release_dir}"
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
fi
chmod -R u=rwX,g=rwX,o=rX "${release_dir}"
ln -sfn "${release_dir}" "${current_link}"
}

View File

@@ -315,7 +315,7 @@ write_single_node_env() {
printf '\n# Single-node Compose runtime overrides\n'
printf 'APP_IMAGE=%s\n' "$app_image"
printf 'APP_PORT=%s\n' "$app_port"
printf 'AETHER_GATEWAY_STATIC_DIR=/srv/frontend\n'
printf 'AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend\n'
printf 'AETHER_LOG_DESTINATION=both\n'
printf 'AETHER_LOG_FORMAT=pretty\n'
printf 'AETHER_LOG_DIR=/app/logs\n'

287
update.sh Executable file
View File

@@ -0,0 +1,287 @@
#!/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"
COMPOSE_WAIT_TIMEOUT_SECS=120
COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS=2
NO_PULL=false
FORCE_RECREATE=false
SHOW_LOGS=false
LOCAL_BUILD=false
PREPARE_ONLY=false
COMPOSE_FILES=()
COMPOSE=()
COMPOSE_ARGS=()
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
resolve_compose_cli() {
if [[ "${#COMPOSE[@]}" -gt 0 ]]; then
return
fi
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
return
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
return
fi
die "docker compose or docker-compose is required"
}
compose() {
"${COMPOSE[@]}" "${COMPOSE_ARGS[@]}" "$@"
}
compose_config() {
compose config "$@"
}
resolve_compose_cli
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
}
resolve_default_compose_files() {
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 [[ "${#COMPOSE_FILES[@]}" -eq 0 ]]; then
resolve_default_compose_files
fi
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_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}"
compose_pull_app() {
compose pull "${APP_SERVICE}"
}
compose_up_app() {
local wait_for_health="${1:-false}"
local -a up_args=(up -d)
if [[ "${FORCE_RECREATE}" == "true" ]]; then
up_args+=(--force-recreate)
fi
if [[ "${wait_for_health}" == "true" ]]; then
up_args+=(--wait --wait-timeout "${COMPOSE_WAIT_TIMEOUT_SECS}")
fi
up_args+=("${APP_SERVICE}")
compose "${up_args[@]}"
}
wait_healthy() {
local timeout="${1:-${COMPOSE_WAIT_TIMEOUT_SECS}}"
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 ps -q "${APP_SERVICE}" 2>/dev/null | head -n 1)"
if [[ -z "${container_id}" ]]; then
sleep "${COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS}"
elapsed=$(( elapsed + COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS ))
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 "${COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS}"
elapsed=$(( elapsed + COMPOSE_HEALTHCHECK_POLL_INTERVAL_SECS ))
done
echo ">>> WARNING: health check timed out after ${timeout}s."
return 1
}
if [[ "${PREPARE_ONLY}" == "true" ]]; then
echo ">>> Preparing update by pulling latest image for ${APP_SERVICE}..."
compose_pull_app
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_pull_app
fi
echo ">>> Recreating ${APP_SERVICE}..."
compose_up_app true || {
echo ">>> Compose up with --wait failed; falling back to simple recreate..."
compose_up_app false
wait_healthy || true
}
echo ">>> Current services:"
compose ps
echo ">>> Done."
if [[ "${SHOW_LOGS}" == "true" ]]; then
compose logs -f "${APP_SERVICE}"
fi