deploy: add one-command installer with configuration preservation
This commit is contained in:
1 parent
ce4dfd635d
commit
7f50d512c7
4 files changed
+401
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user