Files
ChuSQL/scripts/test-linux-lifecycle.py
2026-10-11 17:32:12 +08:00

216 lines
11 KiB
Python

#!/usr/bin/env python3
import json
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
# 用真实发行包验证 Linux 安装、更新及卸载,数据与配置隔离在临时目录。
# 执行命令并将失败日志原样输出。
def run(args, env, cwd=None):
result = subprocess.run(args, env=env, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
if result.returncode:
raise RuntimeError(result.stdout)
return result.stdout
# 选择本机空闲端口。
def free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
# 登录真实服务器并执行 SQL。
def query(port, statements):
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
with sock.makefile("rwb") as stream:
answers = []
for request in [{"method": "hello", "protocol": 1},
{"method": "login", "user": "root", "password": "Lifecycle-test-123!"}] + [
{"method": "query", "sql": sql} for sql in statements]:
stream.write(json.dumps(request).encode() + b"\n")
stream.flush()
answer = json.loads(stream.readline())
assert answer["status"] != "error", answer
answers.append(answer)
return answers[2:]
# 等待服务器可接受登录。
def wait_server(port):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
query(port, [])
return
except (OSError, ValueError):
time.sleep(0.1)
raise RuntimeError("server did not become ready")
# 用真实用户 systemd 验证注册、更新启动和卸载。
def test_systemd(repo, archive, root, env):
systemctl = shutil.which("systemctl")
state = run([systemctl, "--user", "show", "chusql-server.service", "-p", "LoadState"], env)
assert "LoadState=not-found" in state, "existing chusql-server.service: refusing to change it"
runtime_unit = Path(os.environ["XDG_RUNTIME_DIR"]) / "systemd/user/chusql-server.service"
assert not runtime_unit.is_symlink(), "existing runtime service link: refusing to change it"
user = run(["id", "-un"], env).strip()
linger = run(["loginctl", "show-user", user, "-p", "Linger"], env).strip()
home = root / "service-home"
home.mkdir()
env = dict(env, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"),
XDG_STATE_HOME=str(home / ".local/state"), CHUSQL_ADMIN_PASSWORD="Lifecycle-test-123!")
install = root / "service program"
unit = home / ".config/systemd/user/chusql-server.service"
bridge = root / "service-tools"
bridge.mkdir()
wrapper = bridge / "systemctl"
wrapper.write_text(f'''#!/bin/sh
# 将隔离配置中的单元链接到用户 systemd 的运行目录。
if [ "$*" = '--user daemon-reload' ] && [ -f "$CHUSQL_TEST_UNIT" ]; then
"{systemctl}" --user link --runtime "$CHUSQL_TEST_UNIT" || exit 1
fi
exec "{systemctl}" "$@"
''')
wrapper.chmod(0o755)
env.update(PATH=str(bridge) + ":" + env["PATH"], CHUSQL_TEST_UNIT=str(unit))
installer = ["sh", str(repo / "scripts/install.sh"), "--url", archive,
"--component", "both", "--install-dir", str(install)]
try:
run(installer + ["--data-dir", str(root / "service-data"), "--no-start"], env)
config = home / ".config/ChuSQL/settings.toml"
port = free_port()
config.write_text(config.read_text().replace("port = 7777", f"port = {port}"))
run(installer, env)
wait_server(port)
run([systemctl, "--user", "is-active", "--quiet", "chusql-server.service"], env)
run(["sh", str(install / "uninstall.sh"), "--install-dir", str(install), "--yes"], env)
assert not unit.exists() and not (root / "service-data").exists()
print("PASS real systemd registration, update/start and uninstall", flush=True)
finally:
state = run([systemctl, "--user", "show", "chusql-server.service", "-p", "LoadState"], env)
if "LoadState=loaded" in state:
run([systemctl, "--user", "stop", "chusql-server.service"], env)
user_units = Path(os.environ["HOME"]) / ".config/systemd/user"
for link in [runtime_unit, user_units / "chusql-server.service",
user_units / "default.target.wants/chusql-server.service"]:
if link.is_symlink() and link.resolve() == unit.resolve():
link.unlink()
run([systemctl, "--user", "daemon-reload"], dict(env, CHUSQL_TEST_UNIT="/nonexistent"))
if linger == "Linger=no" and run(["loginctl", "show-user", user, "-p", "Linger"], env).strip() == "Linger=yes":
run(["loginctl", "disable-linger", user], env)
# 用本机发行站验证版本查询、HTTP 下载与校验。
def test_online(repo, archive, root, env):
site = root / "release-site"
api = site / "api/v3/repos/test/ChuSQL/releases"
api.parent.mkdir(parents=True)
api.write_text(json.dumps([{"tag_name": "v-test", "prerelease": False,
"assets": [{"name": "chusql-linux-x86_64.tar.gz"}]}], indent=2))
assets = site / "test/ChuSQL/releases/download/v-test"
assets.mkdir(parents=True)
shutil.copy2(archive, assets / Path(archive).name)
shutil.copy2(archive + ".sha256", assets / (Path(archive).name + ".sha256"))
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(site)))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
install = root / "online-program"
env = dict(env, CHUSQL_GITHUB_BASE=f"http://127.0.0.1:{server.server_port}")
installer = ["sh", str(repo / "scripts/install.sh"), "--repo", "test/ChuSQL"]
try:
assert "v-test" in run(installer + ["--list-versions"], env)
output = run(installer + ["--component", "cli", "--install-dir", str(install),
"--data-dir", str(root / "online-data"), "--no-service", "--no-start"], env)
assert "checksum" in output and "(ok)" in output
assert (install / "bin/csql").exists() and not (install / "bin/chusql-web").exists()
run(["sh", str(install / "uninstall.sh"), "--install-dir", str(install), "--yes"], env)
assert not (root / "online-data").exists()
print("PASS release API, latest version selection, HTTP download and checksum (local fixture)", flush=True)
finally:
server.shutdown()
server.server_close()
thread.join()
# 在隔离目录验证配置、SQL 数据和卸载保留选项。
def main():
if sys.platform != "linux" or len(sys.argv) not in (2, 3) or (len(sys.argv) == 3 and sys.argv[2] != "--systemd"):
raise SystemExit("usage: python3 scripts/test-linux-lifecycle.py <linux-package.tar.gz> [--systemd]")
archive = str(Path(sys.argv[1]).resolve())
repo = Path(__file__).resolve().parent.parent
(repo / "logs").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="linux-lifecycle-", dir=repo / "logs") as temporary:
root = Path(temporary)
home = root / "home"
home.mkdir()
env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"),
XDG_DATA_HOME=str(home / ".local/share"), XDG_STATE_HOME=str(home / ".local/state"),
CHUSQL_ADMIN_PASSWORD="Lifecycle-test-123!")
env.pop("LD_LIBRARY_PATH", None)
install = root / "program"
data = install / "custom/nested/data"
config = home / ".config/ChuSQL/settings.toml"
installer = ["sh", str(repo / "scripts/install.sh"), "--url", archive,
"--install-dir", str(install), "--component", "both", "--no-service", "--no-start"]
run(installer + ["--data-dir", str(data)], env)
assert (data / "system/catalog.json").is_file()
assert (install / "uninstall.sh").is_file()
assert "@DATA_DIR@" not in config.read_text()
assert run([str(install / "bin/csql-bootstrap"), "--help"], env)
print("PASS clean installation and dynamic library lookup", flush=True)
port = free_port()
settings = config.read_text().replace("port = 7777", f"port = {port}").replace(str(data), "custom/nested/data") + "\n# preserve custom settings\n"
config.write_text(settings)
server = subprocess.Popen([str(install / "bin/chusql-server"), "--config", str(config)],
env=env, cwd=install, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
wait_server(port)
query(port, ["create database lifecycle", "use lifecycle", "create table saved (id int)",
"insert into saved (id) values (42)"])
env["CHUSQL_ADMIN_PASSWORD"] = "Different-update-password-123!"
run(installer, env)
server.wait(timeout=5)
assert config.read_text() == settings
assert (home / ".profile").read_text().count("# ChuSQL\n") == 1
server = subprocess.Popen([str(install / "bin/chusql-server"), "--config", str(config)],
env=env, cwd=install, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
wait_server(port)
result = query(port, ["use lifecycle", "select id from saved"])[-1]
assert result["rowCount"] == 1 and "42" in json.dumps(result["rows"]), result
print("PASS running update preserves configuration, credentials and SQL data", flush=True)
uninstall = ["sh", str(install / "uninstall.sh"), "--install-dir", str(install), "--yes"]
run(uninstall + ["--keep-data", "--keep-config"], env)
server.wait(timeout=5)
assert data.is_dir() and config.read_text() == settings
assert not (install / "bin").exists()
assert "# ChuSQL" not in (home / ".profile").read_text()
print("PASS uninstall stops server and keeps nested data and configuration", flush=True)
run(installer, env)
run(uninstall, env)
assert not install.exists() and not data.exists() and not config.exists()
print("PASS reinstall and full uninstall discover configured data path", flush=True)
finally:
if server.poll() is None:
server.terminate()
server.wait(timeout=10)
test_online(repo, archive, root, env)
if len(sys.argv) == 3:
with tempfile.TemporaryDirectory(prefix="chusql-systemd-") as service_temporary:
test_systemd(repo, archive, Path(service_temporary), env)
if __name__ == "__main__":
main()