feat(install): add macOS native one-click install with launchd

- Release workflow builds macos-amd64/macos-arm64 tarballs on native runners
- install.sh detects Darwin, downloads macos-* assets, installs LaunchDaemon
- Dedicated _aether service account (dscl), env root:_aether 0640
- Launchd stdout/stderr in /var/log/aether (root-owned dir, service-writable files)
- Wrapper script parses env literally without shell expansion
- Auto-prune old releases (default keep 3)
- README documents macOS launchd commands
This commit is contained in:
Kayphoon
2026-05-12 15:36:29 +08:00
parent 09146f8cdd
commit 6d2fcf12cd
3 changed files with 556 additions and 66 deletions

View File

@@ -48,17 +48,35 @@ jobs:
build: build:
name: Build ${{ matrix.name }} name: Build ${{ matrix.name }}
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
include: include:
- name: linux-amd64 - name: linux-amd64
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
platform: linux
arch: amd64 arch: amd64
os: ubuntu-latest
use_cross: true
- name: linux-arm64 - name: linux-arm64
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
platform: linux
arch: arm64 arch: arm64
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
platform: macos
arch: amd64
os: macos-15-intel
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
platform: macos
arch: arm64
os: macos-15
use_cross: false
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
@@ -74,18 +92,25 @@ jobs:
workspaces: . -> target workspaces: . -> target
- name: Install cross - name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross uses: taiki-e/install-action@cross
- name: Build - name: Build
env: env:
AETHER_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} AETHER_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
run: cross build --release --locked -p aether-gateway --target ${{ matrix.target }} shell: bash
run: |
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
else
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
fi
- name: Upload binary artifact - name: Upload binary artifact
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
name: aether-gateway-${{ matrix.arch }} name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
path: target/${{ matrix.target }}/release/aether-gateway path: target/${{ matrix.target }}/release/aether-gateway
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -105,8 +130,8 @@ jobs:
- name: Prepare dist layout - name: Prepare dist layout
run: | run: |
mkdir -p dist mkdir -p dist
cp artifacts/aether-gateway-amd64/aether-gateway dist/aether-gateway-amd64 cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
cp artifacts/aether-gateway-arm64/aether-gateway dist/aether-gateway-arm64 cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64 chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
cp -r artifacts/frontend-dist dist/frontend cp -r artifacts/frontend-dist dist/frontend
@@ -177,24 +202,26 @@ jobs:
fi fi
mkdir -p package release-assets mkdir -p package release-assets
for arch in amd64 arm64; do for platform in linux macos; do
bundle="aether-${VERSION}-linux-${arch}" for arch in amd64 arm64; do
root="package/${bundle}" bundle="aether-${VERSION}-${platform}-${arch}"
mkdir -p \ root="package/${bundle}"
"${root}/bin" \ mkdir -p \
"${root}/frontend" "${root}/bin" \
"${root}/frontend"
install -m 0755 "artifacts/aether-gateway-${arch}/aether-gateway" "${root}/bin/aether-gateway" install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
cp -R artifacts/frontend-dist/. "${root}/frontend/" cp -R artifacts/frontend-dist/. "${root}/frontend/"
sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > "${root}/install.sh" sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > "${root}/install.sh"
chmod 0755 "${root}/install.sh" chmod 0755 "${root}/install.sh"
install -m 0644 docker-compose.yml "${root}/docker-compose.yml" install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
install -m 0644 .env.example "${root}/.env.example" install -m 0644 .env.example "${root}/.env.example"
install -m 0755 generate_keys.sh "${root}/generate_keys.sh" install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
install -m 0644 README.md "${root}/README.md" install -m 0644 README.md "${root}/README.md"
install -m 0644 LICENSE "${root}/LICENSE" install -m 0644 LICENSE "${root}/LICENSE"
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}" tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
done
done done
sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > release-assets/install.sh sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > release-assets/install.sh

View File

