deploy: automatically install Docker and Compose when missing

This commit is contained in:
SIMKEEP committed 2026-10-06 22:31:35 +08:00
1 parent 7f50d512c7
commit b0d40403b3
4 files changed
+259 -17

No files matched your search

+4 -2
View File
@@ -62,13 +62,15 @@ SIMKEEP 是一个可自行部署的 SIM / eSIM 管理平台。把开通日期、
### 一键部署
服务器已安装 Docker、Compose 插件和 curl 后,执行:
Linux 服务器准备好 curl 后,使用 root 或有 sudo 权限的用户执行:
```bash
bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/rest-rain/simkeep/main/install.sh | bash'
```
脚本默认部署到当前目录下的 `simkeep` 文件夹,首次运行询问端口和 HTTP 访问地址,下载配置并启动镜像。打开显示的地址后创建账号。
脚本会自动安装缺少的 Docker Engine / Compose 插件并启动 Docker 服务,已有可用环境则直接复用。支持 Docker 官方仍支持的 Ubuntu、Debian、CentOS Stream、Rocky Linux、RHEL 和 Fedora,架构为 `amd64` / `arm64`。
默认部署到当前目录下的 `simkeep` 文件夹,首次运行询问端口和 HTTP 访问地址,下载配置并启动镜像。打开显示的地址后创建账号。
再次从同一目录执行相同命令会保留已有 `.env`、`docker-compose.yml` 和数据卷,拉取镜像并更新容器。指定目录、无人值守部署等用法见 [一键部署说明](docs/DOCKER.md#一键部署)。
+8 -4
View File
@@ -4,13 +4,17 @@
## 一键部署
准备好 Docker Engine、Compose 插件及 curl,在服务器执行:
Linux 服务器准备好 curl 后,使用 root 或有 sudo 权限的用户执行:
```bash
bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/rest-rain/simkeep/main/install.sh | bash'
```
首次运行会询问宿主机端口和 HTTP 访问地址。远程部署时填写例如 `http://服务器IP:5180` 的地址;本机试用可直接回车。默认部署目录为当前目录下的 `simkeep`,镜像为 `ghcr.io/rest-rain/simkeep:latest`。Docker 需要已启动,当前用户需能执行 `docker info`。
首次运行会询问宿主机端口和 HTTP 访问地址。远程部署时填写例如 `http://服务器IP:5180` 的地址;本机试用可直接回车。默认部署目录为当前目录下的 `simkeep`,镜像为 `ghcr.io/rest-rain/simkeep:latest`。
Docker Engine 缺失时,脚本从 `https://get.docker.com` 下载官方安装脚本并执行;只有 Compose 缺失时,从 Docker 官方 GitHub 发布页下载插件,校验 SHA256 后安装。Docker 服务未启动时,使用 systemd 或 service 启动;已有可用环境直接复用。
自动安装支持 Docker 官方仍支持的 Ubuntu、Debian、CentOS Stream、Rocky Linux、RHEL 和 Fedora,支持 `amd64` / `arm64`。安装组件、启动服务或访问受限的 Docker socket 时,需要 root 或 sudo 权限。非 root 用户按提示完成 sudo 验证后,脚本会通过 sudo 调用需要权限的命令。
脚本下载缺少的配置文件,创建权限为 `600` 的 `.env`,验证 Compose 配置、拉取镜像并等待健康检查。成功后显示配置目录和访问提示;登录网页后,每个人在“通知设置”配置自己的 Telegram / SMTP。
@@ -22,11 +26,11 @@ bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/rest-rain/simk
bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/rest-rain/simkeep/main/install.sh | bash -s -- --dir ./simkeep --port 5180 --url http://your-server:5180 --non-interactive'
```
`--port`、`--url` 仅用于首次创建 `.env`;已有配置时按文件内容部署。重复执行时使用同一个 `--dir`。没有交互终端且未提供参数时,默认使用端口 `5180` 和 `http://localhost:5180`;远程部署请填写实际地址。
`--port`、`--url` 仅用于首次创建 `.env`;已有配置时按文件内容部署。重复执行时使用同一个 `--dir`。无人值守安装需要 root 或免密码 sudo。没有交互终端且未提供参数时,默认使用端口 `5180` 和 `http://localhost:5180`;远程部署请填写实际地址。
## 在新服务器部署
部署只需要 `docker-compose.yml` 和 `.env`。可下载配置文件及空白模板:
手动部署需先安装 Docker Engine 和 Compose 插件。应用只需要 `docker-compose.yml` 和 `.env`,可下载配置文件及空白模板:
```bash
mkdir -p simkeep
+129 -7
View File
@@ -3,6 +3,10 @@ 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
@@ -10,9 +14,12 @@ fail() {
}
cleanup() {
if [[ -n "$staging" ]]; then
rm -rf -- "$staging"
local directory
for directory in "$staging" "$docker_staging"; do
if [[ -n "$directory" ]]; then
rm -rf -- "$directory"
fi
done
}
usage() {
@@ -25,7 +32,7 @@ usage() {
--non-interactive 使用参数或默认值,不询问配置
--help 显示帮助
需要已安装且可用的 Docker Engine、Docker Compose 插件和 curl。
需要 curl;Linux 上缺少 Docker / Compose 时会自动安装,需要 root 或 sudo 权限。
已有 .env 和 docker-compose.yml 会保留;端口、地址参数仅用于首次部署。
再次执行会按已有配置拉取镜像并更新容器,数据保存在原命名卷中。
USAGE
@@ -60,6 +67,122 @@ download() {
[[ -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
@@ -87,11 +210,9 @@ main() {
validate_url "$public_url"
public_url=${public_url%/}
fi
for dependency in docker curl awk; do
for dependency in curl awk; do
command -v "$dependency" >/dev/null 2>&1 || fail "缺少 $dependency,请先安装。"
done
docker info >/dev/null 2>&1 || fail 'Docker 未启动或当前用户没有权限,请先确认 docker info 能正常执行。'
docker compose version >/dev/null 2>&1 || fail '请先安装 Docker Compose 插件,确认 docker compose version 能正常执行。'
[[ ! -e "$install_dir" || -d "$install_dir" ]] || fail '部署目录已经存在,但不是文件夹。'
for configuration in .env docker-compose.yml; do
@@ -127,6 +248,7 @@ main() {
printf '保留已有 .env,按其中的配置部署;如需调整端口或地址,请编辑该文件。\n'
fi
ensure_docker "$interactive"
umask 077
mkdir -p -- "$install_dir"
install_dir=$(cd -- "$install_dir" && pwd -P)
@@ -154,7 +276,7 @@ main() {
fi
chmod 600 -- "$install_dir/.env"
local compose=(docker compose --project-directory "$install_dir" --env-file "$install_dir/.env" -f "$install_dir/docker-compose.yml")
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。'
+118 -4
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import pty
import select
import shlex
import shutil
import signal
import subprocess
import tempfile
@@ -24,38 +25,93 @@ class InstallerTests(unittest.TestCase):
self.log = self.root / 'commands.jsonl'
bin_dir = self.root / 'bin'
bin_dir.mkdir()
for name in ('bash', 'sh', 'python3', 'cat', 'mkdir', 'mktemp', 'mv', 'chmod', 'rm', 'cp', 'touch', 'sha256sum', 'true', 'sleep'):
(bin_dir / name).symlink_to(shutil.which(name))
command = '''#!/usr/bin/env python3
import json, os, pathlib, shutil, sys
import hashlib, json, os, pathlib, shutil, subprocess, sys
name = pathlib.Path(sys.argv[0]).name
args = sys.argv[1:]
with open(os.environ['SIMKEEP_TEST_LOG'], 'a') as output:
output.write(json.dumps([name, *args]) + '\\n')
state = pathlib.Path(os.environ['SIMKEEP_TEST_STATE'])
if name == 'docker':
if args == ['info']:
if os.getenv('SIMKEEP_TEST_ROOT_SOCKET') and not os.getenv('SIMKEEP_TEST_PRIVILEGED'):
sys.exit(1)
if os.getenv('SIMKEEP_TEST_STOPPED') and not (state / 'started').exists():
sys.exit(1)
sys.exit(int(os.getenv('SIMKEEP_TEST_DOCKER_FAILURE', '0')))
if args == ['context', 'show']:
print(os.getenv('SIMKEEP_TEST_CONTEXT', 'default'))
sys.exit(0)
if 'pull' in args:
sys.exit(int(os.getenv('SIMKEEP_TEST_PULL_FAILURE', '0')))
if 'version' in args:
if os.getenv('SIMKEEP_TEST_COMPOSE_MISSING') and not (state / 'plugin').exists():
sys.exit(1)
print('2.39.0')
elif name == 'curl':
destination = pathlib.Path(args[args.index('-o') + 1])
url = args[-1]
if url == 'https://get.docker.com':
destination.write_text('cp "$SIMKEEP_TEST_DOCKER_FIXTURE" "$SIMKEEP_TEST_BIN/docker"\\ntouch "$SIMKEEP_TEST_STATE/engine"\\n')
sys.exit(0)
if '/docker/compose/releases/latest/download/' in url:
binary = b'compose-plugin-test-fixture\\n'
if url.endswith('.sha256'):
digest = '0' * 64 if os.getenv('SIMKEEP_TEST_BAD_CHECKSUM') else hashlib.sha256(binary).hexdigest()
destination.write_text(digest + ' ' + url.rsplit('/', 1)[-1].removesuffix('.sha256') + '\\n')
else:
destination.write_bytes(binary)
sys.exit(0)
source = pathlib.Path(os.environ['SIMKEEP_TEST_FIXTURES']) / args[-1].rsplit('/', 1)[-1]
if source.name == '.env.example' and os.getenv('SIMKEEP_TEST_DOWNLOAD_FAILURE'):
destination.write_text('partial download')
sys.exit(22)
shutil.copyfile(source, destination)
elif name == 'uname':
print(os.getenv('SIMKEEP_TEST_SYSTEM', 'Linux') if '-s' in args else os.getenv('SIMKEEP_TEST_ARCH', 'aarch64'))
elif name == 'id':
print(os.getenv('SIMKEEP_TEST_UID', '0'))
elif name == 'awk':
if args[-1] == '/etc/os-release':
print(os.getenv('SIMKEEP_TEST_DISTRO', 'ubuntu'))
else:
os.execv(os.environ['SIMKEEP_TEST_AWK'], [os.environ['SIMKEEP_TEST_AWK'], *args])
elif name in ('systemctl', 'service'):
if os.getenv('SIMKEEP_TEST_DOCKER_FAILURE'):
sys.exit(1)
(state / 'started').touch()
elif name == 'install':
if args[-1].endswith('/docker-compose'):
(state / 'plugin').touch()
elif name == 'sudo':
if os.getenv('SIMKEEP_TEST_SUDO_FAILURE'):
sys.exit(1)
args = [arg for arg in args if arg != '-n']
if args == ['-v']:
sys.exit(0)
environment = dict(os.environ, SIMKEEP_TEST_PRIVILEGED='1')
sys.exit(subprocess.call(args, env=environment))
'''
for name in ('docker', 'curl'):
for name in ('docker', 'curl', 'uname', 'id', 'awk', 'systemctl', 'service', 'install', 'sudo'):
executable = bin_dir / name
executable.write_text(command)
executable.chmod(0o755)
self.environment = dict(os.environ)
for name in ('SIMKEEP_IMAGE', 'SIMKEEP_PORT', 'SIMKEEP_PUBLIC_URL', 'SIMKEEP_ENV_FILE'):
for name in ('SIMKEEP_IMAGE', 'SIMKEEP_PORT', 'SIMKEEP_PUBLIC_URL', 'SIMKEEP_ENV_FILE', 'DOCKER_HOST', 'DOCKER_CONTEXT'):
self.environment.pop(name, None)
(self.root / 'state').mkdir()
docker_fixture = self.root / 'fake-docker'
shutil.copy2(bin_dir / 'docker', docker_fixture)
self.environment.update({
'PATH': str(bin_dir) + os.pathsep + self.environment.get('PATH', ''),
'PATH': str(bin_dir),
'SIMKEEP_TEST_LOG': str(self.log),
'SIMKEEP_TEST_FIXTURES': str(ROOT),
'SIMKEEP_TEST_STATE': str(self.root / 'state'),
'SIMKEEP_TEST_BIN': str(bin_dir),
'SIMKEEP_TEST_DOCKER_FIXTURE': str(docker_fixture),
'SIMKEEP_TEST_AWK': shutil.which('awk'),
})
def install(self, *options, environment=None):
@@ -92,6 +148,7 @@ elif name == 'curl':
self.assertIn(str(self.destination / 'docker-compose.yml'), docker[up])
self.assertIn('http://example.invalid:5301', result.stdout)
self.assertFalse(any('down' in args or 'build' in args for args in docker))
self.assertFalse(any(args[0] in ('sudo', 'systemctl', 'service', 'install') for args in self.commands()))
def test_noninteractive_defaults_work_without_a_terminal(self):
result = self.install()
@@ -151,6 +208,63 @@ elif name == 'curl':
self.assertFalse(any('up' in args for args in self.commands()))
self.assertNotIn('部署完成', result.stdout)
def test_missing_docker_is_installed_from_the_official_script(self):
(Path(self.environment['SIMKEEP_TEST_BIN']) / 'docker').unlink()
result = self.install()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.root / 'state/engine').exists())
self.assertTrue(any(args[0] == 'curl' and args[-1] == 'https://get.docker.com' for args in self.commands()))
self.assertTrue((self.destination / '.env').is_file())
def test_missing_compose_is_installed_without_reinstalling_the_engine(self):
result = self.install(environment=dict(self.environment, SIMKEEP_TEST_COMPOSE_MISSING='1'))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.root / 'state/plugin').exists())
self.assertFalse((self.root / 'state/engine').exists())
self.assertFalse(any(args[-1] == 'https://get.docker.com' for args in self.commands()))
def test_corrupt_compose_download_is_rejected_before_installation(self):
environment = dict(self.environment, SIMKEEP_TEST_COMPOSE_MISSING='1', SIMKEEP_TEST_BAD_CHECKSUM='1')
result = self.install(environment=environment)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(any(args[0] == 'curl' and args[-1].endswith('.sha256') for args in self.commands()))
self.assertFalse((self.root / 'state/plugin').exists())
self.assertFalse(any('up' in args for args in self.commands()))
def test_stopped_local_docker_is_started(self):
result = self.install(environment=dict(self.environment, SIMKEEP_TEST_STOPPED='1'))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.root / 'state/started').exists())
def test_nonroot_user_can_deploy_with_sudo_without_changing_group_membership(self):
environment = dict(self.environment, SIMKEEP_TEST_UID='1000', SIMKEEP_TEST_ROOT_SOCKET='1')
result = self.install(environment=environment)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(any(args[0] == 'sudo' and 'up' in args for args in self.commands()))
self.assertFalse((self.root / 'state/started').exists())
def test_unavailable_sudo_fails_before_changing_the_system(self):
(Path(self.environment['SIMKEEP_TEST_BIN']) / 'docker').unlink()
environment = dict(self.environment, SIMKEEP_TEST_UID='1000', SIMKEEP_TEST_SUDO_FAILURE='1')
result = self.install(environment=environment)
self.assertNotEqual(result.returncode, 0)
self.assertIn('root', result.stderr)
self.assertFalse(any(args[0] == 'curl' for args in self.commands()))
def test_unsupported_system_is_rejected_before_running_the_docker_installer(self):
(Path(self.environment['SIMKEEP_TEST_BIN']) / 'docker').unlink()
result = self.install(environment=dict(self.environment, SIMKEEP_TEST_DISTRO='arch'))
self.assertNotEqual(result.returncode, 0)
self.assertIn('arch', result.stderr)
self.assertFalse(any(args[0] == 'curl' for args in self.commands()))
def test_unreachable_remote_docker_does_not_start_a_local_service(self):
environment = dict(self.environment, SIMKEEP_TEST_DOCKER_FAILURE='1', DOCKER_HOST='tcp://example.invalid:2376')
result = self.install(environment=environment)
self.assertNotEqual(result.returncode, 0)
self.assertIn('DOCKER_HOST', result.stderr)
self.assertFalse(any(args[0] in ('sudo', 'systemctl', 'service') for args in self.commands()))
@unittest.skipUnless(hasattr(pty, 'fork'), 'Requires a Unix controlling terminal')
def test_piped_script_reads_installation_answers_from_the_terminal(self):
shell = f'cat {shlex.quote(str(ROOT / "install.sh"))} | bash -s -- --dir {shlex.quote(str(self.destination))}'