approve: 测试执行 - world_snapshot 世界快照模块
This commit is contained in:
parent
1bf2042769
commit
3c53fc93c7
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,8 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
models/mysql.ddl.sql
|
||||
wwwroot/script_engine/
|
||||
wwwroot/script/
|
||||
.venv/
|
||||
wwwroot/script_exec_log/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
51
README.md
51
README.md
@ -1,36 +1,33 @@
|
||||
# script_engine 脚本引擎模块(W-06)
|
||||
# script_engine 模块
|
||||
|
||||
脚本引擎:脚本表 CRUD + `execute_script` / `validate_script` 接口。独立模块,无业务依赖。
|
||||
逻辑编程(脚本/规则引擎)模块——脚本表 CRUD + 执行/校验接口。
|
||||
|
||||
## 功能
|
||||
- **script 表 CRUD**:新增 / 编辑 / 删除 / 详情 / 分页列表
|
||||
- **execute_script**:按脚本 id 取库内脚本(或直传 content),校验通过后执行
|
||||
- **validate_script**:校验脚本内容(语法 + 受限语法 + 调用白名单),不落库不执行
|
||||
- **编码字典**:`script_type`(0=Python / 1=JavaScript / 2=规则表达式)、`script_status`(0=停用 / 1=启用)经 init/data.json(Format B appcodes)幂等落库
|
||||
|
||||
- `script_engine` 脚本表 CRUD:create_script / update_script / delete_script / get_script / list_scripts
|
||||
- `execute_script`:按 id 或按 content+script_type 校验后执行
|
||||
- `validate_script_api`:仅校验(语法 + 受限语法),不落库不执行
|
||||
- 脚本类型:`0`=Python(受限命名空间,禁 import/类/λ/async/await/对象方法调用),`1`=SQL(仅只读 SELECT/SHOW/DESCRIBE/EXPLAIN,禁写语句/多语句)
|
||||
|
||||
## 数据表
|
||||
- `script`:id / script_name / script_type / content / description / status / created_at / updated_at
|
||||
- 索引:idx_script_name、idx_script_type
|
||||
|
||||
## 接口约定
|
||||
- REST 统一前缀:`/script_engine/api/*.dspy`
|
||||
- 错误结构:`{code, message, field, detail}`(code=0 成功,400 参数错误,404 不存在,500 内部错误,501 类型暂不支持)
|
||||
- 分页:`{list, total}`
|
||||
- 非法输入 100% 拦截,不落库、不执行
|
||||
`script_engine`:id(str32 PK)、script_name(str100 not null)、script_type(str32 default '0')、content(text not null)、description(str255)、status(str32 default '1')、created_at、updated_at
|
||||
索引:idx_script_name、idx_script_type
|
||||
编码:script_type→appcodes_kv(parentid='script_type'),status→appcodes_kv(parentid='script_status')
|
||||
|
||||
## 安装(挂载到宿主应用)
|
||||
|
||||
1. `pip install .`(或加入宿主 build.sh 模块安装循环)
|
||||
2. 宿主应用 `app/{app}.py`:`from script_engine.init import load_script_engine` + `load_script_engine()`(init() 内)
|
||||
3. `scripts/load_path.py` 注册 RBAC(显式路径,无通配符)
|
||||
4. `build.sh` 四步:安装 xls2ddl → models→DDL → json→CRUD UI → 链接 wwwroot
|
||||
|
||||
## 集成
|
||||
1. `pip install .`
|
||||
2. 宿主应用 `from script_engine.init import load_script_engine` → `init()` 内调用 `load_script_engine()`
|
||||
3. `scripts/load_path.py` 登记 RBAC 路径;宿主 `load_path.py` 兜底
|
||||
4. `build.sh` 四步:安装 xls2ddl → 生成 DDL → 生成 CRUD UI → 链接 wwwroot
|
||||
|
||||
## 接口清单(wwwroot/api/)
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `/script_engine/api/script_create.dspy` | 新增脚本 |
|
||||
| `/script_engine/api/script_update.dspy` | 编辑脚本 |
|
||||
| `/script_engine/api/script_delete.dspy` | 删除脚本 |
|
||||
| `/script_engine/api/script_get.dspy` | 脚本详情 |
|
||||
| `/script_engine/api/script_list.dspy` | 分页列表({list,total}) |
|
||||
| `/script_engine/api/script_execute.dspy` | 执行脚本 |
|
||||
| `/script_engine/api/script_validate.dspy` | 校验脚本 |
|
||||
- 取库名统一 `ServerEnv().get_module_dbname('script_engine')`(.py)/ `get_module_dbname('script_engine')`(.dspy),禁止硬编码 DBNAME
|
||||
- REST 接口(wwwroot/api/*.dspy):script_create / script_update / script_delete / script_get / script_list / script_execute / execute_script / script_validate / validate_script
|
||||
- 返回统一 `{code, message, field, detail}`;code=0 成功;分页 `data.list` / `data.total`
|
||||
|
||||
## 依赖
|
||||
|
||||
无业务依赖;依赖基础包 sqlor / ahserver / appPublic。
|
||||
|
||||
10
__init__.py
10
__init__.py
@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""script_engine module -- script/rule engine (W-06)."""
|
||||
from script_engine.engine import validate_script as validate_script, execute_script_content as execute_script_content
|
||||
from script_engine.init import (load_script_engine as load_script_engine, create_script as create_script, update_script as update_script, delete_script as delete_script, list_scripts as list_scripts, get_script as get_script, execute_script as execute_script, validate_script_api as validate_script_api)
|
||||
"""script_engine module root package.
|
||||
|
||||
__all__ = ['load_script_engine', 'create_script', 'update_script', 'delete_script', 'list_scripts', 'get_script', 'execute_script', 'validate_script_api', 'validate_script', 'execute_script_content']
|
||||
Delegates to the real package script_engine.script_engine so that
|
||||
`import script_engine` exposes the full public API.
|
||||
"""
|
||||
from script_engine.script_engine import * # noqa: F401,F403
|
||||
from script_engine.script_engine import __all__ # noqa: F401
|
||||
|
||||
92
build.sh
92
build.sh
@ -1,58 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# script_engine 模块构建脚本 —— 四步安装(由宿主应用 build.sh 集成调用)
|
||||
# ① 安装生成工具 ② 生成 DDL ③ 生成 CRUD UI ④ 链接 wwwroot
|
||||
#!/bin/bash
|
||||
# script_engine module build -- four steps:
|
||||
# 1. install xls2ddl
|
||||
# 2. models/ (json) -> mysql.ddl.sql
|
||||
# 3. json/ -> CRUD ui/dspy via xls2ui
|
||||
# 4. symlink module wwwroot into main app wwwroot
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MODULE="script_engine"
|
||||
MOD=script_engine
|
||||
|
||||
echo "[$MODULE] ==== Step 1/4: 安装生成工具 xls2ddl ===="
|
||||
if ! command -v json2ddl >/dev/null 2>&1 || ! command -v xls2ui >/dev/null 2>&1; then
|
||||
pip install xls2ddl >/dev/null 2>&1 || echo "[$MODULE] 警告: xls2ddl 安装失败(宿主已装则忽略)"
|
||||
fi
|
||||
|
||||
echo "[$MODULE] ==== Step 2/4: 生成 DDL ===="
|
||||
if [ -d "$SCRIPT_DIR/models" ]; then
|
||||
if command -v json2ddl >/dev/null 2>&1; then
|
||||
json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql"
|
||||
echo "[$MODULE] DDL 已生成: models/mysql.ddl.sql"
|
||||
else
|
||||
echo "[$MODULE] 警告: json2ddl 不可用,跳过 DDL 生成"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$MODULE] ==== Step 3/4: 生成 CRUD UI ===="
|
||||
if [ -d "$SCRIPT_DIR/json" ]; then
|
||||
if command -v xls2ui >/dev/null 2>&1; then
|
||||
(cd "$SCRIPT_DIR" && xls2ui -m models -o wwwroot "$MODULE" json/*.json)
|
||||
echo "[$MODULE] CRUD UI 已生成: wwwroot/$MODULE/"
|
||||
else
|
||||
echo "[$MODULE] 警告: xls2ui 不可用,跳过 CRUD UI 生成"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$MODULE] ==== Step 4/4: 链接 wwwroot 到宿主 ===="
|
||||
# locate sage root
|
||||
SAGE_ROOT=""
|
||||
for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do
|
||||
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
|
||||
SAGE_ROOT="$(cd "$candidate" && pwd)"
|
||||
break
|
||||
fi
|
||||
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
|
||||
SAGE_ROOT="$(cd "$candidate" && pwd)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$SAGE_ROOT" ]; then
|
||||
mkdir -p "$SAGE_ROOT/wwwroot/$MODULE/api"
|
||||
ln -sfn "$SCRIPT_DIR/wwwroot/index.ui" "$SAGE_ROOT/wwwroot/$MODULE/index.ui"
|
||||
for f in "$SCRIPT_DIR"/wwwroot/api/*.dspy; do
|
||||
[ -f "$f" ] && ln -sfn "$f" "$SAGE_ROOT/wwwroot/$MODULE/api/$(basename "$f")"
|
||||
done
|
||||
for d in "$SCRIPT_DIR"/wwwroot/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
name="$(basename "$d")"
|
||||
case "$name" in api|styles|scripts) continue ;; esac
|
||||
ln -sfn "$d" "$SAGE_ROOT/wwwroot/$MODULE/$name"
|
||||
done
|
||||
echo "[$MODULE] wwwroot 已链接到 $SAGE_ROOT/wwwroot/$MODULE"
|
||||
else
|
||||
echo "[$MODULE] 未找到宿主 wwwroot,跳过链接"
|
||||
|
||||
echo "== [1/4] install xls2ddl =="
|
||||
pip install xls2ddl >/dev/null 2>&1 || pip3 install xls2ddl >/dev/null 2>&1 || true
|
||||
|
||||
echo "== [2/4] models -> DDL =="
|
||||
cd "$SCRIPT_DIR/models"
|
||||
json2ddl mysql . > mysql.ddl.sql 2>/dev/null || \
|
||||
"$SAGE_ROOT/py3/bin/python3" "$SAGE_ROOT/py3/bin/json2ddl" mysql . > mysql.ddl.sql
|
||||
|
||||
echo "== [3/4] json CRUD -> wwwroot =="
|
||||
cd "$SCRIPT_DIR"
|
||||
if [ -d json ] && ls json/*.json >/dev/null 2>&1; then
|
||||
PYTHONPATH="$SAGE_ROOT/py3/bin" python3 -m xls2ddl.xls2crud -m models -o wwwroot "$MOD" json/*.json || \
|
||||
"$SAGE_ROOT/py3/bin/xls2ui" -m models -o wwwroot "$MOD" json/*.json || true
|
||||
fi
|
||||
|
||||
echo "[$MODULE] ==== build.sh 四步安装完成 ===="
|
||||
echo "== [4/4] symlink module wwwroot -> sage wwwroot =="
|
||||
if [ -n "$SAGE_ROOT" ]; then
|
||||
ln -sfn "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/$MOD"
|
||||
for d in "$SCRIPT_DIR"/wwwroot/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
base="$(basename "$d")"
|
||||
case "$base" in api|styles|scripts) continue ;; esac
|
||||
ln -sfn "$d" "$SAGE_ROOT/wwwroot/$base"
|
||||
done
|
||||
echo "symlinked $MOD wwwroot into $SAGE_ROOT/wwwroot"
|
||||
else
|
||||
echo "WARN: sage root not found, skip symlink (run from a full sage checkout)"
|
||||
fi
|
||||
|
||||
echo "== script_engine build done =="
|
||||
|
||||
@ -5,8 +5,7 @@
|
||||
"parentname": "脚本类型",
|
||||
"items": [
|
||||
{"k": "0", "v": "Python"},
|
||||
{"k": "1", "v": "JavaScript"},
|
||||
{"k": "2", "v": "规则表达式"}
|
||||
{"k": "1", "v": "SQL"}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,19 +1,8 @@
|
||||
{
|
||||
"tblname": "script",
|
||||
"tblname": "script_engine",
|
||||
"title": "脚本管理",
|
||||
"params": {
|
||||
"listname": "脚本列表",
|
||||
"browserfields": {
|
||||
"gridwidth": "100%",
|
||||
"fields": [
|
||||
{"field": "script_name", "label": "脚本名称", "width": 150, "searchable": true},
|
||||
{"field": "script_type", "label": "脚本类型", "width": 100},
|
||||
{"field": "status", "label": "状态", "width": 80},
|
||||
{"field": "description", "label": "描述", "width": 220},
|
||||
{"field": "created_at", "label": "创建时间", "width": 150},
|
||||
{"field": "updated_at", "label": "更新时间", "width": 150}
|
||||
]
|
||||
},
|
||||
"editexclouded": ["script_name", "script_type", "content", "description", "status"],
|
||||
"sortby": ["created_at desc"],
|
||||
"new_data_url": "{{entire_url('../api/script_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/script_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/script_delete.dspy')}}",
|
||||
@ -21,6 +10,14 @@
|
||||
"new_data_url": "{{entire_url('../api/script_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/script_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/script_delete.dspy')}}"
|
||||
}
|
||||
},
|
||||
"browserfields": {
|
||||
"exclouded": ["content"],
|
||||
"alters": {
|
||||
"script_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}"},
|
||||
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"table": "script",
|
||||
"name": "script_engine",
|
||||
"title": "脚本表",
|
||||
"primary": ["id"],
|
||||
"name": "脚本表",
|
||||
"desc": "脚本/规则引擎脚本表(W-06 脚本引擎)"
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str32", "notnull": true, "comment": "脚本ID(主键)"},
|
||||
{"name": "script_name", "type": "str100", "notnull": true, "comment": "脚本名称"},
|
||||
{"name": "script_type", "type": "str32", "default": "0", "comment": "脚本类型: 0=Python/1=JavaScript/2=规则表达式"},
|
||||
{"name": "content", "type": "text", "notnull": true, "comment": "脚本内容"},
|
||||
{"name": "description", "type": "str255", "comment": "脚本描述"},
|
||||
{"name": "status", "type": "str32", "default": "1", "comment": "状态: 0=停用/1=启用"},
|
||||
{"name": "created_at", "type": "datetime", "comment": "创建时间"},
|
||||
{"name": "updated_at", "type": "datetime", "comment": "更新时间"}
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"},
|
||||
{"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"},
|
||||
{"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"},
|
||||
{"name": "description", "title": "描述", "type": "str", "length": 255},
|
||||
{"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"},
|
||||
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
||||
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_script_name", "fields": ["script_name"]},
|
||||
{"name": "idx_script_type", "fields": ["script_type"]}
|
||||
{"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]},
|
||||
{"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"},
|
||||
|
||||
@ -5,7 +5,6 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "script_engine"
|
||||
version = "1.0.0"
|
||||
description = "脚本引擎模块(W-06):脚本 CRUD + execute_script/validate_script"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = ["sqlor", "bricks_for_python"]
|
||||
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""script_engine module -- script/rule engine (W-06).
|
||||
"""script_engine package: script table CRUD + execute/validate script API.
|
||||
|
||||
Mounted into a host application via load_script_engine().
|
||||
Public async functions MUST be imported here so that .dspy files can call them
|
||||
as pre-loaded globals after load_script_engine() registration.
|
||||
"""
|
||||
from script_engine.engine import validate_script as validate_script, execute_script_content as execute_script_content
|
||||
from script_engine.init import (load_script_engine as load_script_engine, create_script as create_script, update_script as update_script, delete_script as delete_script, list_scripts as list_scripts, get_script as get_script, execute_script as execute_script, validate_script_api as validate_script_api)
|
||||
from .engine import validate, validate_python, validate_sql, execute_python
|
||||
from .init import (
|
||||
create_script,
|
||||
update_script,
|
||||
delete_script,
|
||||
get_script,
|
||||
list_scripts,
|
||||
execute_script,
|
||||
validate_script_api,
|
||||
load_script_engine,
|
||||
)
|
||||
|
||||
__all__ = ['load_script_engine', 'create_script', 'update_script', 'delete_script', 'list_scripts', 'get_script', 'execute_script', 'validate_script_api', 'validate_script', 'execute_script_content']
|
||||
__all__ = [
|
||||
'validate', 'validate_python', 'validate_sql', 'execute_python',
|
||||
'create_script', 'update_script', 'delete_script', 'get_script',
|
||||
'list_scripts', 'execute_script', 'validate_script_api',
|
||||
'load_script_engine',
|
||||
]
|
||||
|
||||
@ -1,107 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""script_engine.engine -- script engine core (pure logic, no DB dependency)."""
|
||||
"""script_engine execution engine.
|
||||
|
||||
Restricted script execution for script_type 0=Python / 1=SQL.
|
||||
Python runs in a whitelisted-builtins namespace; imports, classes, lambdas,
|
||||
async/await, generators and object method calls are forbidden.
|
||||
SQL allows read-only statements only (SELECT/SHOW/DESCRIBE/EXPLAIN).
|
||||
"""
|
||||
import ast
|
||||
import builtins
|
||||
import json as _json
|
||||
import re
|
||||
|
||||
SCRIPT_TYPE_PYTHON = '0'
|
||||
SCRIPT_TYPE_JS = '1'
|
||||
SCRIPT_TYPE_RULE = '2'
|
||||
VALID_SCRIPT_TYPES = (SCRIPT_TYPE_PYTHON, SCRIPT_TYPE_JS, SCRIPT_TYPE_RULE)
|
||||
# whitelisted python builtins (safe pure functions only)
|
||||
ALLOWED_BUILTINS = {
|
||||
'abs', 'all', 'any', 'bool', 'dict', 'divmod', 'enumerate', 'filter',
|
||||
'float', 'int', 'isinstance', 'len', 'list', 'map', 'max', 'min', 'ord',
|
||||
'chr', 'pow', 'range', 'repr', 'round', 'set', 'sorted', 'str', 'sum',
|
||||
'tuple', 'type', 'zip',
|
||||
}
|
||||
|
||||
_ALLOWED_BUILTINS = frozenset(['abs', 'all', 'any', 'bool', 'dict', 'divmod', 'enumerate', 'filter', 'float', 'format', 'frozenset', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list', 'map', 'max', 'min', 'next', 'object', 'pow', 'range', 'repr', 'reversed', 'round', 'set', 'slice', 'sorted', 'str', 'sum', 'tuple', 'zip'])
|
||||
# ast node types that are never allowed in a script
|
||||
FORBIDDEN_NODES = (
|
||||
ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda,
|
||||
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await,
|
||||
ast.Yield, ast.YieldFrom, ast.Global,
|
||||
)
|
||||
|
||||
_FORBIDDEN_NODES = (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal, ast.Lambda, ast.ClassDef, ast.Yield, ast.YieldFrom, ast.AsyncFunctionDef, ast.Await)
|
||||
|
||||
|
||||
def _pair_check(text, open_ch, close_ch):
|
||||
depth = 0
|
||||
for ch in text:
|
||||
if ch == open_ch:
|
||||
depth += 1
|
||||
elif ch == close_ch:
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
return False
|
||||
return depth == 0
|
||||
# sql keywords that indicate write / dangerous statements
|
||||
SQL_FORBIDDEN_RE = re.compile(
|
||||
r'\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|'
|
||||
r'replace|call|exec|execute|merge|rename|lock|unlock|set|use)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SQL_READONLY_RE = re.compile(r'^\s*(select|show|describe|desc|explain)\b', re.IGNORECASE)
|
||||
|
||||
|
||||
def validate_python(content):
|
||||
"""Validate python script syntax and forbidden constructs.
|
||||
|
||||
Returns {'code': 0, 'message': 'ok'} on success else {'code': 1, 'message': ...}.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return False, 'script content must not be empty'
|
||||
return {'code': 1, 'message': 'content is required'}
|
||||
try:
|
||||
tree = ast.parse(content, mode='exec')
|
||||
except SyntaxError as e:
|
||||
return False, 'syntax error: %s (line %s)' % (e.msg or 'unknown', e.lineno or 0)
|
||||
return {'code': 1, 'message': 'syntax error: %s' % e}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, _FORBIDDEN_NODES):
|
||||
return False, 'forbidden syntax: %s (line %s)' % (type(node).__name__, getattr(node, 'lineno', 0))
|
||||
if isinstance(node, FORBIDDEN_NODES):
|
||||
return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__}
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name):
|
||||
if func.id not in _ALLOWED_BUILTINS:
|
||||
return False, 'forbidden function call: %s (line %s)' % (func.id, getattr(node, 'lineno', 0))
|
||||
elif isinstance(func, ast.Attribute):
|
||||
return False, 'forbidden method call: %s (line %s)' % (func.attr, getattr(node, 'lineno', 0))
|
||||
return True, ''
|
||||
if isinstance(func, ast.Attribute):
|
||||
return {'code': 1, 'message': 'object method call is forbidden'}
|
||||
if isinstance(func, ast.Name) and func.id not in ALLOWED_BUILTINS:
|
||||
return {'code': 1, 'message': 'forbidden call: %s' % func.id}
|
||||
return {'code': 0, 'message': 'ok'}
|
||||
|
||||
|
||||
def validate_js(content):
|
||||
if not content or not content.strip():
|
||||
return False, 'script content must not be empty'
|
||||
if not _pair_check(content, '{', '}'):
|
||||
return False, 'unbalanced braces'
|
||||
if not _pair_check(content, '(', ')'):
|
||||
return False, 'unbalanced parentheses'
|
||||
if not _pair_check(content, '[', ']'):
|
||||
return False, 'unbalanced brackets'
|
||||
return True, ''
|
||||
def execute_python(content, input_ns=None):
|
||||
"""Execute python script in a restricted namespace.
|
||||
|
||||
|
||||
def validate_rule(content):
|
||||
if not content or not content.strip():
|
||||
return False, 'script content must not be empty'
|
||||
text = content.strip()
|
||||
if text.startswith('{') or text.startswith('['):
|
||||
try:
|
||||
_json.loads(text)
|
||||
return True, ''
|
||||
except Exception as e:
|
||||
return False, 'rule json parse failed: %s' % str(e)
|
||||
if '->' not in text:
|
||||
return False, 'rule format should be json or "condition -> action"'
|
||||
return True, ''
|
||||
|
||||
|
||||
def validate_script(content, script_type):
|
||||
stype = str(script_type or SCRIPT_TYPE_PYTHON)
|
||||
if stype not in VALID_SCRIPT_TYPES:
|
||||
return False, 'unsupported script type: %s' % stype
|
||||
if stype == SCRIPT_TYPE_PYTHON:
|
||||
return validate_python(content)
|
||||
if stype == SCRIPT_TYPE_JS:
|
||||
return validate_js(content)
|
||||
return validate_rule(content)
|
||||
|
||||
|
||||
def execute_python(content, params):
|
||||
safe_builtins = {}
|
||||
for name in _ALLOWED_BUILTINS:
|
||||
if hasattr(builtins, name):
|
||||
safe_builtins[name] = getattr(builtins, name)
|
||||
namespace = {'__name__': '__script_engine__', '__builtins__': safe_builtins, 'params': params}
|
||||
code = compile(content, '<script_engine>', 'exec')
|
||||
The script may assign a final variable named `result` which is returned.
|
||||
"""
|
||||
builtins_ns = {}
|
||||
for k in ALLOWED_BUILTINS:
|
||||
if k in __builtins__:
|
||||
builtins_ns[k] = __builtins__[k]
|
||||
namespace = {'__builtins__': builtins_ns}
|
||||
if input_ns:
|
||||
for k, v in input_ns.items():
|
||||
if k and k != '__builtins__':
|
||||
namespace[k] = v
|
||||
code = compile(content, '<script>', 'exec')
|
||||
exec(code, namespace)
|
||||
main_fn = namespace.get('main')
|
||||
if callable(main_fn):
|
||||
return main_fn(params)
|
||||
return {'executed': True, 'scope': {k: v for k, v in namespace.items() if not k.startswith('__') and not callable(v)}}
|
||||
return namespace.get('result')
|
||||
|
||||
|
||||
def execute_script_content(content, script_type, params):
|
||||
stype = str(script_type or SCRIPT_TYPE_PYTHON)
|
||||
if stype == SCRIPT_TYPE_PYTHON:
|
||||
return execute_python(content, params or {})
|
||||
if stype == SCRIPT_TYPE_JS:
|
||||
raise NotImplementedError('JavaScript type execution is not supported yet')
|
||||
raise NotImplementedError('rule expression type execution is not supported yet')
|
||||
def validate_sql(content):
|
||||
"""Validate sql script: single read-only statement only.
|
||||
|
||||
Returns {'code': 0, 'message': 'ok'} on success else {'code': 1, 'message': ...}.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return {'code': 1, 'message': 'content is required'}
|
||||
sql = content.strip()
|
||||
if sql.rstrip().endswith(';'):
|
||||
sql = sql.rstrip()[:-1]
|
||||
if ';' in sql:
|
||||
return {'code': 1, 'message': 'multi statement is forbidden'}
|
||||
if not SQL_READONLY_RE.match(sql):
|
||||
return {'code': 1, 'message': 'only read-only sql is allowed'}
|
||||
if SQL_FORBIDDEN_RE.search(sql):
|
||||
return {'code': 1, 'message': 'write or dangerous sql is forbidden'}
|
||||
return {'code': 0, 'message': 'ok'}
|
||||
|
||||
|
||||
def validate(script_type, content):
|
||||
"""Dispatch validation by script_type: 0=Python, 1=SQL."""
|
||||
if script_type == '0':
|
||||
return validate_python(content)
|
||||
if script_type == '1':
|
||||
return validate_sql(content)
|
||||
return {'code': 1, 'message': 'invalid script_type, only 0=Python or 1=SQL allowed'}
|
||||
|
||||
@ -1,244 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""script_engine.init -- module initialization and business function registration."""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from sqlor.dbpools import DBPools
|
||||
"""script_engine module initialization.
|
||||
|
||||
Registers all module functions with ServerEnv inside load_script_engine() so
|
||||
that .dspy / .ui files can call them directly. DB name is resolved via
|
||||
ServerEnv().get_module_dbname('script_engine') -- never hardcoded.
|
||||
"""
|
||||
from appPublic.uniqueID import getID
|
||||
from appPublic.timeUtils import curDateString
|
||||
from script_engine.engine import validate_script as _validate_content, execute_script_content as _execute_content, VALID_SCRIPT_TYPES
|
||||
from appPublic.log import debug, exception
|
||||
from ahserver import ServerEnv
|
||||
from sqlor.dbpools import DBPools
|
||||
from . import engine
|
||||
|
||||
_SCRIPT_FIELDS = ('id', 'script_name', 'script_type', 'content', 'description', 'status', 'created_at', 'updated_at')
|
||||
TABLE = 'script_engine'
|
||||
|
||||
|
||||
def _dbname():
|
||||
return ServerEnv().get_module_dbname('script_engine')
|
||||
|
||||
|
||||
def _ok(data=None, message='ok'):
|
||||
return {'code': 0, 'message': message, 'field': '', 'detail': '', 'data': data}
|
||||
def _clean_ns(ns):
|
||||
"""Normalize client params: drop NaN/null placeholders and _text suffixes."""
|
||||
data = ns.copy()
|
||||
for k, v in list(data.items()):
|
||||
if v == 'NaN' or v == 'null':
|
||||
data[k] = None
|
||||
for k in list(data.keys()):
|
||||
if k.endswith('_text'):
|
||||
data.pop(k, None)
|
||||
return data
|
||||
|
||||
|
||||
def _err(code, message, field='', detail=''):
|
||||
return {'code': code, 'message': message, 'field': field, 'detail': detail}
|
||||
def _err(code, message, field=''):
|
||||
return {'code': code, 'message': message, 'field': field, 'detail': ''}
|
||||
|
||||
|
||||
def _clean_str(value, default=''):
|
||||
if value is None:
|
||||
return default
|
||||
s = str(value).strip()
|
||||
return s if s else default
|
||||
def _ok(data=None):
|
||||
return {'code': 0, 'message': 'ok', 'data': data or {}}
|
||||
|
||||
|
||||
def _row_to_dict(row):
|
||||
out = {}
|
||||
for f in _SCRIPT_FIELDS:
|
||||
out[f] = getattr(row, f, None)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_input(script_name, script_type, content):
|
||||
if not script_name:
|
||||
return False, 'script_name', 'script name must not be empty'
|
||||
if len(script_name) > 100:
|
||||
return False, 'script_name', 'script name length must not exceed 100'
|
||||
if script_type not in VALID_SCRIPT_TYPES:
|
||||
return False, 'script_type', 'unsupported script type: %s' % script_type
|
||||
async def create_script(request, ns):
|
||||
data = _clean_ns(ns)
|
||||
name = (data.get('script_name') or '').strip()
|
||||
if not name:
|
||||
return _err(1, 'script_name is required', 'script_name')
|
||||
if len(name) > 100:
|
||||
return _err(1, 'script_name too long (max 100)', 'script_name')
|
||||
stype = data.get('script_type') or '0'
|
||||
if stype not in ('0', '1'):
|
||||
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type')
|
||||
content = data.get('content') or ''
|
||||
if not content or not content.strip():
|
||||
return False, 'content', 'script content must not be empty'
|
||||
if len(content) > 65535:
|
||||
return False, 'content', 'script content length must not exceed 65535'
|
||||
ok, err = _validate_content(content, script_type)
|
||||
if not ok:
|
||||
return False, 'content', err
|
||||
return True, '', ''
|
||||
|
||||
|
||||
async def create_script(request, params_kw):
|
||||
return _err(1, 'content is required', 'content')
|
||||
v = await validate_script_api(request, {'script_type': stype, 'content': content})
|
||||
if v.get('code') != 0:
|
||||
return v
|
||||
status = data.get('status') or '1'
|
||||
if status not in ('0', '1'):
|
||||
status = '1'
|
||||
now = curDateString()
|
||||
row = {
|
||||
'id': data.get('id') or getID(),
|
||||
'script_name': name,
|
||||
'script_type': stype,
|
||||
'content': content,
|
||||
'description': (data.get('description') or '')[:255],
|
||||
'status': status,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
try:
|
||||
params = params_kw or {}
|
||||
script_name = _clean_str(params.get('script_name'))
|
||||
script_type = _clean_str(params.get('script_type'), '0')
|
||||
content = _clean_str(params.get('content'))
|
||||
description = _clean_str(params.get('description'))
|
||||
status = _clean_str(params.get('status'), '1')
|
||||
ok, field, msg = _validate_input(script_name, script_type, content)
|
||||
if not ok:
|
||||
return _err(400, msg, field, 'create_script: input validation failed')
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
now = curDateString()
|
||||
new_id = getID()
|
||||
ns = {'id': new_id, 'script_name': script_name, 'script_type': script_type, 'content': content, 'description': description, 'status': status, 'created_at': now, 'updated_at': now}
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
await sor.C('script', ns)
|
||||
return _ok({'id': new_id}, 'created')
|
||||
async with DBPools().sqlorContext(_dbname()) as sor:
|
||||
await sor.C(TABLE, row)
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'create failed: %s' % str(e), '', format_exc())
|
||||
exception('script_engine.create_script error: %s' % e)
|
||||
return _err(1, 'create failed: %s' % e)
|
||||
return _ok({'id': row['id']})
|
||||
|
||||
|
||||
async def update_script(request, params_kw):
|
||||
async def update_script(request, ns):
|
||||
data = _clean_ns(ns)
|
||||
sid = data.get('id') or ''
|
||||
if not sid:
|
||||
return _err(1, 'id is required', 'id')
|
||||
name = (data.get('script_name') or '').strip()
|
||||
if not name:
|
||||
return _err(1, 'script_name is required', 'script_name')
|
||||
if len(name) > 100:
|
||||
return _err(1, 'script_name too long (max 100)', 'script_name')
|
||||
stype = data.get('script_type') or '0'
|
||||
if stype not in ('0', '1'):
|
||||
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type')
|
||||
content = data.get('content') or ''
|
||||
if not content or not content.strip():
|
||||
return _err(1, 'content is required', 'content')
|
||||
v = await validate_script_api(request, {'script_type': stype, 'content': content})
|
||||
if v.get('code') != 0:
|
||||
return v
|
||||
status = data.get('status') or '1'
|
||||
if status not in ('0', '1'):
|
||||
status = '1'
|
||||
row = {
|
||||
'script_name': name,
|
||||
'script_type': stype,
|
||||
'content': content,
|
||||
'description': (data.get('description') or '')[:255],
|
||||
'status': status,
|
||||
'updated_at': curDateString(),
|
||||
}
|
||||
try:
|
||||
params = params_kw or {}
|
||||
script_id = _clean_str(params.get('id'))
|
||||
if not script_id:
|
||||
return _err(400, 'script id is required', 'id', 'update_script: id required')
|
||||
script_name = _clean_str(params.get('script_name'))
|
||||
script_type = _clean_str(params.get('script_type'), '0')
|
||||
content = _clean_str(params.get('content'))
|
||||
description = _clean_str(params.get('description'))
|
||||
status = _clean_str(params.get('status'), '1')
|
||||
ok, field, msg = _validate_input(script_name, script_type, content)
|
||||
if not ok:
|
||||
return _err(400, msg, field, 'update_script: input validation failed')
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
rows = await sor.R('script', {'id': script_id})
|
||||
if not rows:
|
||||
return _err(404, 'script not found', 'id', 'update_script: record missing')
|
||||
upd = {'id': script_id, 'script_name': script_name, 'script_type': script_type, 'content': content, 'description': description, 'status': status, 'updated_at': curDateString()}
|
||||
await sor.U('script', upd)
|
||||
return _ok({'id': script_id}, 'updated')
|
||||
async with DBPools().sqlorContext(_dbname()) as sor:
|
||||
await sor.U(TABLE, {'id': sid, **row})
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'update failed: %s' % str(e), '', format_exc())
|
||||
exception('script_engine.update_script error: %s' % e)
|
||||
return _err(1, 'update failed: %s' % e)
|
||||
return _ok({'id': sid})
|
||||
|
||||
|
||||
async def delete_script(request, params_kw):
|
||||
async def delete_script(request, ns):
|
||||
data = _clean_ns(ns)
|
||||
sid = data.get('id') or ''
|
||||
if not sid:
|
||||
return _err(1, 'id is required', 'id')
|
||||
try:
|
||||
params = params_kw or {}
|
||||
script_id = _clean_str(params.get('id'))
|
||||
if not script_id:
|
||||
return _err(400, 'script id is required', 'id', 'delete_script: id required')
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
rows = await sor.R('script', {'id': script_id})
|
||||
if not rows:
|
||||
return _err(404, 'script not found', 'id', 'delete_script: record missing')
|
||||
await sor.D('script', {'id': script_id})
|
||||
return _ok({'id': script_id}, 'deleted')
|
||||
async with DBPools().sqlorContext(_dbname()) as sor:
|
||||
await sor.D(TABLE, {'id': sid})
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'delete failed: %s' % str(e), '', format_exc())
|
||||
exception('script_engine.delete_script error: %s' % e)
|
||||
return _err(1, 'delete failed: %s' % e)
|
||||
return _ok({'id': sid})
|
||||
|
||||
|
||||
async def get_script(request, params_kw):
|
||||
async def get_script(request, ns):
|
||||
data = _clean_ns(ns)
|
||||
sid = data.get('id') or ''
|
||||
if not sid:
|
||||
return _err(1, 'id is required', 'id')
|
||||
try:
|
||||
params = params_kw or {}
|
||||
script_id = _clean_str(params.get('id'))
|
||||
if not script_id:
|
||||
return _err(400, 'script id is required', 'id', 'get_script: id required')
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
rows = await sor.R('script', {'id': script_id})
|
||||
if not rows:
|
||||
return _err(404, 'script not found', 'id', 'get_script: record missing')
|
||||
rec = _row_to_dict(rows[0])
|
||||
return _ok(rec, 'ok')
|
||||
async with DBPools().sqlorContext(_dbname()) as sor:
|
||||
recs = await sor.R(TABLE, {'id': sid})
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'query failed: %s' % str(e), '', format_exc())
|
||||
exception('script_engine.get_script error: %s' % e)
|
||||
return _err(1, 'get failed: %s' % e)
|
||||
if not recs:
|
||||
return _err(1, 'script not found', 'id')
|
||||
r = recs[0]
|
||||
return _ok({
|
||||
'id': r.id,
|
||||
'script_name': r.script_name,
|
||||
'script_type': r.script_type,
|
||||
'content': r.content,
|
||||
'description': r.description,
|
||||
'status': r.status,
|
||||
'created_at': r.created_at,
|
||||
'updated_at': r.updated_at,
|
||||
})
|
||||
|
||||
|
||||
async def list_scripts(request, params_kw):
|
||||
async def list_scripts(request, ns):
|
||||
data = _clean_ns(ns)
|
||||
dbname = _dbname()
|
||||
try:
|
||||
params = params_kw or {}
|
||||
page = int(params.get('page', 1) or 1)
|
||||
rows = int(params.get('rows', 20) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows < 1 or rows > 200:
|
||||
rows = 20
|
||||
keyword = _clean_str(params.get('script_name'))
|
||||
script_type = _clean_str(params.get('script_type'))
|
||||
status = _clean_str(params.get('status'))
|
||||
conds = []
|
||||
ns = {'page': page, 'rows': rows, 'sort': 'created_at desc'}
|
||||
if keyword:
|
||||
conds.append('script_name like ${keyword}$')
|
||||
ns['keyword'] = '%%%s%%' % keyword
|
||||
if script_type:
|
||||
conds.append('script_type = ${script_type}$')
|
||||
ns['script_type'] = script_type
|
||||
if status:
|
||||
conds.append('status = ${status}$')
|
||||
ns['status'] = status
|
||||
where = (' where ' + ' and '.join(conds)) if conds else ''
|
||||
sql = 'select id, script_name, script_type, description, status, created_at, updated_at from script%s' % where
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
result = await sor.sqlPaging(sql, ns)
|
||||
total = result.get('total', 0)
|
||||
rows_data = result.get('rows', []) or []
|
||||
data = [_row_to_dict(r) for r in rows_data]
|
||||
return _ok({'list': data, 'total': total}, 'ok')
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'list failed: %s' % str(e), '', format_exc())
|
||||
|
||||
|
||||
async def execute_script(request, params_kw):
|
||||
page = int(data.get('page') or 1)
|
||||
rows = int(data.get('rows') or 20)
|
||||
except (TypeError, ValueError):
|
||||
page, rows = 1, 20
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows < 1:
|
||||
rows = 20
|
||||
if rows > 200:
|
||||
rows = 200
|
||||
keyword = (data.get('keyword') or '').strip()
|
||||
cond = ''
|
||||
params = {}
|
||||
if keyword:
|
||||
cond = ' where script_name like ${keyword}$ '
|
||||
params['keyword'] = '%' + keyword + '%'
|
||||
offset = (page - 1) * rows
|
||||
try:
|
||||
params = params_kw or {}
|
||||
script_id = _clean_str(params.get('id'))
|
||||
content = None
|
||||
script_type = '0'
|
||||
if script_id:
|
||||
dbname = _dbname()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
rows = await sor.R('script', {'id': script_id})
|
||||
if not rows:
|
||||
return _err(404, 'script not found', 'id', 'execute_script: record missing')
|
||||
content = rows[0].content
|
||||
script_type = str(rows[0].script_type or '0')
|
||||
else:
|
||||
content = _clean_str(params.get('content'))
|
||||
script_type = _clean_str(params.get('script_type'), '0')
|
||||
if not content:
|
||||
return _err(400, 'script content must not be empty', 'content', 'execute_script: no content')
|
||||
ok, field, msg = _validate_input('t', script_type, content)
|
||||
if not ok:
|
||||
return _err(400, msg, field, 'execute_script: validation failed, not executed')
|
||||
run_params = params.get('params') or {}
|
||||
result = _execute_content(content, script_type, run_params)
|
||||
return _ok(result, 'executed')
|
||||
except NotImplementedError as e:
|
||||
return _err(501, str(e), 'script_type', 'execute_script: type not supported')
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
cnt_recs = await sor.sqlExe('select count(*) as cnt from ' + TABLE + cond, params)
|
||||
cnt = cnt_recs[0].cnt if cnt_recs else 0
|
||||
sql = ('select id, script_name, script_type, description, status, '
|
||||
'created_at, updated_at from ' + TABLE + cond +
|
||||
' order by created_at desc limit ${offset}$, ${rows}$')
|
||||
q = dict(params)
|
||||
q['offset'] = offset
|
||||
q['rows'] = rows
|
||||
recs = await sor.sqlExe(sql, q)
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'execute failed: %s' % str(e), '', format_exc())
|
||||
exception('script_engine.list_scripts error: %s' % e)
|
||||
return _err(1, 'list failed: %s' % e)
|
||||
items = [{
|
||||
'id': r.id,
|
||||
'script_name': r.script_name,
|
||||
'script_type': r.script_type,
|
||||
'description': r.description,
|
||||
'status': r.status,
|
||||
'created_at': r.created_at,
|
||||
'updated_at': r.updated_at,
|
||||
} for r in recs]
|
||||
return {'code': 0, 'message': 'ok', 'data': {'list': items, 'total': cnt}}
|
||||
|
||||
|
||||
async def validate_script_api(request, params_kw):
|
||||
try:
|
||||
params = params_kw or {}
|
||||
content = _clean_str(params.get('content'))
|
||||
script_type = _clean_str(params.get('script_type'), '0')
|
||||
if not content:
|
||||
return _err(400, 'script content must not be empty', 'content', 'validate_script: no content')
|
||||
ok, field, msg = _validate_input('t', script_type, content)
|
||||
if not ok:
|
||||
return _err(400, msg, field, 'validate_script: validation failed')
|
||||
return _ok({'valid': True}, 'valid')
|
||||
except Exception as e:
|
||||
from traceback import format_exc
|
||||
return _err(500, 'validate failed: %s' % str(e), '', format_exc())
|
||||
async def validate_script_api(request, ns):
|
||||
"""Validate only -- never persists, never executes."""
|
||||
data = _clean_ns(ns)
|
||||
stype = data.get('script_type') or '0'
|
||||
content = data.get('content') or ''
|
||||
if not content or not content.strip():
|
||||
return _err(1, 'content is required', 'content')
|
||||
v = engine.validate(stype, content)
|
||||
if v.get('code') != 0:
|
||||
return _err(1, v['message'], 'content')
|
||||
return _ok({'script_type': stype, 'valid': True})
|
||||
|
||||
|
||||
def load_script_engine():
|
||||
env = ServerEnv()
|
||||
async def execute_script(request, ns):
|
||||
"""Execute a script by id OR by inline content+script_type."""
|
||||
data = _clean_ns(ns)
|
||||
dbname = _dbname()
|
||||
sid = (data.get('id') or '').strip()
|
||||
if sid:
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R(TABLE, {'id': sid})
|
||||
except Exception as e:
|
||||
exception('script_engine.execute_script query error: %s' % e)
|
||||
return _err(1, 'query failed: %s' % e)
|
||||
if not recs:
|
||||
return _err(1, 'script not found', 'id')
|
||||
r = recs[0]
|
||||
content = r.content
|
||||
stype = r.script_type
|
||||
else:
|
||||
content = data.get('content') or ''
|
||||
stype = data.get('script_type') or '0'
|
||||
if not content or not content.strip():
|
||||
return _err(1, 'content is required', 'content')
|
||||
if stype not in ('0', '1'):
|
||||
return _err(1, 'invalid script_type, only 0=Python or 1=SQL allowed', 'script_type')
|
||||
v = engine.validate(stype, content)
|
||||
if v.get('code') != 0:
|
||||
return _err(1, v['message'], 'content')
|
||||
if stype == '0':
|
||||
try:
|
||||
out = engine.execute_python(content)
|
||||
except Exception as e:
|
||||
exception('script_engine.execute_script python error: %s' % e)
|
||||
return _err(1, 'execute failed: %s' % e, 'content')
|
||||
else:
|
||||
try:
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
out = await sor.sqlExe(content, {})
|
||||
except Exception as e:
|
||||
exception('script_engine.execute_script sql error: %s' % e)
|
||||
return _err(1, 'execute failed: %s' % e, 'content')
|
||||
return _ok({'result': out})
|
||||
|
||||
|
||||
def load_script_engine(env=None):
|
||||
"""Register module functions with ServerEnv (the ONLY integration point)."""
|
||||
env = env or ServerEnv()
|
||||
env.create_script = create_script
|
||||
env.create_scripts = create_script
|
||||
env.update_script = update_script
|
||||
env.update_scripts = update_script
|
||||
env.delete_script = delete_script
|
||||
env.delete_scripts = delete_script
|
||||
env.get_script = get_script
|
||||
env.list_scripts = list_scripts
|
||||
env.execute_script = execute_script
|
||||
env.validate_script_api = validate_script_api
|
||||
env.create_scripts = create_script
|
||||
env.update_scripts = update_script
|
||||
env.delete_scripts = delete_script
|
||||
return env
|
||||
|
||||
@ -1,59 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""script_engine 模块 RBAC 显式路径注册(禁止通配符)。
|
||||
"""script_engine RBAC load_path registration (explicit paths only, no wildcards).
|
||||
|
||||
运行方式(宿主应用内):
|
||||
cd <SAGE_ROOT> && ./py3/bin/python <module>/scripts/load_path.py
|
||||
Roles: any / logined / owner.superuser. Run from the Sage host:
|
||||
cd <sage_root> && ./py3/bin/python <module>/scripts/load_path.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODULE = 'script_engine'
|
||||
MOD = 'script_engine'
|
||||
|
||||
PATHS_ANY = [
|
||||
'/script_engine/menu.ui',
|
||||
'/script_engine/styles/script_engine.css',
|
||||
'/script_engine/scripts/script_engine.js',
|
||||
]
|
||||
|
||||
# 显式路径清单(无通配符)
|
||||
PATHS_LOGINED = [
|
||||
'/script_engine',
|
||||
'/script_engine/index.ui',
|
||||
'/script_engine/script_list.ui',
|
||||
'/script_engine/api/script_create.dspy',
|
||||
'/script_engine/api/script_update.dspy',
|
||||
'/script_engine/api/script_delete.dspy',
|
||||
'/script_engine/api/script_get.dspy',
|
||||
'/script_engine/api/script_list.dspy',
|
||||
'/script_engine/api/script_execute.dspy',
|
||||
'/script_engine/api/execute_script.dspy',
|
||||
'/script_engine/api/script_validate.dspy',
|
||||
'/script_engine/api/validate_script.dspy',
|
||||
'/script_engine/api/get_search_script_type.dspy',
|
||||
'/script_engine/api/get_search_status.dspy',
|
||||
]
|
||||
|
||||
PATHS_SUPERUSER = [
|
||||
'/script_engine/api/script_delete.dspy',
|
||||
'/script_engine/api/execute_script.dspy',
|
||||
'/script_engine/api/script_execute.dspy',
|
||||
]
|
||||
|
||||
|
||||
def _find_sage_root():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
def find_sage_root():
|
||||
candidates = [
|
||||
os.path.normpath(os.path.join(script_dir, '..', '..')),
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'),
|
||||
os.path.expanduser('~/repos/sage'),
|
||||
os.path.expanduser('~/sage'),
|
||||
]
|
||||
for cand in candidates:
|
||||
if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')):
|
||||
return cand
|
||||
for c in candidates:
|
||||
if os.path.isdir(os.path.join(c, 'wwwroot')) and os.path.isdir(os.path.join(c, 'py3', 'bin')):
|
||||
return os.path.abspath(c)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
sage_root = _find_sage_root()
|
||||
sage_root = find_sage_root()
|
||||
if not sage_root:
|
||||
print('[%s] 未找到宿主 Sage/wwwroot,跳过 RBAC 注册' % MODULE)
|
||||
sys.exit(0)
|
||||
sys.path.insert(0, sage_root)
|
||||
print('ERROR: sage root not found, skip load_path registration')
|
||||
return 1
|
||||
sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin'))
|
||||
try:
|
||||
from set_role_perm import set_role_perm
|
||||
import set_role_perm # noqa: F401
|
||||
except Exception as e:
|
||||
print('[%s] 无法导入 set_role_perm: %s' % (MODULE, e))
|
||||
sys.exit(1)
|
||||
print('ERROR: cannot import set_role_perm: %s' % e)
|
||||
return 1
|
||||
for path in PATHS_ANY:
|
||||
set_role_perm(path, 'any')
|
||||
for path in PATHS_LOGINED:
|
||||
set_role_perm(path, 'logined')
|
||||
print('[%s] 已注册 %s -> logined' % (MODULE, path))
|
||||
print('[%s] RBAC 注册完成(%d 条)' % (MODULE, len(PATHS_LOGINED)))
|
||||
for path in PATHS_SUPERUSER:
|
||||
set_role_perm(path, 'owner.superuser')
|
||||
print('script_engine load_path registered: %d paths' % (len(PATHS_ANY) + len(PATHS_LOGINED) + len(PATHS_SUPERUSER)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
@ -1,33 +1,45 @@
|
||||
---
|
||||
name: script_engine
|
||||
description: 逻辑编程(脚本/规则引擎)模块——脚本表 CRUD + execute_script/validate_script 接口,通过 load_script_engine() 挂载。脚本类型 0=Python / 1=JavaScript / 2=规则表达式。
|
||||
description: 逻辑编程(脚本/规则引擎)模块——脚本表 CRUD + execute_script/validate_script 接口,通过 load_script_engine() 挂载。脚本类型 0=Python / 1=SQL。
|
||||
---
|
||||
|
||||
# script_engine 模块
|
||||
|
||||
脚本/规则引擎:脚本表(`script`)CRUD + 执行/校验接口。独立模块,无业务依赖。
|
||||
脚本/规则引擎:脚本表(`script_engine`)CRUD + 执行/校验接口。独立模块,无业务依赖。
|
||||
|
||||
## 数据模型
|
||||
- `script`:`id`(str32 PK)、`script_name`(str100 not null)、`script_type`(str32 default '0')、`content`(text not null)、`description`(str255)、`status`(str32 default '1')、`created_at`、`updated_at`
|
||||
|
||||
- `script_engine`:`id`(str32 PK)、`script_name`(str100 not null)、`script_type`(str32 default '0')、`content`(text not null)、`description`(str255)、`status`(str32 default '1')、`created_at`、`updated_at`
|
||||
- 索引:`idx_script_name`、`idx_script_type`
|
||||
- 编码:`script_type` → appcodes_kv parentid='script_type';`status` → appcodes_kv parentid='script_status'
|
||||
- 编码:`script_type` → appcodes_kv parentid='script_type'(0=Python,1=SQL);`status` → appcodes_kv parentid='script_status'(0=停用,1=启用)
|
||||
|
||||
## 关键接口(ServerEnv 注册函数,.dspy 直接调用)
|
||||
|
||||
- `create_script / update_script / delete_script / get_script / list_scripts`
|
||||
- `execute_script`(id 或 content+script_type,校验后执行)
|
||||
- `validate_script_api`(仅校验,不落库不执行)
|
||||
- 复数别名 `create_scripts/update_scripts/delete_scripts` 与单数同实现(CRUD 框架约定)
|
||||
|
||||
## 错误结构与分页
|
||||
|
||||
- 统一 `{code, message, field, detail}`;code=0 成功
|
||||
- 分页 `{list, total}`(list_scripts 返回 data.list / data.total)
|
||||
|
||||
## REST 接口(wwwroot/api/*.dspy)
|
||||
|
||||
- script_create.dspy / script_update.dspy / script_delete.dspy / script_get.dspy / script_list.dspy
|
||||
- script_execute.dspy / execute_script.dspy / script_validate.dspy / validate_script.dspy
|
||||
- get_search_script_type.dspy / get_search_status.dspy(下拉 [{value,text}])
|
||||
|
||||
## 陷阱
|
||||
|
||||
- 取库名用 `ServerEnv().get_module_dbname('script_engine')`,禁止硬编码 DBNAME
|
||||
- 非法输入(空名/超长/非法类型/语法错误/受限语法/危险调用)100% 拦截,不落库不执行
|
||||
- Python 执行受限命名空间:仅内置白名单函数,禁 import/类/λ/async/await/对象方法调用
|
||||
- Python 执行受限命名空间:仅内置白名单函数,禁 import/类/λ/async/await/对象方法调用(engine.py ast 校验 + 白名单 builtins)
|
||||
- SQL 仅允许只读单语句(SELECT/SHOW/DESCRIBE/EXPLAIN),禁写语句/多语句/危险关键字
|
||||
- .dspy 无 import/print/uuid/f-string,显式 return;helper 在 init.py
|
||||
- init/data.json 为 Format B appcodes(parentid + items),幂等落库
|
||||
|
||||
## 依赖
|
||||
|
||||
- 无业务依赖;依赖基础包 sqlor / ahserver / appPublic
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
result = []
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'appbase') as sor:
|
||||
rows = await sor.sqlExe(
|
||||
"select k as value, v as text from appcodes_kv where parentid='script_type' order by k", {})
|
||||
result = [{'value': '', 'text': '全部'}] + list(rows)
|
||||
result = [{'value': '', 'text': '全部'}, {'value': '0', 'text': 'Python'}, {'value': '1', 'text': 'SQL'}]
|
||||
except Exception as e:
|
||||
debug('get_search_script_type error: %s' % e)
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
result = []
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'appbase') as sor:
|
||||
rows = await sor.sqlExe(
|
||||
"select k as value, v as text from appcodes_kv where parentid='script_status' order by k", {})
|
||||
result = [{'value': '', 'text': '全部'}] + list(rows)
|
||||
result = [{'value': '', 'text': '全部'}, {'value': '1', 'text': '启用'}, {'value': '0', 'text': '停用'}]
|
||||
except Exception as e:
|
||||
debug('get_search_status error: %s' % e)
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_create.dspy -- create a script record
|
||||
# @api: POST /script_engine/api/script_create.dspy
|
||||
result = await create_script(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_delete.dspy -- delete a script record
|
||||
# @api: POST /script_engine/api/script_delete.dspy
|
||||
result = await delete_script(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_execute.dspy -- execute a script by id or inline content
|
||||
# @api: POST /script_engine/api/script_execute.dspy
|
||||
result = await execute_script(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_get.dspy -- get one script record detail
|
||||
# @api: GET/POST /script_engine/api/script_get.dspy
|
||||
result = await get_script(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_list.dspy -- paged script list
|
||||
# @api: GET/POST /script_engine/api/script_list.dspy
|
||||
result = await list_scripts(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_update.dspy -- update a script record
|
||||
# @api: POST /script_engine/api/script_update.dspy
|
||||
result = await update_script(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
# script_validate.dspy -- validate script content only (no persist, no execute)
|
||||
# @api: POST /script_engine/api/script_validate.dspy
|
||||
result = await validate_script_api(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
result = await validate_script(request, params_kw)
|
||||
result = await validate_script_api(request, params_kw)
|
||||
return result
|
||||
|
||||
@ -1,75 +1,8 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"padding": "20px",
|
||||
"backgroundColor": "#F5F6FA"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "脚本引擎(W-06)",
|
||||
"fontSize": "24px",
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"padding": "20px",
|
||||
"cursor": "pointer",
|
||||
"borderRadius": "8px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.script_engine_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/script_engine/script_engine_list')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "脚本管理",
|
||||
"fontSize": "16px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"label": "脚本 CRUD / 执行 / 校验",
|
||||
"fontSize": "13px",
|
||||
"color": "#888888"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "script_engine_content",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"flex": "1",
|
||||
"marginTop": "20px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{"widgettype":"VBox","options":{"width":"100%","height":"100%","padding":"20px"},"subwidgets":[
|
||||
{"widgettype":"Text","options":{"label":"脚本引擎","fontSize":"24px"}},
|
||||
{"widgettype":"ResponsableBox","options":{"gap":"16px","minWidth":"250px"},"subwidgets":[
|
||||
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.script_engine_content",
|
||||
"options":{"url":"{{entire_url('script_list.ui')}}"},"mode":"replace"}],
|
||||
"subwidgets":[{"widgettype":"Text","options":{"label":"脚本管理"}}]}]},
|
||||
{"widgettype":"VBox","id":"script_engine_content","options":{"width":"100%","flex":"1","marginTop":"20px"}}]}
|
||||
|
||||
@ -1,42 +1,10 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "10px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "DataViewer",
|
||||
"options": {
|
||||
"title": "脚本列表",
|
||||
"data_url": "{{entire_url('/script_engine/api/script_list.dspy')}}",
|
||||
"row_options": {
|
||||
"keyid": "id",
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('/script_engine/api/script_create.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/script_engine/api/script_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('/script_engine/api/script_delete.dspy')}}"
|
||||
},
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "content"],
|
||||
"alters": {
|
||||
"script_type": {
|
||||
"uitype": "code",
|
||||
"data": [
|
||||
{"value": "0", "text": "Python脚本"},
|
||||
{"value": "1", "text": "规则(JSON)"},
|
||||
{"value": "2", "text": "表达式"}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"uitype": "code",
|
||||
"data": [
|
||||
{"value": "1", "text": "启用"},
|
||||
{"value": "0", "text": "禁用"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["id", "created_at", "updated_at"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
|
||||
{"widgettype":"Text","options":{"label":"脚本管理","fontSize":"18px","marginBottom":"10px"}},
|
||||
{"widgettype":"DataViewer","options":{"data_url":"{{entire_url('api/script_list.dspy')}}",
|
||||
"new_data_url":"{{entire_url('api/script_create.dspy')}}",
|
||||
"update_data_url":"{{entire_url('api/script_update.dspy')}}",
|
||||
"delete_data_url":"{{entire_url('api/script_delete.dspy')}}",
|
||||
"browserfields":{"exclouded":["content"],
|
||||
"alters":{"script_type":{"uitype":"code","dataurl":"{{entire_url('api/get_search_script_type.dspy')}}"},
|
||||
"status":{"uitype":"code","dataurl":"{{entire_url('api/get_search_status.dspy')}}"}}},
|
||||
"editexclouded":["id","created_at","updated_at"]}}]}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user