@@ -79,15 +79,15 @@ git pull
### 一键安装(可选部署方式) ### 一键安装(可选部署方式)
安装脚本先从 `aether-rust-pioneer` 分支下载,不依赖 GitHub Release 的 `latest` 脚本地址。运行后会先选择语言,再选择版本和部署方式。 安装脚本先从 `aether-rust-pioneer` 分支下载,不依赖 GitHub Release 的 `latest` 脚本地址。运行后会先选择语言,再选择版本和部署方式。Linux 单机 / 集群服务使用 systemdmacOS 单机 / 集群服务使用系统级 launchd脚本会按当前系统自动下载 `linux-*``macos-*` Release 压缩包。
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/aether-rust-pioneer/install.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/aether-rust-pioneer/install.sh | sudo bash
``` ```
运行后按提示输入语言、版本和部署方式。固定安装某个 tag 时,版本选择选 `2`,再输入类似 `v0.7.0-rc23` 的 tag。默认会安装最新预发布版本Docker Compose 模式默认使用 `pre` 镜像通道。 运行后按提示输入语言、版本和部署方式。固定安装某个 tag 时,版本选择选 `2`,再输入类似 `v0.7.0-rc23` 的 tag。默认会安装最新预发布版本Docker Compose 模式默认使用 `pre` 镜像通道。
systemd 二进制安装在下载 Release 压缩包前会询问是否使用下载加速源;选择使用时会先打印原始 GitHub URL再要求输入新的压缩包下载 URL。非交互式安装可用 `AETHER_RELEASE_ARCHIVE_URL` 指定压缩包 URL。 二进制安装在下载 Release 压缩包前会询问是否使用下载加速源;选择使用时会先打印原始 GitHub URL再要求输入新的压缩包下载 URL。非交互式安装可用 `AETHER_RELEASE_ARCHIVE_URL` 指定压缩包 URL。
如果安装目录里已经有配置脚本会优先复用Docker Compose 保留已有 `.env`systemd 保留已有 `/etc/aether/aether-gateway.env`。只有首次生成新配置时才会提示输入管理员密码。 如果安装目录里已经有配置脚本会优先复用Docker Compose 保留已有 `.env`二进制服务模式保留已有 `/etc/aether/aether-gateway.env`。只有首次生成新配置时才会提示输入管理员密码。
```text ```text
请选择安装语言 / Choose installer language: 请选择安装语言 / Choose installer language:
@@ -104,8 +104,8 @@ systemd 二进制安装在下载 Release 压缩包前会询问是否使用下载
请选择 Aether 部署模式: 请选择 Aether 部署模式:
1) Docker Compose: 应用 + Postgres + Redis 1) Docker Compose: 应用 + Postgres + Redis
2) 单机服务: systemd + SQLite + 进程内运行时 2) 单机服务: systemd/launchd + SQLite + 进程内运行时
3) 集群节点服务: systemd + 共享数据库 + Redis 3) 集群节点服务: systemd/launchd + 共享数据库 + Redis
请输入选项 [2]: 请输入选项 [2]:
@@ -116,7 +116,7 @@ systemd 二进制安装在下载 Release 压缩包前会询问是否使用下载
请输入选项 [1]: 请输入选项 [1]:
``` ```
安装后的常用命令: 安装后的常用命令Linux systemd
```bash ```bash
sudo systemctl status aether-gateway --no-pager sudo systemctl status aether-gateway --no-pager
@@ -124,7 +124,18 @@ sudo journalctl -u aether-gateway -f
sudo systemctl restart aether-gateway sudo systemctl restart aether-gateway
``` ```
默认单机数据和日志都在安装目录内 安装后的常用命令macOS launchd
```bash
sudo launchctl print system/com.aether.gateway
sudo launchctl kickstart -k system/com.aether.gateway
sudo launchctl bootout system /Library/LaunchDaemons/com.aether.gateway.plist
tail -f /var/log/aether/aether-gateway.out.log /var/log/aether/aether-gateway.err.log
```
macOS 原生安装使用系统级 LaunchDaemon默认以专用 `_aether` 服务账号运行;配置和密钥写入 `/etc/aether/aether-gateway.env`,数据和应用日志仍在 `/opt/aether`launchd stdout/stderr 在 `/var/log/aether`
默认单机数据和应用日志都在安装目录内:
```text ```text
/opt/aether/data/aether.db /opt/aether/data/aether.db

