v0.3: SQLite persistence + ProjectService + dependency resolution
- New: services/sensu_db.py (SQLite, 5 tables) - New: services/project_service.py (project registry, port allocation, dep resolution) - Integrated into main.py (step 11.6) - Tests: 23/23 passing (15 original + 8 new)
This commit is contained in:
@@ -23,7 +23,9 @@ from services.plugin_service import PluginService
|
|||||||
from services.permission_service import PermissionService
|
from services.permission_service import PermissionService
|
||||||
from services.api_service import APIService
|
from services.api_service import APIService
|
||||||
from services.shutdown_service import ShutdownService
|
from services.shutdown_service import ShutdownService
|
||||||
from services.web_panel.manager import WebPanelManager
|
from services.web_panel.manager import WebPanelManager
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
from services.sensu_db import SenSuDB
|
||||||
|
|
||||||
from bridges.core_bridge import CoreBridge
|
from bridges.core_bridge import CoreBridge
|
||||||
from bridges.plugin_bridge import PluginBridge
|
from bridges.plugin_bridge import PluginBridge
|
||||||
@@ -122,6 +124,8 @@ class CatFramework:
|
|||||||
# 确保 internet_service 已经实例化(在第10步)
|
# 确保 internet_service 已经实例化(在第10步)
|
||||||
if internet_service:
|
if internet_service:
|
||||||
from services.web_panel.manager import WebPanelManager
|
from services.web_panel.manager import WebPanelManager
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
from services.sensu_db import SenSuDB
|
||||||
|
|
||||||
# 初始化面板管理器
|
# 初始化面板管理器
|
||||||
web_panel = WebPanelManager(base_config, self.service_manager)
|
web_panel = WebPanelManager(base_config, self.service_manager)
|
||||||
@@ -130,6 +134,17 @@ class CatFramework:
|
|||||||
if await web_panel.start():
|
if await web_panel.start():
|
||||||
self.service_manager.register_service("web_panel", web_panel)
|
self.service_manager.register_service("web_panel", web_panel)
|
||||||
logger.info("✅ Web 面板挂载完成")
|
logger.info("✅ Web 面板挂载完成")
|
||||||
|
# 11.6 项目注册表 + 数据库
|
||||||
|
logger.info("> 初始化 项目注册表 中...")
|
||||||
|
try:
|
||||||
|
db = SenSuDB("data/sensu.db")
|
||||||
|
project_service = ProjectService(self.service_manager, db)
|
||||||
|
await project_service.start()
|
||||||
|
self.service_manager.register_service("project", project_service)
|
||||||
|
logger.info("✅ 项目注册表就绪")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"项目注册表初始化跳过: {e}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning("Web 面板初始化未完成")
|
logger.warning("Web 面板初始化未完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SenSu 项目注册表 — 插件声明 project.yaml 申请资源,框架管理生命周期"""
|
||||||
|
import logging, os, yaml, asyncio
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ProjectService:
|
||||||
|
def __init__(self, service_manager, db=None):
|
||||||
|
self.sm = service_manager
|
||||||
|
self.db = db
|
||||||
|
self.projects: Dict[str, dict] = {}
|
||||||
|
self._port_allocations: Dict[int, str] = {}
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
logger.info("项目注册表已就绪")
|
||||||
|
|
||||||
|
def register_project(self, plugin_name: str, project_config: dict) -> bool:
|
||||||
|
"""从 project.yaml 注册项目
|
||||||
|
必需字段: name, port (可选: entrypoint, depends_on, env)
|
||||||
|
"""
|
||||||
|
name = project_config.get("name", plugin_name)
|
||||||
|
if name in self.projects:
|
||||||
|
logger.warning(f"项目 {name} 已注册,跳过")
|
||||||
|
return False
|
||||||
|
|
||||||
|
port = project_config.get("port")
|
||||||
|
if port and port in self._port_allocations:
|
||||||
|
logger.warning(f"端口 {port} 已被 {self._port_allocations[port]} 占用")
|
||||||
|
port = self._find_free_port()
|
||||||
|
|
||||||
|
self.projects[name] = {
|
||||||
|
**project_config,
|
||||||
|
"plugin_name": plugin_name,
|
||||||
|
"port": port,
|
||||||
|
"status": "registered",
|
||||||
|
}
|
||||||
|
if port:
|
||||||
|
self._port_allocations[port] = name
|
||||||
|
|
||||||
|
if self.db:
|
||||||
|
self.db.save_plugin(plugin_name, project_path=project_config.get("path",""),
|
||||||
|
project_port=port or 0)
|
||||||
|
self.db.log_audit(plugin_name, "project_registered", str(project_config))
|
||||||
|
|
||||||
|
logger.info(f"项目已注册: {name} (插件: {plugin_name}, 端口: {port})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _find_free_port(self, start=4200) -> int:
|
||||||
|
used = set(self._port_allocations.keys())
|
||||||
|
for p in range(start, start + 1000):
|
||||||
|
if p not in used:
|
||||||
|
return p
|
||||||
|
return start
|
||||||
|
|
||||||
|
def get_project(self, name: str) -> Optional[dict]:
|
||||||
|
return self.projects.get(name)
|
||||||
|
|
||||||
|
def list_projects(self) -> List[dict]:
|
||||||
|
return [{"name": k, "port": v.get("port"), "status": v.get("status")}
|
||||||
|
for k, v in self.projects.items()]
|
||||||
|
|
||||||
|
def unregister_project(self, name: str):
|
||||||
|
if name in self.projects:
|
||||||
|
p = self.projects.pop(name)
|
||||||
|
if p.get("port"):
|
||||||
|
self._port_allocations.pop(p["port"], None)
|
||||||
|
logger.info(f"项目已注销: {name}")
|
||||||
|
|
||||||
|
def resolve_dependencies(self, plugins: Dict[str, dict]) -> List[str]:
|
||||||
|
"""拓扑排序 — 根据 depends_on 返回正确的加载顺序"""
|
||||||
|
graph = defaultdict(list)
|
||||||
|
in_degree = defaultdict(int)
|
||||||
|
all_plugins = set(plugins.keys())
|
||||||
|
|
||||||
|
for name, cfg in plugins.items():
|
||||||
|
deps = cfg.get("depends_on", [])
|
||||||
|
if isinstance(deps, str):
|
||||||
|
deps = json.loads(deps) if deps.startswith("[") else [deps]
|
||||||
|
for dep in deps:
|
||||||
|
if dep in all_plugins:
|
||||||
|
graph[dep].append(name)
|
||||||
|
in_degree[name] += 1
|
||||||
|
if name not in in_degree:
|
||||||
|
in_degree[name] = 0
|
||||||
|
|
||||||
|
# Kahn's algorithm
|
||||||
|
queue = deque([n for n in all_plugins if in_degree[n] == 0])
|
||||||
|
result = []
|
||||||
|
while queue:
|
||||||
|
node = queue.popleft()
|
||||||
|
result.append(node)
|
||||||
|
for neighbor in graph[node]:
|
||||||
|
in_degree[neighbor] -= 1
|
||||||
|
if in_degree[neighbor] == 0:
|
||||||
|
queue.append(neighbor)
|
||||||
|
|
||||||
|
if len(result) != len(all_plugins):
|
||||||
|
missing = all_plugins - set(result)
|
||||||
|
logger.warning(f"循环依赖或缺失依赖: {missing}, 追加到尾部")
|
||||||
|
result.extend(missing)
|
||||||
|
|
||||||
|
logger.info(f"依赖解析结果: {' → '.join(result)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_project_yaml(plugin_dir: str) -> Optional[dict]:
|
||||||
|
"""从插件目录加载 project.yaml"""
|
||||||
|
yaml_path = os.path.join(plugin_dir, "project.yaml")
|
||||||
|
if os.path.exists(yaml_path):
|
||||||
|
try:
|
||||||
|
with open(yaml_path) as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"解析 project.yaml 失败 {yaml_path}: {e}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SenSu SQLite 持久化层 — 替代零散 JSON 文件"""
|
||||||
|
import sqlite3, os, json, logging, threading
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "data/sensu.db"
|
||||||
|
|
||||||
|
class SenSuDB:
|
||||||
|
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
||||||
|
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||||
|
self.db_path = db_path
|
||||||
|
self._local = threading.local()
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn(self):
|
||||||
|
if not hasattr(self._local, "conn") or self._local.conn is None:
|
||||||
|
self._local.conn = sqlite3.connect(self.db_path)
|
||||||
|
self._local.conn.row_factory = sqlite3.Row
|
||||||
|
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._local.conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return self._local.conn
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
c = self.conn
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
version TEXT DEFAULT '0.1.0',
|
||||||
|
status TEXT DEFAULT 'unloaded',
|
||||||
|
config_json TEXT DEFAULT '{}',
|
||||||
|
permissions_json TEXT DEFAULT '[]',
|
||||||
|
depends_on TEXT DEFAULT '[]',
|
||||||
|
project_path TEXT,
|
||||||
|
project_port INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
plugin_name TEXT,
|
||||||
|
permission TEXT,
|
||||||
|
granted INTEGER DEFAULT 0,
|
||||||
|
granted_at TEXT,
|
||||||
|
PRIMARY KEY (plugin_name, permission)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS config_kv (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plugin_name TEXT,
|
||||||
|
action TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
c.commit()
|
||||||
|
|
||||||
|
# -- Plugin CRUD --
|
||||||
|
def save_plugin(self, name: str, **kwargs):
|
||||||
|
fields = ["name"] + list(kwargs.keys())
|
||||||
|
placeholders = ["?"] * len(fields)
|
||||||
|
values = [name] + list(kwargs.values())
|
||||||
|
for k in ["config", "permissions", "depends_on"]:
|
||||||
|
if k in kwargs and not isinstance(kwargs[k], str):
|
||||||
|
kwargs[k] = json.dumps(kwargs[k])
|
||||||
|
idx = fields.index(k)
|
||||||
|
values[idx] = kwargs[k]
|
||||||
|
sql = f"INSERT OR REPLACE INTO plugins ({','.join(fields)}) VALUES ({','.join(placeholders)})"
|
||||||
|
self.conn.execute("UPDATE plugins SET updated_at=datetime('now') WHERE name=?", [name])
|
||||||
|
self.conn.execute(sql, values)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_plugin(self, name: str) -> Optional[Dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM plugins WHERE name=?", [name]).fetchone()
|
||||||
|
if not row: return None
|
||||||
|
d = dict(row)
|
||||||
|
for f in ["config_json", "permissions_json", "depends_on"]:
|
||||||
|
if d.get(f):
|
||||||
|
try: d[f.replace("_json","")] = json.loads(d.pop(f))
|
||||||
|
except: pass
|
||||||
|
return d
|
||||||
|
|
||||||
|
def list_plugins(self) -> List[Dict]:
|
||||||
|
return [dict(r) for r in self.conn.execute("SELECT name,version,status,project_path,project_port FROM plugins").fetchall()]
|
||||||
|
|
||||||
|
# -- Permissions --
|
||||||
|
def grant_permission(self, plugin: str, perm: str):
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO permissions(plugin_name,permission,granted,granted_at) VALUES(?,?,1,datetime('now'))",
|
||||||
|
[plugin, perm])
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def check_permission(self, plugin: str, perm: str) -> bool:
|
||||||
|
r = self.conn.execute("SELECT granted FROM permissions WHERE plugin_name=? AND permission=?", [plugin, perm]).fetchone()
|
||||||
|
return bool(r and r[0])
|
||||||
|
|
||||||
|
# -- Config --
|
||||||
|
def set_config(self, key: str, value: str):
|
||||||
|
self.conn.execute("INSERT OR REPLACE INTO config_kv(key,value,updated_at) VALUES(?,?,datetime('now'))", [key, value])
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_config(self, key: str, default=None) -> Optional[str]:
|
||||||
|
r = self.conn.execute("SELECT value FROM config_kv WHERE key=?", [key]).fetchone()
|
||||||
|
return r[0] if r else default
|
||||||
|
|
||||||
|
# -- Audit --
|
||||||
|
def log_audit(self, plugin: str, action: str, detail: str = ""):
|
||||||
|
self.conn.execute("INSERT INTO audit_log(plugin_name,action,detail) VALUES(?,?,?)", [plugin, action, detail])
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if hasattr(self._local, "conn") and self._local.conn:
|
||||||
|
self._local.conn.close()
|
||||||
|
self._local.conn = None
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""v0.3 tests: SQLite DB, ProjectService, Dependency Resolution"""
|
||||||
|
import pytest, os, sys, asyncio
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
class TestSenSuDB:
|
||||||
|
@pytest.fixture
|
||||||
|
def db(self):
|
||||||
|
from services.sensu_db import SenSuDB
|
||||||
|
import tempfile
|
||||||
|
d = SenSuDB(":memory:")
|
||||||
|
yield d
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
def test_save_and_get_plugin(self, db):
|
||||||
|
db.save_plugin("test_plugin", version="1.0", status="running")
|
||||||
|
p = db.get_plugin("test_plugin")
|
||||||
|
assert p["name"] == "test_plugin"
|
||||||
|
assert p["version"] == "1.0"
|
||||||
|
assert p["status"] == "running"
|
||||||
|
|
||||||
|
def test_list_plugins(self, db):
|
||||||
|
db.save_plugin("p1", version="1.0")
|
||||||
|
db.save_plugin("p2", version="2.0")
|
||||||
|
lst = db.list_plugins()
|
||||||
|
assert len(lst) >= 2
|
||||||
|
|
||||||
|
def test_permissions(self, db):
|
||||||
|
db.grant_permission("p1", "read")
|
||||||
|
assert db.check_permission("p1", "read") is True
|
||||||
|
assert db.check_permission("p1", "write") is False
|
||||||
|
|
||||||
|
def test_config_kv(self, db):
|
||||||
|
db.set_config("theme", "dark")
|
||||||
|
assert db.get_config("theme") == "dark"
|
||||||
|
assert db.get_config("nonexistent", "default") == "default"
|
||||||
|
|
||||||
|
class TestProjectService:
|
||||||
|
def test_register_project(self):
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
ps = ProjectService(None)
|
||||||
|
ok = ps.register_project("test_plugin", {"name": "TestProject", "port": 5000, "path": "/tmp/test"})
|
||||||
|
assert ok is True
|
||||||
|
p = ps.get_project("TestProject")
|
||||||
|
assert p["port"] == 5000
|
||||||
|
assert p["status"] == "registered"
|
||||||
|
|
||||||
|
def test_port_conflict(self):
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
ps = ProjectService(None)
|
||||||
|
ps.register_project("p1", {"name": "A", "port": 5000})
|
||||||
|
ps.register_project("p2", {"name": "B", "port": 5000})
|
||||||
|
b = ps.get_project("B")
|
||||||
|
assert b["port"] != 5000 # should get a different port
|
||||||
|
|
||||||
|
def test_dependency_resolution(self):
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
ps = ProjectService(None)
|
||||||
|
plugins = {
|
||||||
|
"base": {"depends_on": []},
|
||||||
|
"middle": {"depends_on": ["base"]},
|
||||||
|
"top": {"depends_on": ["middle"]},
|
||||||
|
}
|
||||||
|
order = ps.resolve_dependencies(plugins)
|
||||||
|
assert order.index("base") < order.index("middle")
|
||||||
|
assert order.index("middle") < order.index("top")
|
||||||
|
|
||||||
|
def test_circular_dependency(self):
|
||||||
|
from services.project_service import ProjectService
|
||||||
|
ps = ProjectService(None)
|
||||||
|
plugins = {
|
||||||
|
"a": {"depends_on": ["b"]},
|
||||||
|
"b": {"depends_on": ["a"]},
|
||||||
|
}
|
||||||
|
order = ps.resolve_dependencies(plugins)
|
||||||
|
assert len(order) == 2 # should handle gracefully
|
||||||
Reference in New Issue
Block a user