From 7f50d512c78095bcdbe5770165d8be57e744c81e Mon Sep 17 00:00:00 2001 From: SIMKEEP Date: Tue, 6 Oct 2026 21:01:50 +0800 Subject: [PATCH] deploy: add one-command installer with configuration preservation --- README.md | 14 +++ docs/DOCKER.md | 22 +++++ install.sh | 171 +++++++++++++++++++++++++++++++++++ tests/test_installer.py | 194 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100755 install.sh create mode 100644 tests/test_installer.py diff --git a/README.md b/README.md index 9f12c7b..51cc4f8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ SIMKEEP 是一个可自行部署的 SIM / eSIM 管理平台。把开通日期、 需要 Docker Engine 和 Docker Compose 插件。可直接使用 GHCR 镜像,支持 `amd64` / `arm64`,服务器无需安装 Node.js 或 Python。 +### 一键部署 + +服务器已安装 Docker、Compose 插件和 curl 后,执行: + +```bash +bash -o pipefail -c 'curl -fsSL https://raw.githubusercontent.com/rest-rain/simkeep/main/install.sh | bash' +``` + +脚本默认部署到当前目录下的 `simkeep` 文件夹,首次运行询问端口和 HTTP 访问地址,下载配置并启动镜像。打开显示的地址后创建账号。 + +再次从同一目录执行相同命令会保留已有 `.env`、`docker-compose.yml` 和数据卷,拉取镜像并更新容器。指定目录、无人值守部署等用法见 [一键部署说明](docs/DOCKER.md#一键部署)。 + +### 手动部署 + 将 [docker-compose.yml](docker-compose.yml) 和 [.env.example](.env.example) 下载到同一目录,进入该目录后执行: ```bash diff --git a/docs/DOCKER.md b/docs/DOCKER.md index a8306fe..aac1ca9 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -2,6 +2,28 @@ 需要 Docker Engine 与 Docker Compose 插件。预构建镜像包含前端与后端,支持 `linux/amd64` 和 `linux/arm64`,不需要宿主机安装 Node.js、npm 或 Python。应用使用单个 Uvicorn worker,后台续期提醒与 Web 同时启动。 +## 一键部署 + +准备好 Docker Engine、Compose 插件及 curl,在服务器执行: + +```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`。 + +脚本下载缺少的配置文件,创建权限为 `600` 的 `.env`,验证 Compose 配置、拉取镜像并等待健康检查。成功后显示配置目录和访问提示;登录网页后,每个人在“通知设置”配置自己的 Telegram / SMTP。 + +再次从同一目录执行相同命令,会使用已有配置更新镜像与容器。已有 `.env` 和 `docker-compose.yml` 保留,数据继续使用原命名卷。需要修改地址、端口或固定镜像版本时,编辑部署目录中的 `.env` 再运行。 + +也可指定部署目录及首次配置,供无人值守部署使用: + +```bash +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`;远程部署请填写实际地址。 + ## 在新服务器部署 部署只需要 `docker-compose.yml` 和 `.env`。可下载配置文件及空白模板: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..91cf757 --- /dev/null +++ b/install.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +DOWNLOAD_BASE='https://raw.githubusercontent.com/rest-rain/simkeep/main' +staging='' + +fail() { + printf '错误:%s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "$staging" ]]; then + rm -rf -- "$staging" + fi +} + +usage() { + cat <<'USAGE' +用法:bash install.sh [选项] + + --dir 目录 部署目录,默认当前目录下的 simkeep + --port 端口 首次部署的端口,默认 5180 + --url HTTP地址 首次部署的访问地址,默认 http://localhost:端口 + --non-interactive 使用参数或默认值,不询问配置 + --help 显示帮助 + +需要已安装且可用的 Docker Engine、Docker Compose 插件和 curl。 +已有 .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 为空,请重试。" +} + +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 docker 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 + 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 + + 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 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 "$@" diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..0b2650e --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,194 @@ +"""Exercise the installer with isolated files and fake external Docker/download commands.""" +import json +import os +from pathlib import Path +import pty +import select +import shlex +import signal +import subprocess +import tempfile +import time +import unittest + + +ROOT = Path(__file__).resolve().parent.parent + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='simkeep-installer-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.destination = self.root / 'deployment with spaces' + self.log = self.root / 'commands.jsonl' + bin_dir = self.root / 'bin' + bin_dir.mkdir() + command = '''#!/usr/bin/env python3 +import json, os, pathlib, shutil, 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') +if name == 'docker': + if args == ['info']: + sys.exit(int(os.getenv('SIMKEEP_TEST_DOCKER_FAILURE', '0'))) + if 'pull' in args: + sys.exit(int(os.getenv('SIMKEEP_TEST_PULL_FAILURE', '0'))) + if 'version' in args: + print('2.39.0') +elif name == 'curl': + destination = pathlib.Path(args[args.index('-o') + 1]) + 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) +''' + for name in ('docker', 'curl'): + 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'): + self.environment.pop(name, None) + self.environment.update({ + 'PATH': str(bin_dir) + os.pathsep + self.environment.get('PATH', ''), + 'SIMKEEP_TEST_LOG': str(self.log), + 'SIMKEEP_TEST_FIXTURES': str(ROOT), + }) + + def install(self, *options, environment=None): + return subprocess.run( + ['bash', str(ROOT / 'install.sh'), '--non-interactive', '--dir', str(self.destination), *options], + cwd=self.root, env=environment or self.environment, input='', + text=True, capture_output=True, timeout=10, + ) + + def commands(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def test_help_does_not_contact_docker_or_create_a_deployment(self): + result = self.install('--help') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--dir', result.stdout) + self.assertFalse(self.destination.exists()) + self.assertEqual(self.commands(), []) + + def test_fresh_install_writes_private_configuration_and_starts_the_published_image(self): + result = self.install('--port', '05301', '--url', 'http://example.invalid:5301') + self.assertEqual(result.returncode, 0, result.stderr) + env = self.destination / '.env' + self.assertIn('SIMKEEP_PORT=5301\n', env.read_text()) + self.assertIn('SIMKEEP_PUBLIC_URL=http://example.invalid:5301\n', env.read_text()) + self.assertIn('SIMKEEP_IMAGE=ghcr.io/rest-rain/simkeep:latest\n', env.read_text()) + self.assertEqual(env.stat().st_mode & 0o777, 0o600) + self.assertEqual((self.destination / 'docker-compose.yml').read_bytes(), (ROOT / 'docker-compose.yml').read_bytes()) + docker = [args for args in self.commands() if args[0] == 'docker'] + pull = next(index for index, args in enumerate(docker) if 'pull' in args) + up = next(index for index, args in enumerate(docker) if 'up' in args) + self.assertLess(pull, up) + self.assertIn('--wait', docker[up]) + 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)) + + def test_noninteractive_defaults_work_without_a_terminal(self): + result = self.install() + self.assertEqual(result.returncode, 0, result.stderr) + content = (self.destination / '.env').read_text() + self.assertIn('SIMKEEP_PORT=5180\n', content) + self.assertIn('SIMKEEP_PUBLIC_URL=http://localhost:5180\n', content) + + def test_rerun_preserves_configuration_without_executing_its_contents(self): + self.destination.mkdir() + marker = self.root / 'must-not-exist' + original_env = ('SIMKEEP_PUBLIC_URL=http://example.invalid:5302\nSIMKEEP_PORT=5302\n' + 'SIMKEEP_IMAGE=ghcr.io/rest-rain/simkeep:latest\n' + f'SIMKEEP_SMTP_PASSWORD=$(touch {shlex.quote(str(marker))})\n').encode() + original_compose = (ROOT / 'docker-compose.yml').read_bytes() + b'\n# Existing custom configuration\n' + (self.destination / '.env').write_bytes(original_env) + (self.destination / 'docker-compose.yml').write_bytes(original_compose) + result = self.install() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.destination / '.env').read_bytes(), original_env) + self.assertEqual((self.destination / 'docker-compose.yml').read_bytes(), original_compose) + self.assertFalse(marker.exists()) + self.assertFalse(any(args[0] == 'curl' for args in self.commands())) + + def test_invalid_new_install_settings_do_not_download_or_start_anything(self): + for options in (('--port', '0'), ('--port', '65536'), ('--port', '5180;exit'), + ('--url', 'http://example.invalid:70000'), ('--url', 'http://example.invalid/$USER')): + with self.subTest(options=options): + result = self.install(*options) + self.assertNotEqual(result.returncode, 0) + self.assertNotEqual(result.returncode, 127, result.stderr) + self.assertFalse(self.destination.exists()) + self.assertFalse(any(args[0] == 'curl' or 'up' in args for args in self.commands())) + + def test_unavailable_docker_does_not_create_configuration(self): + environment = dict(self.environment, SIMKEEP_TEST_DOCKER_FAILURE='1') + result = self.install(environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Docker', result.stderr) + self.assertFalse(self.destination.exists()) + self.assertFalse(any(args[0] == 'curl' for args in self.commands())) + + def test_partial_download_does_not_install_partial_configuration(self): + environment = dict(self.environment, SIMKEEP_TEST_DOWNLOAD_FAILURE='1') + result = self.install(environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(any(args[0] == 'curl' for args in self.commands())) + self.assertFalse((self.destination / '.env').exists()) + self.assertFalse((self.destination / 'docker-compose.yml').exists()) + self.assertFalse(any('up' in args for args in self.commands())) + + def test_failed_image_pull_does_not_start_a_container_or_report_success(self): + environment = dict(self.environment, SIMKEEP_TEST_PULL_FAILURE='1') + result = self.install(environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(any('pull' in args for args in self.commands())) + self.assertFalse(any('up' in args for args in self.commands())) + self.assertNotIn('部署完成', result.stdout) + + @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))}' + pid, terminal = pty.fork() + if pid == 0: + os.chdir(self.root) + os.execvpe('bash', ['bash', '-c', shell], self.environment) + output = bytearray() + completed = False + try: + os.write(terminal, b'5303\nhttp://example.invalid:5303\n') + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if select.select([terminal], [], [], 0.1)[0]: + try: + chunk = os.read(terminal, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + waited, status = os.waitpid(pid, os.WNOHANG) + while waited != pid and time.monotonic() < deadline: + time.sleep(0.01) + waited, status = os.waitpid(pid, os.WNOHANG) + completed = waited == pid + self.assertTrue(completed, output.decode(errors='replace')) + self.assertEqual(os.waitstatus_to_exitcode(status), 0, output.decode(errors='replace')) + self.assertTrue((self.destination / '.env').exists(), output.decode(errors='replace')) + content = (self.destination / '.env').read_text() + self.assertIn('SIMKEEP_PORT=5303\n', content) + self.assertIn('SIMKEEP_PUBLIC_URL=http://example.invalid:5303\n', content) + finally: + if not completed: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + os.close(terminal) + + +if __name__ == '__main__': + unittest.main()