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:
2026-06-10 18:59:25 +08:00
parent c73e569958
commit d48ef65b35
4 changed files with 327 additions and 1 deletions
+15
View File
@@ -24,6 +24,8 @@ 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:
+117
View File
@@ -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
+119
View File
@@ -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
+75
View File
@@ -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