mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 20:20:19 +08:00
2844 lines
91 KiB
Bash
Executable File
2844 lines
91 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
REPO="${AETHER_REPO:-fawney19/Aether}"
|
||
SOURCE_REF="${AETHER_SOURCE_REF:-main}"
|
||
SOURCE_REF_EXPLICIT="false"
|
||
if [[ -n "${AETHER_SOURCE_REF:-}" ]]; then
|
||
SOURCE_REF_EXPLICIT="true"
|
||
fi
|
||
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_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:-}}"
|
||
MAX_RELEASE_ARCHIVE_ENTRIES=100000
|
||
MAX_RELEASE_UNPACKED_BYTES=$((2 * 1024 * 1024 * 1024))
|
||
|
||
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
|
||
}
|
||
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
||
trap cleanup EXIT
|
||
fi
|
||
|
||
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"
|
||
SOURCE_REF_EXPLICIT="true"
|
||
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
|
||
}
|
||
|
||
is_safe_release_identifier() {
|
||
local value="$1"
|
||
[[ -n "${value}" && ${#value} -le 128 ]] || return 1
|
||
[[ "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]
|
||
}
|
||
|
||
validate_release_identifier() {
|
||
local value="$1"
|
||
is_safe_release_identifier "${value}" \
|
||
|| die "release version contains unsafe URL or filesystem characters"
|
||
}
|
||
|
||
validate_installer_source_identifiers() {
|
||
[[ ${#REPO} -le 200 && "${REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||
|| die "repository must be a safe GitHub OWNER/REPO identifier"
|
||
[[ -n "${SOURCE_REF}" && ${#SOURCE_REF} -le 240 ]] \
|
||
|| die "source ref must be a non-empty identifier of at most 240 characters"
|
||
[[ "${SOURCE_REF}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||
|| die "source ref contains unsafe URL characters"
|
||
case "/${SOURCE_REF}/" in
|
||
*"//"*|*"/./"*|*"/../"*)
|
||
die "source ref contains an unsafe path component"
|
||
;;
|
||
esac
|
||
if [[ -n "${VERSION}" ]]; then
|
||
validate_release_identifier "${VERSION}"
|
||
fi
|
||
}
|
||
|
||
validate_service_account_identifier() {
|
||
local kind="$1"
|
||
local value="$2"
|
||
[[ -n "${value}" && ${#value} -le 64 ]] \
|
||
|| die "${kind} must be a non-empty account identifier of at most 64 characters"
|
||
[[ "${value}" =~ ^[A-Za-z_][A-Za-z0-9_.-]*\$?$ ]] \
|
||
|| die "${kind} contains unsafe account-name characters"
|
||
}
|
||
|
||
validate_launchd_label() {
|
||
local value="$1"
|
||
[[ -n "${value}" && ${#value} -le 128 \
|
||
&& "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||
|| die "launchd label contains unsafe filesystem or service-name characters"
|
||
}
|
||
|
||
validate_managed_absolute_path() {
|
||
local kind="$1"
|
||
local value="$2"
|
||
[[ "${value}" == /* && ${#value} -le 1024 ]] \
|
||
|| die "${kind} must be an absolute path of at most 1024 characters"
|
||
[[ "${value}" != *$'\n'* && "${value}" != *$'\r'* ]] \
|
||
|| die "${kind} may not contain line breaks"
|
||
[[ "${value}" =~ ^/[A-Za-z0-9._/+,-]+$ ]] \
|
||
|| die "${kind} contains characters unsafe for generated service files"
|
||
case "/${value#/}/" in
|
||
*"//"*|*"/./"*|*"/../"*)
|
||
die "${kind} contains an unsafe path component"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
validate_single_node_managed_paths() {
|
||
validate_service_account_identifier "service user" "${SERVICE_USER}"
|
||
validate_service_account_identifier "service group" "${SERVICE_GROUP}"
|
||
validate_managed_absolute_path "install root" "${INSTALL_ROOT}"
|
||
validate_managed_absolute_path "config directory" "${CONFIG_DIR}"
|
||
validate_managed_absolute_path "env target" "${ENV_TARGET}"
|
||
validate_privileged_path_ancestor "${INSTALL_ROOT}"
|
||
validate_privileged_path_ancestor "${CONFIG_DIR}"
|
||
validate_managed_regular_file "${ENV_TARGET}" true
|
||
case "$(install_os)" in
|
||
linux)
|
||
validate_managed_absolute_path "systemd unit path" "${SYSTEMD_UNIT_PATH}"
|
||
validate_privileged_path_ancestor "${SYSTEMD_UNIT_PATH}"
|
||
validate_managed_regular_file "${SYSTEMD_UNIT_PATH}" true
|
||
;;
|
||
macos)
|
||
validate_launchd_label "${LAUNCHD_LABEL}"
|
||
validate_managed_absolute_path "launchd plist path" "${LAUNCHD_PLIST_PATH}"
|
||
validate_managed_absolute_path "launchd log directory" "${LAUNCHD_LOG_DIR}"
|
||
validate_privileged_path_ancestor "${LAUNCHD_PLIST_PATH}"
|
||
validate_privileged_path_ancestor "${LAUNCHD_LOG_DIR}"
|
||
validate_managed_regular_file "${LAUNCHD_PLIST_PATH}" true
|
||
validate_managed_regular_file "$(launchd_wrapper_path)" true
|
||
validate_managed_regular_file \
|
||
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" false
|
||
validate_managed_regular_file \
|
||
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log" false
|
||
;;
|
||
esac
|
||
}
|
||
|
||
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)
|
||
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 单节点部署(PostgreSQL)
|
||
3) 系统服务单节点部署(需提供 PostgreSQL DATABASE_URL)
|
||
|
||
请输入选项 [3]:
|
||
EOF
|
||
else
|
||
cat >/dev/tty <<EOF
|
||
|
||
Choose Aether deployment mode:
|
||
1) Docker Compose standard deployment (Postgres + Redis)
|
||
2) Docker Compose single-node deployment (PostgreSQL)
|
||
3) System service single-node deployment (PostgreSQL)
|
||
|
||
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
|
||
}
|
||
|
||
validate_https_download_url() {
|
||
local url="$1"
|
||
local authority
|
||
|
||
[[ "${url}" == https://* ]] || die "remote downloads require an absolute HTTPS URL"
|
||
[[ "${url}" != *"#"* ]] || die "remote download URLs may not contain a fragment"
|
||
authority="${url#https://}"
|
||
authority="${authority%%[/?]*}"
|
||
[[ -n "${authority}" && "${authority}" != *"@"* ]] \
|
||
|| die "remote download URLs may not contain credentials or an empty host"
|
||
}
|
||
|
||
download_to() {
|
||
local url="$1"
|
||
local output="$2"
|
||
local mode="${3:-quiet}"
|
||
local show_progress="false"
|
||
command -v curl >/dev/null 2>&1 || die "curl is required for secure remote downloads"
|
||
validate_https_download_url "${url}"
|
||
if [[ "${mode}" == "progress" && -t 2 ]]; then
|
||
show_progress="true"
|
||
fi
|
||
|
||
if [[ "${show_progress}" == "true" ]]; then
|
||
curl -fL --proto '=https' --proto-redir '=https' --progress-bar "${url}" -o "${output}"
|
||
else
|
||
curl -fsSL --proto '=https' --proto-redir '=https' "${url}" -o "${output}"
|
||
fi
|
||
}
|
||
|
||
download_stdout() {
|
||
local url="$1"
|
||
command -v curl >/dev/null 2>&1 || die "curl is required for secure remote downloads"
|
||
validate_https_download_url "${url}"
|
||
curl -fsSL --proto '=https' --proto-redir '=https' "${url}"
|
||
}
|
||
|
||
verify_release_checksum() {
|
||
local archive="$1"
|
||
local checksum_file="$2"
|
||
local asset="$3"
|
||
local expected actual matches
|
||
|
||
[[ -f "${checksum_file}" ]] || die "release checksum manifest is missing"
|
||
matches="$(awk -v asset="${asset}" '
|
||
($2 == asset || $2 == "*" asset) && $1 ~ /^[0-9A-Fa-f]{64}$/ {
|
||
print tolower($1)
|
||
}
|
||
' "${checksum_file}")"
|
||
[[ "$(printf '%s\n' "${matches}" | awk 'NF { count += 1 } END { print count + 0 }')" -eq 1 ]] \
|
||
|| die "release checksum manifest must contain exactly one valid entry for ${asset}"
|
||
expected="$(printf '%s\n' "${matches}" | awk 'NF { print; exit }')"
|
||
|
||
if command -v sha256sum >/dev/null 2>&1; then
|
||
actual="$(sha256sum "${archive}" | awk '{print tolower($1)}')"
|
||
elif command -v shasum >/dev/null 2>&1; then
|
||
actual="$(shasum -a 256 "${archive}" | awk '{print tolower($1)}')"
|
||
else
|
||
die "sha256sum or shasum is required to verify release assets"
|
||
fi
|
||
[[ "${actual}" == "${expected}" ]] || die "SHA256 verification failed for ${asset}"
|
||
}
|
||
|
||
validate_release_archive() {
|
||
local archive="$1"
|
||
local expected_root="${2:-}"
|
||
local members_file listing_file normalized_file root member normalized permissions mode type
|
||
local entry_count size_field tar_version unpacked_bytes
|
||
|
||
members_file="${TMP_ROOT}/archive-members.txt"
|
||
listing_file="${TMP_ROOT}/archive-listing.txt"
|
||
normalized_file="${TMP_ROOT}/archive-members-normalized.txt"
|
||
LC_ALL=C tar -tzf "${archive}" >"${members_file}" 2>/dev/null \
|
||
|| die "release archive cannot be read"
|
||
tar_version="$(tar --version 2>/dev/null | head -n1 || true)"
|
||
case "${tar_version}" in
|
||
*bsdtar*)
|
||
size_field=5
|
||
;;
|
||
*GNU\ tar*)
|
||
size_field=3
|
||
;;
|
||
*)
|
||
die "unsupported tar implementation for safe release validation"
|
||
;;
|
||
esac
|
||
LC_ALL=C tar --numeric-owner -tvzf "${archive}" >"${listing_file}" 2>/dev/null \
|
||
|| die "release archive metadata cannot be read"
|
||
[[ -s "${members_file}" ]] || die "release archive is empty"
|
||
|
||
entry_count="$(wc -l <"${members_file}" | tr -d '[:space:]')"
|
||
[[ "${entry_count}" =~ ^[0-9]+$ && "${entry_count}" -le "${MAX_RELEASE_ARCHIVE_ENTRIES}" ]] \
|
||
|| die "release archive contains too many entries"
|
||
if [[ "${entry_count}" != "$(wc -l <"${listing_file}" | tr -d '[:space:]')" ]]; then
|
||
die "release archive contains malformed member names"
|
||
fi
|
||
unpacked_bytes="$(awk -v size_field="${size_field}" -v max="${MAX_RELEASE_UNPACKED_BYTES}" '
|
||
{
|
||
size = $size_field
|
||
if (size !~ /^[0-9]+$/ || size > max - total) {
|
||
exit 1
|
||
}
|
||
total += size
|
||
}
|
||
END {
|
||
if (total <= max) {
|
||
print total
|
||
}
|
||
}
|
||
' "${listing_file}")" \
|
||
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
|
||
[[ "${unpacked_bytes}" =~ ^[0-9]+$ && "${unpacked_bytes}" -le "${MAX_RELEASE_UNPACKED_BYTES}" ]] \
|
||
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
|
||
while IFS= read -r permissions; do
|
||
type="${permissions:0:1}"
|
||
mode="${permissions:0:10}"
|
||
[[ "${type}" == "-" || "${type}" == "d" ]] \
|
||
|| die "release archive may contain only regular files and directories"
|
||
[[ "${mode}" != *[sStT]* ]] \
|
||
|| die "release archive contains unsafe special permissions"
|
||
[[ "${mode:5:1}" != "w" && "${mode:8:1}" != "w" ]] \
|
||
|| die "release archive contains group- or world-writable members"
|
||
done <"${listing_file}"
|
||
|
||
: >"${normalized_file}"
|
||
root=""
|
||
while IFS= read -r member; do
|
||
[[ -n "${member}" ]] || die "release archive contains an empty member name"
|
||
[[ "${member}" != /* && "${member}" != *\\* ]] \
|
||
|| die "release archive contains an unsafe member path"
|
||
[[ "${member}" =~ ^[A-Za-z0-9._/@%+=,-]+/?$ ]] \
|
||
|| die "release archive contains a member name with unsafe characters"
|
||
|
||
normalized="${member%/}"
|
||
[[ -n "${normalized}" ]] \
|
||
|| die "release archive contains an invalid member path"
|
||
case "/${normalized}/" in
|
||
*"//"*|*"/./"*|*"/../"*)
|
||
die "release archive contains path traversal or an empty path component"
|
||
;;
|
||
esac
|
||
member="${normalized}"
|
||
normalized="${member%%/*}"
|
||
if [[ -z "${root}" ]]; then
|
||
root="${normalized}"
|
||
elif [[ "${normalized}" != "${root}" ]]; then
|
||
die "release archive must contain exactly one top-level bundle directory"
|
||
fi
|
||
printf '%s\n' "${member}" >>"${normalized_file}"
|
||
done <"${members_file}"
|
||
|
||
[[ -n "${root}" ]] || die "release archive did not contain a bundle directory"
|
||
if [[ -n "${expected_root}" && "${root}" != "${expected_root}" ]]; then
|
||
die "release archive root ${root} does not match expected bundle ${expected_root}"
|
||
fi
|
||
[[ -z "$(LC_ALL=C sort "${normalized_file}" | uniq -d | head -n1)" ]] \
|
||
|| die "release archive contains duplicate members"
|
||
printf '%s\n' "${root}"
|
||
}
|
||
|
||
extract_validated_release_archive() {
|
||
local archive="$1"
|
||
local -a tar_args=(-xzf "${archive}" -C "${TMP_ROOT}" --no-same-owner --no-acls --no-xattrs)
|
||
if tar --version 2>/dev/null | head -n1 | grep -qi 'bsdtar'; then
|
||
tar_args+=(--no-fflags --no-mac-metadata)
|
||
elif tar --version 2>/dev/null | head -n1 | grep -qi 'gnu tar'; then
|
||
tar_args+=(--no-selinux)
|
||
fi
|
||
tar "${tar_args[@]}" \
|
||
|| die "release archive extraction failed"
|
||
}
|
||
|
||
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}" ]]
|
||
}
|
||
|
||
stat_file_mode() {
|
||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
|
||
}
|
||
|
||
stat_file_uid() {
|
||
stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" 2>/dev/null
|
||
}
|
||
|
||
stat_file_gid() {
|
||
stat -c '%g' "$1" 2>/dev/null || stat -f '%g' "$1" 2>/dev/null
|
||
}
|
||
|
||
stat_file_link_count() {
|
||
stat -c '%h' "$1" 2>/dev/null || stat -f '%l' "$1" 2>/dev/null
|
||
}
|
||
|
||
validate_managed_regular_file() {
|
||
local path="$1"
|
||
local allow_hardlinks="${2:-true}"
|
||
|
||
[[ ! -L "${path}" ]] || die "managed file may not be a symbolic link: ${path}"
|
||
if [[ -e "${path}" ]]; then
|
||
[[ -f "${path}" ]] || die "managed file path is not a regular file: ${path}"
|
||
if [[ "${allow_hardlinks}" != "true" ]]; then
|
||
[[ "$(stat_file_link_count "${path}")" == "1" ]] \
|
||
|| die "managed file may not have multiple hard links: ${path}"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
validate_managed_parent_directory() {
|
||
local path="$1"
|
||
local parent
|
||
parent="$(dirname -- "${path}")"
|
||
[[ -d "${parent}" && ! -L "${parent}" ]] \
|
||
|| die "managed file parent must be a real directory: ${parent}"
|
||
}
|
||
|
||
validate_privileged_path_ancestor() {
|
||
local path="$1"
|
||
local ancestor canonical current mode permissions first
|
||
ancestor="${path}"
|
||
if [[ ! -d "${ancestor}" ]]; then
|
||
ancestor="$(dirname -- "${ancestor}")"
|
||
fi
|
||
while [[ ! -e "${ancestor}" && ! -L "${ancestor}" ]]; do
|
||
[[ "${ancestor}" != "/" && "${ancestor}" != "." ]] \
|
||
|| die "could not resolve a trusted ancestor for privileged path: ${path}"
|
||
ancestor="$(dirname -- "${ancestor}")"
|
||
done
|
||
[[ -d "${ancestor}" ]] \
|
||
|| die "privileged path ancestor is not a directory: ${ancestor}"
|
||
|
||
current="${ancestor}"
|
||
first="true"
|
||
while :; do
|
||
[[ "$(stat_file_uid "${current}")" == "0" ]] \
|
||
|| die "privileged path component must be owned by root: ${current}"
|
||
if [[ ! -L "${current}" ]]; then
|
||
[[ -d "${current}" ]] \
|
||
|| die "privileged path component is not a directory: ${current}"
|
||
mode="$(stat_file_mode "${current}")"
|
||
[[ "${mode}" =~ ^[0-7]+$ ]] \
|
||
|| die "privileged path component has an invalid mode: ${current}"
|
||
permissions=$((0${mode}))
|
||
if (( (permissions & 0022) != 0 )); then
|
||
if [[ "${first}" == "true" ]] || (( (permissions & 1000) == 0 )); then
|
||
die "privileged path component may not be group- or world-writable: ${current}"
|
||
fi
|
||
fi
|
||
fi
|
||
[[ "${current}" != "/" ]] || break
|
||
current="$(dirname -- "${current}")"
|
||
first="false"
|
||
done
|
||
|
||
canonical="$(cd -- "${ancestor}" && pwd -P)" \
|
||
|| die "could not resolve privileged path ancestor: ${ancestor}"
|
||
current="${canonical}"
|
||
first="true"
|
||
while :; do
|
||
[[ -d "${current}" && ! -L "${current}" ]] \
|
||
|| die "resolved privileged path component is not a real directory: ${current}"
|
||
[[ "$(stat_file_uid "${current}")" == "0" ]] \
|
||
|| die "resolved privileged path component must be owned by root: ${current}"
|
||
mode="$(stat_file_mode "${current}")"
|
||
[[ "${mode}" =~ ^[0-7]+$ ]] \
|
||
|| die "resolved privileged path component has an invalid mode: ${current}"
|
||
permissions=$((0${mode}))
|
||
if (( (permissions & 0022) != 0 )); then
|
||
if [[ "${first}" == "true" ]] || (( (permissions & 1000) == 0 )); then
|
||
die "resolved privileged path component may not be group- or world-writable: ${current}"
|
||
fi
|
||
fi
|
||
[[ "${current}" != "/" ]] || break
|
||
current="$(dirname -- "${current}")"
|
||
first="false"
|
||
done
|
||
}
|
||
|
||
atomic_install_managed_file() {
|
||
local source="$1"
|
||
local target="$2"
|
||
local mode="$3"
|
||
local owner="${4:-}"
|
||
local group="${5:-}"
|
||
local parent base temporary
|
||
|
||
[[ -f "${source}" && ! -L "${source}" ]] \
|
||
|| die "managed file source must be a regular file: ${source}"
|
||
if [[ "${EUID}" -eq 0 && ( -n "${owner}" || -n "${group}" ) ]]; then
|
||
validate_privileged_path_ancestor "${target}"
|
||
fi
|
||
validate_managed_parent_directory "${target}"
|
||
validate_managed_regular_file "${target}" true
|
||
parent="$(dirname -- "${target}")"
|
||
base="$(basename -- "${target}")"
|
||
temporary="$(mktemp "${parent}/.${base}.tmp.XXXXXXXX")" \
|
||
|| die "could not create a temporary managed file in ${parent}"
|
||
|
||
local -a install_args=(-m "${mode}")
|
||
[[ -z "${owner}" ]] || install_args+=(-o "${owner}")
|
||
[[ -z "${group}" ]] || install_args+=(-g "${group}")
|
||
if ! install "${install_args[@]}" "${source}" "${temporary}"; then
|
||
rm -f -- "${temporary}"
|
||
die "could not stage managed file: ${target}"
|
||
fi
|
||
|
||
validate_managed_parent_directory "${target}"
|
||
if [[ -L "${target}" || ( -e "${target}" && ! -f "${target}" ) ]]; then
|
||
rm -f -- "${temporary}"
|
||
die "managed file target changed to an unsafe type: ${target}"
|
||
fi
|
||
case "$(install_os)" in
|
||
linux)
|
||
if ! mv -fT -- "${temporary}" "${target}"; then
|
||
rm -f -- "${temporary}"
|
||
die "could not atomically replace managed file: ${target}"
|
||
fi
|
||
;;
|
||
macos)
|
||
if ! mv -fh -- "${temporary}" "${target}"; then
|
||
rm -f -- "${temporary}"
|
||
die "could not atomically replace managed file: ${target}"
|
||
fi
|
||
;;
|
||
esac
|
||
[[ -f "${target}" && ! -L "${target}" ]] \
|
||
|| die "managed file replacement did not produce a regular file: ${target}"
|
||
}
|
||
|
||
ensure_privileged_directory() {
|
||
local path="$1"
|
||
local mode="$2"
|
||
local owner="$3"
|
||
local group="$4"
|
||
|
||
validate_privileged_path_ancestor "${path}"
|
||
[[ ! -L "${path}" ]] || die "privileged directory may not be a symbolic link: ${path}"
|
||
if [[ -e "${path}" && ! -d "${path}" ]]; then
|
||
die "privileged directory path is not a directory: ${path}"
|
||
fi
|
||
install -d -o "${owner}" -g "${group}" -m "${mode}" "${path}"
|
||
[[ -d "${path}" && ! -L "${path}" ]] \
|
||
|| die "privileged directory became an unsafe path: ${path}"
|
||
}
|
||
|
||
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 || true)"
|
||
|
||
ensure_directory "$(dirname "${target_path}")"
|
||
if [[ -n "${script_dir}" && -f "${script_dir}/${source_path}" && ! -L "${script_dir}/${source_path}" ]]; then
|
||
atomic_install_managed_file \
|
||
"${script_dir}/${source_path}" "${target_path}" "${mode}"
|
||
else
|
||
local downloaded
|
||
downloaded="$(mktemp)"
|
||
download_to "$(raw_project_url "${source_path}")" "${downloaded}"
|
||
atomic_install_managed_file "${downloaded}" "${target_path}" "${mode}"
|
||
rm -f -- "${downloaded}"
|
||
fi
|
||
}
|
||
|
||
install_generate_keys_script() {
|
||
local target_path="$1"
|
||
local script_dir
|
||
script_dir="$(current_script_dir || true)"
|
||
|
||
ensure_directory "$(dirname "${target_path}")"
|
||
if [[ -n "${script_dir}" && -f "${script_dir}/generate_keys.sh" && ! -L "${script_dir}/generate_keys.sh" ]]; then
|
||
atomic_install_managed_file \
|
||
"${script_dir}/generate_keys.sh" "${target_path}" 0755
|
||
else
|
||
write_generate_keys_script "${target_path}"
|
||
fi
|
||
}
|
||
|
||
ensure_directory() {
|
||
local path="$1"
|
||
local mode="${2:-0755}"
|
||
[[ ! -L "${path}" ]] || die "managed directory may not be a symbolic link: ${path}"
|
||
if [[ -e "${path}" && ! -d "${path}" ]]; then
|
||
die "managed directory path is not a directory: ${path}"
|
||
fi
|
||
if [[ ! -d "${path}" ]]; then
|
||
install -d -m "${mode}" "${path}"
|
||
fi
|
||
[[ ! -L "${path}" ]] || die "managed directory became a symbolic link: ${path}"
|
||
}
|
||
|
||
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}"
|
||
}
|
||
|
||
resolve_compose_release_identity() {
|
||
local tag
|
||
tag="$(resolve_version)"
|
||
[[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}"
|
||
validate_release_identifier "${tag}"
|
||
VERSION="${tag}"
|
||
if [[ "${SOURCE_REF_EXPLICIT}" != "true" ]]; then
|
||
SOURCE_REF="${tag}"
|
||
fi
|
||
validate_installer_source_identifiers
|
||
}
|
||
|
||
current_script_dir() {
|
||
local source="${BASH_SOURCE[0]:-}"
|
||
[[ -n "${source}" && -f "${source}" ]] || return 1
|
||
|
||
while [[ -L "${source}" ]]; do
|
||
local source_dir target
|
||
source_dir="$(cd -- "$(dirname -- "${source}")" && pwd -P)"
|
||
target="$(readlink "${source}")"
|
||
if [[ "${target}" == /* ]]; then
|
||
source="${target}"
|
||
else
|
||
source="${source_dir}/${target}"
|
||
fi
|
||
done
|
||
[[ -f "${source}" ]] || return 1
|
||
cd -- "$(dirname -- "${source}")" && pwd -P
|
||
}
|
||
|
||
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 || true)"
|
||
[[ -n "${dir}" ]] || return 1
|
||
if [[ -d "${dir}" && ! -L "${dir}" \
|
||
&& -d "${dir}/bin" && ! -L "${dir}/bin" \
|
||
&& -f "${dir}/bin/aether-gateway" \
|
||
&& ! -L "${dir}/bin/aether-gateway" \
|
||
&& -x "${dir}/bin/aether-gateway" \
|
||
&& -d "${dir}/frontend" && ! -L "${dir}/frontend" ]]; then
|
||
echo "${dir}"
|
||
fi
|
||
}
|
||
|
||
validate_local_bundle_tree() {
|
||
local bundle="$1"
|
||
local binary="${bundle}/bin/aether-gateway"
|
||
local unsafe_path
|
||
|
||
[[ -d "${bundle}" && ! -L "${bundle}" ]] \
|
||
|| die "release bundle must be a real directory: ${bundle}"
|
||
[[ -d "${bundle}/bin" && ! -L "${bundle}/bin" ]] \
|
||
|| die "release bundle bin path must be a real directory: ${bundle}/bin"
|
||
[[ -f "${binary}" && ! -L "${binary}" && -x "${binary}" ]] \
|
||
|| die "release bundle binary must be a regular executable file: ${binary}"
|
||
[[ "$(stat_file_link_count "${binary}")" == "1" ]] \
|
||
|| die "release bundle binary may not have multiple hard links: ${binary}"
|
||
[[ -d "${bundle}/frontend" && ! -L "${bundle}/frontend" ]] \
|
||
|| die "release bundle frontend path must be a real directory: ${bundle}/frontend"
|
||
|
||
if ! unsafe_path="$(find "${bundle}" -type l -print -quit)"; then
|
||
die "could not inspect release bundle for symbolic links: ${bundle}"
|
||
fi
|
||
[[ -z "${unsafe_path}" ]] \
|
||
|| die "release bundle may not contain symbolic links: ${unsafe_path}"
|
||
|
||
if ! unsafe_path="$(find "${bundle}" ! -type d ! -type f -print -quit)"; then
|
||
die "could not inspect release bundle entry types: ${bundle}"
|
||
fi
|
||
[[ -z "${unsafe_path}" ]] \
|
||
|| die "release bundle may contain only directories and regular files: ${unsafe_path}"
|
||
|
||
if ! unsafe_path="$(find "${bundle}" -type f -links +1 -print -quit)"; then
|
||
die "could not inspect release bundle hard links: ${bundle}"
|
||
fi
|
||
[[ -z "${unsafe_path}" ]] \
|
||
|| die "release bundle may not contain multiply-linked files: ${unsafe_path}"
|
||
}
|
||
|
||
download_or_unpack_bundle() {
|
||
TMP_ROOT="$(mktemp -d)"
|
||
local archive_file archive_root archive_source expected_root
|
||
if [[ -n "${ARCHIVE_PATH}" ]]; then
|
||
[[ -f "${ARCHIVE_PATH}" ]] || die "archive not found: ${ARCHIVE_PATH}"
|
||
info "using local archive ${ARCHIVE_PATH}"
|
||
archive_source="$(absolute_path "${ARCHIVE_PATH}")"
|
||
archive_file="${TMP_ROOT}/local-release.tar.gz"
|
||
cp "${archive_source}" "${archive_file}" \
|
||
|| die "could not copy local release archive into the validation directory"
|
||
expected_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}"
|
||
validate_release_identifier "${tag}"
|
||
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
|
||
download_to "${base_url}/SHA256SUMS" "${TMP_ROOT}/SHA256SUMS"
|
||
verify_release_checksum "${archive_file}" "${TMP_ROOT}/SHA256SUMS" "${asset}"
|
||
expected_root="${asset%.tar.gz}"
|
||
fi
|
||
|
||
archive_root="$(validate_release_archive "${archive_file}" "${expected_root}")"
|
||
extract_validated_release_archive "${archive_file}"
|
||
local bundle="${TMP_ROOT}/${archive_root}"
|
||
[[ -n "${bundle}" ]] || die "release archive did not contain a bundle directory"
|
||
validate_local_bundle_tree "${bundle}"
|
||
if [[ -z "${VERSION}" ]]; then
|
||
VERSION="$(derive_local_bundle_version "${bundle}")"
|
||
fi
|
||
validate_release_identifier "${VERSION}"
|
||
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 rendered
|
||
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
|
||
ensure_directory "${output_dir}"
|
||
fi
|
||
rendered="$(mktemp)"
|
||
cat > "${rendered}" <<'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)
|
||
DB_PASSWORD=$(urlsafe_rand 32)
|
||
REDIS_PASSWORD=$(urlsafe_rand 32)
|
||
KEYS
|
||
EOF
|
||
atomic_install_managed_file "${rendered}" "${output}" 0755
|
||
rm -f -- "${rendered}"
|
||
}
|
||
|
||
validate_dotenv_scalar() {
|
||
local key="$1"
|
||
local value="$2"
|
||
|
||
[[ "${value}" != *$'\n'* && "${value}" != *$'\r'* ]] \
|
||
|| die "${key} may not contain CR or LF characters"
|
||
}
|
||
|
||
replace_or_append_env() {
|
||
local file="$1"
|
||
local key="$2"
|
||
local value="$3"
|
||
local parent base staged mode owner group line
|
||
local replaced="false"
|
||
|
||
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|
||
|| die "invalid dotenv key: ${key}"
|
||
validate_dotenv_scalar "${key}" "${value}"
|
||
validate_managed_parent_directory "${file}"
|
||
validate_managed_regular_file "${file}" true
|
||
parent="$(dirname -- "${file}")"
|
||
base="$(basename -- "${file}")"
|
||
staged="$(mktemp "${parent}/.${base}.edit.XXXXXXXX")" \
|
||
|| die "could not create a temporary env file in ${parent}"
|
||
|
||
if [[ -e "${file}" ]]; then
|
||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||
if [[ "${replaced}" == "false" && "${line}" =~ ^#?[[:space:]]*${key}= ]]; then
|
||
printf '%s=%s\n' "${key}" "${value}" >>"${staged}"
|
||
replaced="true"
|
||
else
|
||
printf '%s\n' "${line}" >>"${staged}"
|
||
fi
|
||
done <"${file}"
|
||
fi
|
||
if [[ "${replaced}" == "false" ]]; then
|
||
printf '%s=%s\n' "${key}" "${value}" >> "${staged}"
|
||
fi
|
||
|
||
mode="0600"
|
||
owner=""
|
||
group=""
|
||
if [[ -e "${file}" ]]; then
|
||
mode="$(stat_file_mode "${file}")"
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
owner="$(stat_file_uid "${file}")"
|
||
group="$(stat_file_gid "${file}")"
|
||
fi
|
||
fi
|
||
atomic_install_managed_file "${staged}" "${file}" "${mode}" "${owner}" "${group}"
|
||
rm -f -- "${staged}"
|
||
}
|
||
|
||
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 database_url="${AETHER_DATABASE_URL:-${DATABASE_URL:-${AETHER_GATEWAY_DATA_POSTGRES_URL:-}}}"
|
||
[[ -n "${database_url}" ]] || die "PostgreSQL DATABASE_URL is required for system-service installs; set DATABASE_URL or use --mode compose"
|
||
case "${database_url}" in
|
||
postgres://*|postgresql://*) ;;
|
||
*) die "only PostgreSQL database URLs are supported" ;;
|
||
esac
|
||
validate_dotenv_scalar "DATABASE_URL" "${database_url}"
|
||
local jwt_key encryption_key
|
||
prompt_admin_password
|
||
jwt_key="$(urlsafe_rand 32)"
|
||
encryption_key="$(urlsafe_rand 32)"
|
||
validate_dotenv_scalar "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
|
||
|
||
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=postgres
|
||
AETHER_DATABASE_URL=${database_url}
|
||
DATABASE_URL=${database_url}
|
||
|
||
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}"
|
||
validate_dotenv_scalar "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
|
||
validate_dotenv_scalar "ADMIN_EMAIL" "${ADMIN_EMAIL:-admin@example.local}"
|
||
validate_dotenv_scalar "ADMIN_USERNAME" "${ADMIN_USERNAME:-admin}"
|
||
validate_dotenv_scalar "DATABASE_URL" "${DATABASE_URL:-}"
|
||
validate_dotenv_scalar "REDIS_URL" "${REDIS_URL:-}"
|
||
|
||
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}"
|
||
}
|
||
|
||
generate_compose_env() {
|
||
local output="$1"
|
||
local jwt_key encryption_key db_password redis_password
|
||
prompt_admin_password
|
||
jwt_key="$(urlsafe_rand 32)"
|
||
encryption_key="$(urlsafe_rand 32)"
|
||
db_password="$(urlsafe_rand 32)"
|
||
redis_password="$(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" "${db_password}"
|
||
replace_or_append_env "${output}" "REDIS_PASSWORD" "${redis_password}"
|
||
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"
|
||
generate_compose_env "${output}"
|
||
replace_or_append_env "${output}" "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY" "single-node"
|
||
replace_or_append_env "${output}" "AETHER_DATABASE_DRIVER" "postgres"
|
||
}
|
||
|
||
install_config_dir() {
|
||
validate_privileged_path_ancestor "${CONFIG_DIR}"
|
||
[[ ! -L "${CONFIG_DIR}" ]] || die "config directory may not be a symbolic link: ${CONFIG_DIR}"
|
||
if [[ -e "${CONFIG_DIR}" && ! -d "${CONFIG_DIR}" ]]; then
|
||
die "config directory path is not a directory: ${CONFIG_DIR}"
|
||
fi
|
||
if is_darwin; then
|
||
install -d -o root -g "${SERVICE_GROUP}" -m 0750 "${CONFIG_DIR}"
|
||
else
|
||
install -d -o root -g root -m 0750 "${CONFIG_DIR}"
|
||
fi
|
||
[[ ! -L "${CONFIG_DIR}" ]] || die "config directory became a symbolic link: ${CONFIG_DIR}"
|
||
}
|
||
|
||
install_env_target_from() {
|
||
local source="$1"
|
||
local owner="" group=""
|
||
[[ -f "${source}" && ! -L "${source}" ]] \
|
||
|| die "env source must be a regular file and not a symbolic link: ${source}"
|
||
if is_darwin; then
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
owner="root"
|
||
group="${SERVICE_GROUP}"
|
||
fi
|
||
atomic_install_managed_file "${source}" "${ENV_TARGET}" 0640 "${owner}" "${group}"
|
||
else
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
owner="root"
|
||
group="root"
|
||
fi
|
||
atomic_install_managed_file "${source}" "${ENV_TARGET}" 0600 "${owner}" "${group}"
|
||
fi
|
||
}
|
||
|
||
ensure_env_target_permissions() {
|
||
local owner="" group="" mode="0600"
|
||
validate_managed_regular_file "${ENV_TARGET}" true
|
||
[[ -f "${ENV_TARGET}" ]] || die "env target is missing: ${ENV_TARGET}"
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
owner="root"
|
||
group="root"
|
||
fi
|
||
if is_darwin; then
|
||
mode="0640"
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
group="${SERVICE_GROUP}"
|
||
fi
|
||
fi
|
||
atomic_install_managed_file \
|
||
"${ENV_TARGET}" "${ENV_TARGET}" "${mode}" "${owner}" "${group}"
|
||
}
|
||
|
||
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
|
||
|
||
case "${database_driver}" in
|
||
""|postgres|postgresql) ;;
|
||
*) die "only PostgreSQL database drivers are supported" ;;
|
||
esac
|
||
case "${database_url}" in
|
||
postgres://*|postgresql://*) ;;
|
||
*) die "a PostgreSQL DATABASE_URL is required" ;;
|
||
esac
|
||
|
||
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"
|
||
[[ -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
|
||
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"
|
||
|
||
validate_managed_regular_file "${COMPOSE_DIR}/.env" false
|
||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||
warn "keeping existing ${COMPOSE_DIR}/.env"
|
||
else
|
||
local generated_compose_env
|
||
info "generating ${COMPOSE_DIR}/.env"
|
||
generated_compose_env="$(mktemp)"
|
||
generate_compose_env "${generated_compose_env}"
|
||
atomic_install_managed_file \
|
||
"${generated_compose_env}" "${COMPOSE_DIR}/.env" 0600
|
||
rm -f -- "${generated_compose_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}/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"
|
||
|
||
validate_managed_regular_file "${COMPOSE_DIR}/.env" false
|
||
if [[ -f "${COMPOSE_DIR}/.env" ]]; then
|
||
warn "keeping existing ${COMPOSE_DIR}/.env"
|
||
else
|
||
local generated_compose_env
|
||
info "generating ${COMPOSE_DIR}/.env"
|
||
generated_compose_env="$(mktemp)"
|
||
generate_compose_single_node_env "${generated_compose_env}"
|
||
atomic_install_managed_file \
|
||
"${generated_compose_env}" "${COMPOSE_DIR}/.env" 0600
|
||
rm -f -- "${generated_compose_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}/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
|
||
replace_or_append_env "${ENV_TARGET}" "AETHER_UPDATE_STRATEGY" "manual"
|
||
}
|
||
|
||
switch_current_release_link() {
|
||
local release_dir="$1"
|
||
local current_link="$2"
|
||
local next_link="${current_link}.new"
|
||
|
||
[[ ! -e "${current_link}" || -L "${current_link}" ]] \
|
||
|| die "current release path exists and is not a symbolic link"
|
||
[[ ! -e "${next_link}" || -L "${next_link}" ]] \
|
||
|| die "temporary current release path exists and is not a symbolic link"
|
||
|
||
rm -f -- "${next_link}"
|
||
ln -s -- "${release_dir}" "${next_link}"
|
||
[[ -L "${next_link}" && "$(readlink "${next_link}")" == "${release_dir}" ]] \
|
||
|| die "could not create the temporary current release symbolic link"
|
||
|
||
# Recheck immediately before rename. The install root is root-owned and not
|
||
# writable by the service account, so an unprivileged process cannot race it.
|
||
[[ ! -e "${current_link}" || -L "${current_link}" ]] \
|
||
|| die "current release path changed to a non-symbolic-link"
|
||
case "$(install_os)" in
|
||
linux)
|
||
mv -fT -- "${next_link}" "${current_link}"
|
||
;;
|
||
macos)
|
||
mv -fh -- "${next_link}" "${current_link}"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
select_release_install_directory() {
|
||
local requested="$1"
|
||
local selected
|
||
|
||
if [[ ! -e "${requested}" && ! -L "${requested}" ]]; then
|
||
printf '%s\n' "${requested}"
|
||
return
|
||
fi
|
||
[[ -d "${requested}" && ! -L "${requested}" ]] \
|
||
|| die "release path exists and is not a managed directory: ${requested}"
|
||
|
||
selected="$(mktemp -d "${requested}.XXXXXXXX")" \
|
||
|| die "could not allocate an immutable release directory beside ${requested}"
|
||
[[ -d "${selected}" && ! -L "${selected}" ]] \
|
||
|| die "release staging path is not a real directory: ${selected}"
|
||
printf '%s\n' "${selected}"
|
||
}
|
||
|
||
install_release() {
|
||
local bundle="$1"
|
||
local release_dir
|
||
local current_link="${INSTALL_ROOT}/current"
|
||
|
||
validate_release_identifier "${VERSION}"
|
||
release_dir="${INSTALL_ROOT}/releases/${VERSION}"
|
||
validate_local_bundle_tree "${bundle}"
|
||
|
||
[[ ! -L "${INSTALL_ROOT}" ]] || die "install root may not be a symbolic link: ${INSTALL_ROOT}"
|
||
if [[ -e "${INSTALL_ROOT}" && ! -d "${INSTALL_ROOT}" ]]; then
|
||
die "install root path is not a directory: ${INSTALL_ROOT}"
|
||
fi
|
||
[[ ! -L "${INSTALL_ROOT}/releases" ]] \
|
||
|| die "releases directory may not be a symbolic link: ${INSTALL_ROOT}/releases"
|
||
[[ ! -L "${INSTALL_ROOT}/data" ]] \
|
||
|| die "data directory may not be a symbolic link: ${INSTALL_ROOT}/data"
|
||
[[ ! -L "${INSTALL_ROOT}/logs" ]] \
|
||
|| die "logs directory may not be a symbolic link: ${INSTALL_ROOT}/logs"
|
||
install -d -o root -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
|
||
[[ ! -L "${INSTALL_ROOT}" ]] || die "install root became a symbolic link: ${INSTALL_ROOT}"
|
||
[[ ! -L "${INSTALL_ROOT}/releases" ]] \
|
||
|| die "releases directory became a symbolic link: ${INSTALL_ROOT}/releases"
|
||
chmod 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases"
|
||
install -d -m 0755 "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
|
||
[[ ! -L "${INSTALL_ROOT}/data" && ! -L "${INSTALL_ROOT}/logs" ]] \
|
||
|| die "managed data or log directory became a symbolic link"
|
||
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
|
||
release_dir="$(select_release_install_directory "${release_dir}")"
|
||
info "installing release ${VERSION} into ${release_dir}"
|
||
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
|
||
cp -P "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
|
||
cp -RP "${bundle}/frontend/." "${release_dir}/frontend/"
|
||
validate_local_bundle_tree "${release_dir}"
|
||
chmod -R u=rwX,go=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,go=rX "${release_dir}"
|
||
switch_current_release_link "${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 releases_dir_real
|
||
releases_dir_real="$(cd -- "${releases_dir}" && pwd -P)"
|
||
local dir name parent_real
|
||
local -a safe_release_dirs=()
|
||
for dir in "${releases_dir}"/*; do
|
||
[[ -d "${dir}" && ! -L "${dir}" ]] || continue
|
||
name="$(basename -- "${dir}")"
|
||
is_safe_release_identifier "${name}" || continue
|
||
parent_real="$(cd -- "$(dirname -- "${dir}")" && pwd -P)"
|
||
[[ "${parent_real}" == "${releases_dir_real}" ]] || continue
|
||
[[ "${name}" != "${current_target}" ]] || continue
|
||
safe_release_dirs+=("${dir}")
|
||
done
|
||
|
||
local count="${#safe_release_dirs[@]}"
|
||
|
||
if [[ "${count}" -ge "${keep}" ]]; then
|
||
local to_remove
|
||
to_remove="$(ls -1dt -- "${safe_release_dirs[@]}" 2>/dev/null | tail -n +$((keep)))"
|
||
|
||
local removed=0
|
||
while IFS= read -r dir; do
|
||
[[ -n "${dir}" ]] || continue
|
||
[[ -d "${dir}" && ! -L "${dir}" ]] || continue
|
||
name="$(basename -- "${dir}")"
|
||
is_safe_release_identifier "${name}" || continue
|
||
parent_real="$(cd -- "$(dirname -- "${dir}")" && pwd -P)"
|
||
[[ "${parent_real}" == "${releases_dir_real}" ]] || 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 unit_dir
|
||
rendered_unit="$(mktemp)"
|
||
render_systemd_unit > "${rendered_unit}"
|
||
info "installing systemd unit to ${SYSTEMD_UNIT_PATH}"
|
||
unit_dir="$(dirname -- "${SYSTEMD_UNIT_PATH}")"
|
||
ensure_privileged_directory "${unit_dir}" 0755 root root
|
||
atomic_install_managed_file \
|
||
"${rendered_unit}" "${SYSTEMD_UNIT_PATH}" 0644 root root
|
||
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
|
||
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
|
||
gateway_port="${gateway_port:-8084}"
|
||
|
||
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
|
||
|
||
|
||
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_dir rendered
|
||
wrapper="$(launchd_wrapper_path)"
|
||
wrapper_dir="$(dirname -- "${wrapper}")"
|
||
ensure_privileged_directory "${wrapper_dir}" 0755 root wheel
|
||
rendered="$(mktemp)"
|
||
{
|
||
cat <<'EOF'
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
EOF
|
||
printf 'ENV_TARGET=%q\n' "${ENV_TARGET}"
|
||
printf 'AETHER_BIN=%q\n' "${INSTALL_ROOT}/current/bin/aether-gateway"
|
||
cat <<'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
|
||
} > "${rendered}"
|
||
atomic_install_managed_file "${rendered}" "${wrapper}" 0755 root wheel
|
||
rm -f -- "${rendered}"
|
||
}
|
||
|
||
xml_escape() {
|
||
local value="$1"
|
||
value="${value//&/&}"
|
||
value="${value//</<}"
|
||
value="${value//>/>}"
|
||
value="${value//\"/"}"
|
||
value="${value//\'/'}"
|
||
printf '%s' "${value}"
|
||
}
|
||
|
||
render_launchd_plist() {
|
||
local wrapper label service_user service_group working_directory stdout_path stderr_path
|
||
wrapper="$(xml_escape "$(launchd_wrapper_path)")"
|
||
label="$(xml_escape "${LAUNCHD_LABEL}")"
|
||
service_user="$(xml_escape "${SERVICE_USER}")"
|
||
service_group="$(xml_escape "${SERVICE_GROUP}")"
|
||
working_directory="$(xml_escape "${INSTALL_ROOT}/current")"
|
||
stdout_path="$(xml_escape "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log")"
|
||
stderr_path="$(xml_escape "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log")"
|
||
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>${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>${working_directory}</string>
|
||
<key>RunAtLoad</key>
|
||
<true/>
|
||
<key>KeepAlive</key>
|
||
<true/>
|
||
<key>StandardOutPath</key>
|
||
<string>${stdout_path}</string>
|
||
<key>StandardErrorPath</key>
|
||
<string>${stderr_path}</string>
|
||
<key>Umask</key>
|
||
<integer>23</integer>
|
||
</dict>
|
||
</plist>
|
||
EOF
|
||
}
|
||
|
||
install_launchd_log_files() {
|
||
local path staged
|
||
local -a log_paths=(
|
||
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log"
|
||
"${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log"
|
||
)
|
||
ensure_privileged_directory "${LAUNCHD_LOG_DIR}" 0755 root wheel
|
||
for path in "${log_paths[@]}"; do
|
||
validate_managed_regular_file "${path}" false
|
||
done
|
||
for path in "${log_paths[@]}"; do
|
||
if [[ ! -e "${path}" ]]; then
|
||
staged="$(mktemp)"
|
||
atomic_install_managed_file \
|
||
"${staged}" "${path}" 0640 "${SERVICE_USER}" "${SERVICE_GROUP}"
|
||
rm -f -- "${staged}"
|
||
else
|
||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${path}"
|
||
chmod 0640 "${path}"
|
||
validate_managed_regular_file "${path}" false
|
||
fi
|
||
done
|
||
}
|
||
|
||
install_launchd_unit() {
|
||
local rendered_plist plist_dir
|
||
rendered_plist="$(mktemp)"
|
||
render_launchd_plist > "${rendered_plist}"
|
||
info "installing launchd plist to ${LAUNCHD_PLIST_PATH}"
|
||
plist_dir="$(dirname -- "${LAUNCHD_PLIST_PATH}")"
|
||
ensure_privileged_directory "${plist_dir}" 0755 root wheel
|
||
validate_managed_regular_file "${LAUNCHD_PLIST_PATH}" true
|
||
install_launchd_log_files
|
||
atomic_install_managed_file \
|
||
"${rendered_plist}" "${LAUNCHD_PLIST_PATH}" 0644 root wheel
|
||
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
|
||
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
|
||
gateway_port="${gateway_port:-8084}"
|
||
|
||
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
|
||
|
||
|
||
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
|
||
validate_installer_source_identifiers
|
||
select_mode
|
||
|
||
if [[ "${MODE}" == "compose" ]]; then
|
||
resolve_compose_release_identity
|
||
install_compose_mode
|
||
elif [[ "${MODE}" == "compose-single-node" ]]; then
|
||
resolve_compose_release_identity
|
||
install_compose_single_node_mode
|
||
else
|
||
require_root
|
||
require_service_manager
|
||
validate_single_node_managed_paths
|
||
bundle="$(local_bundle_dir || true)"
|
||
if [[ -z "${bundle}" ]]; then
|
||
download_or_unpack_bundle
|
||
bundle="${BUNDLE_DIR}"
|
||
else
|
||
validate_local_bundle_tree "${bundle}"
|
||
if [[ -z "${VERSION}" ]]; then
|
||
VERSION="$(derive_local_bundle_version "${bundle}")"
|
||
fi
|
||
validate_release_identifier "${VERSION}"
|
||
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
|
||
}
|
||
|
||
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
||
main "$@"
|
||
fi
|