diff --git a/README.md b/README.md index 51cc4f8..f512895 100644 --- a/README.md +++ b/README.md @@ -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#一键部署)。 diff --git a/docs/DOCKER.md b/docs/DOCKER.md index aac1ca9..6cc02bf 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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 diff --git a/install.sh b/install.sh index 91cf757..1cb9bb9 100755 --- a/install.sh +++ b/install.sh @@ -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" - fi + 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。' diff --git a/tests/test_installer.py b/tests/test_installer.py index 0b2650e..179fdfa 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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))}'