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