feat: bugfix module - SQL query, log read, and log tail APIs
- execute_sql.dspy: SELECT-only SQL execution with pagination - read_log.dspy: read last N lines from whitelisted log files - tail_log.dspy: incremental log monitoring from last position - RBAC: developer role only - Security: SQL validation, log file whitelist
This commit is contained in:
parent
cd6fe6e194
commit
3406783d13
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
7
bugfix/__init__.py
Normal file
7
bugfix/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
# bugfix/__init__.py
|
||||
from .init import (
|
||||
execute_select_sql,
|
||||
read_log_file,
|
||||
tail_log_file,
|
||||
load_bugfix,
|
||||
)
|
||||
200
bugfix/init.py
Normal file
200
bugfix/init.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""bugfix 模块 - 开发者调试工具"""
|
||||
import os
|
||||
import re
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from sqlor.dbpools import get_sor_context
|
||||
from appPublic.log import debug, exception
|
||||
|
||||
|
||||
MODULE_NAME = "bugfix"
|
||||
|
||||
# 允许的日志文件白名单
|
||||
ALLOWED_LOGS = ['sage.log', 'backend_accounting.log']
|
||||
|
||||
# 日志监控位置记录 {filename: {'lines': N, 'mtime': timestamp}}
|
||||
_log_tails = {}
|
||||
|
||||
|
||||
def _validate_select_only(sql):
|
||||
"""验证 SQL 只能是 SELECT 语句"""
|
||||
if not sql:
|
||||
return False, "SQL 不能为空"
|
||||
|
||||
# 去除前后空格和注释
|
||||
cleaned = sql.strip()
|
||||
# 移除 -- 注释
|
||||
cleaned = re.sub(r'--.*$', '', cleaned, flags=re.MULTILINE)
|
||||
# 移除 /* */ 注释
|
||||
cleaned = re.sub(r'/\*.*?\*/', '', cleaned, flags=re.DOTALL)
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
if not cleaned:
|
||||
return False, "SQL 不能为空"
|
||||
|
||||
# 检查是否以 SELECT 开头
|
||||
if not cleaned.upper().startswith('SELECT'):
|
||||
return False, "仅允许执行 SELECT 语句"
|
||||
|
||||
# 黑名单检查 - 禁止危险操作
|
||||
blacklist = [
|
||||
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE',
|
||||
'TRUNCATE', 'REPLACE', 'MERGE', 'GRANT', 'REVOKE',
|
||||
'EXEC', 'EXECUTE', 'CALL', 'INTO', 'LOAD_FILE', 'INTO OUTFILE',
|
||||
'INTO DUMPFILE'
|
||||
]
|
||||
upper = cleaned.upper()
|
||||
for kw in blacklist:
|
||||
# 用 \b 匹配完整单词
|
||||
if re.search(r'\b' + kw + r'\b', upper):
|
||||
return False, f"禁止使用 {kw} 语句"
|
||||
|
||||
return True, "OK"
|
||||
|
||||
|
||||
async def execute_select_sql(sql, page=1, rows=20):
|
||||
"""执行 SELECT SQL 查询(sqlor 标准分页)
|
||||
|
||||
Args:
|
||||
sql: SQL 语句(仅允许 SELECT)
|
||||
page: 页码,从 1 开始
|
||||
rows: 每页条数,默认 20
|
||||
|
||||
Returns:
|
||||
dict: {status, total, rows}
|
||||
"""
|
||||
valid, msg = _validate_select_only(sql)
|
||||
if not valid:
|
||||
return {'status': 'error', 'error': msg}
|
||||
|
||||
try:
|
||||
env = ServerEnv()
|
||||
dbname = env.get_module_dbname('bugfix')
|
||||
async with get_sor_context(env, dbname) as sor:
|
||||
ns = {'page': page, 'rows': rows}
|
||||
result = await sor.sqlExe(sql, ns)
|
||||
return {
|
||||
'status': 'ok',
|
||||
'total': result.get('total', 0),
|
||||
'rows': result.get('rows', [])
|
||||
}
|
||||
except Exception as e:
|
||||
exception(f'execute_select_sql error: {e}')
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
async def read_log_file(filename, lines=500):
|
||||
"""读取日志文件
|
||||
|
||||
Args:
|
||||
filename: 日志文件名(必须在白名单中)
|
||||
lines: 读取最后 N 行,默认 500
|
||||
|
||||
Returns:
|
||||
dict: {status, data, filename}
|
||||
"""
|
||||
if filename not in ALLOWED_LOGS:
|
||||
return {'status': 'error', 'error': f'不允许读取 {filename},仅允许: {", ".join(ALLOWED_LOGS)}'}
|
||||
|
||||
try:
|
||||
env = ServerEnv()
|
||||
# 日志目录在 sage/logs/ 下
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
|
||||
# 备用路径
|
||||
if not os.path.isdir(log_dir):
|
||||
log_dir = os.path.expanduser('~/repos/sage/logs')
|
||||
|
||||
log_path = os.path.join(log_dir, filename)
|
||||
if not os.path.isfile(log_path):
|
||||
return {'status': 'error', 'error': f'日志文件不存在: {log_path}'}
|
||||
|
||||
# 读取最后 N 行
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
start = max(0, len(all_lines) - lines)
|
||||
content = ''.join(all_lines[start:])
|
||||
|
||||
return {
|
||||
'status': 'ok',
|
||||
'filename': filename,
|
||||
'total_lines': len(all_lines),
|
||||
'returned_lines': len(all_lines) - start,
|
||||
'content': content
|
||||
}
|
||||
except Exception as e:
|
||||
exception(f'read_log_file error: {e}')
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
async def tail_log_file(filename, reset=False):
|
||||
"""日志监控 - 从上次读取位置继续读新增内容
|
||||
|
||||
Args:
|
||||
filename: 日志文件名(必须在白名单中)
|
||||
reset: True=重置位置到文件末尾,下次从末尾开始监控
|
||||
|
||||
Returns:
|
||||
dict: {status, filename, new_lines, content, total_lines}
|
||||
"""
|
||||
if filename not in ALLOWED_LOGS:
|
||||
return {'status': 'error', 'error': f'不允许读取 {filename},仅允许: {", ".join(ALLOWED_LOGS)}'}
|
||||
|
||||
try:
|
||||
env = ServerEnv()
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
|
||||
if not os.path.isdir(log_dir):
|
||||
log_dir = os.path.expanduser('~/repos/sage/logs')
|
||||
|
||||
log_path = os.path.join(log_dir, filename)
|
||||
if not os.path.isfile(log_path):
|
||||
return {'status': 'error', 'error': f'日志文件不存在: {log_path}'}
|
||||
|
||||
# 获取文件修改时间,如果文件被替换则重置位置
|
||||
mtime = os.path.getmtime(log_path)
|
||||
last = _log_tails.get(filename)
|
||||
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
total = len(all_lines)
|
||||
|
||||
# reset=True 或文件被替换(mtime变了且行数变少)时,跳到末尾
|
||||
if reset or (last and last.get('mtime') != mtime and total < last.get('lines', 0)):
|
||||
_log_tails[filename] = {'lines': total, 'mtime': mtime}
|
||||
return {
|
||||
'status': 'ok',
|
||||
'filename': filename,
|
||||
'new_lines': 0,
|
||||
'content': '',
|
||||
'total_lines': total,
|
||||
'reset': True
|
||||
}
|
||||
|
||||
# 从上次位置继续读
|
||||
start = last.get('lines', 0) if last else max(0, total - 100) # 首次读最后100行
|
||||
new_content = ''.join(all_lines[start:])
|
||||
new_count = total - start
|
||||
|
||||
# 更新位置
|
||||
_log_tails[filename] = {'lines': total, 'mtime': mtime}
|
||||
|
||||
return {
|
||||
'status': 'ok',
|
||||
'filename': filename,
|
||||
'new_lines': new_count,
|
||||
'content': new_content,
|
||||
'total_lines': total
|
||||
}
|
||||
except Exception as e:
|
||||
exception(f'tail_log_file error: {e}')
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
def load_bugfix():
|
||||
"""注册函数到 ServerEnv"""
|
||||
env = ServerEnv()
|
||||
env.execute_select_sql = execute_select_sql
|
||||
env.read_log_file = read_log_file
|
||||
env.tail_log_file = tail_log_file
|
||||
debug(f'[bugfix] module loaded')
|
||||
return True
|
||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bugfix"
|
||||
version = "1.0.0"
|
||||
description = "Developer debugging tools module for Sage platform"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"sqlor",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["bugfix*"]
|
||||
76
scripts/load_path.py
Normal file
76
scripts/load_path.py
Normal file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
bugfix 模块 RBAC 权限管理脚本
|
||||
仅限 developer 角色使用
|
||||
|
||||
使用方法:
|
||||
cd ~/repos/sage
|
||||
./py3/bin/python ~/test/bugfix/scripts/load_path.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def find_sage_root():
|
||||
candidates = [
|
||||
os.path.expanduser("~/repos/sage"),
|
||||
os.path.expanduser("~/sage"),
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
SAGE_ROOT = find_sage_root()
|
||||
if not SAGE_ROOT:
|
||||
print("ERROR: Cannot find Sage root directory")
|
||||
sys.exit(1)
|
||||
|
||||
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
|
||||
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py")
|
||||
|
||||
MOD = "bugfix"
|
||||
|
||||
# ============================================================
|
||||
# 权限路径定义 — 仅 developer 角色可访问
|
||||
# ============================================================
|
||||
|
||||
# developer — 仅开发者
|
||||
PATHS_DEVELOPER = [
|
||||
f"/{MOD}",
|
||||
f"/{MOD}/index.ui",
|
||||
f"/{MOD}/api/execute_sql.dspy",
|
||||
f"/{MOD}/api/read_log.dspy",
|
||||
f"/{MOD}/api/tail_log.dspy",
|
||||
]
|
||||
|
||||
|
||||
def run_set_perm(role, path):
|
||||
cmd = [PYTHON, SET_PERM_SCRIPT, role, path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def register_role_paths(role, paths):
|
||||
count = 0
|
||||
for p in paths:
|
||||
if run_set_perm(role, p):
|
||||
count += 1
|
||||
print(f" {role}: {count}/{len(paths)} paths registered")
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Sage root: {SAGE_ROOT}")
|
||||
print("Registering bugfix module RBAC permissions (developer only)...")
|
||||
total = 0
|
||||
total += register_role_paths("developer", PATHS_DEVELOPER)
|
||||
print(f"\nDone. Total {total} permission entries registered.")
|
||||
print("NOTE: Restart Sage after permission changes to reload RBAC cache.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
wwwroot/api/execute_sql.dspy
Normal file
9
wwwroot/api/execute_sql.dspy
Normal file
@ -0,0 +1,9 @@
|
||||
sql = params_kw.get('sql') or ''
|
||||
page = int(params_kw.get('page') or 1)
|
||||
rows = int(params_kw.get('rows') or 20)
|
||||
|
||||
if not sql:
|
||||
return {'status': 'error', 'error': '缺少 sql 参数'}
|
||||
|
||||
result = await execute_select_sql(sql, page, rows)
|
||||
return result
|
||||
8
wwwroot/api/read_log.dspy
Normal file
8
wwwroot/api/read_log.dspy
Normal file
@ -0,0 +1,8 @@
|
||||
filename = params_kw.get('filename') or ''
|
||||
lines = int(params_kw.get('lines') or 500)
|
||||
|
||||
if not filename:
|
||||
return {'status': 'error', 'error': '缺少 filename 参数'}
|
||||
|
||||
result = await read_log_file(filename, lines)
|
||||
return result
|
||||
8
wwwroot/api/tail_log.dspy
Normal file
8
wwwroot/api/tail_log.dspy
Normal file
@ -0,0 +1,8 @@
|
||||
filename = params_kw.get('filename') or ''
|
||||
reset = params_kw.get('reset', '').lower() in ('1', 'true', 'yes')
|
||||
|
||||
if not filename:
|
||||
return {'status': 'error', 'error': '缺少 filename 参数'}
|
||||
|
||||
result = await tail_log_file(filename, reset)
|
||||
return result
|
||||
Loading…
x
Reference in New Issue
Block a user