Commit 09a35038 authored by wangteng's avatar wangteng

整理优化完善

parent 8cb592c8
......@@ -29,11 +29,10 @@ npm run dev
## 验证与发布
```powershell
cd web
npm run build
cd ..
node scripts/verify/verify_tree.cjs
python deploy/build_release.py
# 正式发布:自动构建、上传、重启并检查前后端和 Oracle
python deploy/deploy_server.py
```
发布产物生成到 `release/`,部署步骤见 [deploy/README.md](deploy/README.md)。
最后一条命令会自动构建发布包、读取 `deploy/server.local.md` 中的本机私有发布配置、上传并重启正式服务,同时执行健康检查。只构建不发布时仍可执行 `python deploy/build_release.py`;完整说明见 [deploy/README.md](deploy/README.md)。
发布前如需单独运行本地前端树校验,可执行 `node scripts/verify/verify_tree.cjs`。
......@@ -2,14 +2,36 @@
本目录只保存部署相关文件,业务代码不应放入此处。
1. 在项目根目录执行 `python deploy/build_release.py`,构建产物生成到 `release/`。
2. 设置 `DBTOOL_PW` 后执行 `python deploy/deploy_server.py`,上传发布包并重启服务。
3. 使用 `python deploy/remote_shell.py "命令"` 执行临时远程检查;密码不会写入脚本或仓库。
## 正式发布(一条命令)
`db-tool.service` 是 systemd 服务定义,`db-tool-nginx.conf` 是 Nginx 反向代理示例。
在项目根目录执行:
```powershell
python deploy/deploy_server.py
```
脚本会自动完成以下步骤:
1. 从 `deploy/server.local.md` 读取本机私有服务器地址、用户、密码和 Oracle 测试数据源 ID。
2. 执行 `build_release.py` 构建前后端发布包。
3. 上传前检查后端入口、正式配置、前端 `index.html/assets/`,并检查 Oracle `libclntsh.so` 符号链接和 ELF 实体库。
4. 备份线上版本和 SQLite 快照,上传并解压包,安装 systemd 配置,迁移数据后重启服务。
5. 检查 systemd、后端本机接口、Nginx `/db-tool/api/health`,并默认测试 Oracle 数据源连接。
`server.local.md` 已加入 `.gitignore`,只允许保存在本机,禁止提交 Git 或打入发布包。环境变量 `DBTOOL_HOST`、`DBTOOL_USER`、`DBTOOL_PW` 和 `DBTOOL_ORACLE_SOURCE_ID` 可临时覆盖文档中的值。
部署脚本默认使用现有服务器地址和 `root` 用户,也可通过 `DBTOOL_HOST`、`DBTOOL_USER` 覆盖;SSH 密码只从 `DBTOOL_PW` 读取。
## 特殊场景
本机正式服务器凭据记录在被 Git 忽略的 `deploy/server.local.md` 中。发布前从该文件读取密码并设置 `DBTOOL_PW`,不要将凭据写入可提交文件或发布包。
```powershell
# 只使用已有 release/,不重新构建
python deploy/deploy_server.py --skip-build
# 临时跳过 Oracle 测试(不建议正式发布使用)
python deploy/deploy_server.py --skip-oracle-check
```
如需只构建不发布,可执行 `python deploy/build_release.py`。临时远程检查使用 `python deploy/remote_shell.py "命令"`;该脚本仍从 `DBTOOL_PW` 读取密码。
`db-tool.service` 是 systemd 服务定义,`db-tool-nginx.conf` 是 Nginx 反向代理示例。
如生产环境需要 Oracle Thick 模式,请在构建前从 Oracle 官方获取 Linux Instant Client,并解压到项目根目录的 `oracle_client/linux/`。该目录是本机构建依赖,不纳入版本控制。
如生产环境需要 Oracle Thick 模式,请从 Oracle 官方获取 Linux Instant Client,并解压到项目根目录的 `oracle_client/linux/`。该目录是本机构建依赖,不纳入版本控制。Linux 服务通过 `db-tool.service` 中的 `LD_LIBRARY_PATH` 加载 Instant Client;不要改成运行时传 `lib_dir`,CentOS 7 与 python-oracledb 3.x 的组合可能因此触发原生段错误。构建脚本会把 Windows 解压产生的伪符号链接恢复为 Linux tar 符号链接。
......@@ -8,6 +8,7 @@ from __future__ import annotations
import os
import subprocess
import tarfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
......@@ -28,7 +29,33 @@ def _add_tree(archive: tarfile.TarFile, root: Path, arcname: str) -> None:
for path in root.rglob("*"):
if _skip(path) or not path.is_file():
continue
archive.add(path, arcname=f"{arcname}/{path.relative_to(root).as_posix()}")
archive_name = f"{arcname}/{path.relative_to(root).as_posix()}"
# Windows 解压 Oracle 官方 ZIP 时,Linux 符号链接会变成内容仅为
# 目标文件名的小文本文件。恢复为 tar 符号链接,否则 Linux 加载
# libclntsh.so 时会把文本当动态库,严重时直接导致进程 SIGSEGV。
if "oracle_client" in path.parts and path.stat().st_size < 256:
try:
link_target = path.read_text(encoding="ascii").strip()
except (OSError, UnicodeDecodeError):
link_target = ""
if link_target and (path.parent / link_target).is_file():
info = tarfile.TarInfo(archive_name)
info.type = tarfile.SYMTYPE
info.linkname = link_target
info.mode = 0o777
# Windows 伪链接常带 1970 时间戳,避免 Linux 解包时出现
# "unknown date" 警告,使用当前构建时间作为链接时间。
info.mtime = int(time.time())
archive.addfile(info)
continue
info = archive.gettarinfo(str(path), arcname=archive_name)
if "oracle_client" in path.parts:
# Windows 文件系统不保存 Unix 可执行位,发布时统一补齐。
info.mode = 0o755
with path.open("rb") as source:
archive.addfile(info, source)
def main() -> None:
......
......@@ -10,6 +10,7 @@ Environment=PYTHONPATH=/opt/app/db-tool
Environment=APP_ENV=production
Environment=WEB_HOST=127.0.0.1
Environment=WEB_PORT=8767
Environment=LD_LIBRARY_PATH=/opt/app/db-tool/web/oracle_client/linux/instantclient_19_32
ExecStart=/opt/app/db-tool/.venv/bin/python -m web.backend.start
Restart=always
RestartSec=5
......
#!/usr/bin/env python3
"""db-tools 正式发版:上传、迁移旧 SQLite、切换 MySQL、重启并验证。"""
"""一键构建并发布 db-tools 正式版本。
默认流程:读取本机私有服务器文档、构建发布包、校验产物、上传、备份、
重启 systemd 服务,并检查后端、Nginx 和可选 Oracle 数据源连接。
"""
from __future__ import annotations
import argparse
import json
import os
import posixpath
import re
import subprocess
import sys
import tarfile
from pathlib import Path
import paramiko
HOST = os.environ.get("DBTOOL_HOST", "192.168.20.175")
USER = os.environ.get("DBTOOL_USER", "root")
ROOT = Path(__file__).resolve().parent.parent
DEPLOY_DIR = ROOT / "deploy"
RELEASE_DIR = ROOT / "release"
SERVICE_FILE = Path(__file__).with_name("db-tool.service")
pw = os.environ.get("DBTOOL_PW")
if not pw:
sys.exit("set DBTOOL_PW first")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=pw, timeout=10, look_for_keys=False, allow_agent=False)
def run(cmd, must=True):
_, out, err = c.exec_command(cmd, timeout=120)
o, e = out.read().decode("utf-8", "replace"), err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
print(f"$ {cmd}\n{o}{('[stderr] ' + e) if e.strip() else ''}rc={rc}\n")
if must and rc != 0:
sys.exit(f"FAILED rc={rc}: {cmd}")
return o
sftp = c.open_sftp()
sftp.put(str(RELEASE_DIR / "backend-deploy.tgz"), "/tmp/dbtool-backend.tgz")
sftp.put(str(RELEASE_DIR / "frontend-deploy.tgz"), "/tmp/dbtool-frontend.tgz")
sftp.put(str(SERVICE_FILE), "/tmp/db-tool.service")
sftp.close()
print("uploaded both deployment packages\n")
# 0) 部署前快照(旧 SQLite 只作为首次 MySQL 迁移源)
run("test -f /opt/app/db-tool/web/data/web.db && md5sum /opt/app/db-tool/web/data/web.db || true")
# 1) 备份当前版本
run("TS=$(date +%Y%m%d-%H%M%S) && mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/backend /opt/app/db-tool/releases/$TS/backend && "
"cp -a /opt/web/db-tool /opt/app/db-tool/releases/$TS/frontend && echo backup at $TS")
SERVICE_FILE = DEPLOY_DIR / "db-tool.service"
SERVER_FILE = DEPLOY_DIR / "server.local.md"
# 2) 停止旧服务并备份其 SQLite,保证迁移读取一致快照
run("systemctl stop db-tool && TS=$(date +%Y%m%d-%H%M%S) && "
"mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/data/web.db /opt/app/db-tool/releases/$TS/web.db")
# 3) 解压后端(不动 data/、logs/),并锁定私有配置权限
run("tar xzf /tmp/dbtool-backend.tgz -C /opt/app/db-tool/web/ && "
"find /opt/app/db-tool/web/backend -name __pycache__ -type d -exec rm -rf {} + ; "
"chmod 600 /opt/app/db-tool/web/backend/configs/llm.yaml /opt/app/db-tool/web/backend/configs/database.production.yaml")
def _markdown_value(text: str, label: str) -> str | None:
"""读取 ``- 标签:`值` `` 形式的私有配置,兼容无反引号写法。"""
match = re.search(
rf"^\s*-\s*{re.escape(label)}\s*[::]\s*`?([^`\r\n]+?)`?\s*$",
text,
flags=re.MULTILINE,
)
return match.group(1).strip() if match else None
# 4) 首次发布时迁移旧 SQLite 到正式 MySQL;已有 MySQL 数据时不覆盖
run("cd /opt/app/db-tool && APP_ENV=production PYTHONPATH=/opt/app/db-tool "
"/opt/app/db-tool/.venv/bin/python -m web.backend.db.migrate_sqlite_to_mysql "
"--source /opt/app/db-tool/web/data/web.db --skip-if-populated")
# 5) 安装正式 systemd 配置并解压前端
run("install -m 644 /tmp/db-tool.service /etc/systemd/system/db-tool.service && systemctl daemon-reload")
run("rm -rf /opt/web/db-tool/assets && tar xzf /tmp/dbtool-frontend.tgz -C /opt/web/db-tool/ && ls /opt/web/db-tool/ /opt/web/db-tool/assets/")
def load_server_settings() -> tuple[str, str, str, int | None]:
"""加载服务器配置,环境变量优先,私有 Markdown 作为默认来源。"""
private_text = SERVER_FILE.read_text(encoding="utf-8") if SERVER_FILE.is_file() else ""
host = os.environ.get("DBTOOL_HOST") or _markdown_value(private_text, "主机") or "192.168.20.175"
user = os.environ.get("DBTOOL_USER") or _markdown_value(private_text, "SSH 用户") or "root"
password = os.environ.get("DBTOOL_PW") or _markdown_value(private_text, "SSH 密码")
source_id = os.environ.get("DBTOOL_ORACLE_SOURCE_ID") or _markdown_value(
private_text, "Oracle 测试数据源 ID"
)
if not password:
raise SystemExit(
f"未找到 SSH 密码,请填写 {SERVER_FILE},或设置 DBTOOL_PW 环境变量。"
)
try:
oracle_source_id = int(source_id) if source_id else None
except ValueError as exc:
raise SystemExit("Oracle 测试数据源 ID 必须是数字。") from exc
return host, user, password, oracle_source_id
def _archive_members(path: Path) -> list[tarfile.TarInfo]:
if not path.is_file() or path.stat().st_size == 0:
raise SystemExit(f"发布包不存在或为空:{path}")
try:
with tarfile.open(path, "r:gz") as archive:
return archive.getmembers()
except (tarfile.TarError, OSError) as exc:
raise SystemExit(f"发布包无法读取:{path}: {exc}") from exc
def validate_release_packages(require_oracle: bool = False) -> None:
"""上传前校验关键文件,避免坏包把线上服务停在半途。"""
backend = RELEASE_DIR / "backend-deploy.tgz"
frontend = RELEASE_DIR / "frontend-deploy.tgz"
backend_members = _archive_members(backend)
frontend_members = _archive_members(frontend)
backend_names = {member.name for member in backend_members}
# tarfile 可能保留 ``./`` 前缀(前端归档的根目录就是 ``.``)。
frontend_names = {member.name.lstrip("./").rstrip("/") for member in frontend_members}
missing = sorted(
{"backend/app.py", "backend/configs/database.production.yaml"} - backend_names
)
if missing:
raise SystemExit(f"后端发布包缺少:{', '.join(missing)}")
if "index.html" not in frontend_names or not any(
name.startswith("assets/") for name in frontend_names
):
raise SystemExit("前端发布包缺少 index.html 或 assets/。")
oracle_links = [member for member in backend_members if member.name.endswith("/libclntsh.so")]
if oracle_links or require_oracle:
if not oracle_links:
raise SystemExit("已配置 Oracle 测试数据源,但后端发布包未包含 Oracle Instant Client。")
if not any(member.issym() for member in oracle_links):
raise SystemExit("Oracle 发布包中的 libclntsh.so 不是符号链接。")
link = next(member for member in oracle_links if member.issym())
target_name = posixpath.normpath(
posixpath.join(posixpath.dirname(link.name), link.linkname)
)
target = next(
(member for member in backend_members if member.name == target_name and not member.issym()),
None,
)
if target is None:
raise SystemExit(f"Oracle 符号链接目标不存在:{link.name} -> {link.linkname}")
with tarfile.open(backend, "r:gz") as archive:
content = archive.extractfile(target)
if content is None or content.read(4) != b"\x7fELF":
raise SystemExit("Oracle 目标库不是 ELF 文件。")
print("发布包预检查通过:后端、前端和 Oracle 文件结构正常。")
# 6) 重启
run("systemctl restart db-tool && sleep 4 && systemctl is-active db-tool && ss -tlnp | grep 8767")
def build_release() -> None:
print("开始构建发布包……")
subprocess.run([sys.executable, str(DEPLOY_DIR / "build_release.py")], cwd=ROOT, check=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="构建并发布 db-tools 正式版本")
parser.add_argument("--skip-build", action="store_true", help="使用现有 release/ 发布包")
parser.add_argument(
"--skip-oracle-check", action="store_true", help="跳过发布后的 Oracle 数据源连接测试"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not args.skip_build:
build_release()
host, user, password, oracle_source_id = load_server_settings()
validate_release_packages(require_oracle=oracle_source_id is not None)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
host,
username=user,
password=password,
timeout=10,
look_for_keys=False,
allow_agent=False,
)
def run(command: str, must: bool = True) -> str:
_, stdout, stderr = client.exec_command(command, timeout=120)
output = stdout.read().decode("utf-8", "replace")
error = stderr.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
print(f"$ {command}\n{output}{('[stderr] ' + error) if error.strip() else ''}rc={code}\n")
if must and code != 0:
raise SystemExit(f"远程命令失败 rc={code}: {command}")
return output
sftp = client.open_sftp()
try:
sftp.put(str(RELEASE_DIR / "backend-deploy.tgz"), "/tmp/dbtool-backend.tgz")
sftp.put(str(RELEASE_DIR / "frontend-deploy.tgz"), "/tmp/dbtool-frontend.tgz")
sftp.put(str(SERVICE_FILE), "/tmp/db-tool.service")
finally:
sftp.close()
print("发布包和 systemd 配置上传完成。")
# 部署前确认目标目录存在,并保留旧 SQLite 作为迁移快照。
run("test -d /opt/app/db-tool/web/backend && test -d /opt/web/db-tool")
run("test -f /opt/app/db-tool/web/data/web.db && md5sum /opt/app/db-tool/web/data/web.db || true")
run(
"TS=$(date +%Y%m%d-%H%M%S) && mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/backend /opt/app/db-tool/releases/$TS/backend && "
"cp -a /opt/web/db-tool /opt/app/db-tool/releases/$TS/frontend && echo backup at $TS"
)
run(
"systemctl stop db-tool && TS=$(date +%Y%m%d-%H%M%S) && "
"mkdir -p /opt/app/db-tool/releases/$TS && "
"cp -a /opt/app/db-tool/web/data/web.db /opt/app/db-tool/releases/$TS/web.db"
)
run(
"tar xzf /tmp/dbtool-backend.tgz -C /opt/app/db-tool/web/ && "
"find /opt/app/db-tool/web/backend -name __pycache__ -type d -exec rm -rf {} + ; "
"chmod 600 /opt/app/db-tool/web/backend/configs/llm.yaml "
"/opt/app/db-tool/web/backend/configs/database.production.yaml"
)
run(
"cd /opt/app/db-tool && APP_ENV=production PYTHONPATH=/opt/app/db-tool "
"/opt/app/db-tool/.venv/bin/python -m web.backend.db.migrate_sqlite_to_mysql "
"--source /opt/app/db-tool/web/data/web.db --skip-if-populated"
)
run("install -m 644 /tmp/db-tool.service /etc/systemd/system/db-tool.service && systemctl daemon-reload")
run(
"rm -rf /opt/web/db-tool/assets && tar xzf /tmp/dbtool-frontend.tgz -C /opt/web/db-tool/ "
"&& test -f /opt/web/db-tool/index.html"
)
run("systemctl restart db-tool && sleep 4 && systemctl is-active db-tool && ss -tlnp | grep 8767")
run("curl -fsS http://127.0.0.1:8767/api/health")
run("curl -fsS http://127.0.0.1/db-tool/api/health")
if oracle_source_id is not None and not args.skip_oracle_check:
result = run(
f"curl -fsS -X POST http://127.0.0.1/db-tool/api/data-sources/{oracle_source_id}/test "
"-H 'Content-Type: application/json' --data '{}'"
)
try:
if json.loads(result).get("ok") is not True:
raise SystemExit("Oracle 数据源连接测试未返回 ok=true。")
except json.JSONDecodeError as exc:
raise SystemExit("Oracle 数据源连接测试返回了非 JSON 内容。") from exc
run("journalctl -u db-tool -n 12 --no-pager | tail -12")
print("正式发布完成,服务和健康检查均通过。")
finally:
client.close()
# 7) 部署后核对
run("curl -fsS http://127.0.0.1:8767/api/health && journalctl -u db-tool -n 12 --no-pager | tail -12")
c.close()
print("DEPLOY DONE")
if __name__ == "__main__":
main()
......@@ -7,6 +7,10 @@ import sys
import paramiko
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
HOST = os.environ.get("DBTOOL_HOST", "192.168.20.175")
USER = os.environ.get("DBTOOL_USER", "root")
password = os.environ.get("DBTOOL_PW")
......
......@@ -338,6 +338,18 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
import oracledb
bundled_dir = _bundled_oracle_client_dir(lib_dir)
if bundled_dir:
if sys.platform.startswith("linux"):
# CentOS 7 + python-oracledb 3.x 使用 lib_dir 动态加载时可能在
# ODPI-C 内部触发 SIGSEGV。Linux 应在进程启动前通过
# LD_LIBRARY_PATH 暴露客户端,再使用无参数初始化。
library_paths = os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep)
if bundled_dir not in library_paths:
raise RuntimeError(
"Linux Oracle Thick 模式要求在进程启动前将 Instant Client "
f"加入 LD_LIBRARY_PATH:{bundled_dir}"
)
oracledb.init_oracle_client()
else:
oracledb.init_oracle_client(lib_dir=bundled_dir)
logger.info(f"[DB] Oracle Instant Client 已加载(项目内置)lib_dir={bundled_dir}")
else:
......@@ -351,7 +363,6 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
# (Instant Client 12.x 是用 MSVC 2013 编译的,依赖 MSVCR120.dll;
# 装 vcredist 2015-2022 不够,必须装 VC++ 2013 Redist)
if "DPI-1047" in err_str:
import os
oci_in_lib_dir = False
if lib_dir:
oci_in_lib_dir = os.path.exists(os.path.join(lib_dir, "oci.dll"))
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment