Files
Aether/install.sh
T

2230 lines
65 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
REPO="${AETHER_REPO:-fawney19/Aether}"
SOURCE_REF="${AETHER_SOURCE_REF:-main}"
VERSION="${AETHER_VERSION:-}"
CHANNEL="${AETHER_CHANNEL:-stable}"
CHANNEL_EXPLICIT="false"
if [[ -n "${AETHER_CHANNEL:-}" ]]; then
CHANNEL_EXPLICIT="true"
fi
MODE="${AETHER_INSTALL_MODE:-auto}"
INSTALL_ROOT_EXPLICIT="false"
if [[ -n "${INSTALL_ROOT:-}" ]]; then
INSTALL_ROOT_EXPLICIT="true"
fi
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aether}"
CONFIG_DIR="${CONFIG_DIR:-/etc/aether}"
COMPOSE_DIR="${AETHER_COMPOSE_DIR:-}"
COMPOSE_DIR_EXPLICIT="false"
if [[ -n "${AETHER_COMPOSE_DIR:-}" ]]; then
COMPOSE_DIR_EXPLICIT="true"
fi
IMAGE_REPO="${AETHER_IMAGE_REPO:-ghcr.io/fawney19/aether}"
APP_IMAGE="${AETHER_APP_IMAGE:-}"
SERVICE_USER_EXPLICIT="false"
SERVICE_GROUP_EXPLICIT="false"
if [[ -n "${SERVICE_USER:-}" ]]; then
SERVICE_USER_EXPLICIT="true"
fi
if [[ -n "${SERVICE_GROUP:-}" ]]; then
SERVICE_GROUP_EXPLICIT="true"
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"
SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
LAUNCHD_PLIST_PATH="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist"
TMP_ROOT=""
ARCHIVE_PATH=""
BUNDLE_DIR=""
ENV_SOURCE=""
SKIP_START="false"
GENERATED_ENV=""
ADMIN_PASSWORD_SOURCE=""
UI_LANG="${AETHER_LANG:-${AETHER_LANGUAGE:-auto}}"
RELEASE_KEEP="${AETHER_RELEASE_KEEP:-3}"
RELEASE_ARCHIVE_URL="${AETHER_RELEASE_ARCHIVE_URL:-${AETHER_DOWNLOAD_URL:-}}"
usage() {
cat <<'EOF'
Usage: install.sh [options]
Install Aether Gateway.
Options:
--mode MODE Deployment mode: compose, compose-single-node, or single-node
compose: Docker Compose app + Postgres + Redis
compose-single-node: Docker Compose single-node app
single-node: single-node system service
Linux services use systemd; macOS services use launchd
--channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, beta, or nightly
stable/latest resolves the latest stable tag (default)
rc resolves the latest tag like v0.7.0-rc.1
beta resolves the latest tag like v0.7.0-beta.1
nightly resolves the rolling nightly build from main
--version VERSION Exact release tag to install, for example v0.7.0-rc.1 or nightly
--repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether)
--source-ref REF Source branch/tag used for compose templates (default: main)
--archive PATH Install from a local release tarball instead of downloading
--download-url URL Download the release archive from this URL instead of GitHub
--env-file PATH Use an existing aether-gateway.env file
--install-root PATH Install root for system service mode (default: /opt/aether)
Also makes the default Docker Compose directory PATH/compose
--compose-dir PATH Docker Compose deployment directory (default: current directory)
--config-dir PATH Config directory (default: /etc/aether)
--lang LANG Installer language: zh or en
--skip-start Install files, but do not start Docker Compose or restart the service
--keep-releases N Keep the latest N releases, prune older ones (default: 3, 0=disable)
-h, --help Show this help
Environment overrides:
AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION
AETHER_LANG or AETHER_LANGUAGE
AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL
AETHER_LAUNCHD_LABEL, AETHER_LAUNCHD_LOG_DIR, AETHER_RELEASE_KEEP
AETHER_IMAGE_REPO, AETHER_APP_IMAGE
INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP
ADMIN_PASSWORD (required for non-interactive first install when generating a new env)
EOF
}
die() {
if ui_is_zh; then
echo "错误: $*" >&2
else
echo "ERROR: $*" >&2
fi
exit 1
}
info() {
echo ">>> $*" >&2
}
warn() {
if ui_is_zh; then
echo "警告: $*" >&2
else
echo "WARNING: $*" >&2
fi
}
ui_is_zh() {
case "${UI_LANG}" in
zh|zh-*|cn|chinese|Chinese|中文)
return 0
;;
*)
return 1
;;
esac
}
interactive_tty_available() {
[[ -r /dev/tty && -w /dev/tty ]]
}
normalize_ui_lang() {
local value="$1"
value="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
case "${value}" in
zh|zh-cn|cn|chinese|中文)
echo "zh"
;;
en|en-us|english|英语)
echo "en"
;;
auto|"")
echo "auto"
;;
*)
die "unsupported installer language: ${value}; expected zh or en"
;;
esac
}
select_language() {
UI_LANG="$(normalize_ui_lang "${UI_LANG}")"
if [[ "${UI_LANG}" != "auto" ]]; then
return
fi
if interactive_tty_available; then
cat >/dev/tty <<'EOF'
请选择安装语言 / Choose installer language:
1) 中文
2) English
请输入选项 / Enter choice [1]:
EOF
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
UI_LANG="zh"
;;
2)
UI_LANG="en"
;;
*)
UI_LANG="zh"
die "无效的语言选项: ${choice}"
;;
esac
else
UI_LANG="en"
fi
}
cleanup() {
if [[ -n "${TMP_ROOT}" && -d "${TMP_ROOT}" ]]; then
rm -rf "${TMP_ROOT}"
fi
}
trap cleanup EXIT
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
[[ $# -ge 2 ]] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--channel)
[[ $# -ge 2 ]] || die "--channel requires a value"
CHANNEL="$2"
CHANNEL_EXPLICIT="true"
shift 2
;;
--version)
[[ $# -ge 2 ]] || die "--version requires a value"
VERSION="$2"
shift 2
;;
--repo)
[[ $# -ge 2 ]] || die "--repo requires a value"
REPO="$2"
shift 2
;;
--source-ref)
[[ $# -ge 2 ]] || die "--source-ref requires a value"
SOURCE_REF="$2"
shift 2
;;
--archive)
[[ $# -ge 2 ]] || die "--archive requires a path"
ARCHIVE_PATH="$2"
shift 2
;;
--download-url|--archive-url|--release-url)
[[ $# -ge 2 ]] || die "--download-url requires a value"
RELEASE_ARCHIVE_URL="$2"
shift 2
;;
--env-file)
[[ $# -ge 2 ]] || die "--env-file requires a path"
ENV_SOURCE="$2"
shift 2
;;
--install-root)
[[ $# -ge 2 ]] || die "--install-root requires a path"
INSTALL_ROOT="$2"
INSTALL_ROOT_EXPLICIT="true"
shift 2
;;
--compose-dir)
[[ $# -ge 2 ]] || die "--compose-dir requires a path"
COMPOSE_DIR="$2"
COMPOSE_DIR_EXPLICIT="true"
shift 2
;;
--config-dir)
[[ $# -ge 2 ]] || die "--config-dir requires a path"
CONFIG_DIR="$2"
ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
shift 2
;;
--lang|--language)
[[ $# -ge 2 ]] || die "--lang requires a value"
UI_LANG="$2"
shift 2
;;
--skip-start)
SKIP_START="true"
shift
;;
--keep-releases)
[[ $# -ge 2 ]] || die "--keep-releases requires a number"
RELEASE_KEEP="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
}
install_os() {
case "$(uname -s)" in
Linux)
echo "linux"
;;
Darwin)
echo "macos"
;;
*)
if ui_is_zh; then
die "Aether 二进制安装仅支持 Linux 和 macOS"
else
die "Aether binary install is only supported on Linux and macOS"
fi
;;
esac
}
is_darwin() {
[[ "$(install_os)" == "macos" ]]
}
apply_platform_defaults() {
if is_darwin; then
if [[ "${SERVICE_USER_EXPLICIT}" != "true" ]]; then
SERVICE_USER="_aether"
fi
if [[ "${SERVICE_GROUP_EXPLICIT}" != "true" ]]; then
SERVICE_GROUP="_aether"
fi
fi
}
require_supported_os() {
install_os >/dev/null
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
if ui_is_zh; then
die "请使用 root 运行"
else
die "run as root"
fi
fi
}
require_systemd() {
if ! command -v systemctl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 systemctl"
else
die "systemctl not found"
fi
fi
}
require_launchd() {
if ! command -v launchctl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 launchctl"
else
die "launchctl not found"
fi
fi
}
require_service_manager() {
case "$(install_os)" in
linux)
require_systemd
;;
macos)
require_launchd
;;
esac
}
service_manager_name() {
case "$(install_os)" in
linux)
echo "systemd"
;;
macos)
echo "launchd"
;;
esac
}
select_version() {
if [[ -n "${VERSION}" || -n "${ARCHIVE_PATH}" || "${CHANNEL_EXPLICIT}" == "true" ]]; then
return
fi
if interactive_tty_available; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请选择 Aether 版本:
1) 最新正式版
2) 最新 RC 预发布版
3) 最新 Beta 预发布版
4) 最新 nightly 构建版
5) 指定 tag,例如 v0.7.0-rc.1
请输入选项 [1]:
EOF
else
cat >/dev/tty <<'EOF'
Choose Aether version:
1) Latest stable release
2) Latest RC prerelease
3) Latest beta prerelease
4) Latest nightly build
5) Exact tag, for example v0.7.0-rc.1
Enter choice [1]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
CHANNEL="stable"
;;
2)
CHANNEL="rc"
;;
3)
CHANNEL="beta"
;;
4)
CHANNEL="nightly"
;;
5)
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请输入准确 tag:
EOF
else
cat >/dev/tty <<'EOF'
Enter exact tag:
EOF
fi
IFS= read -r VERSION </dev/tty || VERSION=""
if [[ -z "${VERSION}" ]]; then
if ui_is_zh; then
die "准确 tag 不能为空"
else
die "exact tag cannot be empty"
fi
fi
;;
*)
if ui_is_zh; then
die "无效的版本选项: ${choice}"
else
die "invalid version choice: ${choice}"
fi
;;
esac
fi
}
select_mode() {
case "${MODE}" in
compose|docker|docker-compose)
MODE="compose"
return
;;
compose-single-node|docker-single-node|docker-single-node-compose)
MODE="compose-single-node"
return
;;
single-node|service|systemd|launchd|sqlite)
MODE="single-node"
return
;;
cluster|multi|multi-node)
if ui_is_zh; then
die "集群部署模式暂未开放;请先选择 compose、compose-single-node 或 single-node"
else
die "cluster deployment mode is temporarily disabled; choose compose, compose-single-node, or single-node"
fi
;;
auto|"")
;;
*)
die "unsupported install mode: ${MODE}; expected compose, compose-single-node, or single-node"
;;
esac
if interactive_tty_available; then
if ui_is_zh; then
cat >/dev/tty <<EOF
请选择 Aether 部署模式:
1) Docker Compose 标准部署(Postgres + Redis
2) Docker Compose 单节点部署(SQLite
3) 系统服务单节点部署(SQLite)
请输入选项 [3]:
EOF
else
cat >/dev/tty <<EOF
Choose Aether deployment mode:
1) Docker Compose standard deployment (Postgres + Redis)
2) Docker Compose single-node deployment (SQLite)
3) System service single-node deployment (SQLite)
Enter choice [3]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-3}" in
1)
MODE="compose"
;;
2)
MODE="compose-single-node"
;;
3)
MODE="single-node"
;;
*)
if ui_is_zh; then
die "无效的部署模式选项: ${choice}"
else
die "invalid deployment mode choice: ${choice}"
fi
;;
esac
else
MODE="single-node"
fi
}
prompt_admin_password() {
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
ADMIN_PASSWORD_SOURCE="environment"
return
fi
if interactive_tty_available; then
local password confirm
while true; do
if ui_is_zh; then
printf '\n请输入初始管理员密码: ' >/dev/tty
else
printf '\nEnter initial admin password: ' >/dev/tty
fi
stty -echo </dev/tty
IFS= read -r password </dev/tty || password=""
stty echo </dev/tty
if ui_is_zh; then
printf '\n请再次输入初始管理员密码: ' >/dev/tty
else
printf '\nConfirm initial admin password: ' >/dev/tty
fi
stty -echo </dev/tty
IFS= read -r confirm </dev/tty || confirm=""
stty echo </dev/tty
printf '\n' >/dev/tty
[[ -n "${password}" ]] || {
if ui_is_zh; then
echo "管理员密码不能为空。" >/dev/tty
else
echo "Admin password cannot be empty." >/dev/tty
fi
continue
}
[[ "${password}" == "${confirm}" ]] || {
if ui_is_zh; then
echo "两次输入的密码不一致。" >/dev/tty
else
echo "Passwords did not match." >/dev/tty
fi
continue
}
ADMIN_PASSWORD="${password}"
ADMIN_PASSWORD_SOURCE="prompt"
return
done
fi
if ui_is_zh; then
die "非交互式安装生成新配置时必须设置 ADMIN_PASSWORD"
else
die "ADMIN_PASSWORD is required when installing without an interactive terminal"
fi
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
die "unsupported CPU architecture: $(uname -m)"
;;
esac
}
download_to() {
local url="$1"
local output="$2"
local mode="${3:-quiet}"
local show_progress="false"
if [[ "${mode}" == "progress" && -t 2 ]]; then
show_progress="true"
fi
if command -v curl >/dev/null 2>&1; then
if [[ "${show_progress}" == "true" ]]; then
curl -fL --progress-bar "${url}" -o "${output}"
else
curl -fsSL "${url}" -o "${output}"
fi
elif command -v wget >/dev/null 2>&1; then
if [[ "${show_progress}" == "true" ]]; then
wget -O "${output}" "${url}"
else
wget -qO "${output}" "${url}"
fi
else
die "curl or wget is required to download release assets"
fi
}
download_stdout() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "${url}"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "${url}"
else
die "curl or wget is required to download release metadata"
fi
}
select_release_download_urls() {
local original_archive_url="$1"
if [[ -z "${RELEASE_ARCHIVE_URL}" && interactive_tty_available ]]; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
是否使用下载加速源?
1) 否,使用原始 GitHub 地址
2) 是,手动填写新的下载 URL
请输入选项 [1]:
EOF
else
cat >/dev/tty <<'EOF'
Use an accelerated download URL?
1) No, use the original GitHub URL
2) Yes, enter a replacement download URL
Enter choice [1]:
EOF
fi
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice:-1}" in
1)
;;
2)
if ui_is_zh; then
cat >/dev/tty <<EOF
原始压缩包 URL:
${original_archive_url}
请输入新的压缩包下载 URL:
EOF
else
cat >/dev/tty <<EOF
Original archive URL:
${original_archive_url}
Enter replacement archive download URL:
EOF
fi
IFS= read -r RELEASE_ARCHIVE_URL </dev/tty || RELEASE_ARCHIVE_URL=""
[[ -n "${RELEASE_ARCHIVE_URL}" ]] || {
if ui_is_zh; then
die "新的压缩包下载 URL 不能为空"
else
die "replacement archive download URL cannot be empty"
fi
}
;;
*)
if ui_is_zh; then
die "无效的下载源选项: ${choice}"
else
die "invalid download source choice: ${choice}"
fi
;;
esac
fi
if [[ -z "${RELEASE_ARCHIVE_URL}" ]]; then
RELEASE_ARCHIVE_URL="${original_archive_url}"
elif [[ "${RELEASE_ARCHIVE_URL}" != "${original_archive_url}" ]]; then
if ui_is_zh; then
info "使用自定义压缩包下载 URL"
info "原始压缩包 URL: ${original_archive_url}"
else
info "using custom archive download URL"
info "original archive URL: ${original_archive_url}"
fi
fi
}
raw_project_url() {
local path="$1"
printf 'https://raw.githubusercontent.com/%s/%s/%s' "${REPO}" "${SOURCE_REF}" "${path}"
}
same_path() {
local left="$1"
local right="$2"
local left_dir right_dir left_base right_base
[[ -e "${left}" && -e "${right}" ]] || return 1
left_dir="$(cd -- "$(dirname -- "${left}")" && pwd -P)"
right_dir="$(cd -- "$(dirname -- "${right}")" && pwd -P)"
left_base="$(basename -- "${left}")"
right_base="$(basename -- "${right}")"
[[ "${left_dir}/${left_base}" == "${right_dir}/${right_base}" ]]
}
resolve_compose_dir() {
if [[ -n "${COMPOSE_DIR}" ]]; then
return
fi
if [[ "${INSTALL_ROOT_EXPLICIT}" == "true" || "${COMPOSE_DIR_EXPLICIT}" == "true" ]]; then
COMPOSE_DIR="${INSTALL_ROOT}/compose"
else
COMPOSE_DIR="$(pwd -P)"
fi
}
install_project_file() {
local source_path="$1"
local target_path="$2"
local mode="$3"
local script_dir
script_dir="$(current_script_dir)"
install -d -m 0755 "$(dirname "${target_path}")"
if [[ -f "${script_dir}/${source_path}" ]]; then
if same_path "${script_dir}/${source_path}" "${target_path}"; then
chmod "${mode}" "${target_path}"
else
install -m "${mode}" "${script_dir}/${source_path}" "${target_path}"
fi
else
download_to "$(raw_project_url "${source_path}")" "${target_path}"
chmod "${mode}" "${target_path}"
fi
}
install_generate_keys_script() {
local target_path="$1"
local script_dir
script_dir="$(current_script_dir)"
install -d -m 0755 "$(dirname "${target_path}")"
if [[ -f "${script_dir}/generate_keys.sh" ]]; then
if same_path "${script_dir}/generate_keys.sh" "${target_path}"; then
chmod 0755 "${target_path}"
else
install -m 0755 "${script_dir}/generate_keys.sh" "${target_path}"
fi
else
write_generate_keys_script "${target_path}"
fi
}
ensure_directory() {
local path="$1"
local mode="${2:-0755}"
if [[ ! -d "${path}" ]]; then
install -d -m "${mode}" "${path}"
fi
}
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
if ui_is_zh; then
die "未找到可用的 Docker Compose,请先安装 Docker 和 Compose 插件"
else
die "no usable Docker Compose found; install Docker and the Compose plugin first"
fi
}
compose_command() {
resolve_compose_cli
printf '%s\n' "${COMPOSE_CLI[*]}"
}
run_compose() {
resolve_compose_cli
"${COMPOSE_CLI[@]}" "$@"
}
compose_next_steps() {
local gateway_port
local compose_cmd
compose_cmd="$(compose_command)"
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${COMPOSE_DIR}/.env" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
cat <<EOF
Install complete.
Docker Compose service:
cd ${COMPOSE_DIR}
./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
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${COMPOSE_DIR}
EOF
}
compose_manual_start_steps() {
local compose_cmd
compose_cmd="$(compose_command)"
cat <<EOF
Next steps:
cd ${COMPOSE_DIR}
${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}
./generate_keys.sh
EOF
}
start_compose_deployment() {
local -a compose_args=(--project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml")
info "pulling Docker Compose images"
run_compose "${compose_args[@]}" pull
info "starting Docker Compose services"
run_compose "${compose_args[@]}" up -d
}
resolve_version() {
if [[ -n "${VERSION}" ]]; then
echo "${VERSION}"
return
fi
local tag=""
case "${CHANNEL}" in
stable|latest)
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
head -n1 || true)"
;;
rc)
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' |
head -n1 || true)"
;;
beta)
tag="$(download_stdout "https://api.github.com/repos/${REPO}/releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$' |
head -n1 || true)"
;;
nightly)
# The nightly release is a single rolling tag, so no API listing is
# needed (and unauthenticated release-list calls are rate-limited).
tag="nightly"
;;
*)
die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly"
;;
esac
echo "${tag}"
}
current_script_dir() {
local source="${BASH_SOURCE[0]}"
if [[ -n "${source}" && -f "${source}" ]]; then
cd -- "$(dirname -- "${source}")" && pwd
else
pwd
fi
}
ensure_tmp_root() {
if [[ -z "${TMP_ROOT}" ]]; then
TMP_ROOT="$(mktemp -d)"
fi
}
absolute_path() {
local path="$1"
local dir
local base
if [[ "${path}" == /* ]]; then
printf '%s\n' "${path}"
return
fi
dir="$(dirname "${path}")"
base="$(basename "${path}")"
printf '%s/%s\n' "$(cd "${dir}" && pwd -P)" "${base}"
}
absolute_path_maybe_missing() {
local path="$1"
if [[ "${path}" == /* ]]; then
printf '%s\n' "${path}"
else
printf '%s/%s\n' "$(pwd -P)" "${path}"
fi
}
local_bundle_dir() {
local dir
dir="$(current_script_dir)"
if [[ -x "${dir}/bin/aether-gateway" && -d "${dir}/frontend" ]]; then
echo "${dir}"
fi
}
download_or_unpack_bundle() {
TMP_ROOT="$(mktemp -d)"
if [[ -n "${ARCHIVE_PATH}" ]]; then
[[ -f "${ARCHIVE_PATH}" ]] || die "archive not found: ${ARCHIVE_PATH}"
info "using local archive ${ARCHIVE_PATH}"
tar -xzf "${ARCHIVE_PATH}" -C "${TMP_ROOT}"
else
local os arch
os="$(install_os)"
arch="$(detect_arch)"
local tag asset base_url archive_url archive_file
tag="$(resolve_version)"
[[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}"
VERSION="${tag}"
asset="aether-${tag}-${os}-${arch}.tar.gz"
base_url="https://github.com/${REPO}/releases/download/${tag}"
archive_url="${base_url}/${asset}"
archive_file="${TMP_ROOT}/${asset}"
select_release_download_urls "${archive_url}"
if [[ "${RELEASE_ARCHIVE_URL}" == "${archive_url}" ]]; then
info "downloading ${asset} from ${REPO}"
elif ui_is_zh; then
info "从自定义 URL 下载 ${asset}"
else
info "downloading ${asset} from custom URL"
fi
download_to "${RELEASE_ARCHIVE_URL}" "${archive_file}" progress
tar -xzf "${archive_file}" -C "${TMP_ROOT}"
fi
local bundle
bundle="$(find "${TMP_ROOT}" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[[ -n "${bundle}" ]] || die "release archive did not contain a bundle directory"
[[ -x "${bundle}/bin/aether-gateway" ]] || die "bundle is missing bin/aether-gateway"
[[ -d "${bundle}/frontend" ]] || die "bundle is missing frontend"
if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")"
fi
BUNDLE_DIR="${bundle}"
}
urlsafe_rand() {
local bytes="$1"
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 "${bytes}" | tr '+/' '-_' | tr -d '='
else
od -An -N "${bytes}" -tx1 /dev/urandom | tr -d ' \n'
fi
}
write_generate_keys_script() {
local output="$1"
local output_dir output_dir_normalized config_dir_normalized
output_dir="$(dirname "${output}")"
output_dir_normalized="${output_dir%/}"
config_dir_normalized="${CONFIG_DIR%/}"
[[ -n "${output_dir_normalized}" ]] || output_dir_normalized="/"
[[ -n "${config_dir_normalized}" ]] || config_dir_normalized="/"
if is_darwin && [[ "${output_dir_normalized}" == "${config_dir_normalized}" ]]; then
install_config_dir
else
install -d -m 0755 "${output_dir}"
fi
cat > "${output}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
urlsafe_rand() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 "$1" | tr '+/' '-_' | tr -d '='
else
od -An -N "$1" -tx1 /dev/urandom | tr -d ' \n'
fi
}
cat <<KEYS
JWT_SECRET_KEY=$(urlsafe_rand 32)
ENCRYPTION_KEY=$(urlsafe_rand 32)
KEYS
EOF
chmod 0755 "${output}"
}
replace_or_append_env() {
local file="$1"
local key="$2"
local value="$3"
if grep -qE "^#?[[:space:]]*${key}=" "${file}"; then
local tmp_file
tmp_file="$(mktemp)"
awk -v key="${key}" -v value="${value}" '
BEGIN {
pattern = "^#?[[:space:]]*" key "="
replacement = key "=" value
}
$0 ~ pattern && replaced == 0 {
print replacement
replaced = 1
next
}
{ print }
' "${file}" > "${tmp_file}"
cat "${tmp_file}" > "${file}"
rm -f "${tmp_file}"
else
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
fi
}
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
strip_optional_quotes() {
local value="$1"
if [[ ${#value} -ge 2 ]]; then
if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
fi
printf '%s' "${value}"
}
is_placeholder_value() {
local value="$1"
case "${value}" in
*change-me*|*change-this*|*your_secure_password_here*|*your_redis_password_here*)
return 0
;;
*)
return 1
;;
esac
}
derive_local_bundle_version() {
local bundle="$1"
local name
name="$(basename "${bundle}")"
case "${name}" in
aether-*-linux-*|aether-*-macos-*)
name="${name#aether-}"
name="${name%-linux-*}"
name="${name%-macos-*}"
;;
esac
if [[ -z "${name}" || "${name}" == "." || "${name}" == "/" ]]; then
name="$(date +%Y%m%d%H%M%S)"
fi
echo "${name}"
}
generate_first_install_env() {
local output="$1"
local jwt_key encryption_key
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=self
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
AETHER_DATABASE_DRIVER=sqlite
AETHER_DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=admin@example.local
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
generate_cluster_env() {
local output="$1"
local jwt_key encryption_key role
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
role="${AETHER_GATEWAY_NODE_ROLE:-frontdoor}"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=manual
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
AETHER_GATEWAY_NODE_ROLE=${role}
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=redis
API_KEY_PREFIX=sk
DATABASE_URL=${DATABASE_URL:-}
REDIS_URL=${REDIS_URL:-}
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.local}
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
compose_image() {
if [[ -n "${APP_IMAGE}" ]]; then
echo "${APP_IMAGE}"
return
fi
local tag=""
if [[ -n "${VERSION}" ]]; then
tag="${VERSION#v}"
else
case "${CHANNEL}" in
stable|latest)
tag="latest"
;;
rc|beta|nightly)
tag="${CHANNEL}"
;;
*)
die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly"
;;
esac
fi
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
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
cp "${COMPOSE_DIR}/.env.example" "${output}"
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
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}}"
replace_or_append_env "${output}" "ENCRYPTION_KEY" "${ENCRYPTION_KEY:-${encryption_key}}"
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_UPDATE_STRATEGY" "docker"
replace_or_append_env "${output}" "AETHER_DOCKER_UPDATE_COMMAND" "./update.sh"
append_compose_log_env_defaults "${output}"
replace_or_append_env "${output}" "AETHER_GATEWAY_DATABASE_MODE" "auto"
}
generate_compose_single_node_env() {
local output="$1"
local jwt_key encryption_key
prompt_admin_password
jwt_key="$(urlsafe_rand 32)"
encryption_key="$(urlsafe_rand 32)"
cat > "${output}" <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
$(compose_log_env_block)
APP_IMAGE=$(compose_image)
APP_PORT=$(compose_app_port)
AETHER_UPDATE_STRATEGY=docker
AETHER_DOCKER_UPDATE_COMMAND=./update.sh
AETHER_GATEWAY_STATIC_DIR=${COMPOSE_RELEASE_FRONTEND_DIR}
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_DATABASE_MODE=auto
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
AETHER_DATABASE_DRIVER=sqlite
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}}
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.local}
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
install_config_dir() {
if is_darwin; then
install -d -o root -g "${SERVICE_GROUP}" -m 0750 "${CONFIG_DIR}"
else
install -d -m 0750 "${CONFIG_DIR}"
fi
}
install_env_target_from() {
local source="$1"
if is_darwin; then
install -o root -g "${SERVICE_GROUP}" -m 0640 "${source}" "${ENV_TARGET}"
else
install -m 0600 "${source}" "${ENV_TARGET}"
fi
}
ensure_env_target_permissions() {
if is_darwin && [[ -f "${ENV_TARGET}" ]]; then
chown root:"${SERVICE_GROUP}" "${ENV_TARGET}"
chmod 0640 "${ENV_TARGET}"
fi
}
install_systemd_support_files() {
install_config_dir
write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh"
}
find_nologin_shell() {
if [[ -x /usr/sbin/nologin ]]; then
echo "/usr/sbin/nologin"
elif [[ -x /sbin/nologin ]]; then
echo "/sbin/nologin"
else
echo "/bin/false"
fi
}
ensure_service_account() {
if ! getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
info "creating group ${SERVICE_GROUP}"
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
info "creating user ${SERVICE_USER}"
useradd \
--system \
--gid "${SERVICE_GROUP}" \
--home-dir "${INSTALL_ROOT}" \
--shell "$(find_nologin_shell)" \
"${SERVICE_USER}"
fi
}
macos_next_system_id() {
local record_type="$1"
local id_attr="$2"
dscl . -list "/${record_type}" "${id_attr}" 2>/dev/null |
awk '
$NF ~ /^[0-9]+$/ && $NF >= 350 && $NF < 500 { used[$NF] = 1 }
END {
for (i = 350; i < 500; i++) {
if (!(i in used)) {
print i
exit
}
}
}
'
}
macos_group_id() {
dscl . -read "/Groups/${SERVICE_GROUP}" PrimaryGroupID 2>/dev/null |
awk '/PrimaryGroupID:/ { print $2 }'
}
ensure_macos_service_account() {
local gid uid
if ! command -v dscl >/dev/null 2>&1; then
if ui_is_zh; then
die "未找到 dscl,无法创建 macOS 服务账号"
else
die "dscl not found; cannot create macOS service account"
fi
fi
if ! dscl . -read "/Groups/${SERVICE_GROUP}" >/dev/null 2>&1; then
gid="$(macos_next_system_id Groups PrimaryGroupID)"
[[ -n "${gid}" ]] || die "could not allocate a macOS service group id"
info "creating macOS group ${SERVICE_GROUP}"
dscl . -create "/Groups/${SERVICE_GROUP}"
dscl . -create "/Groups/${SERVICE_GROUP}" PrimaryGroupID "${gid}"
dscl . -create "/Groups/${SERVICE_GROUP}" Password "*"
fi
gid="$(macos_group_id)"
[[ -n "${gid}" ]] || die "could not resolve macOS group id for ${SERVICE_GROUP}"
if ! dscl . -read "/Users/${SERVICE_USER}" >/dev/null 2>&1; then
uid="$(macos_next_system_id Users UniqueID)"
[[ -n "${uid}" ]] || die "could not allocate a macOS service user id"
info "creating macOS user ${SERVICE_USER}"
dscl . -create "/Users/${SERVICE_USER}"
dscl . -create "/Users/${SERVICE_USER}" UserShell /usr/bin/false
dscl . -create "/Users/${SERVICE_USER}" RealName "Aether Gateway"
dscl . -create "/Users/${SERVICE_USER}" UniqueID "${uid}"
dscl . -create "/Users/${SERVICE_USER}" PrimaryGroupID "${gid}"
dscl . -create "/Users/${SERVICE_USER}" NFSHomeDirectory "${INSTALL_ROOT}"
dscl . -create "/Users/${SERVICE_USER}" IsHidden 1
dscl . -create "/Users/${SERVICE_USER}" Password "*"
fi
}
env_file_value() {
local file="$1"
local key="$2"
awk -v key="${key}" '
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (line ~ /^#/ || line !~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
next
}
name = line
sub(/=.*/, "", name)
if (name == key) {
value = line
sub(/^[^=]*=/, "", value)
print value
}
}
' "${file}" | tail -n1 | tr -d '[:space:]'
}
ensure_env_matches_requested_mode() {
local file="$1"
local mode="$2"
local topology
topology="$(env_file_value "${file}" "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY")"
topology="${topology:-single-node}"
if [[ "${mode}" == "cluster" ]]; then
[[ "${topology}" == "multi-node" ]] || die "existing env ${file} is ${topology}; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single-node"
cluster_env_has_required_backends "${file}" || die "existing multi-node env ${file} must define DATABASE_URL and REDIS_URL"
elif [[ "${mode}" == "single-node" && "${topology}" == "multi-node" ]]; then
die "existing env ${file} is multi-node; cluster mode is temporarily disabled, edit the env file"
fi
}
cluster_env_has_required_backends() {
local file="$1"
local database_url redis_url
database_url="$(env_file_value "${file}" "AETHER_DATABASE_URL")"
[[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "DATABASE_URL")"
[[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_POSTGRES_URL")"
redis_url="$(env_file_value "${file}" "REDIS_URL")"
[[ -n "${redis_url}" ]] || redis_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_REDIS_URL")"
[[ -n "${database_url}" && -n "${redis_url}" ]]
}
validate_env_file() {
local env_file="$1"
local raw_line=""
local line=""
local key=""
local value=""
local line_no=0
local topology="single-node"
local node_role="all"
local database_driver=""
local runtime_backend=""
local db_password=""
local redis_password=""
local database_url=""
local redis_url=""
local jwt_secret_key=""
local encryption_key=""
local video_task_store_path=""
local static_dir=""
[[ -f "${env_file}" ]] || die "env file not found: ${env_file}"
info "validating env file ${env_file}"
while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do
line_no=$((line_no + 1))
line="${raw_line%$'\r'}"
line="$(trim_whitespace "${line}")"
[[ -z "${line}" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
[[ "${line}" == export\ * ]] && die "env file ${env_file}:${line_no} must not use 'export'"
[[ "${line}" == *'${'* ]] && die "env file ${env_file}:${line_no} must not use variable expansion"
[[ "${line}" == *'$('* ]] && die "env file ${env_file}:${line_no} must not use command substitution"
[[ "${line}" == *'`'* ]] && die "env file ${env_file}:${line_no} must not use command substitution"
[[ "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "env file ${env_file}:${line_no} must be KEY=VALUE"
key="${line%%=*}"
value="${line#*=}"
value="$(strip_optional_quotes "${value}")"
case "${key}" in
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY)
topology="${value}"
;;
AETHER_GATEWAY_NODE_ROLE)
node_role="${value}"
;;
AETHER_DATABASE_DRIVER)
database_driver="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
;;
AETHER_RUNTIME_BACKEND)
runtime_backend="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')"
;;
AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)
[[ -n "${value}" ]] && database_url="${value}"
;;
REDIS_URL|AETHER_GATEWAY_DATA_REDIS_URL)
[[ -n "${value}" ]] && redis_url="${value}"
;;
DB_PASSWORD)
db_password="${value}"
;;
REDIS_PASSWORD)
redis_password="${value}"
;;
JWT_SECRET_KEY)
jwt_secret_key="${value}"
;;
ENCRYPTION_KEY|AETHER_GATEWAY_DATA_ENCRYPTION_KEY)
[[ -n "${value}" ]] && encryption_key="${value}"
;;
AETHER_GATEWAY_VIDEO_TASK_STORE_PATH)
video_task_store_path="${value}"
;;
AETHER_GATEWAY_STATIC_DIR)
static_dir="${value}"
;;
esac
done < "${env_file}"
case "${topology}" in
single-node|multi-node)
;;
*)
die "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY must be single-node or multi-node"
;;
esac
case "${node_role}" in
all|frontdoor|background)
;;
*)
die "AETHER_GATEWAY_NODE_ROLE must be all, frontdoor, or background"
;;
esac
[[ -n "${jwt_secret_key}" ]] || die "JWT_SECRET_KEY is required"
[[ -n "${encryption_key}" ]] || die "ENCRYPTION_KEY or AETHER_GATEWAY_DATA_ENCRYPTION_KEY is required"
is_placeholder_value "${jwt_secret_key}" && die "JWT_SECRET_KEY still uses the example placeholder"
is_placeholder_value "${encryption_key}" && die "ENCRYPTION_KEY still uses the example placeholder"
if [[ -n "${database_url}" ]] && is_placeholder_value "${database_url}"; then
die "DATABASE_URL still uses the example placeholder"
fi
if [[ -n "${redis_url}" ]] && is_placeholder_value "${redis_url}"; then
die "REDIS_URL still uses the example placeholder"
fi
local database_is_sqlite="false"
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
database_is_sqlite="true"
fi
if [[ "${topology}" == "multi-node" ]]; then
[[ "${node_role}" != "all" ]] || die "multi-node deployment requires AETHER_GATEWAY_NODE_ROLE=frontdoor or background"
[[ -n "${database_url}" ]] || die "multi-node deployment requires AETHER_DATABASE_URL, DATABASE_URL, or AETHER_GATEWAY_DATA_POSTGRES_URL"
[[ "${database_is_sqlite}" != "true" ]] || die "multi-node deployment must use shared Postgres/MySQL, not SQLite"
[[ -n "${redis_url}" ]] || die "multi-node deployment requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
[[ "${runtime_backend}" != "memory" ]] || die "multi-node deployment must not use AETHER_RUNTIME_BACKEND=memory"
[[ -z "${video_task_store_path}" ]] || die "multi-node deployment must not set AETHER_GATEWAY_VIDEO_TASK_STORE_PATH"
else
if [[ "${node_role}" != "all" ]]; then
warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are not enabled by this installer"
fi
if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then
die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
fi
if [[ -z "${database_url}" && -z "${redis_url}" ]]; then
warn "single-node env is running in minimal mode without full Postgres/Redis persistence"
elif [[ "${database_is_sqlite}" == "true" && -z "${redis_url}" ]]; then
info "single-node env is using SQLite with in-process runtime coordination"
fi
fi
if is_placeholder_value "${db_password}"; then
warn "DB_PASSWORD still uses the example placeholder"
fi
if is_placeholder_value "${redis_password}"; then
warn "REDIS_PASSWORD still uses the example placeholder"
fi
if [[ -n "${static_dir}" && "${static_dir}" != "${INSTALL_ROOT}/current/frontend" ]]; then
warn "AETHER_GATEWAY_STATIC_DIR points to ${static_dir}; install script still publishes frontend to ${INSTALL_ROOT}/current/frontend"
fi
}
resolve_service_env_source() {
local mode="$1"
if [[ -n "${ENV_SOURCE}" ]]; then
[[ -f "${ENV_SOURCE}" ]] || die "env file not found: ${ENV_SOURCE}"
ensure_env_matches_requested_mode "${ENV_SOURCE}" "${mode}"
echo "${ENV_SOURCE}"
return
fi
if [[ -f "${ENV_TARGET}" ]]; then
ensure_env_matches_requested_mode "${ENV_TARGET}" "${mode}"
echo ""
return
fi
GENERATED_ENV="${TMP_ROOT:-$(mktemp -d)}/aether-gateway.env"
if [[ -z "${TMP_ROOT}" ]]; then
TMP_ROOT="$(dirname "${GENERATED_ENV}")"
fi
if [[ "${mode}" == "cluster" ]]; then
info "generating multi-node env file"
generate_cluster_env "${GENERATED_ENV}"
if ! cluster_env_has_required_backends "${GENERATED_ENV}"; then
install_config_dir
install_env_target_from "${GENERATED_ENV}"
cat <<EOF
Multi-node env scaffolded:
${ENV_TARGET}
Fill DATABASE_URL and REDIS_URL, then rerun:
sudo AETHER_INSTALL_MODE=cluster bash install.sh
Or provide them non-interactively:
curl -fsSL https://raw.githubusercontent.com/${REPO}/${SOURCE_REF}/install.sh | sudo DATABASE_URL=postgresql://... REDIS_URL=redis://... bash -s -- --mode cluster
EOF
exit 1
fi
else
info "generating first-install single-node env file"
generate_first_install_env "${GENERATED_ENV}"
fi
echo "${GENERATED_ENV}"
}
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
warn "keeping existing ${COMPOSE_DIR}/.env"
else
info "generating ${COMPOSE_DIR}/.env"
generate_compose_env "${COMPOSE_DIR}/.env"
chmod 0600 "${COMPOSE_DIR}/.env"
fi
cat <<EOF
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
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
}
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}/data"
ensure_directory "${COMPOSE_DIR}/logs"
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
warn "keeping existing ${COMPOSE_DIR}/.env"
else
info "generating ${COMPOSE_DIR}/.env"
generate_compose_single_node_env "${COMPOSE_DIR}/.env"
chmod 0600 "${COMPOSE_DIR}/.env"
fi
cat <<EOF
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
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
}
install_env_file() {
local env_file="$1"
install_config_dir
if [[ -n "${env_file}" ]]; then
info "installing env file to ${ENV_TARGET}"
install_env_target_from "${env_file}"
else
ensure_env_target_permissions
fi
}
install_release() {
local bundle="$1"
local release_dir="${INSTALL_ROOT}/releases/${VERSION}"
local current_link="${INSTALL_ROOT}/current"
[[ -x "${bundle}/bin/aether-gateway" ]] || die "binary not found or not executable: ${bundle}/bin/aether-gateway"
[[ -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"
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" \
"${INSTALL_ROOT}/logs"
else
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
"${INSTALL_ROOT}/data" \
"${INSTALL_ROOT}/logs"
fi
rm -rf "${release_dir}"
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,g=rwX,o=rX "${release_dir}"
if is_darwin; then
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
else
chown -R root:"${SERVICE_GROUP}" "${release_dir}"
fi
chmod -R u=rwX,g=rwX,o=rX "${release_dir}"
ln -sfn "${release_dir}" "${current_link}"
}
prune_old_releases() {
local keep="${RELEASE_KEEP}"
[[ "${keep}" =~ ^[0-9]+$ ]] || return 0
[[ "${keep}" -gt 0 ]] || return 0
local releases_dir="${INSTALL_ROOT}/releases"
[[ -d "${releases_dir}" ]] || return 0
local current_target
current_target="$(readlink "${INSTALL_ROOT}/current" 2>/dev/null || true)"
current_target="$(basename "${current_target}" 2>/dev/null || true)"
local count=0
local dir
while IFS= read -r dir; do
[[ -n "${dir}" ]] || continue
local name
name="$(basename "${dir}")"
[[ "${name}" != "${current_target}" ]] || continue
count=$((count + 1))
done < <(ls -1dt "${releases_dir}"/*/ 2>/dev/null)
if [[ "${count}" -ge "${keep}" ]]; then
local to_remove
to_remove="$(ls -1dt "${releases_dir}"/*/ 2>/dev/null | while IFS= read -r d; do
local n
n="$(basename "${d}")"
[[ "${n}" != "${current_target}" ]] || continue
printf '%s\n' "${d}"
done | tail -n +$((keep)))"
local removed=0
while IFS= read -r dir; do
[[ -n "${dir}" ]] || continue
info "pruning old release: $(basename "${dir}")"
rm -rf "${dir}"
removed=$((removed + 1))
done <<< "${to_remove}"
if [[ "${removed}" -gt 0 ]]; then
if ui_is_zh; then
info "已清理 ${removed} 个旧版本(保留最新 ${keep} 个)"
else
info "pruned ${removed} old release(s), keeping latest ${keep}"
fi
fi
fi
}
render_systemd_unit() {
cat <<EOF
[Unit]
Description=Aether Gateway
Documentation=https://github.com/${REPO}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
WorkingDirectory=${INSTALL_ROOT}/current
EnvironmentFile=${ENV_TARGET}
ExecStart=${INSTALL_ROOT}/current/bin/aether-gateway
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
UMask=0027
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
}
install_systemd_unit() {
local rendered_unit
rendered_unit="$(mktemp)"
render_systemd_unit > "${rendered_unit}"
info "installing systemd unit to ${SYSTEMD_UNIT_PATH}"
install -m 0644 "${rendered_unit}" "${SYSTEMD_UNIT_PATH}"
rm -f "${rendered_unit}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" >/dev/null
}
restart_service_if_requested() {
if [[ "${SKIP_START}" == "true" ]]; then
info "skipping service restart"
return
fi
info "restarting ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
}
print_systemd_next_steps() {
local gateway_port
local database_driver
local database_url
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
cat <<EOF
Install complete.
Gateway service:
sudo systemctl status ${SERVICE_NAME} --no-pager
sudo journalctl -u ${SERVICE_NAME} -n 100 --no-pager
sudo journalctl -u ${SERVICE_NAME} -f
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
cat <<EOF
SQLite data:
${database_url#sqlite://}
EOF
fi
cat <<EOF
Database:
schema migrations and data backfills are prepared automatically before startup
Current release:
${INSTALL_ROOT}/current
EOF
}
launchd_wrapper_path() {
printf '%s/bin/%s-launchd\n' "${INSTALL_ROOT}" "${SERVICE_NAME}"
}
install_launchd_support_files() {
install_config_dir
write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh"
}
write_launchd_wrapper() {
local wrapper
wrapper="$(launchd_wrapper_path)"
install -d -o root -g wheel -m 0755 "$(dirname "${wrapper}")"
cat > "${wrapper}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
ENV_TARGET="${ENV_TARGET}"
AETHER_BIN="${INSTALL_ROOT}/current/bin/aether-gateway"
EOF
cat >> "${wrapper}" <<'EOF'
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "${value}"
}
strip_optional_quotes() {
local value="$1"
if [[ ${#value} -ge 2 ]]; then
if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
fi
printf '%s' "${value}"
}
if [[ ! -r "${ENV_TARGET}" ]]; then
echo "Aether env file not found or not readable: ${ENV_TARGET}" >&2
exit 1
fi
while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do
line="${raw_line%$'\r'}"
line="$(trim_whitespace "${line}")"
[[ -z "${line}" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
if [[ "${line}" == export\ * || ! "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
echo "Invalid Aether env line: ${line}" >&2
exit 1
fi
key="${line%%=*}"
value="${line#*=}"
value="$(strip_optional_quotes "${value}")"
export "${key}=${value}"
done < "${ENV_TARGET}"
exec "${AETHER_BIN}"
EOF
chmod 0755 "${wrapper}"
chown root:wheel "${wrapper}"
}
render_launchd_plist() {
local wrapper
wrapper="$(launchd_wrapper_path)"
cat <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${wrapper}</string>
</array>
<key>UserName</key>
<string>${SERVICE_USER}</string>
<key>GroupName</key>
<string>${SERVICE_GROUP}</string>
<key>WorkingDirectory</key>
<string>${INSTALL_ROOT}/current</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log</string>
<key>StandardErrorPath</key>
<string>${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log</string>
<key>Umask</key>
<integer>23</integer>
</dict>
</plist>
EOF
}
install_launchd_log_files() {
install -d -o root -g wheel -m 0755 "${LAUNCHD_LOG_DIR}"
touch "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log"
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log"
chmod 0640 "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log"
}
install_launchd_unit() {
local rendered_plist
rendered_plist="$(mktemp)"
render_launchd_plist > "${rendered_plist}"
info "installing launchd plist to ${LAUNCHD_PLIST_PATH}"
install_launchd_log_files
install -d -o root -g wheel -m 0755 "$(dirname "${LAUNCHD_PLIST_PATH}")"
install -o root -g wheel -m 0644 "${rendered_plist}" "${LAUNCHD_PLIST_PATH}"
rm -f "${rendered_plist}"
}
restart_launchd_if_requested() {
if [[ "${SKIP_START}" == "true" ]]; then
info "skipping launchd service restart"
return
fi
info "restarting ${LAUNCHD_LABEL} with launchd"
launchctl bootout system "${LAUNCHD_PLIST_PATH}" >/dev/null 2>&1 || true
launchctl bootstrap system "${LAUNCHD_PLIST_PATH}"
launchctl kickstart -k "system/${LAUNCHD_LABEL}"
}
print_launchd_next_steps() {
local gateway_port
local database_driver
local database_url
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
cat <<EOF
Install complete.
Gateway service:
sudo launchctl print system/${LAUNCHD_LABEL}
sudo launchctl kickstart -k system/${LAUNCHD_LABEL}
sudo launchctl bootout system ${LAUNCHD_PLIST_PATH}
Logs:
tail -f ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then
cat <<EOF
SQLite data:
${database_url#sqlite://}
EOF
fi
cat <<EOF
Database:
schema migrations and data backfills are prepared automatically before startup
Current release:
${INSTALL_ROOT}/current
EOF
}
install_systemd_mode() {
local bundle="$1"
local env_file="$2"
ensure_service_account
install_systemd_support_files
install_env_file "${env_file}"
validate_env_file "${ENV_TARGET}"
install_release "${bundle}"
prune_old_releases
install_systemd_unit
restart_service_if_requested
print_systemd_next_steps
}
install_launchd_mode() {
local bundle="$1"
local env_file="$2"
ensure_macos_service_account
install_launchd_support_files
install_env_file "${env_file}"
validate_env_file "${ENV_TARGET}"
install_release "${bundle}"
prune_old_releases
write_launchd_wrapper
install_launchd_unit
restart_launchd_if_requested
print_launchd_next_steps
}
main() {
local bundle env_file
parse_args "$@"
select_language
require_supported_os
apply_platform_defaults
select_version
select_mode
if [[ "${MODE}" == "compose" ]]; then
install_compose_mode
elif [[ "${MODE}" == "compose-single-node" ]]; then
install_compose_single_node_mode
else
require_root
require_service_manager
bundle="$(local_bundle_dir || true)"
if [[ -z "${bundle}" ]]; then
download_or_unpack_bundle
bundle="${BUNDLE_DIR}"
else
if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")"
fi
info "installing from local extracted bundle ${bundle}"
fi
if is_darwin; then
ensure_macos_service_account
fi
env_file="$(resolve_service_env_source "${MODE}")"
case "$(install_os)" in
linux)
install_systemd_mode "${bundle}" "${env_file}"
;;
macos)
install_launchd_mode "${bundle}" "${env_file}"
;;
esac
fi
if [[ -n "${ADMIN_PASSWORD_SOURCE}" ]]; then
local password_note
if [[ "${ADMIN_PASSWORD_SOURCE}" == "prompt" ]]; then
password_note="set from prompt"
else
password_note="set from ADMIN_PASSWORD"
fi
cat <<EOF
Initial admin:
username: admin
password: ${password_note}
The password is stored in the generated env file. Change it after first login.
EOF
fi
}
main "$@"