deploy: add one-command installer with configuration preservation

This commit is contained in:
SIMKEEP committed 2026-10-06 21:01:50 +08:00
1 parent ce4dfd635d
commit 7f50d512c7
4 files changed
+401

No files matched your search

+14
View File
@@ -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
+22
View File
@@ -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`。可下载配置文件及空白模板:
Executable
+171
View File
@@ -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 "$@"
+194
View File
@@ -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()