294 lines
11 KiB
Bash
Executable File
294 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
|
||
DOWNLOAD_BASE='https://raw.githubusercontent.com/rest-rain/simkeep/main'
|
||
staging=''
|
||
docker_staging=''
|
||
root_command=()
|
||
docker_command=(docker)
|
||
root_ready=0
|
||
|
||
fail() {
|
||
printf '错误:%s\n' "$*" >&2
|
||
exit 1
|
||
}
|
||
|
||
cleanup() {
|
||
local directory
|
||
for directory in "$staging" "$docker_staging"; do
|
||
if [[ -n "$directory" ]]; then
|
||
rm -rf -- "$directory"
|
||
fi
|
||
done
|
||
}
|
||
|
||
usage() {
|
||
cat <<'USAGE'
|
||
用法:bash install.sh [选项]
|
||
|
||
--dir 目录 部署目录,默认当前目录下的 simkeep
|
||
--port 端口 首次部署的端口,默认 5180
|
||
--url HTTP地址 首次部署的访问地址,默认 http://localhost:端口
|
||
--non-interactive 使用参数或默认值,不询问配置
|
||
--help 显示帮助
|
||
|
||
需要 curl;Linux 上缺少 Docker / Compose 时会自动安装,需要 root 或 sudo 权限。
|
||
已有 .env 和 docker-compose.yml 会保留;端口、地址参数仅用于首次部署。
|
||
再次执行会按已有配置拉取镜像并更新容器,数据保存在原命名卷中。
|
||
USAGE
|
||
}
|
||
|
||
normalize_port() {
|
||
[[ "$1" =~ ^[0-9]{1,5}$ ]] || fail '端口必须是 1 到 65535 的整数。'
|
||
local value
|
||
value=$((10#$1))
|
||
((value >= 1 && value <= 65535)) || fail '端口必须是 1 到 65535 的整数。'
|
||
printf '%s' "$value"
|
||
}
|
||
|
||
validate_url() {
|
||
local pattern='^http://(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9][A-Za-z0-9._-]*)(:([0-9]{1,5}))?/?$'
|
||
[[ "$1" =~ $pattern ]] || fail '访问地址请使用 http://服务器IP:端口 或 http://域名:端口。'
|
||
if [[ -n "${BASH_REMATCH[3]}" ]]; then
|
||
normalize_port "${BASH_REMATCH[3]}" >/dev/null
|
||
fi
|
||
}
|
||
|
||
prompt() {
|
||
local answer
|
||
printf '%s [%s]:' "$1" "$2" >&3
|
||
IFS= read -r answer <&3 || fail '未能读取配置,请使用 --non-interactive 和相应参数。'
|
||
printf '%s' "${answer:-$2}"
|
||
}
|
||
|
||
download() {
|
||
curl -fsSL --connect-timeout 15 --max-time 60 --retry 2 \
|
||
-o "$staging/$1" "$DOWNLOAD_BASE/$1" || fail "下载 $1 失败,请检查 GitHub 网络后重试。"
|
||
[[ -s "$staging/$1" ]] || fail "下载的 $1 为空,请重试。"
|
||
}
|
||
|
||
require_root() {
|
||
if ((root_ready)); then
|
||
return
|
||
fi
|
||
if [[ "$(id -u)" != '0' ]]; then
|
||
command -v sudo >/dev/null 2>&1 || fail '自动安装或启动 Docker 需要 root 权限,请使用 root 或 sudo 运行。'
|
||
if (($1)); then
|
||
sudo -v || fail '无法取得 root 权限,请使用 root 或可用的 sudo 运行。'
|
||
root_command=(sudo)
|
||
else
|
||
sudo -n true || fail '无人值守安装需要 root 或免密码 sudo 权限。'
|
||
root_command=(sudo -n)
|
||
fi
|
||
fi
|
||
root_ready=1
|
||
}
|
||
|
||
require_linux() {
|
||
[[ "$(uname -s)" == 'Linux' ]] || fail '自动安装 Docker / Compose 仅支持 Linux;其他系统请先安装 Docker Desktop。'
|
||
case "$(uname -m)" in
|
||
x86_64|amd64|aarch64|arm64) ;;
|
||
*) fail '自动安装目前支持 amd64 / arm64。' ;;
|
||
esac
|
||
}
|
||
|
||
prepare_docker_downloads() {
|
||
if [[ -z "$docker_staging" ]]; then
|
||
docker_staging=$(
|
||
umask 077
|
||
mktemp -d "${TMPDIR:-/tmp}/simkeep-docker.XXXXXX"
|
||
)
|
||
trap cleanup EXIT
|
||
fi
|
||
}
|
||
|
||
install_engine() {
|
||
local distro
|
||
require_linux
|
||
[[ -r /etc/os-release ]] || fail '无法识别 Linux 发行版,请手动安装 Docker。'
|
||
distro=$(awk -F= '$1 == "ID" { gsub(/"/, "", $2); print $2 }' /etc/os-release)
|
||
case "$distro" in
|
||
ubuntu|debian|centos|rocky|rhel|fedora) ;;
|
||
*) fail "当前发行版 ${distro:-未知} 不支持自动安装,请手动安装 Docker 后重试。" ;;
|
||
esac
|
||
require_root "$1"
|
||
prepare_docker_downloads
|
||
printf '未检测到 Docker,正在使用 Docker 官方脚本安装。\n'
|
||
curl -fsSL --connect-timeout 15 --max-time 60 --retry 2 \
|
||
-o "$docker_staging/docker-install.sh" https://get.docker.com || fail 'Docker 官方安装脚本下载失败,请检查网络。'
|
||
[[ -s "$docker_staging/docker-install.sh" ]] || fail 'Docker 官方安装脚本为空。'
|
||
"${root_command[@]}" sh "$docker_staging/docker-install.sh" || fail 'Docker 自动安装失败,请检查安装输出后重试。'
|
||
hash -r
|
||
command -v docker >/dev/null 2>&1 || fail '安装完成后仍找不到 Docker 命令。'
|
||
}
|
||
|
||
install_compose() {
|
||
local architecture filename expected actual directory='/usr/local/lib/docker/cli-plugins'
|
||
require_linux
|
||
command -v sha256sum >/dev/null 2>&1 || fail '安装 Compose 需要 sha256sum,请先安装 coreutils。'
|
||
require_root "$1"
|
||
prepare_docker_downloads
|
||
case "$(uname -m)" in
|
||
x86_64|amd64) architecture=x86_64 ;;
|
||
aarch64|arm64) architecture=aarch64 ;;
|
||
esac
|
||
filename="docker-compose-linux-$architecture"
|
||
printf '未检测到可用的 Compose 插件,正在下载官方版本。\n'
|
||
curl -fsSL --connect-timeout 15 --max-time 180 --retry 2 \
|
||
-o "$docker_staging/$filename" "https://github.com/docker/compose/releases/latest/download/$filename" || fail 'Compose 下载失败。'
|
||
curl -fsSL --connect-timeout 15 --max-time 60 --retry 2 \
|
||
-o "$docker_staging/$filename.sha256" "https://github.com/docker/compose/releases/latest/download/$filename.sha256" || fail 'Compose 校验文件下载失败。'
|
||
expected=$(awk 'NR == 1 { print $1 }' "$docker_staging/$filename.sha256")
|
||
actual=$(sha256sum "$docker_staging/$filename")
|
||
actual=${actual%% *}
|
||
[[ "$expected" =~ ^[[:xdigit:]]{64}$ && "$actual" == "$expected" ]] || fail 'Compose 文件校验失败,已停止安装,请重试。'
|
||
"${root_command[@]}" install -d -m 0755 "$directory"
|
||
"${root_command[@]}" install -m 0755 "$docker_staging/$filename" "$directory/docker-compose"
|
||
}
|
||
|
||
ensure_docker() {
|
||
local context _attempt
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
[[ -z "${DOCKER_HOST:-}" && -z "${DOCKER_CONTEXT:-}" ]] || fail '已设置 DOCKER_HOST / DOCKER_CONTEXT,请先安装并配置所选 Docker 客户端。'
|
||
install_engine "$1"
|
||
fi
|
||
if ! docker info >/dev/null 2>&1; then
|
||
[[ -z "${DOCKER_HOST:-}" ]] || fail '无法连接 DOCKER_HOST 指定的 Docker,请检查连接配置。'
|
||
context=$(docker context show 2>/dev/null) || fail '无法读取 Docker context,请检查 Docker 客户端。'
|
||
[[ "$context" == 'default' ]] || fail "无法连接 Docker context $context,请先启动或修复该环境。"
|
||
require_linux
|
||
require_root "$1"
|
||
docker_command=("${root_command[@]}" docker)
|
||
if ! "${docker_command[@]}" info >/dev/null 2>&1; then
|
||
printf '正在启动 Docker 服务。\n'
|
||
if command -v systemctl >/dev/null 2>&1 && "${root_command[@]}" systemctl enable --now docker; then
|
||
:
|
||
elif command -v service >/dev/null 2>&1 && "${root_command[@]}" service docker start; then
|
||
:
|
||
else
|
||
fail '无法启动 Docker 服务,请检查 Docker 安装与系统服务管理器。'
|
||
fi
|
||
for _attempt in {1..20}; do
|
||
if "${docker_command[@]}" info >/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
"${docker_command[@]}" info >/dev/null 2>&1 || fail 'Docker 服务未就绪,请检查 Docker 服务日志。'
|
||
fi
|
||
fi
|
||
if ! "${docker_command[@]}" compose version >/dev/null 2>&1; then
|
||
install_compose "$1"
|
||
"${docker_command[@]}" compose version >/dev/null 2>&1 || fail 'Compose 安装后仍不可用,请检查 Docker 客户端或已有插件。'
|
||
fi
|
||
}
|
||
|
||
main() {
|
||
local install_dir="$PWD/simkeep" port='' public_url='' interactive=1 has_terminal=0
|
||
local new_env=1 new_compose=1
|
||
while (($#)); do
|
||
case "$1" in
|
||
--dir|--port|--url)
|
||
[[ $# -ge 2 && -n "$2" ]] || fail "$1 缺少参数。"
|
||
case "$1" in
|
||
--dir) install_dir=$2 ;;
|
||
--port) port=$2 ;;
|
||
--url) public_url=$2 ;;
|
||
esac
|
||
shift 2
|
||
;;
|
||
--non-interactive) interactive=0; shift ;;
|
||
--help|-h) usage; return ;;
|
||
*) fail "未知选项:$1。使用 --help 查看帮助。" ;;
|
||
esac
|
||
done
|
||
|
||
if [[ -n "$port" ]]; then
|
||
port=$(normalize_port "$port")
|
||
fi
|
||
if [[ -n "$public_url" ]]; then
|
||
validate_url "$public_url"
|
||
public_url=${public_url%/}
|
||
fi
|
||
for dependency in curl awk; do
|
||
command -v "$dependency" >/dev/null 2>&1 || fail "缺少 $dependency,请先安装。"
|
||
done
|
||
|
||
[[ ! -e "$install_dir" || -d "$install_dir" ]] || fail '部署目录已经存在,但不是文件夹。'
|
||
for configuration in .env docker-compose.yml; do
|
||
if [[ -e "$install_dir/$configuration" || -L "$install_dir/$configuration" ]]; then
|
||
[[ -f "$install_dir/$configuration" ]] || fail "$configuration 已经存在,但不是可用文件。"
|
||
fi
|
||
done
|
||
[[ ! -f "$install_dir/.env" ]] || new_env=0
|
||
[[ ! -f "$install_dir/docker-compose.yml" ]] || new_compose=0
|
||
|
||
if ((new_env)); then
|
||
if ((interactive)) && { exec 3<>/dev/tty; } 2>/dev/null; then
|
||
has_terminal=1
|
||
fi
|
||
if [[ -z "$port" ]]; then
|
||
if ((has_terminal)); then
|
||
port=$(prompt '宿主机端口' '5180')
|
||
else
|
||
port=5180
|
||
fi
|
||
port=$(normalize_port "$port")
|
||
fi
|
||
if [[ -z "$public_url" ]]; then
|
||
if ((has_terminal)); then
|
||
public_url=$(prompt 'HTTP 访问地址(远程部署时填写服务器 IP 或域名)' "http://localhost:$port")
|
||
else
|
||
public_url="http://localhost:$port"
|
||
fi
|
||
validate_url "$public_url"
|
||
public_url=${public_url%/}
|
||
fi
|
||
else
|
||
printf '保留已有 .env,按其中的配置部署;如需调整端口或地址,请编辑该文件。\n'
|
||
fi
|
||
|
||
ensure_docker "$interactive"
|
||
umask 077
|
||
mkdir -p -- "$install_dir"
|
||
install_dir=$(cd -- "$install_dir" && pwd -P)
|
||
staging=$(mktemp -d "$install_dir/.simkeep-install.XXXXXX")
|
||
trap cleanup EXIT
|
||
if ((new_compose)); then
|
||
download docker-compose.yml
|
||
fi
|
||
if ((new_env)); then
|
||
download .env.example
|
||
awk -v port="$port" -v url="$public_url" '
|
||
/^SIMKEEP_PORT=/ { print "SIMKEEP_PORT=" port; next }
|
||
/^SIMKEEP_PUBLIC_URL=/ { print "SIMKEEP_PUBLIC_URL=" url; next }
|
||
{ print }
|
||
' "$staging/.env.example" > "$staging/.env"
|
||
fi
|
||
if ((new_compose)); then
|
||
mv -- "$staging/docker-compose.yml" "$install_dir/docker-compose.yml"
|
||
fi
|
||
if ((new_env)); then
|
||
if [[ ! -e "$install_dir/.env.example" && ! -L "$install_dir/.env.example" ]]; then
|
||
mv -- "$staging/.env.example" "$install_dir/.env.example"
|
||
fi
|
||
mv -- "$staging/.env" "$install_dir/.env"
|
||
fi
|
||
chmod 600 -- "$install_dir/.env"
|
||
|
||
local compose=("${docker_command[@]}" compose --project-directory "$install_dir" --env-file "$install_dir/.env" -f "$install_dir/docker-compose.yml")
|
||
"${compose[@]}" config --quiet || fail 'Compose 配置验证失败,请检查部署目录中的配置文件。'
|
||
"${compose[@]}" pull web || fail '镜像拉取失败,请检查网络和 .env 中的 SIMKEEP_IMAGE 后重试。'
|
||
"${compose[@]}" up -d --wait --wait-timeout 120 web || fail '容器未通过健康检查,请在部署目录执行 docker compose logs --tail=100 web。'
|
||
|
||
printf '\n部署完成。\n配置目录:%s\n' "$install_dir"
|
||
if ((new_env)); then
|
||
printf '访问地址:%s\n' "$public_url"
|
||
else
|
||
printf '访问地址以 .env 中的 SIMKEEP_PUBLIC_URL 为准。\n'
|
||
fi
|
||
printf '首次使用请在网页创建账号,再到通知设置配置 Telegram / 邮件。\n'
|
||
}
|
||
|
||
main "$@"
|