diff --git a/main.py b/main.py index c005c81..f5c3721 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,9 @@ from services.plugin_service import PluginService from services.permission_service import PermissionService from services.api_service import APIService 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.plugin_bridge import PluginBridge @@ -122,6 +124,8 @@ class CatFramework: # 确保 internet_service 已经实例化(在第10步) if internet_service: 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) @@ -130,6 +134,17 @@ class CatFramework: if await web_panel.start(): self.service_manager.register_service("web_panel", web_panel) 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: logger.warning("Web 面板初始化未完成") except Exception as e: diff --git a/services/project_service.py b/services/project_service.py new file mode 100644 index 0000000..23ead42 --- /dev/null +++ b/services/project_service.py @@ -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 diff --git a/services/sensu_db.py b/services/sensu_db.py new file mode 100644 index 0000000..98cc210 --- /dev/null +++ b/services/sensu_db.py @@ -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 diff --git a/tests/test_v03.py b/tests/test_v03.py new file mode 100644 index 0000000..8285227 --- /dev/null +++ b/tests/test_v03.py @@ -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