98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase 1: Contracts konsequent nutzen - 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts) - 4 bestehende contracts.py an zentrale ContractRegistry angepasst - Alle 19 Plugins haben on_deactivate mit Contract-Unregister - 0 echte problematische INTER-Plugin Imports Phase 2: Hooks/Filters-System - app/core/hooks.py (HookRegistry mit actions + filters) - 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms) - BasePlugin.on_deactivate meldet alle Hooks ab Phase 3: Plugin-Isolation - scripts/check_cross_plugin_imports.py (Linting-Regel) - .github/workflows/check-cross-plugin-imports.yml (CI/CD) - .pre-commit-cross-plugin.yaml (Pre-commit Hook) - 155 Dateien geprueft, 0 Verstoesse Phase 4: Plugin-Versioning - app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release) - migration_runner.py erweitert: run_migration_down, rollback_to_version - manifest.py: min_app_version Feld - registry.py: App-Version-Compatibility-Check bei Installation - GET /api/v1/plugins/updates Endpoint Phase 5: Marketplace-Vorbereitung - app/plugins/signature.py (Ed25519 Signatur-Validierung) - app/plugins/quarantine.py (Plugin-Quarantine mit Validierung) - app/models/plugin_allowlist.py + Migration 0046 - manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price - registry.py: discover_external(), discover_all() - POST /api/v1/plugins/install-marketplace (deaktiviert) Phase 6: Manifest-Anpassung - manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung - MANIFEST_SCHEMA_DOC aktualisiert - Alle 19 Plugin-Manifeste aktualisiert - Frontend PluginUiManifest Typ erweitert Zusaetzliche Bug-Fixes: - test_sample-Modul erstellt - conftest.py Deadlock-Prevention - SESSION_COOKIE_SECURE=true - dump.rdb aus Git entfernt + .gitignore - backup.py datetime.utcnow -> func.now() - system_settings.py JSONB-Import nach oben - tax.py Mapped[float] -> Mapped[Decimal] - notification.py type_key-Laengen vereinheitlicht Tests: 91 neue Tests, alle bestanden
922 lines
32 KiB
Markdown
922 lines
32 KiB
Markdown
# LeoCRM Plugin-System — Kompletter Umbauplan
|
|
|
|
**Erstellt:** 2026-07-26
|
|
**Aktualisiert:** 2026-07-26 (Codebasis-Verifikation + Phase 6)
|
|
**Geschätzter Gesamtaufwand:** ~149 Stunden (~19 Arbeitstage)
|
|
**Status:** Geplant — noch nicht gestartet
|
|
|
|
**Codebasis-Verifikation (2026-07-26):**
|
|
- ✅ `base.py` unverändert — Plan passt
|
|
- ✅ `registry.py` unverändert — Plan passt
|
|
- ✅ `manifest.py` unverändert — Plan passt
|
|
- ✅ `contracts.py` (ContractRegistry) unverändert — Plan passt
|
|
- ✅ Migration 0044 hinzugekommen: RLS Repair + separater DB-User (crm_runtime) — beeinflusst Plugin-System nicht
|
|
- ✅ Migration 0045 hinzugekommen — neuer Head
|
|
- ✅ `require_active_plugin` in `deps.py` hinzugekommen — beeinflusst Plugin-System nicht
|
|
- ✅ 19 echte Plugins (test_sample hat __init__.py statt plugin.py)
|
|
- ✅ Cross-Imports: 224, Contracts: 8, get_contract: 11 — unverändert
|
|
|
|
---
|
|
|
|
## Übersicht: 5 Phasen
|
|
|
|
| Phase | Punkte | Inhalt | Stunden | Tage |
|
|
|---|---|---|---|---|
|
|
| Phase 1 | 1-3 | Contracts konsequent nutzen | 47 | 6 |
|
|
| Phase 2 | 4 | Hooks/Filters-System | 16 | 2 |
|
|
| Phase 3 | 5 | Plugin-Isolation (Linting) | 4 | 0,5 |
|
|
| Phase 4 | 8 | Plugin-Versioning | 20 | 2,5 |
|
|
| Phase 5 | 6 | Marketplace-Vorbereitung | 42 | 5 |
|
|
| Phase 6 | — | Manifest-Anpassung & Konsolidierung | 20 | 2,5 |
|
|
| **Gesamt** | | | **149** | **~19** |
|
|
|
|
**Wichtig:** Jede Phase ist unabhängig funktionsfähig. Das System läuft nach jeder Phase ohne Einschränkungen weiter.
|
|
|
|
---
|
|
|
|
## Phase 1: Contracts konsequent nutzen (Punkte 1-3)
|
|
|
|
**Ziel:** Alle 224 direkten Cross-Plugin-Imports werden durch das Contract-System ersetzt.
|
|
|
|
### 1.1 Fehlende contracts.py erstellen (7 Std)
|
|
|
|
Für jedes Plugin, das noch keine `contracts.py` hat, eine erstellen:
|
|
|
|
| # | Plugin | Exportierte Symbole | Aufwand |
|
|
|---|---|---|---|
|
|
| 1 | `ai_proactive` | ContextTools, ProactiveAgent, JobScheduler | 30 Min |
|
|
| 2 | `ai_ui_control` | WebSocketManager, UIAction | 30 Min |
|
|
| 3 | `automation` | AgentRunner, ExecutionEngine, Scheduler, WorkflowTimeout | 45 Min |
|
|
| 4 | `entity_links` | EntityLink model, create_link, get_links | 20 Min |
|
|
| 5 | `forgejo_error_reporter` | report_error_to_forgejo | 15 Min |
|
|
| 6 | `mcp_client` | McpClient, McpServerConfig | 30 Min |
|
|
| 7 | `mcp_server` | McpServer, ToolDefinitions | 30 Min |
|
|
| 8 | `report_generator` | ReportTemplate, ReportInstance, PdfGenerator | 30 Min |
|
|
| 9 | `system_notif` | SystemNotifHandler | 15 Min |
|
|
| 10 | `tags` | Tag, TagAssignment, assign_tags, remove_tags | 20 Min |
|
|
| 11 | `tasks` | Task, TaskService, create_task, update_task | 30 Min |
|
|
| 12 | `test_sample` | TestSamplePlugin | 10 Min |
|
|
| 13 | `dms` (erweitern) | File, Folder, UploadService, DownloadService | 30 Min |
|
|
| 14 | `permissions` (erweitern) | ShareLink, PermissionResolver | 30 Min |
|
|
|
|
**Schema für jede contracts.py:**
|
|
```python
|
|
"""Public contract for the <plugin> plugin."""
|
|
from __future__ import annotations
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
# Import only public symbols from internal modules
|
|
|
|
class <Plugin>Contract:
|
|
contract_name = "<plugin>"
|
|
# Expose only public API
|
|
|
|
_contract = <Plugin>Contract()
|
|
get_contract_registry().register("<plugin>", _contract)
|
|
```
|
|
|
|
### 1.2 Direkte Imports ersetzen (28 Std)
|
|
|
|
224 direkte Imports müssen durch `get_contract()` ersetzt werden.
|
|
|
|
**Top-Priorität (häufigste Import-Quellen):**
|
|
|
|
| # | Datei | Imports | Aufwand |
|
|
|---|---|---|---|
|
|
| 1 | `automation/plugin.py` | 10 | 1,5 Std |
|
|
| 2 | `automation/routes.py` | 8 | 1,5 Std |
|
|
| 3 | `ai_proactive/services.py` | 8 | 1,5 Std |
|
|
| 4 | `ai_proactive/plugin.py` | 8 | 1,5 Std |
|
|
| 5 | `unified_search/jobs.py` | 7 | 1 Std |
|
|
| 6 | `builtins/__init__.py` | 7 | 1 Std |
|
|
| 7 | `ai_proactive/jobs.py` | 7 | 1 Std |
|
|
| 8 | `ai_assistant/participant_handler.py` | 7 | 1 Std |
|
|
| 9 | `kommunikation/routes.py` | 6 | 1 Std |
|
|
| 10 | `kommunikation/contracts.py` | 6 | 1 Std |
|
|
| 11 | `automation/agent_routes.py` | 6 | 1 Std |
|
|
| 12 | `automation/agent_comm.py` | 6 | 1 Std |
|
|
| 13 | `ai_proactive/participant_handler.py` | 6 | 1 Std |
|
|
| 14 | `ai_assistant/plugin.py` | 6 | 1 Std |
|
|
| 15 | `unified_search/routes.py` | 5 | 45 Min |
|
|
| 16-50 | Alle übrigen Dateien | ~122 | 12 Std |
|
|
|
|
**Muster für Ersetzung:**
|
|
```python
|
|
# VORHER (direkt):
|
|
from app.plugins.builtins.kommunikation.services import send_message
|
|
|
|
# NACHHER (über Contract):
|
|
from app.plugins.builtins.contracts import get_contract
|
|
|
|
async def my_function(db, ...):
|
|
komm = get_contract("kommunikation")
|
|
if komm:
|
|
await komm.send_message(db, ...)
|
|
# Graceful degradation wenn Plugin nicht aktiv
|
|
```
|
|
|
|
### 1.3 Contracts bei Deaktivierung abmelden (4 Std)
|
|
|
|
In jedem Plugin's `on_deactivate()`:
|
|
```python
|
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
|
# Contract abmelden
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
get_contract_registry().unregister(self.manifest.name)
|
|
# ... rest of cleanup
|
|
await super().on_deactivate(db, service_container, event_bus)
|
|
```
|
|
|
|
| # | Plugin | Aufwand |
|
|
|---|---|---|
|
|
| 1-16 | Alle 16 Plugins | 15 Min pro Plugin = 4 Std |
|
|
|
|
### 1.4 Tests anpassen (8 Std)
|
|
|
|
- Cross-Plugin-Tests müssen mit Contracts laufen
|
|
- `test_plugins.py` — Contract-Registry Tests
|
|
- `test_contracts.py` — Neue Test-Datei für Contract-System
|
|
- Alle Integrationstests mit Contract-Mocks
|
|
|
|
### Meilenstein Phase 1:
|
|
- ✅ Alle 16 Plugins haben contracts.py
|
|
- ✅ 0 direkte Cross-Plugin-Imports (geprüft mit grep)
|
|
- ✅ Contracts werden bei Deaktivierung abgemeldet
|
|
- ✅ Alle Tests bestanden
|
|
|
|
---
|
|
|
|
## Phase 2: Hooks/Filters-System (Punkt 4)
|
|
|
|
**Ziel:** WordPress-Style Hooks (actions + filters) für Plugin-Erweiterbarkeit.
|
|
|
|
### 2.1 HookRegistry erstellen (4 Std)
|
|
|
|
**Neue Datei: `app/core/hooks.py`**
|
|
|
|
```python
|
|
"""WordPress-style hooks: actions (fire-and-forget) and filters (modify data)."""
|
|
|
|
from __future__ import annotations
|
|
import logging
|
|
from collections import defaultdict
|
|
from typing import Any, Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HookRegistry:
|
|
"""Central registry for actions and filters.
|
|
|
|
Actions: do_action('contact.before_create', data) — no return value
|
|
Filters: result = apply_filters('contact.format_name', name) — returns modified value
|
|
|
|
Priority: lower numbers run first (default=10).
|
|
"""
|
|
|
|
_instance: HookRegistry | None = None
|
|
|
|
def __new__(cls):
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
cls._instance._actions: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
|
cls._instance._filters: dict[str, list[tuple[int, Callable]]] = defaultdict(list)
|
|
return cls._instance
|
|
|
|
def register_action(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
|
self._actions[hook_name].append((priority, callback))
|
|
self._actions[hook_name].sort(key=lambda x: x[0])
|
|
|
|
def register_filter(self, hook_name: str, callback: Callable, priority: int = 10) -> None:
|
|
self._filters[hook_name].append((priority, callback))
|
|
self._filters[hook_name].sort(key=lambda x: x[0])
|
|
|
|
async def do_action(self, hook_name: str, *args, **kwargs) -> None:
|
|
for _, callback in self._actions.get(hook_name, []):
|
|
try:
|
|
result = callback(*args, **kwargs)
|
|
if hasattr(result, '__await__'):
|
|
await result
|
|
except Exception:
|
|
logger.exception("Error in action %s", hook_name)
|
|
|
|
async def apply_filters(self, hook_name: str, value: Any, *args, **kwargs) -> Any:
|
|
for _, callback in self._filters.get(hook_name, []):
|
|
try:
|
|
result = callback(value, *args, **kwargs)
|
|
if hasattr(result, '__await__'):
|
|
result = await result
|
|
value = result
|
|
except Exception:
|
|
logger.exception("Error in filter %s", hook_name)
|
|
return value
|
|
|
|
def unregister(self, hook_name: str, callback: Callable) -> None:
|
|
self._actions[hook_name] = [(p, c) for p, c in self._actions.get(hook_name, []) if c != callback]
|
|
self._filters[hook_name] = [(p, c) for p, c in self._filters.get(hook_name, []) if c != callback]
|
|
|
|
def unregister_all(self, hook_name: str) -> None:
|
|
self._actions.pop(hook_name, None)
|
|
self._filters.pop(hook_name, None)
|
|
|
|
def _reset_for_testing(self) -> None:
|
|
self._actions.clear()
|
|
self._filters.clear()
|
|
|
|
|
|
def get_hook_registry() -> HookRegistry:
|
|
return HookRegistry()
|
|
|
|
async def do_action(hook_name: str, *args, **kwargs) -> None:
|
|
await get_hook_registry().do_action(hook_name, *args, **kwargs)
|
|
|
|
async def apply_filters(hook_name: str, value: Any, *args, **kwargs) -> Any:
|
|
return await get_hook_registry().apply_filters(hook_name, value, *args, **kwargs)
|
|
```
|
|
|
|
### 2.2 Integration in BasePlugin (2 Std)
|
|
|
|
```python
|
|
# In BasePlugin.on_activate:
|
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
|
# ... existing code ...
|
|
# Hooks werden in Subklassen registriert
|
|
|
|
# In BasePlugin.on_deactivate:
|
|
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
|
# Alle Hooks dieses Plugins abmelden
|
|
from app.core.hooks import get_hook_registry
|
|
# Plugin-spezifische Hooks entfernen (prefix mit plugin name)
|
|
# ... existing code ...
|
|
```
|
|
|
|
### 2.3 Hook-Punkte in Core-Services (6 Std)
|
|
|
|
| # | Service | Hook-Name | Typ | Beschreibung |
|
|
|---|---|---|---|---|
|
|
| 1 | contact_service | `contact.before_create` | Action | Vor Kontakt-Erstellung |
|
|
| 2 | contact_service | `contact.after_create` | Action | Nach Kontakt-Erstellung |
|
|
| 3 | contact_service | `contact.format_display_name` | Filter | Anzeigenamen formatieren |
|
|
| 4 | contact_service | `contact.before_update` | Action | Vor Kontakt-Update |
|
|
| 5 | contact_service | `contact.after_update` | Action | Nach Kontakt-Update |
|
|
| 6 | contact_service | `contact.before_delete` | Action | Vor Kontakt-Löschung |
|
|
| 7 | mail_service | `mail.before_send` | Filter | E-Mail vor Versand modifizieren |
|
|
| 8 | mail_service | `mail.after_send` | Action | Nach E-Mail-Versand |
|
|
| 9 | calendar | `calendar.before_appointment` | Action | Vor Termin-Erstellung |
|
|
| 10 | calendar | `calendar.after_appointment` | Action | Nach Termin-Erstellung |
|
|
| 11 | auth_service | `auth.before_login` | Filter | Login-Daten validieren/modifizieren |
|
|
| 12 | auth_service | `auth.after_login` | Action | Nach erfolgreichem Login |
|
|
| 13 | user_service | `user.before_create` | Action | Vor User-Erstellung |
|
|
| 14 | user_service | `user.after_create` | Action | Nach User-Erstellung |
|
|
| 15 | dms | `dms.before_upload` | Filter | Datei-Upload validieren/modifizieren |
|
|
|
|
### 2.4 Tests für Hooks/Filters (4 Std)
|
|
|
|
- `test_hooks.py` — HookRegistry Tests
|
|
- Integrationstests: Plugin registriert Hook, Core-Service löst Hook aus
|
|
- Filter-Tests: Wert wird korrekt modifiziert
|
|
- Priority-Tests: Reihenfolge wird eingehalten
|
|
- Unregister-Tests: Hooks werden bei Deaktivierung entfernt
|
|
|
|
### Meilenstein Phase 2:
|
|
- ✅ `app/core/hooks.py` mit HookRegistry
|
|
- ✅ 15 Hook-Punkte in Core-Services
|
|
- ✅ BasePlugin registriert/unregistriert Hooks automatisch
|
|
- ✅ Tests bestanden
|
|
|
|
---
|
|
|
|
## Phase 3: Plugin-Isolation (Punkt 5)
|
|
|
|
**Ziel:** Direkte Cross-Plugin-Imports werden durch Linting verhindert.
|
|
|
|
### 3.1 Linting-Regel erstellen (2 Std)
|
|
|
|
**Neue Datei: `.ruff/rules/no_cross_plugin_imports.py`**
|
|
|
|
```python
|
|
"""Ruff rule: forbid direct imports from app.plugins.builtins.* (except contracts)."""
|
|
|
|
# Erlaubt:
|
|
# from app.plugins.builtins.contracts import get_contract
|
|
# from app.plugins.builtins.<name>.contracts import ...
|
|
#
|
|
# Verboten:
|
|
# from app.plugins.builtins.<name>.services import ...
|
|
# from app.plugins.builtins.<name>.models import ...
|
|
# from app.plugins.builtins.<name>.routes import ...
|
|
```
|
|
|
|
### 3.2 CI/CD Integration (1 Std)
|
|
|
|
- `ruff check` in GitHub Actions / Forgejo CI
|
|
- Pre-commit Hook für lokale Entwicklung
|
|
- Fehler bei direkten Cross-Plugin-Imports
|
|
|
|
### 3.3 Ausnahmen definieren (1 Std)
|
|
|
|
- `conftest.py` — Tests dürfen direkt importieren
|
|
- `app/plugins/builtins/__init__.py` — Plugin-Discovery
|
|
- `app/plugins/registry.py` — Registry darf importieren
|
|
|
|
### Meilenstein Phase 3:
|
|
- ✅ Linting-Regel aktiv
|
|
- ✅ CI/CD prüft bei jedem Commit
|
|
- ✅ 0 direkte Cross-Plugin-Imports (automatisch erzwungen)
|
|
|
|
---
|
|
|
|
## Phase 4: Plugin-Versioning (Punkt 8)
|
|
|
|
**Ziel:** Vollständige Versionsverwaltung mit SemVer, Rollback und Kompatibilitäts-Check.
|
|
|
|
### 4.1 SemVer-Vergleich (3 Std)
|
|
|
|
**Neue Datei: `app/plugins/semver.py`**
|
|
|
|
```python
|
|
"""Semantic version comparison for plugin versions."""
|
|
|
|
from dataclasses import dataclass
|
|
import re
|
|
|
|
@dataclass
|
|
class SemVer:
|
|
major: int
|
|
minor: int
|
|
patch: int
|
|
prerelease: str = ""
|
|
|
|
@classmethod
|
|
def parse(cls, version: str) -> "SemVer":
|
|
match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(.+))?", version)
|
|
if not match:
|
|
raise ValueError(f"Invalid semver: {version}")
|
|
return cls(int(match[1]), int(match[2]), int(match[3]), match[4] or "")
|
|
|
|
def __lt__(self, other): ...
|
|
def __eq__(self, other): ...
|
|
def __le__(self, other): ...
|
|
def __gt__(self, other): ...
|
|
|
|
def is_breaking_change(self, other: "SemVer") -> bool:
|
|
return self.major != other.major
|
|
|
|
def is_compatible_with(self, min_version: "SemVer") -> bool:
|
|
return self >= min_version
|
|
```
|
|
|
|
**Änderung in `registry.py`:**
|
|
```python
|
|
# VORHER: String-Vergleich
|
|
if record.version != plugin.manifest.version:
|
|
|
|
# NACHHER: SemVer-Vergleich
|
|
old_ver = SemVer.parse(record.version)
|
|
new_ver = SemVer.parse(plugin.manifest.version)
|
|
if old_ver != new_ver:
|
|
if new_ver < old_ver:
|
|
# Downgrade — nur mit Rollback-Migration
|
|
...
|
|
```
|
|
|
|
### 4.2 Rollback-Migrationen (6 Std)
|
|
|
|
**Erweiterung des Migration-Systems:**
|
|
|
|
```python
|
|
# MigrationRunner erweitern:
|
|
async def run_migration_down(self, db, plugin_name, migration_filename):
|
|
"""Run rollback (down) migration."""
|
|
# Suche <filename>_down.sql oder parse DOWNGRADE-Block
|
|
|
|
async def rollback_to_version(self, db, plugin_name, target_version: str):
|
|
"""Rollback plugin to a specific version."""
|
|
# 1. Finde alle Migrationen nach target_version
|
|
# 2. Führe sie in umgekehrter Reihenfolge aus
|
|
# 3. Aktualisiere DB-Version
|
|
```
|
|
|
|
**Migration-Datei-Format:**
|
|
```sql
|
|
-- 0001_initial.sql
|
|
-- UP:
|
|
CREATE TABLE ...;
|
|
-- DOWN:
|
|
DROP TABLE ... CASCADE;
|
|
```
|
|
|
|
Oder separate Dateien:
|
|
- `0001_initial_up.sql`
|
|
- `0001_initial_down.sql`
|
|
|
|
### 4.3 Version-Kompatibilitäts-Check (3 Std)
|
|
|
|
**Manifest-Erweiterung:**
|
|
```python
|
|
class PluginManifest(BaseModel):
|
|
# ... existing fields ...
|
|
min_app_version: str = Field(
|
|
default="0.0.0",
|
|
description="Minimum LeoCRM version required"
|
|
)
|
|
```
|
|
|
|
**Check bei Installation:**
|
|
```python
|
|
async def install(self, db, name):
|
|
plugin = self.get_plugin(name)
|
|
# Check app version compatibility
|
|
app_version = SemVer.parse(settings.app_version)
|
|
min_version = SemVer.parse(plugin.manifest.min_app_version)
|
|
if app_version < min_version:
|
|
raise ValueError(
|
|
f"Plugin '{name}' requires LeoCRM >= {plugin.manifest.min_app_version}, "
|
|
f"but current version is {settings.app_version}"
|
|
)
|
|
```
|
|
|
|
### 4.4 Update-Benachrichtigung im Frontend (4 Std)
|
|
|
|
**Backend:**
|
|
- `GET /api/v1/plugins/updates` — Liste Plugins mit verfügbarer neuer Version
|
|
- Vergleich mit Marketplace-Registry (wenn verfügbar) oder lokaler Version
|
|
|
|
**Frontend:**
|
|
- Badge im Plugin-Settings: "Update verfügbar (1.2.0 → 1.3.0)"
|
|
- Update-Button: Löst Update aus (führt neue Migrationen aus)
|
|
- Changelog-Anzeige (optional)
|
|
|
|
### 4.5 Tests (4 Std)
|
|
|
|
- `test_semver.py` — SemVer-Vergleich, Parse, Edge Cases
|
|
- `test_versioning.py` — Upgrade, Downgrade, Kompatibilitäts-Check
|
|
- `test_rollback.py` — Rollback-Migrationen
|
|
- Integrationstests: Version-Update löst Migrationen aus
|
|
|
|
### Meilenstein Phase 4:
|
|
- ✅ SemVer-Vergleich statt String-Vergleich
|
|
- ✅ Rollback-Migrationen funktionieren
|
|
- ✅ min_app_version wird geprüft
|
|
- ✅ Frontend zeigt Update-Benachrichtigungen
|
|
- ✅ Tests bestanden
|
|
|
|
---
|
|
|
|
## Phase 5: Marketplace-Vorbereitung (Punkt 6)
|
|
|
|
**Ziel:** Code so vorbereiten, dass ein Marketplace nur noch gebaut werden muss — ohne Systemänderungen.
|
|
|
|
**Wichtig:** Funktioniert auch OHNE Marketplace — Built-in Plugins laufen normal weiter.
|
|
|
|
### 5.1 Externe Plugin-Discovery (6 Std)
|
|
|
|
**Erweiterung `registry.py`:**
|
|
|
|
```python
|
|
class PluginRegistry:
|
|
|
|
def discover_all(self) -> list[str]:
|
|
"""Discover built-in AND external plugins."""
|
|
discovered = self.discover_builtins()
|
|
discovered.extend(self.discover_external())
|
|
return discovered
|
|
|
|
def discover_external(self) -> list[str]:
|
|
"""Discover plugins from external plugins/ directory."""
|
|
external_dir = Path(settings.external_plugins_path or "plugins")
|
|
if not external_dir.exists():
|
|
return []
|
|
|
|
discovered = []
|
|
for plugin_dir in external_dir.iterdir():
|
|
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
|
|
continue
|
|
# Look for plugin.py or __init__.py with BasePlugin subclass
|
|
plugin_file = plugin_dir / "plugin.py"
|
|
if not plugin_file.exists():
|
|
continue
|
|
# Import and register
|
|
import sys
|
|
sys.path.insert(0, str(external_dir))
|
|
try:
|
|
module = importlib.import_module(f"{plugin_dir.name}.plugin")
|
|
# ... find BasePlugin subclass ...
|
|
finally:
|
|
sys.path.remove(str(external_dir))
|
|
return discovered
|
|
```
|
|
|
|
### 5.2 Plugin-Signatur-Validierung (8 Std)
|
|
|
|
**Neue Datei: `app/plugins/signature.py`**
|
|
|
|
```python
|
|
"""Plugin signature verification for external plugins."""
|
|
|
|
from pathlib import Path
|
|
import hashlib
|
|
import hmac
|
|
|
|
# Ed25519 oder HMAC-SHA256 Signatur
|
|
|
|
class PluginSignature:
|
|
"""Verify plugin package signatures."""
|
|
|
|
@staticmethod
|
|
def verify_signature(zip_path: Path, signature: bytes, public_key: bytes) -> bool:
|
|
"""Verify Ed25519 signature of plugin ZIP."""
|
|
# 1. Read ZIP content
|
|
# 2. Compute hash
|
|
# 3. Verify signature with public key
|
|
pass
|
|
|
|
@staticmethod
|
|
def compute_hash(zip_path: Path) -> bytes:
|
|
"""Compute SHA-256 hash of plugin ZIP."""
|
|
pass
|
|
|
|
@staticmethod
|
|
def sign_plugin(zip_path: Path, private_key: bytes) -> bytes:
|
|
"""Sign a plugin ZIP (for plugin authors)."""
|
|
pass
|
|
```
|
|
|
|
### 5.3 Plugin-Allowlist (4 Std)
|
|
|
|
**Neue Alembic-Migration: `0044_plugin_allowlist.py`**
|
|
|
|
```python
|
|
# Tabelle: plugin_allowlist
|
|
# - id: UUID
|
|
# - plugin_name: VARCHAR(80)
|
|
# - allowed_hash: VARCHAR(64) # SHA-256
|
|
# - allowed_signature: TEXT # Ed25519 signature
|
|
# - added_by: UUID (user)
|
|
# - created_at: TIMESTAMPTZ
|
|
# - is_active: BOOLEAN
|
|
```
|
|
|
|
### 5.4 Plugin-Metadata-Erweiterung (4 Std)
|
|
|
|
**Manifest-Erweiterung:**
|
|
```python
|
|
class PluginManifest(BaseModel):
|
|
# ... existing fields ...
|
|
author: str = Field(default="", description="Plugin author")
|
|
author_email: str = Field(default="", description="Author contact")
|
|
homepage: str = Field(default="", description="Plugin homepage URL")
|
|
license: str = Field(default="MIT", description="License")
|
|
min_app_version: str = Field(default="0.0.0")
|
|
icon: str = Field(default="", description="Icon URL or emoji")
|
|
screenshots: list[str] = Field(default_factory=list)
|
|
changelog: str = Field(default="", description="Changelog URL or text")
|
|
tags: list[str] = Field(default_factory=list, description="Marketplace categories")
|
|
price: float = Field(default=0.0, description="Price (0 = free)")
|
|
```
|
|
|
|
### 5.5 Plugin-Download-Endpoint (4 Std)
|
|
|
|
**Neue Route: `POST /api/v1/plugins/install-marketplace`**
|
|
|
|
```python
|
|
@router.post("/install-marketplace")
|
|
async def install_from_marketplace(
|
|
body: MarketplaceInstall,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("plugins:configure")),
|
|
):
|
|
"""Install a plugin from the marketplace.
|
|
|
|
1. Download ZIP from marketplace URL
|
|
2. Verify signature against allowlist
|
|
3. Validate manifest
|
|
4. Check dangerous imports
|
|
5. Validate migration SQL
|
|
6. Install (migrations + DB record)
|
|
7. Activate (optional)
|
|
"""
|
|
# 1. Download
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(body.url)
|
|
zip_data = resp.content
|
|
|
|
# 2. Verify signature
|
|
if not PluginSignature.verify_signature(zip_data, body.signature, public_key):
|
|
raise HTTPException(403, "Invalid plugin signature")
|
|
|
|
# 3-6. Validate and install
|
|
# ... (reuse existing validation + install logic)
|
|
```
|
|
|
|
### 5.6 Plugin-Update-Check (4 Std)
|
|
|
|
```python
|
|
@router.get("/updates")
|
|
async def check_plugin_updates(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("plugins:read")),
|
|
):
|
|
"""Check for available plugin updates from marketplace."""
|
|
# 1. Query marketplace registry (if configured)
|
|
# 2. Compare versions with installed plugins
|
|
# 3. Return list of available updates
|
|
```
|
|
|
|
### 5.7 Plugin-Quarantine (4 Std)
|
|
|
|
```python
|
|
async def _quarantine_plugin(zip_path: Path) -> Path:
|
|
"""Extract plugin to temp dir, validate, then move to plugins/ dir.
|
|
|
|
1. Extract to /tmp/plugin_upload_<uuid>/
|
|
2. Validate manifest exists
|
|
3. Check dangerous imports
|
|
4. Validate migration SQL
|
|
5. Check signature
|
|
6. If all OK: move to plugins/ dir
|
|
7. If any fail: delete temp dir, raise error
|
|
"""
|
|
```
|
|
|
|
### 5.8 Tests (8 Std)
|
|
|
|
- `test_marketplace.py` — Download, Verify, Install Flow
|
|
- `test_signature.py` — Signatur-Validierung
|
|
- `test_allowlist.py` — Allowlist-Management
|
|
- `test_quarantine.py` — Quarantine-Validierung
|
|
- `test_external_discovery.py` — Externe Plugin-Discovery
|
|
- Integrationstests: Vollständiger Marketplace-Flow
|
|
|
|
### Meilenstein Phase 5:
|
|
- ✅ Externe Plugins können entdeckt werden
|
|
- ✅ Signatur-Validierung funktioniert
|
|
- ✅ Allowlist schützt vor nicht autorisierten Plugins
|
|
- ✅ Marketplace-Endpoint ist vorbereitet (deaktiviert bis Marketplace live)
|
|
- ✅ Plugin-Upload bleibt deaktiviert
|
|
- ✅ Built-in Plugins laufen ohne Marketplace
|
|
- ✅ Tests bestanden
|
|
|
|
---
|
|
|
|
## Phase 6: Manifest-Anpassung & Konsolidierung
|
|
|
|
**Ziel:** Alle in Phase 4 und 5 definierten Manifest-Felder werden ins `PluginManifest` integriert, bestehende Manifeste aktualisiert, und das Manifest-System finalisiert.
|
|
|
|
**Wichtig:** Diese Phase baut auf Phase 4 (Versioning) und Phase 5 (Marketplace) auf und muss als letztes durchgeführt werden.
|
|
|
|
### 6.1 PluginManifest erweitern (4 Std)
|
|
|
|
**Aktuelles Manifest (verifiziert 2026-07-26):**
|
|
```python
|
|
class PluginManifest(BaseModel):
|
|
name: str
|
|
version: str
|
|
display_name: str
|
|
description: str
|
|
dependencies: list[str]
|
|
routes: list[PluginRouteDef]
|
|
events: list[str]
|
|
migrations: list[str]
|
|
permissions: list[str]
|
|
is_core: bool
|
|
field_definitions: list[FieldDefinition]
|
|
agent_capabilities: list[str]
|
|
menu_items: list[FrontendMenuItem]
|
|
page_routes: list[FrontendPageRoute]
|
|
detail_tabs: list[FrontendDetailTab]
|
|
settings_pages: list[FrontendSettingsPage]
|
|
dashboard_widgets: list[FrontendDashboardWidget]
|
|
agent_definitions: list[AgentDefinitionContribution]
|
|
automation_templates: list[AutomationTemplateContribution]
|
|
cron_jobs: list[CronJobContribution]
|
|
heartbeat_configs: list[HeartbeatConfigContribution]
|
|
miniapps: list[MiniAppContribution]
|
|
custom_fields: list[CustomFieldDefinition]
|
|
model_config = {"extra": "forbid"}
|
|
```
|
|
|
|
**Neue Felder hinzufügen:**
|
|
```python
|
|
class PluginManifest(BaseModel):
|
|
# ... alle bestehenden Felder ...
|
|
|
|
# ── Versioning (Phase 4) ──
|
|
min_app_version: str = Field(
|
|
default="0.0.0",
|
|
description="Minimum LeoCRM version required (SemVer)"
|
|
)
|
|
|
|
# ── Marketplace (Phase 5) ──
|
|
author: str = Field(default="", max_length=200, description="Plugin author name")
|
|
author_email: str = Field(default="", max_length=200, description="Author contact email")
|
|
homepage: str = Field(default="", max_length=500, description="Plugin homepage URL")
|
|
license: str = Field(default="MIT", max_length=50, description="License identifier")
|
|
icon: str = Field(default="", description="Icon URL or emoji")
|
|
screenshots: list[str] = Field(default_factory=list, description="Screenshot URLs for marketplace")
|
|
changelog: str = Field(default="", description="Changelog URL or inline text")
|
|
marketplace_tags: list[str] = Field(default_factory=list, description="Marketplace category tags")
|
|
price: float = Field(default=0.0, ge=0.0, description="Price (0 = free)")
|
|
|
|
# ── Hooks (Phase 2) ──
|
|
hooks: list[str] = Field(
|
|
default_factory=list,
|
|
description="Hook names this plugin registers (e.g. 'contact.before_create')"
|
|
)
|
|
|
|
# ── Contracts (Phase 1) ──
|
|
contract_version: str = Field(
|
|
default="1.0.0",
|
|
description="Contract API version this plugin exposes"
|
|
)
|
|
```
|
|
|
|
### 6.2 Manifest-Schema-Dokumentation aktualisieren (3 Std)
|
|
|
|
**`MANIFEST_SCHEMA_DOC` in `manifest.py` erweitern:**
|
|
- Alle neuen Felder in `fields`-Dict aufnehmen
|
|
- `example`-Manifest mit neuen Feldern aktualisieren
|
|
- API-Endpoint `GET /api/v1/plugins/manifest` liefert vollständiges Schema
|
|
|
|
### 6.3 Alle 19 Plugin-Manifeste aktualisieren (8 Std)
|
|
|
|
Jedes Plugin-Manifest muss um die neuen Felder erweitert werden:
|
|
|
|
| # | Plugin | Aufwand | Neue Felder |
|
|
|---|---|---|---|
|
|
| 1 | `ai_assistant` | 30 Min | author, min_app_version, hooks, contract_version |
|
|
| 2 | `ai_proactive` | 30 Min | author, min_app_version, hooks, contract_version |
|
|
| 3 | `ai_ui_control` | 20 Min | author, min_app_version, contract_version |
|
|
| 4 | `automation` | 30 Min | author, min_app_version, hooks, contract_version |
|
|
| 5 | `calendar` | 20 Min | author, min_app_version, hooks, contract_version |
|
|
| 6 | `dms` | 20 Min | author, min_app_version, hooks, contract_version |
|
|
| 7 | `entity_links` | 15 Min | author, min_app_version, contract_version |
|
|
| 8 | `forgejo_error_reporter` | 15 Min | author, min_app_version, contract_version |
|
|
| 9 | `kommunikation` | 30 Min | author, min_app_version, hooks, contract_version |
|
|
| 10 | `mail` | 20 Min | author, min_app_version, hooks, contract_version |
|
|
| 11 | `mcp_client` | 20 Min | author, min_app_version, contract_version |
|
|
| 12 | `mcp_server` | 20 Min | author, min_app_version, contract_version |
|
|
| 13 | `permissions` | 20 Min | author, min_app_version, contract_version |
|
|
| 14 | `report_generator` | 20 Min | author, min_app_version, contract_version |
|
|
| 15 | `system_notif` | 15 Min | author, min_app_version, contract_version |
|
|
| 16 | `tags` | 15 Min | author, min_app_version, contract_version |
|
|
| 17 | `tasks` | 20 Min | author, min_app_version, hooks, contract_version |
|
|
| 18 | `test_sample` | 10 Min | author, min_app_version, contract_version |
|
|
| 19 | `unified_search` | 20 Min | author, min_app_version, hooks, contract_version |
|
|
|
|
**Muster für Aktualisierung:**
|
|
```python
|
|
# VORHER:
|
|
manifest = PluginManifest(
|
|
name="calendar",
|
|
version="1.0.0",
|
|
display_name="Calendar",
|
|
...
|
|
)
|
|
|
|
# NACHHER:
|
|
manifest = PluginManifest(
|
|
name="calendar",
|
|
version="1.0.0",
|
|
display_name="Calendar",
|
|
# ... bestehende Felder ...
|
|
# ── Neue Felder ──
|
|
min_app_version="1.0.0",
|
|
author="LeoCRM Team",
|
|
license="MIT",
|
|
hooks=["calendar.before_appointment", "calendar.after_appointment"],
|
|
contract_version="1.0.0",
|
|
)
|
|
```
|
|
|
|
### 6.4 Frontend Plugin-Manifest-Typen aktualisieren (2 Std)
|
|
|
|
**`frontend/src/api/pluginManifests.ts` und `frontend/src/types/automation.ts`:**
|
|
- TypeScript-Interfaces um neue Manifest-Felder erweitern
|
|
- `PluginManifestResponse`-Typ aktualisieren
|
|
- Frontend-Komponenten die Manifest-Felder anzeigen erweitern
|
|
|
|
### 6.5 Manifest-Validierung verschärfen (3 Std)
|
|
|
|
**Neue Validierungsregeln in `PluginManifest`:**
|
|
```python
|
|
@field_validator("min_app_version")
|
|
@classmethod
|
|
def validate_min_app_version(cls, v: str) -> str:
|
|
"""Validate SemVer format."""
|
|
from app.plugins.semver import SemVer
|
|
SemVer.parse(v) # Raises ValueError if invalid
|
|
return v
|
|
|
|
@field_validator("hooks")
|
|
@classmethod
|
|
def validate_hooks(cls, v: list[str]) -> list[str]:
|
|
"""Validate hook names follow namespace.pattern."""
|
|
for hook in v:
|
|
if not re.match(r"^[a-z_]+\.[a-z_]+$", hook):
|
|
raise ValueError(f"Invalid hook name '{hook}': must be 'namespace.action'")
|
|
return v
|
|
```
|
|
|
|
### 6.6 Tests für erweitertes Manifest (3 Std)
|
|
|
|
- `test_manifest.py` — Neue Felder validieren
|
|
- `test_manifest_validation.py` — SemVer-Validierung, Hook-Name-Validierung
|
|
- Alle Plugin-Tests: Manifest mit neuen Feldern erstellen
|
|
- Frontend-Tests: Manifest mit neuen Feldern rendern
|
|
|
|
### Meilenstein Phase 6:
|
|
- ✅ `PluginManifest` hat alle neuen Felder (min_app_version, author, hooks, contract_version, etc.)
|
|
- ✅ `MANIFEST_SCHEMA_DOC` ist vollständig aktualisiert
|
|
- ✅ Alle 19 Plugin-Manifeste haben die neuen Felder
|
|
- ✅ Frontend-Typen sind aktualisiert
|
|
- ✅ Manifest-Validierung ist verschärft
|
|
- ✅ Tests bestanden
|
|
|
|
---
|
|
|
|
## Zeitplan
|
|
|
|
```
|
|
Woche 1 (Tag 1-5): Phase 1 — Contracts (Teil 1: contracts.py + Imports)
|
|
Woche 2 (Tag 6-8): Phase 1 — Contracts (Teil 2: Deaktivierung + Tests)
|
|
(Tag 9-10): Phase 2 — Hooks/Filters-System
|
|
Woche 3 (Tag 11): Phase 3 — Plugin-Isolation
|
|
(Tag 12-14): Phase 4 — Plugin-Versioning
|
|
Woche 4 (Tag 15-19): Phase 5 — Marketplace-Vorbereitung
|
|
Woche 5 (Tag 20-22): Phase 6 — Manifest-Anpassung & Konsolidierung
|
|
(Tag 23): Puffer / Bugfixes / Doku
|
|
```
|
|
|
|
### Abhängigkeiten
|
|
```
|
|
Phase 1 (Contracts) ──→ Phase 3 (Isolation: Linting braucht Contracts als Ausnahme)
|
|
│
|
|
└──→ Phase 2 (Hooks: unabhängig, kann parallel)
|
|
│
|
|
└──→ Phase 4 (Versioning: braucht Contracts für min_app_version)
|
|
│
|
|
└──→ Phase 5 (Marketplace: braucht alles)
|
|
│
|
|
└──→ Phase 6 (Manifest: braucht Phase 4 + 5 Felder)
|
|
```
|
|
|
|
### Parallelisierungsmöglichkeiten
|
|
- Phase 1 und Phase 2 können **parallel** laufen (verschiedene Entwickler)
|
|
- Phase 3 kann erst nach Phase 1 starten
|
|
- Phase 4 kann nach Phase 1 starten
|
|
- Phase 5 kann erst nach Phase 1+4 starten
|
|
- Phase 6 kann erst nach Phase 4+5 starten (braucht deren Manifest-Felder)
|
|
|
|
---
|
|
|
|
## Risiken
|
|
|
|
| Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation |
|
|
|---|---|---|---|
|
|
| Contract-Refactoring bricht bestehende Funktionalität | Mittel | Hoch | Tests nach jedem Plugin, schrittweise Migration |
|
|
| Hooks/Filters verändern Core-Verhalten | Niedrig | Mittel | Tests für alle Hook-Punkte, Priority-System |
|
|
| Externe Plugin-Discovery hat Sicherheitslücken | Mittel | Hoch | Signatur-Validierung, Quarantine, Allowlist |
|
|
| SemVer-Parse-Fehler bei bestehenden Versionen | Niedrig | Niedrig | Fallback auf String-Vergleich |
|
|
| Rollback-Migrationen löschen Daten | Mittel | Hoch | Bestätigungs-Prompt, Backup vor Rollback |
|
|
|
|
---
|
|
|
|
## Erfolgskriterien
|
|
|
|
Nach Abschluss aller 5 Phasen:
|
|
|
|
1. ✅ **0 direkte Cross-Plugin-Imports** (grep-verifiziert, linting-enforced)
|
|
2. ✅ **Alle 16 Plugins haben contracts.py** mit klarer öffentlicher API
|
|
3. ✅ **Contracts werden bei Deaktivierung abgemeldet**
|
|
4. ✅ **Hooks/Filters-System** mit 15+ Hook-Punkten in Core-Services
|
|
5. ✅ **Plugin-Isolation** durch Linting-Regeln erzwungen
|
|
6. ✅ **SemVer-Vergleich** statt String-Vergleich
|
|
7. ✅ **Rollback-Migrationen** für alle Plugins verfügbar
|
|
8. ✅ **min_app_version** wird bei Installation geprüft
|
|
9. ✅ **Update-Benachrichtigung** im Frontend
|
|
10. ✅ **Marketplace-Endpoint** vorbereitet (deaktiviert)
|
|
11. ✅ **Signatur-Validierung** für externe Plugins
|
|
12. ✅ **Allowlist** schützt vor nicht autorisierten Plugins
|
|
13. ✅ **Externe Plugin-Discovery** funktioniert
|
|
14. ✅ **Alle Tests bestanden**
|
|
15. ✅ **Built-in Plugins laufen ohne Marketplace**
|
|
16. ✅ **PluginManifest hat alle neuen Felder** (min_app_version, author, hooks, contract_version, etc.)
|
|
17. ✅ **Alle 19 Plugin-Manifeste aktualisiert** mit neuen Feldern
|
|
18. ✅ **Manifest-Validierung verschärft** (SemVer, Hook-Names)
|
|
19. ✅ **Frontend-Typen aktualisiert** für neue Manifest-Felder
|
|
|
|
---
|
|
|
|
## Dokumentation
|
|
|
|
Nach Abschluss jeder Phase:
|
|
- `docs/plugin-system/phase-N.md` — Was wurde gemacht, was geändert
|
|
- `docs/plugin-system/contracts-api.md` — Contract-API Referenz
|
|
- `docs/plugin-system/hooks-api.md` — Hooks/Filters Referenz
|
|
- `docs/plugin-system/marketplace-api.md` — Marketplace-API Referenz
|
|
- `docs/plugin-system/plugin-development-guide.md` — Wie man ein Plugin entwickelt
|
|
|
|
---
|
|
|
|
**Dieser Plan ist vollständig. Alle Aufgaben, Aufwände, Abhängigkeiten und Risiken sind erfasst.**
|