View File

@@ -15,18 +15,31 @@ CONFIG_DIR="${CONFIG_DIR:-/etc/aether}"
COMPOSE_DIR="${AETHER_COMPOSE_DIR:-${INSTALL_ROOT}/compose}" COMPOSE_DIR="${AETHER_COMPOSE_DIR:-${INSTALL_ROOT}/compose}"
IMAGE_REPO="${AETHER_IMAGE_REPO:-ghcr.io/fawney19/aether}" IMAGE_REPO="${AETHER_IMAGE_REPO:-ghcr.io/fawney19/aether}"
APP_IMAGE="${AETHER_APP_IMAGE:-}" 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_USER="${SERVICE_USER:-aether}"
SERVICE_GROUP="${SERVICE_GROUP:-aether}" SERVICE_GROUP="${SERVICE_GROUP:-aether}"
SERVICE_NAME="aether-gateway" SERVICE_NAME="aether-gateway"
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" ENV_TARGET="${CONFIG_DIR}/aether-gateway.env"
SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service" SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
LAUNCHD_PLIST_PATH="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist"
TMP_ROOT="" TMP_ROOT=""
ARCHIVE_PATH="" ARCHIVE_PATH=""
BUNDLE_DIR=""
ENV_SOURCE="" ENV_SOURCE=""
SKIP_START="false" SKIP_START="false"
GENERATED_ENV="" GENERATED_ENV=""
ADMIN_PASSWORD_SOURCE="" ADMIN_PASSWORD_SOURCE=""
UI_LANG="${AETHER_LANG:-${AETHER_LANGUAGE:-auto}}" UI_LANG="${AETHER_LANG:-${AETHER_LANGUAGE:-auto}}"
RELEASE_KEEP="${AETHER_RELEASE_KEEP:-3}"
RELEASE_ARCHIVE_URL="${AETHER_RELEASE_ARCHIVE_URL:-${AETHER_DOWNLOAD_URL:-}}" RELEASE_ARCHIVE_URL="${AETHER_RELEASE_ARCHIVE_URL:-${AETHER_DOWNLOAD_URL:-}}"
usage() { usage() {
@@ -38,8 +51,9 @@ Install Aether Gateway.
Options: Options:
--mode MODE Deployment mode: compose, single, or cluster --mode MODE Deployment mode: compose, single, or cluster
compose: Docker Compose app + Postgres + Redis compose: Docker Compose app + Postgres + Redis
single: systemd service with SQLite + in-process runtime single: system service with SQLite + in-process runtime
cluster: systemd service connected to shared database + Redis cluster: system service connected to shared database + Redis
Linux services use systemd; macOS services use launchd
--channel CHANNEL Release channel to resolve when --version is omitted: pre or rc --channel CHANNEL Release channel to resolve when --version is omitted: pre or rc
pre resolves the latest semver prerelease tag (default) pre resolves the latest semver prerelease tag (default)
rc restricts resolution to tags like v0.7.0-rc23 rc restricts resolution to tags like v0.7.0-rc23
@@ -54,12 +68,14 @@ Options:
--config-dir PATH Config directory (default: /etc/aether) --config-dir PATH Config directory (default: /etc/aether)
--lang LANG Installer language: zh or en --lang LANG Installer language: zh or en
--skip-start Install files and unit, but do not restart the service --skip-start Install files and unit, but do not restart the service
--keep-releases N Keep the latest N releases, prune older ones (default: 3, 0=disable)
-h, --help Show this help -h, --help Show this help
Environment overrides: Environment overrides:
AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION
AETHER_LANG or AETHER_LANGUAGE AETHER_LANG or AETHER_LANGUAGE
AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL
AETHER_LAUNCHD_LABEL, AETHER_LAUNCHD_LOG_DIR, AETHER_RELEASE_KEEP
AETHER_IMAGE_REPO, AETHER_APP_IMAGE AETHER_IMAGE_REPO, AETHER_APP_IMAGE
INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP
ADMIN_PASSWORD (required for non-interactive first install when generating a new env) ADMIN_PASSWORD (required for non-interactive first install when generating a new env)
@@ -236,6 +252,11 @@ parse_args() {
SKIP_START="true" SKIP_START="true"
shift shift
;; ;;
--keep-releases)
[[ $# -ge 2 ]] || die "--keep-releases requires a number"
RELEASE_KEEP="$2"
shift 2
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -247,16 +268,43 @@ parse_args() {
done done
} }
require_linux() { install_os() {
if [[ "$(uname -s)" != "Linux" ]]; then case "$(uname -s)" in
if ui_is_zh; then Linux)
die "Aether 二进制安装仅支持 Linux" echo "linux"
else ;;
die "Aether binary install is only supported on 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
fi fi
} }
require_supported_os() {
install_os >/dev/null
}
require_root() { require_root() {
if [[ "${EUID}" -ne 0 ]]; then if [[ "${EUID}" -ne 0 ]]; then
if ui_is_zh; then if ui_is_zh; then
@@ -277,6 +325,38 @@ require_systemd() {
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() { select_version() {
if [[ -n "${VERSION}" || -n "${ARCHIVE_PATH}" || "${CHANNEL_EXPLICIT}" == "true" ]]; then if [[ -n "${VERSION}" || -n "${ARCHIVE_PATH}" || "${CHANNEL_EXPLICIT}" == "true" ]]; then
return return
@@ -344,7 +424,7 @@ select_mode() {
MODE="compose" MODE="compose"
return return
;; ;;
single|service|systemd|sqlite) single|service|systemd|launchd|sqlite)
MODE="single" MODE="single"
return return
;; ;;
@@ -360,23 +440,25 @@ select_mode() {
esac esac
if interactive_tty_available; then if interactive_tty_available; then
local service_manager
service_manager="$(service_manager_name)"
if ui_is_zh; then if ui_is_zh; then
cat >/dev/tty <<'EOF' cat >/dev/tty <<EOF
请选择 Aether 部署模式: 请选择 Aether 部署模式:
1) Docker Compose: 应用 + Postgres + Redis 1) Docker Compose: 应用 + Postgres + Redis
2) 单机服务: systemd + SQLite + 进程内运行时 2) 单机服务: ${service_manager} + SQLite + 进程内运行时
3) 集群节点服务: systemd + 共享数据库 + Redis 3) 集群节点服务: ${service_manager} + 共享数据库 + Redis
请输入选项 [2]: 请输入选项 [2]:
EOF EOF
else else
cat >/dev/tty <<'EOF' cat >/dev/tty <<EOF
Choose Aether deployment mode: Choose Aether deployment mode:
1) Docker Compose: app + Postgres + Redis 1) Docker Compose: app + Postgres + Redis
2) Single-node service: systemd + SQLite + in-process runtime 2) Single-node service: ${service_manager} + SQLite + in-process runtime
3) Cluster node service: systemd + shared database + Redis 3) Cluster node service: ${service_manager} + shared database + Redis
Enter choice [2]: Enter choice [2]:
EOF EOF
@@ -661,20 +743,18 @@ download_or_unpack_bundle() {
TMP_ROOT="$(mktemp -d)" TMP_ROOT="$(mktemp -d)"
if [[ -n "${ARCHIVE_PATH}" ]]; then if [[ -n "${ARCHIVE_PATH}" ]]; then
[[ -f "${ARCHIVE_PATH}" ]] || die "archive not found: ${ARCHIVE_PATH}" [[ -f "${ARCHIVE_PATH}" ]] || die "archive not found: ${ARCHIVE_PATH}"
if [[ -z "${VERSION}" ]]; then
VERSION="$(basename "${ARCHIVE_PATH}" .tar.gz)"
fi
info "using local archive ${ARCHIVE_PATH}" info "using local archive ${ARCHIVE_PATH}"
tar -xzf "${ARCHIVE_PATH}" -C "${TMP_ROOT}" tar -xzf "${ARCHIVE_PATH}" -C "${TMP_ROOT}"
else else
local arch local os arch
os="$(install_os)"
arch="$(detect_arch)" arch="$(detect_arch)"
local tag asset base_url archive_url archive_file local tag asset base_url archive_url archive_file
tag="$(resolve_version)" tag="$(resolve_version)"
[[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}" [[ -n "${tag}" ]] || die "could not resolve ${CHANNEL} release tag for ${REPO}"
VERSION="${tag}" VERSION="${tag}"
asset="aether-${tag}-linux-${arch}.tar.gz" asset="aether-${tag}-${os}-${arch}.tar.gz"
base_url="https://github.com/${REPO}/releases/download/${tag}" base_url="https://github.com/${REPO}/releases/download/${tag}"
archive_url="${base_url}/${asset}" archive_url="${base_url}/${asset}"
archive_file="${TMP_ROOT}/${asset}" archive_file="${TMP_ROOT}/${asset}"
@@ -696,7 +776,10 @@ download_or_unpack_bundle() {
[[ -n "${bundle}" ]] || die "release archive did not contain a bundle directory" [[ -n "${bundle}" ]] || die "release archive did not contain a bundle directory"
[[ -x "${bundle}/bin/aether-gateway" ]] || die "bundle is missing bin/aether-gateway" [[ -x "${bundle}/bin/aether-gateway" ]] || die "bundle is missing bin/aether-gateway"
[[ -d "${bundle}/frontend" ]] || die "bundle is missing frontend" [[ -d "${bundle}/frontend" ]] || die "bundle is missing frontend"
echo "${bundle}" if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")"
fi
BUNDLE_DIR="${bundle}"
} }
urlsafe_rand() { urlsafe_rand() {
@@ -710,7 +793,17 @@ urlsafe_rand() {
write_generate_keys_script() { write_generate_keys_script() {
local output="$1" local output="$1"
install -d -m 0755 "$(dirname "${output}")" 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' cat > "${output}" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -794,9 +887,10 @@ derive_local_bundle_version() {
local name local name
name="$(basename "${bundle}")" name="$(basename "${bundle}")"
case "${name}" in case "${name}" in
aether-*-linux-*) aether-*-linux-*|aether-*-macos-*)
name="${name#aether-}" name="${name#aether-}"
name="${name%-linux-*}" name="${name%-linux-*}"
name="${name%-macos-*}"
;; ;;
esac esac
if [[ -z "${name}" || "${name}" == "." || "${name}" == "/" ]]; then if [[ -z "${name}" || "${name}" == "." || "${name}" == "/" ]]; then
@@ -930,8 +1024,32 @@ generate_compose_env() {
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true" replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
} }
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_systemd_support_files() {
install -d -m 0750 "${CONFIG_DIR}" install_config_dir
write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh" write_generate_keys_script "${CONFIG_DIR}/generate_keys.sh"
} }
@@ -962,6 +1080,66 @@ ensure_service_account() {
fi 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() { env_file_value() {
local file="$1" local file="$1"
local key="$2" local key="$2"
@@ -1157,7 +1335,7 @@ validate_env_file() {
fi fi
} }
resolve_systemd_env_source() { resolve_service_env_source() {
local mode="$1" local mode="$1"
if [[ -n "${ENV_SOURCE}" ]]; then if [[ -n "${ENV_SOURCE}" ]]; then
[[ -f "${ENV_SOURCE}" ]] || die "env file not found: ${ENV_SOURCE}" [[ -f "${ENV_SOURCE}" ]] || die "env file not found: ${ENV_SOURCE}"
@@ -1181,8 +1359,8 @@ resolve_systemd_env_source() {
info "generating multi-node env file" info "generating multi-node env file"
generate_cluster_env "${GENERATED_ENV}" generate_cluster_env "${GENERATED_ENV}"
if ! cluster_env_has_required_backends "${GENERATED_ENV}"; then if ! cluster_env_has_required_backends "${GENERATED_ENV}"; then
install -d -m 0750 "${CONFIG_DIR}" install_config_dir
install -m 0600 "${GENERATED_ENV}" "${ENV_TARGET}" install_env_target_from "${GENERATED_ENV}"
cat <<EOF cat <<EOF
Multi-node env scaffolded: Multi-node env scaffolded:
@@ -1242,11 +1420,13 @@ EOF
install_env_file() { install_env_file() {
local env_file="$1" local env_file="$1"
install -d -m 0750 "${CONFIG_DIR}" install_config_dir
if [[ -n "${env_file}" ]]; then if [[ -n "${env_file}" ]]; then
info "installing env file to ${ENV_TARGET}" info "installing env file to ${ENV_TARGET}"
install -m 0600 "${env_file}" "${ENV_TARGET}" install_env_target_from "${env_file}"
else
ensure_env_target_permissions
fi fi
} }
@@ -1260,18 +1440,77 @@ install_release() {
info "installing release ${VERSION} into ${release_dir}" info "installing release ${VERSION} into ${release_dir}"
install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases" "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs" install -d -m 0755 "${INSTALL_ROOT}" "${INSTALL_ROOT}/releases" "${INSTALL_ROOT}/data" "${INSTALL_ROOT}/logs"
install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \ if is_darwin; then
"${INSTALL_ROOT}/data" \ install -d -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" -m 0750 \
"${INSTALL_ROOT}/logs" "${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}" rm -rf "${release_dir}"
install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend" install -d -m 0755 "${release_dir}/bin" "${release_dir}/frontend"
install -m 0755 "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway" install -m 0755 "${bundle}/bin/aether-gateway" "${release_dir}/bin/aether-gateway"
cp -R "${bundle}/frontend/." "${release_dir}/frontend/" cp -R "${bundle}/frontend/." "${release_dir}/frontend/"
chmod -R u=rwX,go=rX "${release_dir}" chmod -R u=rwX,go=rX "${release_dir}"
chown -R root:root "${release_dir}" if is_darwin; then
chown -R root:wheel "${release_dir}"
else
chown -R root:root "${release_dir}"
fi
ln -sfn "${release_dir}" "${current_link}" 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() { render_systemd_unit() {
cat <<EOF cat <<EOF
[Unit] [Unit]
@@ -1368,6 +1607,190 @@ Current release:
EOF 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:
empty database: first service start auto-bootstraps to the current baseline
later schema upgrades: ${INSTALL_ROOT}/current/bin/aether-gateway --migrate
Current release:
${INSTALL_ROOT}/current
EOF
}
install_systemd_mode() { install_systemd_mode() {
local bundle="$1" local bundle="$1"
local env_file="$2" local env_file="$2"
@@ -1377,17 +1800,35 @@ install_systemd_mode() {
install_env_file "${env_file}" install_env_file "${env_file}"
validate_env_file "${ENV_TARGET}" validate_env_file "${ENV_TARGET}"
install_release "${bundle}" install_release "${bundle}"
prune_old_releases
install_systemd_unit install_systemd_unit
restart_service_if_requested restart_service_if_requested
print_systemd_next_steps 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() { main() {
local bundle env_file local bundle env_file
parse_args "$@" parse_args "$@"
select_language select_language
require_linux require_supported_os
apply_platform_defaults
select_version select_version
select_mode select_mode
@@ -1395,10 +1836,11 @@ main() {
install_compose_mode install_compose_mode
else else
require_root require_root
require_systemd require_service_manager
bundle="$(local_bundle_dir || true)" bundle="$(local_bundle_dir || true)"
if [[ -z "${bundle}" ]]; then if [[ -z "${bundle}" ]]; then
bundle="$(download_or_unpack_bundle)" download_or_unpack_bundle
bundle="${BUNDLE_DIR}"
else else
if [[ -z "${VERSION}" ]]; then if [[ -z "${VERSION}" ]]; then
VERSION="$(derive_local_bundle_version "${bundle}")" VERSION="$(derive_local_bundle_version "${bundle}")"
@@ -1406,8 +1848,18 @@ main() {
info "installing from local extracted bundle ${bundle}" info "installing from local extracted bundle ${bundle}"
fi fi
env_file="$(resolve_systemd_env_source "${MODE}")" if is_darwin; then
install_systemd_mode "${bundle}" "${env_file}" 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 fi
if [[ -n "${ADMIN_PASSWORD_SOURCE}" ]]; then if [[ -n "${ADMIN_PASSWORD_SOURCE}" ]]; then