Files

294 lines
11 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -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 "$@"