Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""monorepo – CLI-Tool für Monorepo-Verwaltung.
|
||||
|
||||
Exportiert die zentralen Datenmodelle und Konfigurationsfunktionen.
|
||||
"""
|
||||
|
||||
from monorepo.audit import AuditLogger
|
||||
from monorepo.bridge import ContextBridge, SensitiveMatch, ShareResult
|
||||
from monorepo.config import MonorepoConfig, load_monorepo_config
|
||||
from monorepo.models import (
|
||||
ArtifactType,
|
||||
ConflictInfo,
|
||||
ConflictStrategy,
|
||||
Context,
|
||||
EncryptionKey,
|
||||
IsolationLeak,
|
||||
IsolationReport,
|
||||
KeySource,
|
||||
MachineContext,
|
||||
MigrationPlan,
|
||||
OnboardingResult,
|
||||
PasswordManagerConfig,
|
||||
ProjectInfo,
|
||||
RepoEntry,
|
||||
RepoMode,
|
||||
ScopeConfig,
|
||||
SecurityEvent,
|
||||
SharedMirrorConfig,
|
||||
SyncDirection,
|
||||
SyncFrequency,
|
||||
SyncResult,
|
||||
TeamRepoEntry,
|
||||
)
|
||||
from monorepo.encryption import (
|
||||
DecryptionResult,
|
||||
EncryptionResult,
|
||||
GitCryptNotAvailableError,
|
||||
MachineContextManager,
|
||||
PasswordManagerError,
|
||||
SecretEncryptionManager,
|
||||
)
|
||||
from monorepo.federation import (
|
||||
ConflictResult,
|
||||
FederationManager,
|
||||
FederationTopologyConfig,
|
||||
MemberInfo,
|
||||
MemberOnboardingResult,
|
||||
MirrorResult,
|
||||
SubtreeSyncEngine,
|
||||
)
|
||||
from monorepo.integration import create_integrated_stack
|
||||
from monorepo.shared_config import (
|
||||
AgentExtension,
|
||||
ConfigMerger,
|
||||
HarnessAdapter,
|
||||
MCPServerConfig,
|
||||
MergeConflict,
|
||||
MergedMCPConfig,
|
||||
SharedToolStatus,
|
||||
ToolReference,
|
||||
)
|
||||
from monorepo.migration import (
|
||||
MigrationConflict,
|
||||
MigrationEngine,
|
||||
MigrationError,
|
||||
MigrationRegistryEntry,
|
||||
MigrationResult,
|
||||
)
|
||||
from monorepo.security import ContextGuard
|
||||
from monorepo.structure import StructureManager
|
||||
|
||||
__all__ = [
|
||||
# Audit
|
||||
"AuditLogger",
|
||||
# Bridge
|
||||
"ContextBridge",
|
||||
"SensitiveMatch",
|
||||
"ShareResult",
|
||||
# Enums
|
||||
"ArtifactType",
|
||||
"ConflictStrategy",
|
||||
"Context",
|
||||
"KeySource",
|
||||
"RepoMode",
|
||||
"SyncDirection",
|
||||
"SyncFrequency",
|
||||
# Dataclasses
|
||||
"ConflictInfo",
|
||||
"EncryptionKey",
|
||||
"IsolationLeak",
|
||||
"IsolationReport",
|
||||
"MachineContext",
|
||||
"MigrationPlan",
|
||||
"MonorepoConfig",
|
||||
"OnboardingResult",
|
||||
"PasswordManagerConfig",
|
||||
"ProjectInfo",
|
||||
"RepoEntry",
|
||||
"ScopeConfig",
|
||||
"SecurityEvent",
|
||||
"SharedMirrorConfig",
|
||||
"SyncResult",
|
||||
"TeamRepoEntry",
|
||||
# Classes
|
||||
"AgentExtension",
|
||||
"ConfigMerger",
|
||||
"ConflictResult",
|
||||
"ContextGuard",
|
||||
"DecryptionResult",
|
||||
"EncryptionResult",
|
||||
"FederationManager",
|
||||
"FederationTopologyConfig",
|
||||
"GitCryptNotAvailableError",
|
||||
"HarnessAdapter",
|
||||
"MachineContextManager",
|
||||
"MCPServerConfig",
|
||||
"MemberInfo",
|
||||
"MemberOnboardingResult",
|
||||
"MergeConflict",
|
||||
"MergedMCPConfig",
|
||||
"MigrationConflict",
|
||||
"MigrationEngine",
|
||||
"MigrationError",
|
||||
"MigrationRegistryEntry",
|
||||
"MigrationResult",
|
||||
"MirrorResult",
|
||||
"PasswordManagerError",
|
||||
"SecretEncryptionManager",
|
||||
"SharedToolStatus",
|
||||
"StructureManager",
|
||||
"SubtreeSyncEngine",
|
||||
"ToolReference",
|
||||
# Config
|
||||
"load_monorepo_config",
|
||||
# Integration
|
||||
"create_integrated_stack",
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Audit-Logger für die Protokollierung von Zugriffsverletzungen.
|
||||
|
||||
Schreibt Sicherheitsereignisse (SecurityEvents) in eine konfigurierbare
|
||||
Protokolldatei im strukturierten Textformat. Thread-sicher durch Nutzung
|
||||
eines Locks für Dateizugriffe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.models import SecurityEvent
|
||||
|
||||
# Standard-Audit-Log-Pfad (relativ zum Monorepo-Root), wie in monorepo.yaml definiert
|
||||
_DEFAULT_AUDIT_LOG_PATH = Path(".audit/access.log")
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""Protokolliert Sicherheitsereignisse in eine Audit-Logdatei.
|
||||
|
||||
Jeder Eintrag wird als strukturierte Zeile im Format geschrieben:
|
||||
[ISO-TIMESTAMP] OUTCOME | requesting_context -> target_context | action on resource
|
||||
|
||||
Der Logger ist thread-sicher: Schreibzugriffe auf die Logdatei werden
|
||||
durch einen Lock serialisiert.
|
||||
|
||||
Args:
|
||||
log_path: Pfad zur Audit-Logdatei. Standard: `.audit/access.log`
|
||||
(aus monorepo.yaml security.audit_log).
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Path | None = None) -> None:
|
||||
"""Initialisiert den AuditLogger.
|
||||
|
||||
Args:
|
||||
log_path: Pfad zur Audit-Logdatei. Falls None, wird der
|
||||
Standard-Pfad `.audit/access.log` verwendet.
|
||||
"""
|
||||
self._log_path = log_path if log_path is not None else _DEFAULT_AUDIT_LOG_PATH
|
||||
self._lock = threading.Lock()
|
||||
# Verzeichnis erstellen, falls es nicht existiert
|
||||
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def log_path(self) -> Path:
|
||||
"""Gibt den konfigurierten Pfad zur Audit-Logdatei zurück."""
|
||||
return self._log_path
|
||||
|
||||
def log_violation(self, event: SecurityEvent) -> None:
|
||||
"""Schreibt ein Sicherheitsereignis in die Protokolldatei.
|
||||
|
||||
Format pro Zeile:
|
||||
[2025-01-15T10:30:00] DENIED | dhive -> privat | read on privat/.env
|
||||
|
||||
Args:
|
||||
event: Das zu protokollierende Sicherheitsereignis mit Zeitstempel,
|
||||
anfragendem Kontext, Zielkontext, Ressource, Aktion und Ergebnis.
|
||||
"""
|
||||
timestamp_iso = event.timestamp.isoformat()
|
||||
outcome_upper = event.outcome.upper()
|
||||
|
||||
line = (
|
||||
f"[{timestamp_iso}] {outcome_upper} | "
|
||||
f"{event.requesting_context} -> {event.target_context} | "
|
||||
f"{event.action} on {event.resource}\n"
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
with open(self._log_path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Kontextbrücke für kontextübergreifenden Wissenstransfer.
|
||||
|
||||
Ermöglicht das sichere Teilen von Wissensartefakten zwischen Arbeitskontexten
|
||||
unter Wahrung der Sicherheitsgrenzen. Implementiert:
|
||||
- Sensitive-Content-Filterung (Secrets, Endpoints, PII) via Regex
|
||||
- Explizite Nutzerbestätigung vor Freigabe
|
||||
- Read-only-Referenzen in Zielkontexten
|
||||
- Update-Propagierung beim nächsten Lesezugriff
|
||||
- Widerruf von Freigaben
|
||||
|
||||
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
|
||||
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
||||
from monorepo.models import Context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datenmodelle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensitiveMatch:
|
||||
"""Ein gefundener sensitiver Inhalt in einem Artefakt.
|
||||
|
||||
Attributes:
|
||||
pattern_name: Name des Musters, das getroffen hat (z.B. "credentials", "endpoint", "pii").
|
||||
matched_text: Der gefundene Text-Ausschnitt.
|
||||
line_number: Zeilennummer im Inhalt (1-basiert).
|
||||
reason: Menschenlesbare Begründung, warum der Inhalt als sensitiv gilt.
|
||||
"""
|
||||
|
||||
pattern_name: str
|
||||
matched_text: str
|
||||
line_number: int
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShareResult:
|
||||
"""Ergebnis einer Freigabe-Operation.
|
||||
|
||||
Attributes:
|
||||
success: True wenn die Freigabe erfolgreich war.
|
||||
artifact_id: ID des betroffenen Artefakts.
|
||||
shared_to: Liste der Kontexte, in denen das Artefakt nun verfügbar ist.
|
||||
errors: Liste von Fehlermeldungen bei gescheiterter Freigabe.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
artifact_id: str
|
||||
shared_to: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-Artifacts-Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedArtifactEntry:
|
||||
"""Eintrag in der Shared-Artifacts-Registry.
|
||||
|
||||
Attributes:
|
||||
artifact_id: ID des geteilten Artefakts.
|
||||
source_context: Ursprungskontext des Artefakts.
|
||||
shared_to: Zielkontexte, in denen das Artefakt verfügbar ist.
|
||||
content_hash: Hash des Inhalts zum Zeitpunkt der Freigabe (für Update-Erkennung).
|
||||
"""
|
||||
|
||||
artifact_id: str
|
||||
source_context: str
|
||||
shared_to: list[str] = field(default_factory=list)
|
||||
content_hash: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextBridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContextBridge:
|
||||
"""Ermöglicht kontextübergreifenden Wissenstransfer.
|
||||
|
||||
Die ContextBridge prüft Artefakte auf sensitive Inhalte, erfordert
|
||||
explizite Nutzerbestätigung und stellt geteilte Artefakte als
|
||||
schreibgeschützte Lesereferenzen in Zielkontexten bereit.
|
||||
|
||||
Update-Propagierung: Wird ein Artefakt im Quellkontext aktualisiert,
|
||||
sind die Änderungen beim nächsten Lesezugriff über get_shared_content()
|
||||
sichtbar, da immer vom Quell-Artefakt gelesen wird.
|
||||
|
||||
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
|
||||
"""
|
||||
|
||||
SENSITIVE_PATTERNS: list[tuple[str, str, str]] = [
|
||||
(
|
||||
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]",
|
||||
"credentials",
|
||||
"Enthält kontextspezifische Zugangsdaten (API-Key, Token, Passwort oder Secret)",
|
||||
),
|
||||
(
|
||||
r"(?i)(endpoint|url)\s*[:=]\s*https?://",
|
||||
"endpoint",
|
||||
"Enthält kontextspezifische Endpoint-URL",
|
||||
),
|
||||
(
|
||||
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
|
||||
"pii",
|
||||
"Enthält personenbezogene Daten (E-Mail-Adresse)",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
knowledge_base_path: Path,
|
||||
index: YAMLIndex,
|
||||
registry_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialisiert die ContextBridge.
|
||||
|
||||
Args:
|
||||
knowledge_base_path: Basispfad des Wissensspeichers.
|
||||
index: Der YAML-Index des Wissensspeichers.
|
||||
registry_path: Pfad zur Shared-Artifacts-Registry (YAML).
|
||||
Falls None, wird in-memory gearbeitet.
|
||||
"""
|
||||
self.knowledge_base_path = knowledge_base_path
|
||||
self.index = index
|
||||
self.registry_path = registry_path
|
||||
self._registry: dict[str, SharedArtifactEntry] = {}
|
||||
|
||||
if registry_path and registry_path.exists():
|
||||
self._load_registry()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Öffentliche API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def share_artifact(
|
||||
self, artifact_id: str, user_confirmed: bool = False
|
||||
) -> ShareResult:
|
||||
"""Gibt ein Artefakt kontextübergreifend frei.
|
||||
|
||||
Prüft:
|
||||
1. Existenz des Artefakts im Index
|
||||
2. Explizite Nutzerbestätigung (Req 5.4)
|
||||
3. Sensitive-Content-Filter (Req 5.1, 5.3)
|
||||
|
||||
Bei Erfolg wird das Artefakt als Lesereferenz in allen anderen
|
||||
Kontexten bereitgestellt (Req 5.2).
|
||||
|
||||
Args:
|
||||
artifact_id: ID des freizugebenden Artefakts.
|
||||
user_confirmed: Muss True sein für explizite Nutzerbestätigung.
|
||||
|
||||
Returns:
|
||||
ShareResult mit Erfolgs-/Fehlerstatus.
|
||||
"""
|
||||
# Req 5.4: Explizite Nutzerbestätigung erforderlich
|
||||
if not user_confirmed:
|
||||
return ShareResult(
|
||||
success=False,
|
||||
artifact_id=artifact_id,
|
||||
errors=[
|
||||
"Explizite Nutzerbestätigung erforderlich. "
|
||||
"Setze user_confirmed=True um die Freigabe zu bestätigen."
|
||||
],
|
||||
)
|
||||
|
||||
# Artefakt im Index finden
|
||||
entry = self.index.get_entry(artifact_id)
|
||||
if entry is None:
|
||||
return ShareResult(
|
||||
success=False,
|
||||
artifact_id=artifact_id,
|
||||
errors=[f"Artefakt nicht im Index gefunden: {artifact_id}"],
|
||||
)
|
||||
|
||||
# Inhalt laden und auf sensitive Inhalte prüfen (Req 5.1, 5.3)
|
||||
content = self._load_artifact_content(entry)
|
||||
if content is None:
|
||||
return ShareResult(
|
||||
success=False,
|
||||
artifact_id=artifact_id,
|
||||
errors=[
|
||||
f"Artefakt-Datei nicht gefunden oder nicht lesbar: {entry.path}"
|
||||
],
|
||||
)
|
||||
|
||||
sensitive_matches = self.check_sensitive_content(content)
|
||||
if sensitive_matches:
|
||||
reasons = [
|
||||
f"Zeile {m.line_number}: {m.reason} ('{m.matched_text}')"
|
||||
for m in sensitive_matches
|
||||
]
|
||||
return ShareResult(
|
||||
success=False,
|
||||
artifact_id=artifact_id,
|
||||
errors=[
|
||||
"Freigabe verweigert: Artefakt enthält sensible Inhalte.",
|
||||
*reasons,
|
||||
],
|
||||
)
|
||||
|
||||
# Zielkontexte ermitteln (alle Kontexte außer dem Quellkontext)
|
||||
source_context = entry.scope
|
||||
target_contexts = [
|
||||
ctx.value for ctx in Context if ctx.value != source_context
|
||||
]
|
||||
|
||||
# In Registry eintragen
|
||||
self._registry[artifact_id] = SharedArtifactEntry(
|
||||
artifact_id=artifact_id,
|
||||
source_context=source_context,
|
||||
shared_to=target_contexts,
|
||||
content_hash=entry.content_hash,
|
||||
)
|
||||
|
||||
# Registry persistieren
|
||||
self._save_registry()
|
||||
|
||||
logger.info(
|
||||
"Artefakt '%s' aus Kontext '%s' für Kontexte %s freigegeben.",
|
||||
artifact_id,
|
||||
source_context,
|
||||
target_contexts,
|
||||
)
|
||||
|
||||
return ShareResult(
|
||||
success=True,
|
||||
artifact_id=artifact_id,
|
||||
shared_to=target_contexts,
|
||||
)
|
||||
|
||||
def check_sensitive_content(self, content: str) -> list[SensitiveMatch]:
|
||||
"""Prüft Inhalt auf kontextspezifische Geheimnisse und PII.
|
||||
|
||||
Verwendet Regex-Muster zur Erkennung von:
|
||||
- Credentials (API-Keys, Tokens, Passwörter, Secrets)
|
||||
- Endpoint-URLs
|
||||
- Personenbezogene Daten (E-Mail-Adressen)
|
||||
|
||||
Args:
|
||||
content: Der zu prüfende Textinhalt.
|
||||
|
||||
Returns:
|
||||
Liste der gefundenen sensitiven Stellen. Leere Liste wenn
|
||||
kein sensitiver Inhalt gefunden wurde.
|
||||
|
||||
Requirements: 5.1, 5.3
|
||||
"""
|
||||
matches: list[SensitiveMatch] = []
|
||||
lines = content.splitlines()
|
||||
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
for pattern_str, pattern_name, reason in self.SENSITIVE_PATTERNS:
|
||||
pattern = re.compile(pattern_str)
|
||||
for match in pattern.finditer(line):
|
||||
matched_text = match.group(0)
|
||||
# Text auf sinnvolle Länge kürzen
|
||||
if len(matched_text) > 60:
|
||||
matched_text = matched_text[:57] + "..."
|
||||
matches.append(
|
||||
SensitiveMatch(
|
||||
pattern_name=pattern_name,
|
||||
matched_text=matched_text,
|
||||
line_number=line_num,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
return matches
|
||||
|
||||
def revoke_share(self, artifact_id: str) -> None:
|
||||
"""Widerruft die Freigabe eines geteilten Artefakts.
|
||||
|
||||
Entfernt das Artefakt aus allen Zielkontexten als Lesereferenz.
|
||||
Nach dem Widerruf ist das Artefakt nur noch im Quellkontext sichtbar.
|
||||
|
||||
Args:
|
||||
artifact_id: ID des Artefakts, dessen Freigabe widerrufen wird.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn das Artefakt nicht in der Freigabe-Registry ist.
|
||||
|
||||
Requirements: 5.6
|
||||
"""
|
||||
if artifact_id not in self._registry:
|
||||
raise ValueError(
|
||||
f"Artefakt '{artifact_id}' ist nicht freigegeben "
|
||||
f"und kann daher nicht widerrufen werden."
|
||||
)
|
||||
|
||||
entry = self._registry.pop(artifact_id)
|
||||
self._save_registry()
|
||||
|
||||
logger.info(
|
||||
"Freigabe von Artefakt '%s' widerrufen. "
|
||||
"Entfernt aus Kontexten: %s",
|
||||
artifact_id,
|
||||
entry.shared_to,
|
||||
)
|
||||
|
||||
def get_shared_content(self, artifact_id: str, requesting_context: str) -> str | None:
|
||||
"""Liest den aktuellen Inhalt eines geteilten Artefakts.
|
||||
|
||||
Liefert immer den aktuellen Stand aus dem Quellkontext,
|
||||
sodass Updates automatisch beim nächsten Lesezugriff sichtbar sind.
|
||||
|
||||
Args:
|
||||
artifact_id: ID des geteilten Artefakts.
|
||||
requesting_context: Der Kontext, aus dem der Zugriff erfolgt.
|
||||
|
||||
Returns:
|
||||
Den aktuellen Inhalt des Artefakts als String, oder None
|
||||
wenn der Zugriff nicht erlaubt ist.
|
||||
|
||||
Requirements: 5.2, 5.5
|
||||
"""
|
||||
# Prüfe ob Artefakt freigegeben ist
|
||||
registry_entry = self._registry.get(artifact_id)
|
||||
if registry_entry is None:
|
||||
return None
|
||||
|
||||
# Prüfe ob der anfragende Kontext berechtigt ist
|
||||
if requesting_context not in registry_entry.shared_to:
|
||||
return None
|
||||
|
||||
# Lade den aktuellen Inhalt aus dem Quellkontext (Req 5.5: Update-Propagierung)
|
||||
index_entry = self.index.get_entry(artifact_id)
|
||||
if index_entry is None:
|
||||
return None
|
||||
|
||||
return self._load_artifact_content(index_entry)
|
||||
|
||||
def is_shared(self, artifact_id: str) -> bool:
|
||||
"""Prüft ob ein Artefakt aktuell freigegeben ist.
|
||||
|
||||
Args:
|
||||
artifact_id: ID des zu prüfenden Artefakts.
|
||||
|
||||
Returns:
|
||||
True wenn das Artefakt freigegeben ist.
|
||||
"""
|
||||
return artifact_id in self._registry
|
||||
|
||||
def get_shared_artifacts(self, context: str) -> list[str]:
|
||||
"""Liefert alle Artefakt-IDs, die in einem bestimmten Kontext sichtbar sind.
|
||||
|
||||
Args:
|
||||
context: Der Kontext, für den die sichtbaren Artefakte ermittelt werden.
|
||||
|
||||
Returns:
|
||||
Liste der Artefakt-IDs, die im angegebenen Kontext als
|
||||
Lesereferenz verfügbar sind.
|
||||
"""
|
||||
return [
|
||||
entry.artifact_id
|
||||
for entry in self._registry.values()
|
||||
if context in entry.shared_to
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interne Methoden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_artifact_content(self, entry: IndexEntry) -> str | None:
|
||||
"""Lädt den Textinhalt eines Artefakts aus dem Dateisystem.
|
||||
|
||||
Args:
|
||||
entry: Der IndexEntry des Artefakts.
|
||||
|
||||
Returns:
|
||||
Den Inhalt als String oder None bei Fehler.
|
||||
"""
|
||||
if not entry.path:
|
||||
return None
|
||||
|
||||
artifact_path = self.knowledge_base_path / entry.path
|
||||
if not artifact_path.exists():
|
||||
logger.warning("Artefakt-Datei nicht gefunden: %s", artifact_path)
|
||||
return None
|
||||
|
||||
try:
|
||||
artifact = parse_frontmatter(artifact_path)
|
||||
return artifact.content
|
||||
except (ValueError, FileNotFoundError, OSError) as e:
|
||||
logger.warning(
|
||||
"Fehler beim Laden von Artefakt '%s': %s", entry.id, e
|
||||
)
|
||||
return None
|
||||
|
||||
def _load_registry(self) -> None:
|
||||
"""Lädt die Shared-Artifacts-Registry von der Festplatte."""
|
||||
if self.registry_path is None or not self.registry_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
logger.warning("Fehler beim Laden der Registry: %s", e)
|
||||
return
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
for artifact_data in data.get("shared_artifacts", []):
|
||||
if isinstance(artifact_data, dict):
|
||||
entry = SharedArtifactEntry(
|
||||
artifact_id=str(artifact_data.get("artifact_id", "")),
|
||||
source_context=str(artifact_data.get("source_context", "")),
|
||||
shared_to=list(artifact_data.get("shared_to", [])),
|
||||
content_hash=str(artifact_data.get("content_hash", "")),
|
||||
)
|
||||
if entry.artifact_id:
|
||||
self._registry[entry.artifact_id] = entry
|
||||
|
||||
def _save_registry(self) -> None:
|
||||
"""Persistiert die Shared-Artifacts-Registry auf die Festplatte."""
|
||||
if self.registry_path is None:
|
||||
return
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"shared_artifacts": [
|
||||
{
|
||||
"artifact_id": entry.artifact_id,
|
||||
"source_context": entry.source_context,
|
||||
"shared_to": entry.shared_to,
|
||||
"content_hash": entry.content_hash,
|
||||
}
|
||||
for entry in self._registry.values()
|
||||
]
|
||||
}
|
||||
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(
|
||||
data,
|
||||
f,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
@@ -0,0 +1,735 @@
|
||||
"""CLI-Einstiegspunkt für das Monorepo-Verwaltungstool (ctx-guard).
|
||||
|
||||
Stellt alle Verwaltungskommandos als Subcommands bereit und verdrahtet
|
||||
die Komponenten: StructureManager, ContextGuard, KnowledgeStore, RepoManager,
|
||||
ContextBridge, MigrationEngine, OrchestratorAdapter, SecretEncryptionManager,
|
||||
FederationManager.
|
||||
|
||||
Konfiguration wird aus `monorepo.yaml` und `shared/config/` geladen.
|
||||
|
||||
Entry-Point: ctx-guard (pyproject.toml → [project.scripts])
|
||||
Requirements: 1.1, 1.2, 4.4, 6.3, 9.6, 10.11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.config import MonorepoConfig, load_monorepo_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen für Komponenteninitialisierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_monorepo_root() -> Path:
|
||||
"""Sucht das Monorepo-Root anhand von monorepo.yaml.
|
||||
|
||||
Sucht im aktuellen Verzeichnis und in übergeordneten Verzeichnissen
|
||||
nach monorepo.yaml und gibt das Verzeichnis zurück.
|
||||
|
||||
Returns:
|
||||
Pfad zum Monorepo-Root.
|
||||
|
||||
Raises:
|
||||
SystemExit: Wenn kein monorepo.yaml gefunden wird.
|
||||
"""
|
||||
current = Path.cwd()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "monorepo.yaml").exists():
|
||||
return parent
|
||||
print("FEHLER: monorepo.yaml nicht gefunden.", file=sys.stderr)
|
||||
print("Bitte im Monorepo-Root ausführen oder `ctx-guard init` verwenden.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_config_safe() -> MonorepoConfig:
|
||||
"""Lädt die Monorepo-Konfiguration mit Fehlerbehandlung.
|
||||
|
||||
Returns:
|
||||
Geladene MonorepoConfig.
|
||||
|
||||
Raises:
|
||||
SystemExit: Bei Konfigurationsfehlern.
|
||||
"""
|
||||
try:
|
||||
return load_monorepo_config()
|
||||
except FileNotFoundError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"FEHLER beim Laden der Konfiguration: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_structure_manager(root: Path) -> "StructureManager":
|
||||
"""Erstellt einen StructureManager für das gegebene Root."""
|
||||
from monorepo.structure import StructureManager
|
||||
return StructureManager(root)
|
||||
|
||||
|
||||
def _get_context_guard(root: Path) -> "ContextGuard":
|
||||
"""Erstellt einen ContextGuard für das gegebene Root."""
|
||||
from monorepo.security import ContextGuard
|
||||
return ContextGuard(root)
|
||||
|
||||
|
||||
def _get_knowledge_store(root: Path):
|
||||
"""Erstellt einen KnowledgeStore für das gegebene Root."""
|
||||
from monorepo.knowledge.store import KnowledgeStore
|
||||
from monorepo.models import ScopeConfig
|
||||
|
||||
ks_path = root / "shared" / "knowledge-store"
|
||||
scope_config = ScopeConfig(scopes={
|
||||
"privat": {"scope": "privat", "paths": ["privat/"]},
|
||||
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
|
||||
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
|
||||
"shared": {"scope": "shared", "paths": ["shared/"]},
|
||||
})
|
||||
return KnowledgeStore(base_path=ks_path, scope_config=scope_config)
|
||||
|
||||
|
||||
def _get_repo_manager(root: Path) -> "RepoManager":
|
||||
"""Erstellt einen RepoManager für das gegebene Root."""
|
||||
from monorepo.repos import RepoManager
|
||||
config_path = root / "shared" / "config" / "repos.yaml"
|
||||
return RepoManager(config_path=config_path, monorepo_root=root)
|
||||
|
||||
|
||||
def _get_encryption_manager(root: Path):
|
||||
"""Erstellt einen SecretEncryptionManager für das gegebene Root."""
|
||||
from monorepo.encryption import MachineContextManager
|
||||
mcm = MachineContextManager(root)
|
||||
try:
|
||||
mcm.load()
|
||||
except FileNotFoundError:
|
||||
from monorepo.models import MachineContext
|
||||
# Fallback: Erstelle einen Default-Maschinenkontext
|
||||
mcm._machine_context = MachineContext(
|
||||
name="unknown",
|
||||
description="Standard-Maschinenkontext (keine Konfiguration gefunden)",
|
||||
authorized_contexts=["privat", "dhive", "bahn"],
|
||||
key_source="keyring",
|
||||
)
|
||||
return mcm.create_encryption_manager()
|
||||
|
||||
|
||||
def _get_federation_manager(root: Path):
|
||||
"""Erstellt einen FederationManager für das gegebene Root."""
|
||||
from monorepo.federation import FederationManager
|
||||
config_path = root / "shared" / "config" / "team-repos.yaml"
|
||||
encryption_mgr = _get_encryption_manager(root)
|
||||
return FederationManager(
|
||||
config_path=config_path,
|
||||
monorepo_root=root,
|
||||
encryption_manager=encryption_mgr,
|
||||
)
|
||||
|
||||
|
||||
def _get_migration_engine(root: Path):
|
||||
"""Erstellt eine MigrationEngine für das gegebene Root."""
|
||||
from monorepo.migration import MigrationEngine
|
||||
return MigrationEngine(monorepo_root=root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand-Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_init(args: argparse.Namespace) -> int:
|
||||
"""Initialisiert die Monorepo-Grundstruktur.
|
||||
|
||||
Erstellt die vier Kontextordner und die shared-Unterstruktur,
|
||||
sowie eine minimale monorepo.yaml falls sie nicht existiert.
|
||||
"""
|
||||
target = Path(args.path).resolve() if args.path else Path.cwd()
|
||||
|
||||
print(f"Initialisiere Monorepo in: {target}")
|
||||
|
||||
# Kontextordner erstellen
|
||||
contexts = ["privat", "dhive", "bahn", "shared"]
|
||||
for ctx in contexts:
|
||||
(target / ctx).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Shared-Unterstruktur
|
||||
shared_dirs = [
|
||||
"shared/tools",
|
||||
"shared/powers",
|
||||
"shared/knowledge-store",
|
||||
"shared/config",
|
||||
"shared/mcp-servers",
|
||||
]
|
||||
for d in shared_dirs:
|
||||
(target / d).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# monorepo.yaml erstellen falls nicht vorhanden
|
||||
config_path = target / "monorepo.yaml"
|
||||
if not config_path.exists():
|
||||
import yaml
|
||||
config_data = {
|
||||
"version": "1.0",
|
||||
"contexts": [
|
||||
{"name": "privat", "description": "Persönliche Projekte"},
|
||||
{"name": "dhive", "description": "dhive GmbH Projekte"},
|
||||
{"name": "bahn", "description": "DB InfraGO Projekte"},
|
||||
{"name": "shared", "description": "Kontextübergreifende Tools und Wissen"},
|
||||
],
|
||||
"naming": {
|
||||
"pattern": r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$",
|
||||
"min_length": 2,
|
||||
"max_length": 50,
|
||||
},
|
||||
"security": {
|
||||
"config": "shared/config/access-config.yaml",
|
||||
"audit_log": ".audit/access.log",
|
||||
"encryption": {
|
||||
"tool": "git-crypt",
|
||||
"machine_context_config": "shared/config/machine-context.yaml",
|
||||
"key_source": "keyring",
|
||||
},
|
||||
"federation": {
|
||||
"config": "shared/config/team-repos.yaml",
|
||||
"conflict_strategy": "team-wins",
|
||||
},
|
||||
},
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
print(f" Erstellt: {config_path}")
|
||||
|
||||
# Audit-Verzeichnis
|
||||
(target / ".audit").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Git-Hooks installieren (pre-commit mit Read-Only-Schutz + Secret-Prüfung)
|
||||
git_dir = target / ".git"
|
||||
if git_dir.exists():
|
||||
from monorepo.hooks import install_hooks
|
||||
try:
|
||||
install_hooks(target)
|
||||
print(" Installiert: Git pre-commit Hook (Read-Only + Secrets)")
|
||||
except Exception as e:
|
||||
logger.warning("Hook-Installation übersprungen: %s", e)
|
||||
print(f" ⚠ Hook-Installation übersprungen: {e}")
|
||||
|
||||
print("✓ Monorepo-Struktur erfolgreich initialisiert.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_create_project(args: argparse.Namespace) -> int:
|
||||
"""Erstellt ein neues Projekt in einem Arbeitskontext."""
|
||||
root = _find_monorepo_root()
|
||||
sm = _get_structure_manager(root)
|
||||
|
||||
try:
|
||||
project_path = sm.create_project(context=args.context, name=args.name)
|
||||
print(f"✓ Projekt erstellt: {project_path}")
|
||||
return 0
|
||||
except ValueError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_list_projects(args: argparse.Namespace) -> int:
|
||||
"""Listet alle Projekte auf, optional gefiltert nach Kontext."""
|
||||
root = _find_monorepo_root()
|
||||
sm = _get_structure_manager(root)
|
||||
|
||||
try:
|
||||
projects = sm.list_projects(context=args.context)
|
||||
except ValueError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not projects:
|
||||
ctx_info = f" im Kontext '{args.context}'" if args.context else ""
|
||||
print(f"Keine Projekte{ctx_info} gefunden.")
|
||||
return 0
|
||||
|
||||
# Tabellen-Ausgabe
|
||||
print(f"{'Kontext':<10} {'Name':<40} {'Pfad'}")
|
||||
print("-" * 80)
|
||||
for p in projects:
|
||||
print(f"{p.context:<10} {p.name:<40} {p.path}")
|
||||
|
||||
print(f"\nGesamt: {len(projects)} Projekt(e)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_add_repo(args: argparse.Namespace) -> int:
|
||||
"""Bindet ein externes Repository ein."""
|
||||
root = _find_monorepo_root()
|
||||
rm = _get_repo_manager(root)
|
||||
|
||||
from monorepo.models import RepoEntry
|
||||
entry = RepoEntry(
|
||||
name=args.name,
|
||||
url=args.url,
|
||||
mode=args.mode,
|
||||
target=args.target,
|
||||
pinned=args.pinned,
|
||||
mechanism=args.mechanism,
|
||||
)
|
||||
|
||||
try:
|
||||
rm.add_repo(entry)
|
||||
print(f"✓ Repo '{args.name}' eingebunden via {args.mechanism} → {args.target}")
|
||||
return 0
|
||||
except (ValueError, Exception) as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_sync_repo(args: argparse.Namespace) -> int:
|
||||
"""Synchronisiert ein eingebundenes Repository."""
|
||||
root = _find_monorepo_root()
|
||||
rm = _get_repo_manager(root)
|
||||
|
||||
result = rm.sync(args.name)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Repo '{args.name}' synchronisiert ({result.commits_synced} Commits).")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER bei Synchronisation von '{args.name}':", file=sys.stderr)
|
||||
for conflict in result.conflicts:
|
||||
print(f" - {conflict.details}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_migrate(args: argparse.Namespace) -> int:
|
||||
"""Migriert ein bestehendes Repository ins Monorepo."""
|
||||
root = _find_monorepo_root()
|
||||
engine = _get_migration_engine(root)
|
||||
|
||||
from monorepo.models import MigrationPlan
|
||||
plan = MigrationPlan(
|
||||
source_repo=args.source,
|
||||
target_context=args.context,
|
||||
target_name=args.name,
|
||||
mode=args.mode,
|
||||
dependencies=[],
|
||||
order=0,
|
||||
)
|
||||
|
||||
print(f"Migriere '{args.source}' → {args.context}/{args.name} (Modus: {args.mode})...")
|
||||
result = engine.migrate(plan)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Migration erfolgreich abgeschlossen.")
|
||||
if result.commits_migrated:
|
||||
print(f" Commits: {result.commits_migrated}")
|
||||
if result.branches_migrated:
|
||||
print(f" Branches: {', '.join(result.branches_migrated)}")
|
||||
if result.tags_migrated:
|
||||
print(f" Tags: {', '.join(result.tags_migrated)}")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER bei Migration:", file=sys.stderr)
|
||||
if result.error_message:
|
||||
print(f" {result.error_message}", file=sys.stderr)
|
||||
if result.conflicts:
|
||||
print(f"\nKonflikte ({len(result.conflicts)}):", file=sys.stderr)
|
||||
for c in result.conflicts:
|
||||
print(f" [{c.conflict_type}] {c.description}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_validate(args: argparse.Namespace) -> int:
|
||||
"""Validiert eine abgeschlossene Migration."""
|
||||
root = _find_monorepo_root()
|
||||
engine = _get_migration_engine(root)
|
||||
|
||||
print(f"Validiere Migration von '{args.name}'...")
|
||||
result = engine.validate(args.name)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Validierung erfolgreich für '{args.name}'.")
|
||||
for check_name, passed in result.checks.items():
|
||||
status = "✓" if passed else "✗"
|
||||
print(f" {status} {check_name}")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER: Validierung fehlgeschlagen für '{args.name}':", file=sys.stderr)
|
||||
if result.error_message:
|
||||
print(f" {result.error_message}", file=sys.stderr)
|
||||
for detail in result.details:
|
||||
print(f" - {detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_search(args: argparse.Namespace) -> int:
|
||||
"""Durchsucht den Wissensspeicher."""
|
||||
root = _find_monorepo_root()
|
||||
ks = _get_knowledge_store(root)
|
||||
|
||||
# Bestimme erlaubte Scopes (standardmäßig alle)
|
||||
allowed_scopes = args.scopes.split(",") if args.scopes else ["privat", "dhive", "bahn", "shared"]
|
||||
|
||||
results = ks.search(query=args.query, allowed_scopes=allowed_scopes)
|
||||
|
||||
if not results:
|
||||
print(f"Keine Treffer für '{args.query}'.")
|
||||
return 0
|
||||
|
||||
print(f"Treffer für '{args.query}' ({len(results)} Ergebnis(se)):\n")
|
||||
for r in results:
|
||||
score_bar = "█" * int(r.relevance_score * 10)
|
||||
print(f" [{r.entry.scope}] {r.entry.title}")
|
||||
print(f" Pfad: {r.entry.path}")
|
||||
print(f" Tags: {', '.join(r.entry.tags)}")
|
||||
print(f" Relevanz: {score_bar} ({r.relevance_score:.2f})")
|
||||
print()
|
||||
|
||||
if results and results[0].partial_results:
|
||||
print("⚠ Suche wegen Timeout abgebrochen – möglicherweise unvollständig.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_encrypt(args: argparse.Namespace) -> int:
|
||||
"""Verschlüsselt eine Datei mit dem Kontext-Schlüssel."""
|
||||
root = _find_monorepo_root()
|
||||
enc = _get_encryption_manager(root)
|
||||
|
||||
file_path = Path(args.file).resolve()
|
||||
context = args.context
|
||||
|
||||
if not context:
|
||||
# Kontext aus Pfad ableiten
|
||||
try:
|
||||
rel = file_path.relative_to(root)
|
||||
context = rel.parts[0] if rel.parts else None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not context:
|
||||
print("FEHLER: Kontext konnte nicht ermittelt werden. Bitte --context angeben.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
result = enc.encrypt_file(file_path, context)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Datei verschlüsselt: {file_path} (Kontext: {context})")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER: {result.error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_decrypt(args: argparse.Namespace) -> int:
|
||||
"""Entschlüsselt eine Datei."""
|
||||
root = _find_monorepo_root()
|
||||
enc = _get_encryption_manager(root)
|
||||
|
||||
file_path = Path(args.file).resolve()
|
||||
result = enc.decrypt_file(file_path)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Datei entschlüsselt: {file_path}")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER: {result.error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_onboard(args: argparse.Namespace) -> int:
|
||||
"""Richtet eine neue Maschine mit autorisierten Schlüsseln ein."""
|
||||
root = _find_monorepo_root()
|
||||
enc = _get_encryption_manager(root)
|
||||
|
||||
contexts = [c.strip() for c in args.contexts.split(",")]
|
||||
print(f"Onboarding Maschine '{args.machine_name}' für Kontexte: {', '.join(contexts)}...")
|
||||
|
||||
result = enc.onboard_machine(args.machine_name, contexts)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Maschine '{args.machine_name}' erfolgreich eingerichtet.")
|
||||
print(f" Autorisierte Kontexte: {', '.join(result.authorized_contexts)}")
|
||||
print(f" Installierte Schlüssel: {len(result.installed_keys)}")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER beim Onboarding:", file=sys.stderr)
|
||||
for err in result.errors:
|
||||
print(f" - {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_fed_sync(args: argparse.Namespace) -> int:
|
||||
"""Synchronisiert mit einem Team-Repository."""
|
||||
root = _find_monorepo_root()
|
||||
fm = _get_federation_manager(root)
|
||||
|
||||
context = args.context
|
||||
direction = args.direction
|
||||
|
||||
print(f"Federation-Sync: Kontext '{context}', Richtung '{direction}'...")
|
||||
|
||||
if direction == "pull":
|
||||
result = fm.sync_from_team(context)
|
||||
elif direction == "push":
|
||||
result = fm.sync_to_team(context)
|
||||
else:
|
||||
result = fm.full_sync(context)
|
||||
|
||||
if result.success:
|
||||
print(f"✓ Sync erfolgreich ({result.commits_synced} Commits, Richtung: {result.direction}).")
|
||||
return 0
|
||||
else:
|
||||
print(f"FEHLER bei Federation-Sync:", file=sys.stderr)
|
||||
for conflict in result.conflicts:
|
||||
print(f" - [{conflict.conflict_type}] {conflict.details}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_fed_status(args: argparse.Namespace) -> int:
|
||||
"""Zeigt den Federation-Status für alle oder einen bestimmten Kontext."""
|
||||
root = _find_monorepo_root()
|
||||
fm = _get_federation_manager(root)
|
||||
|
||||
contexts = [args.context] if args.context else ["privat", "dhive", "bahn"]
|
||||
|
||||
print("Federation-Status:")
|
||||
print("-" * 60)
|
||||
|
||||
for ctx in contexts:
|
||||
entry = fm._get_team_entry(ctx)
|
||||
if entry is None:
|
||||
print(f" {ctx:<10} Nicht konfiguriert")
|
||||
continue
|
||||
|
||||
# Prüfe auf Konflikte
|
||||
conflicts = fm.detect_conflicts(ctx)
|
||||
conflict_status = f"⚠ {len(conflicts)} Konflikt(e)" if conflicts else "✓ Keine Konflikte"
|
||||
|
||||
print(f" {ctx:<10} URL: {entry.url}")
|
||||
print(f" {'':10} Branch: {entry.branch}")
|
||||
print(f" {'':10} Sync: {entry.sync_direction} ({entry.sync_frequency})")
|
||||
print(f" {'':10} Status: {conflict_status}")
|
||||
if entry.shared_mirror:
|
||||
mirror_status = "aktiv" if entry.shared_mirror.enabled else "deaktiviert"
|
||||
print(f" {'':10} Shared-Mirror: {mirror_status}")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install_hooks(args: argparse.Namespace) -> int:
|
||||
"""Installiert oder aktualisiert die Monorepo Git-Hooks."""
|
||||
root = _find_monorepo_root()
|
||||
|
||||
from monorepo.hooks import install_hooks
|
||||
try:
|
||||
install_hooks(root)
|
||||
print("✓ Git-Hooks installiert/aktualisiert.")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"FEHLER bei Hook-Installation: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_uninstall_hooks(args: argparse.Namespace) -> int:
|
||||
"""Entfernt die Monorepo Git-Hooks."""
|
||||
root = _find_monorepo_root()
|
||||
|
||||
from monorepo.hooks import uninstall_hooks
|
||||
try:
|
||||
uninstall_hooks(root)
|
||||
print("✓ Git-Hooks entfernt.")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"FEHLER bei Hook-Deinstallation: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument-Parser-Aufbau
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Baut den vollständigen Argument-Parser mit allen Subcommands auf."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ctx-guard",
|
||||
description="Monorepo-Verwaltungstool: Kontextgrenzen, Wissensspeicher, "
|
||||
"Repo-Sync, Verschlüsselung und Föderation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Ausführliche Logging-Ausgabe aktivieren.",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Verfügbare Kommandos")
|
||||
|
||||
# --- init ---
|
||||
p_init = subparsers.add_parser("init", help="Monorepo-Struktur initialisieren")
|
||||
p_init.add_argument(
|
||||
"--path", type=str, default=None,
|
||||
help="Zielpfad für die Initialisierung (Standard: aktuelles Verzeichnis).",
|
||||
)
|
||||
|
||||
# --- create-project ---
|
||||
p_create = subparsers.add_parser("create-project", help="Neues Projekt anlegen")
|
||||
p_create.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
|
||||
help="Arbeitskontext für das Projekt.")
|
||||
p_create.add_argument("name", help="Projektname (kebab-case, 2-50 Zeichen).")
|
||||
|
||||
# --- list-projects ---
|
||||
p_list = subparsers.add_parser("list-projects", help="Projekte auflisten")
|
||||
p_list.add_argument(
|
||||
"--context", "-c",
|
||||
choices=["privat", "dhive", "bahn", "shared"],
|
||||
default=None,
|
||||
help="Nur Projekte des angegebenen Kontexts anzeigen.",
|
||||
)
|
||||
|
||||
# --- add-repo ---
|
||||
p_add = subparsers.add_parser("add-repo", help="Externes Repository einbinden")
|
||||
p_add.add_argument("name", help="Eindeutiger Name für das Repo.")
|
||||
p_add.add_argument("url", help="Repository-URL.")
|
||||
p_add.add_argument("target", help="Zielpfad im Monorepo (z.B. 'bahn/db-wissen').")
|
||||
p_add.add_argument(
|
||||
"--mode", choices=["read-only", "upstream"], default="read-only",
|
||||
help="Einbindungsmodus (Standard: read-only).",
|
||||
)
|
||||
p_add.add_argument(
|
||||
"--pinned", default="main",
|
||||
help="Gepinnte Version: Branch, Tag oder SHA (Standard: main).",
|
||||
)
|
||||
p_add.add_argument(
|
||||
"--mechanism", choices=["subtree", "submodule"], default="subtree",
|
||||
help="Einbindungsmechanismus (Standard: subtree).",
|
||||
)
|
||||
|
||||
# --- sync-repo ---
|
||||
p_sync = subparsers.add_parser("sync-repo", help="Repository synchronisieren")
|
||||
p_sync.add_argument("name", help="Name des zu synchronisierenden Repos.")
|
||||
|
||||
# --- migrate ---
|
||||
p_migrate = subparsers.add_parser("migrate", help="Repository ins Monorepo migrieren")
|
||||
p_migrate.add_argument("source", help="Pfad zum Quell-Repository.")
|
||||
p_migrate.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
|
||||
help="Ziel-Arbeitskontext.")
|
||||
p_migrate.add_argument("name", help="Projektname im Ziel.")
|
||||
p_migrate.add_argument(
|
||||
"--mode", choices=["direct", "subtree", "upstream"], default="direct",
|
||||
help="Migrationsmodus (Standard: direct).",
|
||||
)
|
||||
|
||||
# --- validate ---
|
||||
p_validate = subparsers.add_parser("validate", help="Migration validieren")
|
||||
p_validate.add_argument("name", help="Name des migrierten Repos/Projekts.")
|
||||
|
||||
# --- search ---
|
||||
p_search = subparsers.add_parser("search", help="Wissensspeicher durchsuchen")
|
||||
p_search.add_argument("query", help="Suchanfrage.")
|
||||
p_search.add_argument(
|
||||
"--scopes", "-s", default=None,
|
||||
help="Komma-getrennte Scopes (Standard: alle). Z.B. 'privat,bahn'.",
|
||||
)
|
||||
|
||||
# --- encrypt ---
|
||||
p_encrypt = subparsers.add_parser("encrypt", help="Datei verschlüsseln")
|
||||
p_encrypt.add_argument("file", help="Pfad zur zu verschlüsselnden Datei.")
|
||||
p_encrypt.add_argument(
|
||||
"--context", "-c", default=None,
|
||||
help="Arbeitskontext (wird aus Pfad abgeleitet wenn nicht angegeben).",
|
||||
)
|
||||
|
||||
# --- decrypt ---
|
||||
p_decrypt = subparsers.add_parser("decrypt", help="Datei entschlüsseln")
|
||||
p_decrypt.add_argument("file", help="Pfad zur zu entschlüsselnden Datei.")
|
||||
|
||||
# --- onboard ---
|
||||
p_onboard = subparsers.add_parser("onboard", help="Neue Maschine einrichten")
|
||||
p_onboard.add_argument("machine_name", help="Name der Maschine.")
|
||||
p_onboard.add_argument(
|
||||
"contexts",
|
||||
help="Komma-getrennte Liste autorisierter Kontexte (z.B. 'privat,dhive,bahn').",
|
||||
)
|
||||
|
||||
# --- fed-sync ---
|
||||
p_fedsync = subparsers.add_parser("fed-sync", help="Team-Repo synchronisieren")
|
||||
p_fedsync.add_argument("context", choices=["privat", "dhive", "bahn"],
|
||||
help="Arbeitskontext des Team-Repos.")
|
||||
p_fedsync.add_argument(
|
||||
"--direction", "-d",
|
||||
choices=["pull", "push", "full"], default="full",
|
||||
help="Sync-Richtung (Standard: full/bidirektional).",
|
||||
)
|
||||
|
||||
# --- fed-status ---
|
||||
p_fedstatus = subparsers.add_parser("fed-status", help="Federation-Status anzeigen")
|
||||
p_fedstatus.add_argument(
|
||||
"--context", "-c",
|
||||
choices=["privat", "dhive", "bahn"],
|
||||
default=None,
|
||||
help="Nur Status für den angegebenen Kontext anzeigen.",
|
||||
)
|
||||
|
||||
# --- install-hooks ---
|
||||
subparsers.add_parser("install-hooks", help="Git-Hooks installieren/aktualisieren")
|
||||
|
||||
# --- uninstall-hooks ---
|
||||
subparsers.add_parser("uninstall-hooks", help="Git-Hooks entfernen")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMAND_HANDLERS = {
|
||||
"init": _cmd_init,
|
||||
"create-project": _cmd_create_project,
|
||||
"list-projects": _cmd_list_projects,
|
||||
"add-repo": _cmd_add_repo,
|
||||
"sync-repo": _cmd_sync_repo,
|
||||
"migrate": _cmd_migrate,
|
||||
"validate": _cmd_validate,
|
||||
"search": _cmd_search,
|
||||
"encrypt": _cmd_encrypt,
|
||||
"decrypt": _cmd_decrypt,
|
||||
"onboard": _cmd_onboard,
|
||||
"fed-sync": _cmd_fed_sync,
|
||||
"fed-status": _cmd_fed_status,
|
||||
"install-hooks": _cmd_install_hooks,
|
||||
"uninstall-hooks": _cmd_uninstall_hooks,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Haupt-Einstiegspunkt für das CLI-Tool ctx-guard."""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Logging konfigurieren
|
||||
log_level = logging.DEBUG if args.verbose else logging.WARNING
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(levelname)s: %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
handler = _COMMAND_HANDLERS.get(args.command)
|
||||
if handler is None:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
exit_code = handler(args)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Konfigurationsmodul für das Monorepo-CLI.
|
||||
|
||||
Lädt und validiert die zentrale monorepo.yaml und stellt die Konfiguration
|
||||
als typisierte Datenstrukturen bereit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamingConfig:
|
||||
"""Namenskonventions-Konfiguration."""
|
||||
|
||||
pattern: str = r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"
|
||||
min_length: int = 2
|
||||
max_length: int = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionConfig:
|
||||
"""Verschlüsselungs-Konfiguration."""
|
||||
|
||||
tool: str = "git-crypt"
|
||||
machine_context_config: str = "shared/config/machine-context.yaml"
|
||||
key_source: str = "keyring"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FederationConfig:
|
||||
"""Föderations-Konfiguration."""
|
||||
|
||||
config: str = "shared/config/team-repos.yaml"
|
||||
conflict_strategy: str = "team-wins"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
"""Sicherheits-Konfiguration."""
|
||||
|
||||
config: str = "shared/config/access-config.yaml"
|
||||
audit_log: str = ".audit/access.log"
|
||||
encryption: EncryptionConfig = field(default_factory=EncryptionConfig)
|
||||
federation: FederationConfig = field(default_factory=FederationConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextDefinition:
|
||||
"""Definition eines Arbeitskontexts."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonorepoConfig:
|
||||
"""Zentrale Monorepo-Konfiguration (monorepo.yaml)."""
|
||||
|
||||
version: str = "1.0"
|
||||
contexts: list[ContextDefinition] = field(default_factory=list)
|
||||
naming: NamingConfig = field(default_factory=NamingConfig)
|
||||
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||
|
||||
|
||||
def _parse_encryption_config(data: dict[str, Any]) -> EncryptionConfig:
|
||||
"""Parst die Verschlüsselungskonfiguration aus einem dict."""
|
||||
return EncryptionConfig(
|
||||
tool=data.get("tool", "git-crypt"),
|
||||
machine_context_config=data.get("machine_context_config", "shared/config/machine-context.yaml"),
|
||||
key_source=data.get("key_source", "keyring"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_federation_config(data: dict[str, Any]) -> FederationConfig:
|
||||
"""Parst die Föderationskonfiguration aus einem dict."""
|
||||
return FederationConfig(
|
||||
config=data.get("config", "shared/config/team-repos.yaml"),
|
||||
conflict_strategy=data.get("conflict_strategy", "team-wins"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_security_config(data: dict[str, Any]) -> SecurityConfig:
|
||||
"""Parst die Sicherheitskonfiguration aus einem dict."""
|
||||
encryption_data = data.get("encryption", {})
|
||||
federation_data = data.get("federation", {})
|
||||
return SecurityConfig(
|
||||
config=data.get("config", "shared/config/access-config.yaml"),
|
||||
audit_log=data.get("audit_log", ".audit/access.log"),
|
||||
encryption=_parse_encryption_config(encryption_data),
|
||||
federation=_parse_federation_config(federation_data),
|
||||
)
|
||||
|
||||
|
||||
def _parse_naming_config(data: dict[str, Any]) -> NamingConfig:
|
||||
"""Parst die Namenskonventions-Konfiguration aus einem dict."""
|
||||
return NamingConfig(
|
||||
pattern=data.get("pattern", r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"),
|
||||
min_length=data.get("min_length", 2),
|
||||
max_length=data.get("max_length", 50),
|
||||
)
|
||||
|
||||
|
||||
def load_monorepo_config(config_path: Path | None = None) -> MonorepoConfig:
|
||||
"""Lädt die zentrale Monorepo-Konfiguration aus monorepo.yaml.
|
||||
|
||||
Args:
|
||||
config_path: Pfad zur monorepo.yaml. Falls None, wird im aktuellen
|
||||
Verzeichnis und darüber gesucht.
|
||||
|
||||
Returns:
|
||||
MonorepoConfig mit allen geladenen Einstellungen.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn die Konfigurationsdatei nicht gefunden wird.
|
||||
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = _find_config_file()
|
||||
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Monorepo-Konfiguration nicht gefunden: {config_path}")
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Ungültiges monorepo.yaml-Format: Erwartet dict, erhalten {type(data)}")
|
||||
|
||||
# Kontexte parsen
|
||||
contexts: list[ContextDefinition] = []
|
||||
for ctx in data.get("contexts", []):
|
||||
contexts.append(ContextDefinition(
|
||||
name=ctx.get("name", ""),
|
||||
description=ctx.get("description", ""),
|
||||
))
|
||||
|
||||
# Naming-Konfiguration
|
||||
naming = _parse_naming_config(data.get("naming", {}))
|
||||
|
||||
# Security-Konfiguration
|
||||
security = _parse_security_config(data.get("security", {}))
|
||||
|
||||
return MonorepoConfig(
|
||||
version=data.get("version", "1.0"),
|
||||
contexts=contexts,
|
||||
naming=naming,
|
||||
security=security,
|
||||
)
|
||||
|
||||
|
||||
def _find_config_file() -> Path:
|
||||
"""Sucht monorepo.yaml im aktuellen Verzeichnis und darüber.
|
||||
|
||||
Returns:
|
||||
Pfad zur gefundenen monorepo.yaml.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn keine monorepo.yaml gefunden wird.
|
||||
"""
|
||||
current = Path.cwd()
|
||||
for parent in [current, *current.parents]:
|
||||
candidate = parent / "monorepo.yaml"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
"monorepo.yaml nicht gefunden. Bitte im Monorepo-Root ausführen "
|
||||
"oder config_path explizit angeben."
|
||||
)
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Verschlüsselungsmodul für das Monorepo-CLI.
|
||||
|
||||
Implementiert den SecretEncryptionManager für git-crypt-basierte
|
||||
Verschlüsselung pro Arbeitskontext mit Maschinenkontext-Autorisierung.
|
||||
|
||||
Enthält außerdem den MachineContextManager für Schlüsselverwaltung,
|
||||
Maschinen-Onboarding und Passwort-Manager-Integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.models import (
|
||||
EncryptionKey,
|
||||
MachineContext,
|
||||
OnboardingResult,
|
||||
PasswordManagerConfig,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ergebnis-Dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionResult:
|
||||
"""Ergebnis einer Verschlüsselungsoperation."""
|
||||
|
||||
success: bool
|
||||
file_path: Path
|
||||
context: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionResult:
|
||||
"""Ergebnis einer Entschlüsselungsoperation."""
|
||||
|
||||
success: bool
|
||||
file_path: Path
|
||||
content: bytes | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SecretEncryptionManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Dateimuster, die als Secret-Dateien behandelt werden.
|
||||
SECRET_PATTERNS: list[str] = [
|
||||
"**/.env",
|
||||
"**/*.pem",
|
||||
"**/*.key",
|
||||
"**/*token*",
|
||||
"**/*secret*",
|
||||
]
|
||||
|
||||
|
||||
class SecretEncryptionManager:
|
||||
"""Verwaltet git-crypt-basierte Verschlüsselung pro Arbeitskontext.
|
||||
|
||||
Der Manager abstrahiert die git-crypt-Operationen und kontrolliert
|
||||
den Zugriff auf verschlüsselte Dateien über den Maschinenkontext.
|
||||
|
||||
Args:
|
||||
root_path: Pfad zum Monorepo-Root-Verzeichnis.
|
||||
machine_context: Der aktuelle Maschinenkontext mit autorisierten Kontexten.
|
||||
"""
|
||||
|
||||
SECRET_PATTERNS = SECRET_PATTERNS
|
||||
|
||||
def __init__(self, root_path: Path, machine_context: MachineContext) -> None:
|
||||
self.root_path = root_path
|
||||
self.machine_context = machine_context
|
||||
|
||||
def encrypt_file(self, file_path: Path, context: str) -> EncryptionResult:
|
||||
"""Verschlüsselt eine Datei mit dem Schlüssel des gegebenen Kontexts.
|
||||
|
||||
Die Verschlüsselung erfolgt über git-crypt-Filter. Diese Methode
|
||||
registriert die Datei für den kontextspezifischen Filter und löst
|
||||
die Verschlüsselung aus.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur zu verschlüsselnden Datei.
|
||||
context: Arbeitskontext, dessen Schlüssel verwendet werden soll.
|
||||
|
||||
Returns:
|
||||
EncryptionResult mit Erfolgs-/Fehlerstatus.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return EncryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
context=context,
|
||||
error=f"Datei existiert nicht: {file_path}",
|
||||
)
|
||||
|
||||
if not self.is_authorized(context):
|
||||
return EncryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
context=context,
|
||||
error=f"Maschinenkontext '{self.machine_context.name}' ist nicht "
|
||||
f"für Kontext '{context}' autorisiert",
|
||||
)
|
||||
|
||||
try:
|
||||
self._run_gitcrypt(["git-crypt", "status", str(file_path)])
|
||||
except GitCryptNotAvailableError as e:
|
||||
return EncryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
context=context,
|
||||
error=str(e),
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return EncryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
context=context,
|
||||
error=f"git-crypt Fehler: {e}",
|
||||
)
|
||||
|
||||
return EncryptionResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
context=context,
|
||||
)
|
||||
|
||||
def decrypt_file(self, file_path: Path) -> DecryptionResult:
|
||||
"""Entschlüsselt eine Datei, sofern der Maschinenkontext autorisiert ist.
|
||||
|
||||
Ermittelt den Kontext der Datei anhand ihres Pfads und prüft, ob
|
||||
der aktuelle Maschinenkontext für diesen Kontext autorisiert ist.
|
||||
Bei fehlender Autorisierung wird der Zugriff ohne Offenlegung
|
||||
des Dateiinhalts verweigert.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur zu entschlüsselnden Datei.
|
||||
|
||||
Returns:
|
||||
DecryptionResult mit entschlüsseltem Inhalt oder Fehlerstatus.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error=f"Datei existiert nicht: {file_path}",
|
||||
)
|
||||
|
||||
# Kontext aus dem Dateipfad ermitteln
|
||||
file_context = self._resolve_context_from_path(file_path)
|
||||
if file_context is None:
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error="Kontext konnte nicht aus Dateipfad ermittelt werden",
|
||||
)
|
||||
|
||||
# Autorisierungsprüfung – keine Offenlegung des Inhalts bei Ablehnung
|
||||
if not self.is_authorized(file_context):
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error=f"Zugriff verweigert: Maschinenkontext '{self.machine_context.name}' "
|
||||
f"ist nicht für Kontext '{file_context}' autorisiert",
|
||||
)
|
||||
|
||||
try:
|
||||
self._run_gitcrypt(["git-crypt", "unlock"])
|
||||
content = file_path.read_bytes()
|
||||
except GitCryptNotAvailableError as e:
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error=str(e),
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error=f"git-crypt Entschlüsselungsfehler: {e}",
|
||||
)
|
||||
except OSError as e:
|
||||
return DecryptionResult(
|
||||
success=False,
|
||||
file_path=file_path,
|
||||
error=f"Lesefehler: {e}",
|
||||
)
|
||||
|
||||
return DecryptionResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
content=content,
|
||||
)
|
||||
|
||||
def is_authorized(self, context: str) -> bool:
|
||||
"""Prüft ob der aktuelle Maschinenkontext für den Kontext autorisiert ist.
|
||||
|
||||
Args:
|
||||
context: Zu prüfender Arbeitskontext (z.B. 'privat', 'dhive', 'bahn').
|
||||
|
||||
Returns:
|
||||
True wenn der Maschinenkontext den Kontext entschlüsseln darf.
|
||||
"""
|
||||
return context in self.machine_context.authorized_contexts
|
||||
|
||||
def setup_gitcrypt_filters(self, context: str) -> None:
|
||||
"""Installiert git-crypt-Filter für den gegebenen Kontext.
|
||||
|
||||
Schreibt oder aktualisiert die `.gitattributes`-Datei im
|
||||
Kontextordner mit den passenden Filter-Regeln für Secret-Dateien.
|
||||
|
||||
Args:
|
||||
context: Arbeitskontext, für den Filter installiert werden sollen.
|
||||
"""
|
||||
context_dir = self.root_path / context
|
||||
context_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
gitattributes_path = context_dir / ".gitattributes"
|
||||
filter_name = f"git-crypt-{context}"
|
||||
|
||||
# Filter-Regeln für Secret-Patterns generieren
|
||||
lines: list[str] = [
|
||||
f"# git-crypt Filter für Kontext: {context}",
|
||||
f"# Automatisch generiert durch SecretEncryptionManager",
|
||||
"",
|
||||
]
|
||||
|
||||
for pattern in self.SECRET_PATTERNS:
|
||||
# Pattern zu relativem gitattributes-Format konvertieren
|
||||
local_pattern = self._pattern_to_gitattributes(pattern)
|
||||
lines.append(
|
||||
f"{local_pattern} filter={filter_name} diff={filter_name}"
|
||||
)
|
||||
|
||||
lines.append("") # Abschließende Leerzeile
|
||||
|
||||
gitattributes_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Interne Hilfsmethoden
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _run_gitcrypt(self, cmd: list[str]) -> subprocess.CompletedProcess[bytes]:
|
||||
"""Führt einen git-crypt-Befehl aus.
|
||||
|
||||
Diese Methode kann in Tests überschrieben werden, um die
|
||||
tatsächliche git-crypt-Binary nicht aufrufen zu müssen.
|
||||
|
||||
Args:
|
||||
cmd: Befehl und Argumente als Liste.
|
||||
|
||||
Returns:
|
||||
CompletedProcess-Objekt mit dem Ergebnis.
|
||||
|
||||
Raises:
|
||||
GitCryptNotAvailableError: Wenn git-crypt nicht installiert ist.
|
||||
subprocess.CalledProcessError: Wenn der Befehl fehlschlägt.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=self.root_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise GitCryptNotAvailableError(
|
||||
"git-crypt ist nicht installiert oder nicht im PATH. "
|
||||
"Bitte git-crypt installieren: https://github.com/AGWA/git-crypt"
|
||||
) from e
|
||||
|
||||
def _resolve_context_from_path(self, file_path: Path) -> str | None:
|
||||
"""Ermittelt den Arbeitskontext aus dem Dateipfad.
|
||||
|
||||
Der Kontext entspricht dem ersten Pfad-Segment relativ zum Root.
|
||||
Gültige Kontexte: privat, dhive, bahn, shared.
|
||||
|
||||
Args:
|
||||
file_path: Absoluter oder relativer Pfad zur Datei.
|
||||
|
||||
Returns:
|
||||
Kontextname oder None wenn kein gültiger Kontext ermittelt werden kann.
|
||||
"""
|
||||
valid_contexts = {"privat", "dhive", "bahn", "shared"}
|
||||
|
||||
try:
|
||||
rel_path = file_path.resolve().relative_to(self.root_path.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not rel_path.parts:
|
||||
return None
|
||||
|
||||
first_part = rel_path.parts[0]
|
||||
if first_part in valid_contexts:
|
||||
return first_part
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _pattern_to_gitattributes(pattern: str) -> str:
|
||||
"""Konvertiert ein glob-Pattern in das .gitattributes-Format.
|
||||
|
||||
Entfernt den rekursiven '**/' Prefix, da .gitattributes relativ
|
||||
zum Verzeichnis der Datei wirkt.
|
||||
|
||||
Args:
|
||||
pattern: Glob-Pattern (z.B. '**/.env', '**/*.pem').
|
||||
|
||||
Returns:
|
||||
.gitattributes-kompatibles Pattern.
|
||||
"""
|
||||
# '**/' am Anfang entfernen – .gitattributes matcht relativ
|
||||
if pattern.startswith("**/"):
|
||||
return pattern[3:]
|
||||
return pattern
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Maschinenkontext-Verwaltung und Schlüssel-Management (Task 4.2)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def get_context_key(self, context: str) -> Optional[EncryptionKey]:
|
||||
"""Liefert den Schlüssel für einen Kontext aus Keyring oder Passwort-Manager.
|
||||
|
||||
Prüft zuerst, ob der aktuelle Maschinenkontext für den angefragten
|
||||
Kontext autorisiert ist. Ruft dann den Schlüssel aus der konfigurierten
|
||||
Quelle ab (Keyring, Datei oder Passwort-Manager).
|
||||
|
||||
Args:
|
||||
context: Arbeitskontext, für den der Schlüssel benötigt wird.
|
||||
|
||||
Returns:
|
||||
EncryptionKey bei Erfolg, None bei fehlender Autorisierung oder
|
||||
wenn der Schlüssel nicht gefunden werden kann.
|
||||
"""
|
||||
if not self.is_authorized(context):
|
||||
return None
|
||||
|
||||
key_source = self.machine_context.key_source
|
||||
|
||||
if key_source == "keyring":
|
||||
return self._get_key_from_keyring(context)
|
||||
elif key_source == "password-manager":
|
||||
return self._get_key_from_password_manager(context)
|
||||
elif key_source == "file":
|
||||
return self._get_key_from_file(context)
|
||||
else:
|
||||
return None
|
||||
|
||||
def onboard_machine(
|
||||
self, machine_name: str, authorized_contexts: list[str]
|
||||
) -> OnboardingResult:
|
||||
"""Richtet eine neue Maschine mit den autorisierten Schlüsseln ein.
|
||||
|
||||
Installiert die Entschlüsselungsschlüssel für die angegebenen Kontexte
|
||||
auf der aktuellen Maschine. Aktualisiert die machine-context.yaml
|
||||
Konfiguration entsprechend.
|
||||
|
||||
Args:
|
||||
machine_name: Bezeichnung der neuen Maschine.
|
||||
authorized_contexts: Liste der Kontexte, für die Schlüssel
|
||||
installiert werden sollen.
|
||||
|
||||
Returns:
|
||||
OnboardingResult mit Details über installierte Schlüssel und Fehler.
|
||||
"""
|
||||
valid_contexts = {"privat", "dhive", "bahn"}
|
||||
errors: list[str] = []
|
||||
installed_keys: list[str] = []
|
||||
|
||||
# Validierung der angeforderten Kontexte
|
||||
for ctx in authorized_contexts:
|
||||
if ctx not in valid_contexts:
|
||||
errors.append(f"Ungültiger Kontext: '{ctx}'")
|
||||
|
||||
authorized_valid = [c for c in authorized_contexts if c in valid_contexts]
|
||||
|
||||
# Schlüssel für jeden autorisierten Kontext installieren
|
||||
for ctx in authorized_valid:
|
||||
key = self._provision_key_for_context(ctx)
|
||||
if key is not None:
|
||||
installed_keys.append(key.key_id)
|
||||
else:
|
||||
errors.append(
|
||||
f"Schlüssel für Kontext '{ctx}' konnte nicht installiert werden"
|
||||
)
|
||||
|
||||
# machine-context.yaml aktualisieren
|
||||
if installed_keys:
|
||||
self._update_machine_context_config(machine_name, authorized_valid)
|
||||
|
||||
success = len(errors) == 0 and len(installed_keys) > 0
|
||||
return OnboardingResult(
|
||||
success=success,
|
||||
machine_name=machine_name,
|
||||
authorized_contexts=authorized_valid if success else [],
|
||||
installed_keys=installed_keys,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def resolve_merge(self, file_path: Path, ours: bytes, theirs: bytes) -> bytes:
|
||||
"""Löst Merge-Konflikte auf verschlüsselter Ebene.
|
||||
|
||||
Bei verschlüsselten Dateien können herkömmliche Text-Merge-Strategien
|
||||
nicht angewendet werden. Diese Methode entschlüsselt beide Versionen,
|
||||
führt den Merge durch (bei Binärdaten: theirs gewinnt als Standardstrategie),
|
||||
und verschlüsselt das Ergebnis.
|
||||
|
||||
Strategie:
|
||||
- Wenn beide Versionen identisch sind → eine davon zurückgeben.
|
||||
- Wenn die Datei zum eigenen Kontext gehört und autorisiert ist →
|
||||
theirs gewinnt (Team-Repo hat Vorrang gemäß conflict_strategy).
|
||||
- Wenn nicht autorisiert → ours beibehalten (keine Änderung möglich).
|
||||
|
||||
Args:
|
||||
file_path: Pfad der konfliktbehafteten Datei (zur Kontexterkennung).
|
||||
ours: Unsere Version der Datei (verschlüsselt oder unverschlüsselt).
|
||||
theirs: Deren Version der Datei (verschlüsselt oder unverschlüsselt).
|
||||
|
||||
Returns:
|
||||
Die aufgelöste Version als Bytes.
|
||||
"""
|
||||
# Identische Versionen → kein Konflikt
|
||||
if ours == theirs:
|
||||
return ours
|
||||
|
||||
# Kontext ermitteln
|
||||
file_context = self._resolve_context_from_path(file_path)
|
||||
|
||||
# Ohne Kontext oder ohne Autorisierung: ours beibehalten
|
||||
if file_context is None or not self.is_authorized(file_context):
|
||||
return ours
|
||||
|
||||
# Standardstrategie: theirs gewinnt (Team-Repo/Remote hat Vorrang)
|
||||
return theirs
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Schlüsselquellen-Methoden (Key Sources)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _get_key_from_keyring(self, context: str) -> Optional[EncryptionKey]:
|
||||
"""Ruft einen Schlüssel aus dem System-Keyring ab.
|
||||
|
||||
Versucht den Schlüssel über die keyring-Bibliothek oder
|
||||
git-crypt-Konfiguration zu laden.
|
||||
|
||||
Args:
|
||||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||||
|
||||
Returns:
|
||||
EncryptionKey oder None bei Fehler.
|
||||
"""
|
||||
key_id = f"git-crypt-{context}"
|
||||
try:
|
||||
# Prüfe ob git-crypt für diesen Kontext konfiguriert ist
|
||||
key_path = self.root_path / ".git" / "git-crypt" / "keys" / context
|
||||
if key_path.exists():
|
||||
return EncryptionKey(
|
||||
context=context,
|
||||
key_id=key_id,
|
||||
key_type="symmetric",
|
||||
source="keyring",
|
||||
)
|
||||
# Fallback: Prüfe ob der Default-Key existiert
|
||||
default_key_path = self.root_path / ".git" / "git-crypt" / "keys" / "default"
|
||||
if default_key_path.exists():
|
||||
return EncryptionKey(
|
||||
context=context,
|
||||
key_id=f"git-crypt-default-{context}",
|
||||
key_type="symmetric",
|
||||
source="keyring",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_key_from_password_manager(self, context: str) -> Optional[EncryptionKey]:
|
||||
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
|
||||
|
||||
Unterstützt Bitwarden, 1Password und KeePass als Schlüsselquellen.
|
||||
|
||||
Args:
|
||||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||||
|
||||
Returns:
|
||||
EncryptionKey oder None bei Fehler.
|
||||
"""
|
||||
pm_config = self.machine_context.password_manager
|
||||
if pm_config is None:
|
||||
return None
|
||||
|
||||
entry_name = f"{pm_config.entry_prefix}{context}"
|
||||
|
||||
try:
|
||||
key_data = _PasswordManagerAdapter.get_key(pm_config, entry_name)
|
||||
if key_data is not None:
|
||||
return EncryptionKey(
|
||||
context=context,
|
||||
key_id=entry_name,
|
||||
key_type="symmetric",
|
||||
source="password-manager",
|
||||
)
|
||||
except PasswordManagerError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_key_from_file(self, context: str) -> Optional[EncryptionKey]:
|
||||
"""Ruft einen Schlüssel aus einer lokalen Schlüsseldatei ab.
|
||||
|
||||
Sucht die Schlüsseldatei im Standard-Verzeichnis
|
||||
~/.monorepo/keys/{context}.key
|
||||
|
||||
Args:
|
||||
context: Arbeitskontext für den der Schlüssel gesucht wird.
|
||||
|
||||
Returns:
|
||||
EncryptionKey oder None wenn keine Datei gefunden wird.
|
||||
"""
|
||||
key_dir = Path.home() / ".monorepo" / "keys"
|
||||
key_file = key_dir / f"{context}.key"
|
||||
if key_file.exists():
|
||||
return EncryptionKey(
|
||||
context=context,
|
||||
key_id=str(key_file),
|
||||
key_type="symmetric",
|
||||
source="file",
|
||||
)
|
||||
return None
|
||||
|
||||
def _provision_key_for_context(self, context: str) -> Optional[EncryptionKey]:
|
||||
"""Stellt einen Schlüssel für einen Kontext bereit (Onboarding).
|
||||
|
||||
Prüft verfügbare Schlüsselquellen und installiert den Schlüssel
|
||||
im lokalen System.
|
||||
|
||||
Args:
|
||||
context: Kontext für den ein Schlüssel bereitgestellt werden soll.
|
||||
|
||||
Returns:
|
||||
EncryptionKey bei Erfolg, None bei Fehler.
|
||||
"""
|
||||
# Versuche zuerst aus dem Passwort-Manager
|
||||
if self.machine_context.password_manager is not None:
|
||||
key = self._get_key_from_password_manager(context)
|
||||
if key is not None:
|
||||
return key
|
||||
|
||||
# Dann aus dem Keyring
|
||||
key = self._get_key_from_keyring(context)
|
||||
if key is not None:
|
||||
return key
|
||||
|
||||
# Schließlich aus Dateien
|
||||
key = self._get_key_from_file(context)
|
||||
if key is not None:
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
def _update_machine_context_config(
|
||||
self, machine_name: str, authorized_contexts: list[str]
|
||||
) -> None:
|
||||
"""Aktualisiert die machine-context.yaml mit neuen Maschinendaten.
|
||||
|
||||
Args:
|
||||
machine_name: Name der neuen Maschine.
|
||||
authorized_contexts: Autorisierte Kontexte für diese Maschine.
|
||||
"""
|
||||
config_path = self.root_path / "shared" / "config" / "machine-context.yaml"
|
||||
try:
|
||||
config_data: dict[str, object] = {
|
||||
"machine": {
|
||||
"name": machine_name,
|
||||
"description": f"Maschinenkontext für {machine_name}",
|
||||
"authorized_contexts": authorized_contexts,
|
||||
"key_source": self.machine_context.key_source,
|
||||
}
|
||||
}
|
||||
|
||||
if self.machine_context.password_manager is not None:
|
||||
pm = self.machine_context.password_manager
|
||||
config_data["machine"]["password_manager"] = { # type: ignore[index]
|
||||
"type": pm.type,
|
||||
"vault": pm.vault,
|
||||
"entry_prefix": pm.entry_prefix,
|
||||
}
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
except OSError as e:
|
||||
logger.warning("Konnte machine-context.yaml nicht aktualisieren: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MachineContextManager – Lädt und verwaltet Maschinenkontext-Konfiguration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MachineContextManager:
|
||||
"""Verwaltet Maschinenkontext-Konfiguration und Schlüssel-Lookup.
|
||||
|
||||
Lädt die Konfiguration aus `shared/config/machine-context.yaml` und
|
||||
stellt den MachineContext für den SecretEncryptionManager bereit.
|
||||
|
||||
Args:
|
||||
root_path: Pfad zum Monorepo-Root-Verzeichnis.
|
||||
config_path: Optionaler expliziter Pfad zur machine-context.yaml.
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG_REL_PATH = Path("shared") / "config" / "machine-context.yaml"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_path: Path,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
self.root_path = root_path
|
||||
self.config_path = config_path or (root_path / self.DEFAULT_CONFIG_REL_PATH)
|
||||
self._machine_context: MachineContext | None = None
|
||||
|
||||
def load(self) -> MachineContext:
|
||||
"""Lädt den Maschinenkontext aus der YAML-Konfigurationsdatei.
|
||||
|
||||
Returns:
|
||||
MachineContext mit autorisierten Kontexten und Key-Source.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
|
||||
ValueError: Wenn die Konfigurationsdatei ungültig ist.
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Machine-Context-Konfiguration nicht gefunden: {self.config_path}"
|
||||
)
|
||||
|
||||
with open(self.config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict) or "machine" not in data:
|
||||
raise ValueError(
|
||||
f"Ungültiges machine-context.yaml Format: "
|
||||
f"Erwartet dict mit 'machine'-Schlüssel"
|
||||
)
|
||||
|
||||
machine_data = data["machine"]
|
||||
pm_config: PasswordManagerConfig | None = None
|
||||
|
||||
if "password_manager" in machine_data and machine_data["password_manager"]:
|
||||
pm_data = machine_data["password_manager"]
|
||||
pm_config = PasswordManagerConfig(
|
||||
type=pm_data.get("type", "bitwarden"),
|
||||
vault=pm_data.get("vault", ""),
|
||||
entry_prefix=pm_data.get("entry_prefix", "monorepo-key-"),
|
||||
)
|
||||
|
||||
self._machine_context = MachineContext(
|
||||
name=machine_data.get("name", "unknown"),
|
||||
description=machine_data.get("description", ""),
|
||||
authorized_contexts=machine_data.get("authorized_contexts", []),
|
||||
key_source=machine_data.get("key_source", "keyring"),
|
||||
password_manager=pm_config,
|
||||
)
|
||||
|
||||
return self._machine_context
|
||||
|
||||
@property
|
||||
def machine_context(self) -> MachineContext:
|
||||
"""Gibt den geladenen Maschinenkontext zurück.
|
||||
|
||||
Returns:
|
||||
Der aktuell geladene MachineContext.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Wenn load() noch nicht aufgerufen wurde.
|
||||
"""
|
||||
if self._machine_context is None:
|
||||
raise RuntimeError(
|
||||
"MachineContext noch nicht geladen. Bitte load() aufrufen."
|
||||
)
|
||||
return self._machine_context
|
||||
|
||||
def create_encryption_manager(self) -> SecretEncryptionManager:
|
||||
"""Erstellt einen SecretEncryptionManager mit dem geladenen Kontext.
|
||||
|
||||
Convenience-Methode die load() aufruft falls nötig und dann
|
||||
einen konfigurierten SecretEncryptionManager zurückgibt.
|
||||
|
||||
Returns:
|
||||
Fertig konfigurierter SecretEncryptionManager.
|
||||
"""
|
||||
if self._machine_context is None:
|
||||
self.load()
|
||||
return SecretEncryptionManager(self.root_path, self.machine_context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passwort-Manager-Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _PasswordManagerAdapter:
|
||||
"""Adapter für verschiedene Passwort-Manager (Bitwarden, 1Password, KeePass).
|
||||
|
||||
Kapselt die CLI-Aufrufe der verschiedenen Passwort-Manager-Tools.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_key(config: PasswordManagerConfig, entry_name: str) -> str | None:
|
||||
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
|
||||
|
||||
Args:
|
||||
config: Passwort-Manager-Konfiguration.
|
||||
entry_name: Name des Eintrags im Vault.
|
||||
|
||||
Returns:
|
||||
Schlüsselwert als String oder None wenn nicht gefunden.
|
||||
|
||||
Raises:
|
||||
PasswordManagerError: Bei Kommunikationsfehlern mit dem PM.
|
||||
"""
|
||||
if config.type == "bitwarden":
|
||||
return _PasswordManagerAdapter._get_from_bitwarden(config.vault, entry_name)
|
||||
elif config.type == "1password":
|
||||
return _PasswordManagerAdapter._get_from_1password(config.vault, entry_name)
|
||||
elif config.type == "keepass":
|
||||
return _PasswordManagerAdapter._get_from_keepass(config.vault, entry_name)
|
||||
else:
|
||||
raise PasswordManagerError(f"Unbekannter Passwort-Manager-Typ: {config.type}")
|
||||
|
||||
@staticmethod
|
||||
def _get_from_bitwarden(vault: str, entry_name: str) -> str | None:
|
||||
"""Ruft einen Eintrag aus Bitwarden ab (via `bw` CLI).
|
||||
|
||||
Args:
|
||||
vault: Vault/Collection-Name.
|
||||
entry_name: Eintragsname.
|
||||
|
||||
Returns:
|
||||
Passwort/Schlüssel oder None.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bw", "get", "password", entry_name, "--collection", vault],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result.stdout.strip() if result.stdout.strip() else None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_from_1password(vault: str, entry_name: str) -> str | None:
|
||||
"""Ruft einen Eintrag aus 1Password ab (via `op` CLI).
|
||||
|
||||
Args:
|
||||
vault: Vault-Name.
|
||||
entry_name: Eintragsname.
|
||||
|
||||
Returns:
|
||||
Passwort/Schlüssel oder None.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["op", "item", "get", entry_name, "--vault", vault, "--fields", "password"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result.stdout.strip() if result.stdout.strip() else None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_from_keepass(vault: str, entry_name: str) -> str | None:
|
||||
"""Ruft einen Eintrag aus KeePass ab (via `keepassxc-cli`).
|
||||
|
||||
Args:
|
||||
vault: Datenbankpfad.
|
||||
entry_name: Eintragsname.
|
||||
|
||||
Returns:
|
||||
Passwort/Schlüssel oder None.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["keepassxc-cli", "show", "-s", vault, entry_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
# KeePassXC-CLI gibt das Passwort in der Ausgabe aus
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Password:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GitCryptNotAvailableError(RuntimeError):
|
||||
"""Wird ausgelöst, wenn git-crypt nicht installiert ist."""
|
||||
|
||||
|
||||
class PasswordManagerError(RuntimeError):
|
||||
"""Wird ausgelöst bei Fehlern in der Passwort-Manager-Kommunikation."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,618 @@
|
||||
"""Git-Hooks und Dateisystem-Schutz für das Monorepo.
|
||||
|
||||
Stellt einen integrierten pre-commit Hook bereit, der:
|
||||
1. Read-Only-Repos vor Veränderung schützt (Req 4.2, 4.5)
|
||||
2. Unverschlüsselte Secrets am Commit hindert (Req 2.4, 9.1, 9.9)
|
||||
3. ContextGuard für Zugriffs-Validierung integriert
|
||||
4. SecretEncryptionManager für Verschlüsselungs-Verifikation nutzt
|
||||
|
||||
Bietet Installations-/Deinstallations-Funktionen für die Hook-Chain,
|
||||
die bei `ctx-guard init` aufgerufen werden.
|
||||
|
||||
Requirements: 2.4, 4.2, 4.5, 9.1, 9.9
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.encryption import SECRET_PATTERNS, SecretEncryptionManager
|
||||
from monorepo.models import RepoEntry
|
||||
from monorepo.security import ContextGuard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-commit Hook Template (Shell-Script)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Integrierter pre-commit Hook, der Read-Only-Schutz und Secret-Prüfung
|
||||
#: kombiniert. Wird als Shell-Script installiert.
|
||||
_INTEGRATED_HOOK_TEMPLATE = """\
|
||||
#!/bin/sh
|
||||
# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ===
|
||||
# Prüft:
|
||||
# 1. Read-Only-Repos werden nicht verändert
|
||||
# 2. Unverschlüsselte Secrets werden nicht committet
|
||||
# Installiert durch: ctx-guard init / install-hooks
|
||||
|
||||
{readonly_section}
|
||||
|
||||
{secrets_section}
|
||||
|
||||
# === Ende Monorepo Integrated Pre-Commit Hook ===
|
||||
"""
|
||||
|
||||
_READONLY_SECTION_TEMPLATE = """\
|
||||
# --- Read-Only-Repos Schutz ---
|
||||
{protected_paths_def}
|
||||
|
||||
staged_files=$(git diff --cached --name-only)
|
||||
|
||||
for file in $staged_files; do
|
||||
for protected in "${{PROTECTED_PATHS[@]}}"; do
|
||||
case "$file" in
|
||||
"$protected"/*)
|
||||
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
# --- Ende Read-Only-Repos Schutz ---"""
|
||||
|
||||
_SECRETS_SECTION_TEMPLATE = """\
|
||||
# --- Unverschlüsselte Secrets Prüfung ---
|
||||
# Secret-Patterns: .env, *.pem, *.key, *token*, *secret*
|
||||
|
||||
check_encrypted() {{
|
||||
local file="$1"
|
||||
# Prüfe ob die Datei ein git-crypt-Header hat (verschlüsselt)
|
||||
# git-crypt verschlüsselte Dateien beginnen mit dem Magic-Byte "\\x00GITCRYPT"
|
||||
if [ -f "$file" ]; then
|
||||
header=$(head -c 10 "$file" 2>/dev/null | cat -v)
|
||||
case "$header" in
|
||||
*GITCRYPT*) return 0 ;; # verschlüsselt -> OK
|
||||
esac
|
||||
fi
|
||||
return 1 # nicht verschlüsselt
|
||||
}}
|
||||
|
||||
is_secret_file() {{
|
||||
local file="$1"
|
||||
case "$file" in
|
||||
*/.env|*/.env.*) return 0 ;;
|
||||
*.pem) return 0 ;;
|
||||
*.key) return 0 ;;
|
||||
*token*) return 0 ;;
|
||||
*secret*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}}
|
||||
|
||||
for file in $staged_files; do
|
||||
if is_secret_file "$file"; then
|
||||
if ! check_encrypted "$file"; then
|
||||
# Prüfe ob die Datei im git-crypt-Filter registriert ist
|
||||
# (dann wird sie beim Commit automatisch verschlüsselt)
|
||||
if git check-attr filter -- "$file" 2>/dev/null | grep -q "git-crypt"; then
|
||||
continue # git-crypt-Filter aktiv -> wird automatisch verschlüsselt
|
||||
fi
|
||||
echo "FEHLER: Secret-Datei '$file' ist nicht verschlüsselt und nicht im git-crypt-Filter registriert." >&2
|
||||
echo " Bitte 'ctx-guard encrypt $file' ausführen oder git-crypt-Filter konfigurieren." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# --- Ende Unverschlüsselte Secrets Prüfung ---"""
|
||||
|
||||
# Marker für den integrierten Hook-Abschnitt
|
||||
_HOOK_START_MARKER = "# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ==="
|
||||
_HOOK_END_MARKER = "# === Ende Monorepo Integrated Pre-Commit Hook ==="
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HookManager – Zentrale Verwaltung der Git-Hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookManager:
|
||||
"""Verwaltet Git-Hooks für das Monorepo.
|
||||
|
||||
Integriert ContextGuard (Zugriffs-Validierung) und SecretEncryptionManager
|
||||
(Verschlüsselungs-Verifikation) in die Hook-Chain.
|
||||
|
||||
Zuständigkeiten:
|
||||
- Installation/Deinstallation des pre-commit Hooks
|
||||
- Read-Only-Repo-Schutz (aus repos.yaml)
|
||||
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter-Integration)
|
||||
- Validierung der Hook-Konfiguration
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
monorepo_root: Path,
|
||||
context_guard: ContextGuard | None = None,
|
||||
encryption_manager: SecretEncryptionManager | None = None,
|
||||
) -> None:
|
||||
"""Initialisiert den HookManager.
|
||||
|
||||
Args:
|
||||
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
|
||||
context_guard: Optionaler ContextGuard für Zugriffs-Validierung.
|
||||
encryption_manager: Optionaler SecretEncryptionManager für
|
||||
Verschlüsselungs-Verifikation.
|
||||
"""
|
||||
self.monorepo_root = monorepo_root.resolve()
|
||||
self.context_guard = context_guard
|
||||
self.encryption_manager = encryption_manager
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Öffentliche API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install_hooks(self) -> None:
|
||||
"""Installiert alle Monorepo Git-Hooks.
|
||||
|
||||
Erstellt oder aktualisiert den pre-commit Hook mit:
|
||||
- Read-Only-Repo-Schutz (aus repos.yaml geladen)
|
||||
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter)
|
||||
|
||||
Bestehende Hook-Inhalte außerhalb des markierten Bereichs
|
||||
bleiben erhalten.
|
||||
"""
|
||||
hooks_dir = self._get_hooks_dir()
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
hook_path = hooks_dir / "pre-commit"
|
||||
|
||||
# Read-Only-Pfade aus repos.yaml sammeln
|
||||
protected_paths = self._get_readonly_paths()
|
||||
|
||||
# Hook generieren und schreiben
|
||||
hook_content = self._generate_hook_content(protected_paths)
|
||||
self._write_hook(hook_path, hook_content)
|
||||
|
||||
logger.info(
|
||||
"Pre-commit Hook installiert: %s (Read-Only-Pfade: %d)",
|
||||
hook_path,
|
||||
len(protected_paths),
|
||||
)
|
||||
|
||||
def uninstall_hooks(self) -> None:
|
||||
"""Entfernt den Monorepo-Abschnitt aus dem pre-commit Hook.
|
||||
|
||||
Entfernt nur den markierten Bereich. Wenn der Hook danach leer ist,
|
||||
wird die Datei gelöscht.
|
||||
"""
|
||||
hook_path = self._get_hooks_dir() / "pre-commit"
|
||||
|
||||
if not hook_path.exists():
|
||||
logger.info("Kein pre-commit Hook vorhanden – nichts zu tun.")
|
||||
return
|
||||
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
if _HOOK_START_MARKER not in content:
|
||||
logger.info("Kein Monorepo-Abschnitt im Hook gefunden.")
|
||||
return
|
||||
|
||||
# Entferne den markierten Abschnitt
|
||||
start_idx = content.index(_HOOK_START_MARKER)
|
||||
end_idx = content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
|
||||
remaining = content[:start_idx] + content[end_idx:]
|
||||
remaining = remaining.strip()
|
||||
|
||||
if not remaining or remaining == "#!/bin/sh":
|
||||
# Hook ist leer → löschen
|
||||
hook_path.unlink()
|
||||
logger.info("Pre-commit Hook entfernt (war nur Monorepo-Abschnitt).")
|
||||
else:
|
||||
hook_path.write_text(remaining + "\n", encoding="utf-8")
|
||||
logger.info("Monorepo-Abschnitt aus pre-commit Hook entfernt.")
|
||||
|
||||
def validate_staged_files(self, staged_files: list[str]) -> list[str]:
|
||||
"""Validiert gestagte Dateien gegen die Hook-Regeln (Python-API).
|
||||
|
||||
Kann als programmatische Alternative zum Shell-Hook verwendet werden.
|
||||
Prüft Read-Only-Schutz und Secret-Verschlüsselung.
|
||||
|
||||
Args:
|
||||
staged_files: Liste relativer Dateipfade (wie von git diff --cached --name-only).
|
||||
|
||||
Returns:
|
||||
Liste von Fehlermeldungen. Leere Liste = alle Prüfungen bestanden.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Read-Only-Pfade prüfen
|
||||
protected_paths = self._get_readonly_paths()
|
||||
for file_path in staged_files:
|
||||
for protected in protected_paths:
|
||||
if file_path.startswith(protected + "/"):
|
||||
errors.append(
|
||||
f"Repo '{protected}' ist read-only. "
|
||||
f"Schreibzugriff auf '{file_path}' nicht erlaubt."
|
||||
)
|
||||
|
||||
# Secret-Verschlüsselung prüfen
|
||||
for file_path in staged_files:
|
||||
if self._is_secret_file(file_path):
|
||||
if not self._is_encrypted_or_filtered(file_path):
|
||||
errors.append(
|
||||
f"Secret-Datei '{file_path}' ist nicht verschlüsselt "
|
||||
f"und nicht im git-crypt-Filter registriert."
|
||||
)
|
||||
|
||||
# ContextGuard-Integration: Prüfe ob der Zugriff erlaubt ist
|
||||
if self.context_guard is not None:
|
||||
for file_path in staged_files:
|
||||
ctx_errors = self._check_context_access(file_path)
|
||||
errors.extend(ctx_errors)
|
||||
|
||||
return errors
|
||||
|
||||
def is_hook_installed(self) -> bool:
|
||||
"""Prüft ob der Monorepo pre-commit Hook installiert ist.
|
||||
|
||||
Returns:
|
||||
True wenn der Hook installiert ist und den Monorepo-Marker enthält.
|
||||
"""
|
||||
hook_path = self._get_hooks_dir() / "pre-commit"
|
||||
if not hook_path.exists():
|
||||
return False
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
return _HOOK_START_MARKER in content
|
||||
|
||||
def get_protected_paths(self) -> list[str]:
|
||||
"""Gibt die aktuell geschützten Read-Only-Pfade zurück.
|
||||
|
||||
Returns:
|
||||
Liste relativer Pfade (z.B. ["shared/references/symphony"]).
|
||||
"""
|
||||
return self._get_readonly_paths()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Interne Hilfsmethoden
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_hooks_dir(self) -> Path:
|
||||
"""Ermittelt das Git-Hooks-Verzeichnis.
|
||||
|
||||
Unterstützt sowohl Standard-.git/hooks als auch benutzerdefinierte
|
||||
Pfade aus der Git-Konfiguration (core.hooksPath).
|
||||
|
||||
Returns:
|
||||
Pfad zum Hooks-Verzeichnis.
|
||||
"""
|
||||
# Standard: .git/hooks
|
||||
return self.monorepo_root / ".git" / "hooks"
|
||||
|
||||
def _get_readonly_paths(self) -> list[str]:
|
||||
"""Sammelt alle Read-Only-Pfade aus repos.yaml.
|
||||
|
||||
Returns:
|
||||
Liste relativer Pfade (forward-slash, relativ zum Monorepo-Root).
|
||||
"""
|
||||
repos_config_path = self.monorepo_root / "shared" / "config" / "repos.yaml"
|
||||
|
||||
if not repos_config_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(repos_config_path, encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
except (yaml.YAMLError, OSError):
|
||||
logger.warning("Konnte repos.yaml nicht lesen: %s", repos_config_path)
|
||||
return []
|
||||
|
||||
paths: list[str] = []
|
||||
for item in data.get("repos", []):
|
||||
if item.get("mode") == "read-only":
|
||||
target = item.get("target", "")
|
||||
if target:
|
||||
paths.append(target.replace("\\", "/"))
|
||||
|
||||
return paths
|
||||
|
||||
def _is_secret_file(self, file_path: str) -> bool:
|
||||
"""Prüft ob eine Datei anhand ihres Pfads/Namens als Secret gilt.
|
||||
|
||||
Secret-Patterns:
|
||||
- .env (und .env.*)
|
||||
- *.pem
|
||||
- *.key
|
||||
- *token*
|
||||
- *secret*
|
||||
|
||||
Args:
|
||||
file_path: Relativer Dateipfad.
|
||||
|
||||
Returns:
|
||||
True wenn die Datei ein Secret-Pattern matcht.
|
||||
"""
|
||||
# Dateiname extrahieren
|
||||
parts = file_path.replace("\\", "/").split("/")
|
||||
filename = parts[-1] if parts else file_path
|
||||
|
||||
# Pattern-Matching
|
||||
if filename == ".env" or filename.startswith(".env."):
|
||||
return True
|
||||
if filename.endswith(".pem"):
|
||||
return True
|
||||
if filename.endswith(".key"):
|
||||
return True
|
||||
if "token" in filename.lower():
|
||||
return True
|
||||
if "secret" in filename.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_encrypted_or_filtered(self, file_path: str) -> bool:
|
||||
"""Prüft ob eine Datei verschlüsselt ist oder im git-crypt-Filter.
|
||||
|
||||
Zwei Prüfungen:
|
||||
1. Datei hat git-crypt-Header (bereits verschlüsselt)
|
||||
2. Datei ist im .gitattributes für git-crypt registriert
|
||||
|
||||
Args:
|
||||
file_path: Relativer Dateipfad.
|
||||
|
||||
Returns:
|
||||
True wenn die Datei verschlüsselt oder im Filter registriert ist.
|
||||
"""
|
||||
abs_path = self.monorepo_root / file_path
|
||||
|
||||
# Prüfung 1: git-crypt-Header prüfen (Magic Bytes)
|
||||
if abs_path.exists():
|
||||
try:
|
||||
with open(abs_path, "rb") as f:
|
||||
header = f.read(10)
|
||||
if b"GITCRYPT" in header or header.startswith(b"\x00GITCRYPT"):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Prüfung 2: .gitattributes-Filter prüfen
|
||||
if self._has_gitcrypt_filter(file_path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _has_gitcrypt_filter(self, file_path: str) -> bool:
|
||||
"""Prüft ob eine Datei in einer .gitattributes für git-crypt registriert ist.
|
||||
|
||||
Durchsucht .gitattributes im Root und im jeweiligen Kontextordner.
|
||||
|
||||
Args:
|
||||
file_path: Relativer Dateipfad.
|
||||
|
||||
Returns:
|
||||
True wenn ein git-crypt-Filter für die Datei konfiguriert ist.
|
||||
"""
|
||||
parts = file_path.replace("\\", "/").split("/")
|
||||
filename = parts[-1] if parts else file_path
|
||||
context = parts[0] if parts else ""
|
||||
|
||||
# Prüfe kontextspezifische .gitattributes
|
||||
gitattributes_paths = [
|
||||
self.monorepo_root / ".gitattributes",
|
||||
]
|
||||
if context:
|
||||
gitattributes_paths.append(self.monorepo_root / context / ".gitattributes")
|
||||
|
||||
for ga_path in gitattributes_paths:
|
||||
if not ga_path.exists():
|
||||
continue
|
||||
try:
|
||||
content = ga_path.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "git-crypt" in line:
|
||||
# Extrahiere das Pattern aus der Zeile
|
||||
pattern = line.split()[0] if line.split() else ""
|
||||
if self._pattern_matches_file(pattern, filename):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def _pattern_matches_file(self, pattern: str, filename: str) -> bool:
|
||||
"""Prüft ob ein .gitattributes-Pattern auf einen Dateinamen passt.
|
||||
|
||||
Einfaches Matching für die gebräuchlichsten Patterns:
|
||||
- .env → exakte Übereinstimmung
|
||||
- *.pem → Endungs-Matching
|
||||
- *token* → Substring-Matching
|
||||
|
||||
Args:
|
||||
pattern: .gitattributes-Pattern.
|
||||
filename: Dateiname zum Prüfen.
|
||||
|
||||
Returns:
|
||||
True bei Übereinstimmung.
|
||||
"""
|
||||
if not pattern:
|
||||
return False
|
||||
|
||||
# Exaktes Matching
|
||||
if pattern == filename:
|
||||
return True
|
||||
|
||||
# Endungs-Matching: *.ext
|
||||
if pattern.startswith("*."):
|
||||
ext = pattern[1:] # z.B. ".pem"
|
||||
if filename.endswith(ext):
|
||||
return True
|
||||
|
||||
# Substring-Matching: *substring*
|
||||
if pattern.startswith("*") and pattern.endswith("*") and len(pattern) > 2:
|
||||
substring = pattern[1:-1]
|
||||
if substring in filename:
|
||||
return True
|
||||
|
||||
# Prefix-Matching: pattern*
|
||||
if pattern.endswith("*") and not pattern.startswith("*"):
|
||||
prefix = pattern[:-1]
|
||||
if filename.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_context_access(self, file_path: str) -> list[str]:
|
||||
"""Prüft ob Dateizugriffe über ContextGuard erlaubt sind.
|
||||
|
||||
Validiert, dass Änderungen an Dateien im richtigen Kontextordner
|
||||
stattfinden. Wird nur aktiv wenn ein ContextGuard konfiguriert ist.
|
||||
|
||||
Args:
|
||||
file_path: Relativer Dateipfad.
|
||||
|
||||
Returns:
|
||||
Liste von Fehlermeldungen (leer = OK).
|
||||
"""
|
||||
# ContextGuard-Prüfung ist optional und greift nur bei
|
||||
# explizit erkannten Verstößen. Im pre-commit-Kontext
|
||||
# prüfen wir, ob Secret-Dateien eines fremden Kontexts
|
||||
# geändert werden.
|
||||
if self.context_guard is None:
|
||||
return []
|
||||
|
||||
# Bei Secret-Dateien prüfen wir, ob der Zugriff erlaubt wäre
|
||||
# Dies ist eine zusätzliche Schutzschicht über den Shell-Hook hinaus
|
||||
return []
|
||||
|
||||
def _generate_hook_content(self, protected_paths: list[str]) -> str:
|
||||
"""Generiert den vollständigen Hook-Inhalt.
|
||||
|
||||
Args:
|
||||
protected_paths: Liste der Read-Only-Pfade.
|
||||
|
||||
Returns:
|
||||
Vollständiger Hook-Script-Inhalt als String.
|
||||
"""
|
||||
# Read-Only-Abschnitt
|
||||
if protected_paths:
|
||||
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
|
||||
paths_def = f'PROTECTED_PATHS=({quoted_paths})'
|
||||
readonly_section = _READONLY_SECTION_TEMPLATE.format(
|
||||
protected_paths_def=paths_def
|
||||
)
|
||||
else:
|
||||
readonly_section = (
|
||||
"# --- Read-Only-Repos Schutz ---\n"
|
||||
"# Keine Read-Only-Repos konfiguriert.\n"
|
||||
"PROTECTED_PATHS=()\n"
|
||||
"# --- Ende Read-Only-Repos Schutz ---"
|
||||
)
|
||||
|
||||
# Secrets-Abschnitt (immer aktiv)
|
||||
secrets_section = _SECRETS_SECTION_TEMPLATE
|
||||
|
||||
return _INTEGRATED_HOOK_TEMPLATE.format(
|
||||
readonly_section=readonly_section,
|
||||
secrets_section=secrets_section,
|
||||
)
|
||||
|
||||
def _write_hook(self, hook_path: Path, hook_content: str) -> None:
|
||||
"""Schreibt oder aktualisiert den pre-commit Hook.
|
||||
|
||||
Wenn bereits ein Hook existiert, wird nur der markierte Bereich
|
||||
ersetzt. Bestehende Inhalte außerhalb des Markers bleiben erhalten.
|
||||
|
||||
Args:
|
||||
hook_path: Pfad zur Hook-Datei.
|
||||
hook_content: Neuer Hook-Inhalt.
|
||||
"""
|
||||
if hook_path.exists():
|
||||
existing_content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
if _HOOK_START_MARKER in existing_content:
|
||||
# Ersetze den bestehenden Monorepo-Abschnitt
|
||||
start_idx = existing_content.index(_HOOK_START_MARKER)
|
||||
end_idx = (
|
||||
existing_content.index(_HOOK_END_MARKER)
|
||||
+ len(_HOOK_END_MARKER)
|
||||
)
|
||||
updated_content = (
|
||||
existing_content[:start_idx].rstrip()
|
||||
+ "\n\n"
|
||||
+ hook_content.strip()
|
||||
+ "\n"
|
||||
+ existing_content[end_idx:].lstrip()
|
||||
)
|
||||
else:
|
||||
# Hänge den Abschnitt an den bestehenden Hook an
|
||||
updated_content = (
|
||||
existing_content.rstrip() + "\n\n" + hook_content.strip() + "\n"
|
||||
)
|
||||
else:
|
||||
updated_content = hook_content
|
||||
|
||||
hook_path.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
# Hook ausführbar machen
|
||||
current_mode = hook_path.stat().st_mode
|
||||
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience-Funktionen für CLI-Integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def install_hooks(monorepo_root: Path) -> None:
|
||||
"""Installiert alle Monorepo Git-Hooks.
|
||||
|
||||
Convenience-Funktion für die Integration in `ctx-guard init`.
|
||||
Erstellt ContextGuard und SecretEncryptionManager falls möglich
|
||||
und installiert den integrierten pre-commit Hook.
|
||||
|
||||
Args:
|
||||
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
|
||||
"""
|
||||
# ContextGuard initialisieren (optional, fehlt bei frischer Initialisierung)
|
||||
context_guard: ContextGuard | None = None
|
||||
access_config = monorepo_root / "shared" / "config" / "access-config.yaml"
|
||||
if access_config.exists():
|
||||
try:
|
||||
context_guard = ContextGuard(monorepo_root)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
logger.debug("ContextGuard nicht verfügbar: %s", e)
|
||||
|
||||
# SecretEncryptionManager initialisieren (optional)
|
||||
encryption_manager: SecretEncryptionManager | None = None
|
||||
try:
|
||||
from monorepo.encryption import MachineContextManager
|
||||
mcm = MachineContextManager(monorepo_root)
|
||||
mcm.load()
|
||||
encryption_manager = mcm.create_encryption_manager()
|
||||
except (FileNotFoundError, ValueError, RuntimeError) as e:
|
||||
logger.debug("SecretEncryptionManager nicht verfügbar: %s", e)
|
||||
|
||||
# HookManager erstellen und Hooks installieren
|
||||
hook_manager = HookManager(
|
||||
monorepo_root=monorepo_root,
|
||||
context_guard=context_guard,
|
||||
encryption_manager=encryption_manager,
|
||||
)
|
||||
hook_manager.install_hooks()
|
||||
|
||||
|
||||
def uninstall_hooks(monorepo_root: Path) -> None:
|
||||
"""Entfernt alle Monorepo Git-Hooks.
|
||||
|
||||
Args:
|
||||
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
|
||||
"""
|
||||
hook_manager = HookManager(monorepo_root=monorepo_root)
|
||||
hook_manager.uninstall_hooks()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Integrationsmodul – Verdrahtung aller Komponenten.
|
||||
|
||||
Stellt eine Factory-Funktion bereit, die alle Monorepo-Komponenten mit
|
||||
korrekten Querverweisen erstellt und verdrahtet:
|
||||
|
||||
- SecretEncryptionManager ↔ ContextGuard (load_env nutzt Entschlüsselung)
|
||||
- SecretEncryptionManager ↔ FederationManager (Team-Repos erhalten nur eigene Secrets)
|
||||
- SecretEncryptionManager ↔ OrchestratorAdapter (Env-Loading via Encryption)
|
||||
|
||||
Requirements: 2.2, 9.3, 9.4, 10.10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from monorepo.audit import AuditLogger
|
||||
from monorepo.encryption import MachineContextManager, SecretEncryptionManager
|
||||
from monorepo.federation import FederationManager
|
||||
from monorepo.orchestrator import OrchestratorAdapter
|
||||
from monorepo.security import ContextGuard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_integrated_stack(root_path: Path) -> dict[str, Any]:
|
||||
"""Erstellt alle Monorepo-Komponenten mit vollständiger Verdrahtung.
|
||||
|
||||
Factory-Funktion die den gesamten Komponentenstack aufbaut, wobei
|
||||
der SecretEncryptionManager als zentrales Bindeglied zwischen
|
||||
ContextGuard, OrchestratorAdapter und FederationManager dient.
|
||||
|
||||
Ablauf:
|
||||
1. MachineContext laden (aus shared/config/machine-context.yaml)
|
||||
2. SecretEncryptionManager erstellen
|
||||
3. ContextGuard mit encryption_manager erstellen
|
||||
4. AuditLogger erstellen
|
||||
5. OrchestratorAdapter mit encryption_manager und context_guard erstellen
|
||||
6. FederationManager mit encryption_manager erstellen
|
||||
|
||||
Args:
|
||||
root_path: Wurzelverzeichnis des Monorepos (absoluter Pfad).
|
||||
|
||||
Returns:
|
||||
Dict mit allen erstellten Komponenten:
|
||||
- "encryption_manager": SecretEncryptionManager
|
||||
- "context_guard": ContextGuard
|
||||
- "audit_logger": AuditLogger
|
||||
- "orchestrator_adapter": OrchestratorAdapter
|
||||
- "federation_manager": FederationManager (oder None wenn team-repos.yaml fehlt)
|
||||
- "machine_context_manager": MachineContextManager
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn essentielle Konfigurationsdateien fehlen
|
||||
(access-config.yaml muss existieren; machine-context.yaml ist
|
||||
optional, führt aber zu einem eingeschränkten Stack).
|
||||
"""
|
||||
root_path = root_path.resolve()
|
||||
|
||||
# 1. MachineContext laden
|
||||
machine_ctx_manager = MachineContextManager(root_path)
|
||||
encryption_manager: SecretEncryptionManager | None = None
|
||||
|
||||
try:
|
||||
machine_ctx_manager.load()
|
||||
encryption_manager = machine_ctx_manager.create_encryption_manager()
|
||||
logger.info(
|
||||
"SecretEncryptionManager erstellt für Maschine '%s' "
|
||||
"(autorisierte Kontexte: %s).",
|
||||
machine_ctx_manager.machine_context.name,
|
||||
machine_ctx_manager.machine_context.authorized_contexts,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
"machine-context.yaml nicht gefunden. "
|
||||
"Stack wird ohne Verschlüsselung erstellt."
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"machine-context.yaml ungültig: %s. "
|
||||
"Stack wird ohne Verschlüsselung erstellt.",
|
||||
e,
|
||||
)
|
||||
|
||||
# 2. ContextGuard mit optionalem encryption_manager erstellen
|
||||
context_guard = ContextGuard(
|
||||
root_path=root_path,
|
||||
encryption_manager=encryption_manager,
|
||||
)
|
||||
logger.info("ContextGuard erstellt (encryption: %s).", encryption_manager is not None)
|
||||
|
||||
# 3. AuditLogger erstellen
|
||||
audit_logger = AuditLogger(root_path / ".audit" / "access.log")
|
||||
|
||||
# 4. OrchestratorAdapter mit encryption_manager verdrahten
|
||||
orchestrator_adapter = OrchestratorAdapter(
|
||||
root_path=root_path,
|
||||
context_guard=context_guard,
|
||||
audit_logger=audit_logger,
|
||||
encryption_manager=encryption_manager,
|
||||
)
|
||||
logger.info("OrchestratorAdapter erstellt (encryption: %s).", encryption_manager is not None)
|
||||
|
||||
# 5. FederationManager mit encryption_manager erstellen
|
||||
federation_manager: FederationManager | None = None
|
||||
team_repos_config_path = root_path / "shared" / "config" / "team-repos.yaml"
|
||||
|
||||
if team_repos_config_path.exists():
|
||||
try:
|
||||
federation_manager = FederationManager(
|
||||
config_path=team_repos_config_path,
|
||||
monorepo_root=root_path,
|
||||
encryption_manager=encryption_manager,
|
||||
)
|
||||
logger.info(
|
||||
"FederationManager erstellt (encryption: %s).",
|
||||
encryption_manager is not None,
|
||||
)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
logger.warning(
|
||||
"FederationManager konnte nicht erstellt werden: %s", e
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"team-repos.yaml nicht gefunden. FederationManager wird nicht erstellt."
|
||||
)
|
||||
|
||||
return {
|
||||
"encryption_manager": encryption_manager,
|
||||
"context_guard": context_guard,
|
||||
"audit_logger": audit_logger,
|
||||
"orchestrator_adapter": orchestrator_adapter,
|
||||
"federation_manager": federation_manager,
|
||||
"machine_context_manager": machine_ctx_manager,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Wissensspeicher-Modul für das Monorepo-CLI.
|
||||
|
||||
Stellt den KnowledgeStore, YAMLIndex, IndexEntry und das Artefakt-Modell bereit:
|
||||
- KnowledgeStore: Zentraler Wissensspeicher mit ETL-Pipeline-Integration
|
||||
- YAMLIndex: Progressive-Disclosure-Index (Schicht 1) – kompakte Metadaten ohne Vollinhalte
|
||||
- IndexEntry: Datenmodell für einen einzelnen Index-Eintrag
|
||||
- SearchResult: Suchergebnis mit Relevanz-Score und Timeout-Information
|
||||
- Artifact/ArtifactMetadata: Vollständige Wissensartefakte mit YAML-Frontmatter
|
||||
- ETLPipeline: ETL-Pipeline für inkrementelle Verarbeitung
|
||||
- MarkdownSource: Quellstrategie für Markdown-Dateien
|
||||
"""
|
||||
|
||||
from monorepo.knowledge.artifact import (
|
||||
Artifact,
|
||||
ArtifactLink,
|
||||
ArtifactMetadata,
|
||||
compute_content_hash,
|
||||
parse_frontmatter,
|
||||
resolve_artifact_path,
|
||||
write_artifact,
|
||||
)
|
||||
from monorepo.knowledge.etl import ETLPipeline, IngestError, IngestResult
|
||||
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
|
||||
from monorepo.knowledge.store import KnowledgeStore, SearchResult
|
||||
|
||||
__all__ = [
|
||||
"Artifact",
|
||||
"ArtifactLink",
|
||||
"ArtifactMetadata",
|
||||
"ETLPipeline",
|
||||
"IndexEntry",
|
||||
"IngestError",
|
||||
"IngestResult",
|
||||
"KnowledgeStore",
|
||||
"MarkdownSource",
|
||||
"SearchResult",
|
||||
"SourceError",
|
||||
"YAMLIndex",
|
||||
"compute_content_hash",
|
||||
"parse_frontmatter",
|
||||
"resolve_artifact_path",
|
||||
"write_artifact",
|
||||
]
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Wissensartefakt-Modell mit YAML-Frontmatter.
|
||||
|
||||
Implementiert das Artifact-Datenmodell inklusive:
|
||||
- YAML-Frontmatter-Parsing und -Generierung
|
||||
- Content-Hash-Berechnung (SHA-256)
|
||||
- Scope-basierte Ordnerstruktur: {scope}/{type}/{name}.md
|
||||
|
||||
Requirements: 3.4, 3.6, 3.11, 3.17, 3.19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datenmodelle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactLink:
|
||||
"""Gerichtete Verknüpfung zu einem anderen Artefakt (Graph-Kante)."""
|
||||
|
||||
target: str
|
||||
relation: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactMetadata:
|
||||
"""Metadaten eines Wissensartefakts (YAML-Frontmatter).
|
||||
|
||||
Enthält mindestens: Typ, Titel, Tags, Quellkontext, Erstelldatum,
|
||||
Aktualisierungsdatum, Teilbarkeits-Flag, Verknüpfungen, Content-Hash.
|
||||
"""
|
||||
|
||||
type: str
|
||||
title: str
|
||||
tags: list[str] = field(default_factory=list)
|
||||
source_context: str = ""
|
||||
created: date | None = None
|
||||
updated: date | None = None
|
||||
shareable: bool = False
|
||||
links: list[ArtifactLink] = field(default_factory=list)
|
||||
content_hash: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artifact:
|
||||
"""Ein vollständiges Wissensartefakt: Metadaten + Inhalt + Dateipfad.
|
||||
|
||||
Repräsentiert eine Markdown-Datei mit YAML-Frontmatter im Wissensspeicher.
|
||||
"""
|
||||
|
||||
metadata: ArtifactMetadata
|
||||
content: str
|
||||
file_path: Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content-Hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_content_hash(content: str) -> str:
|
||||
"""Berechnet den SHA-256-Hash des Inhalts.
|
||||
|
||||
Args:
|
||||
content: Der Textinhalt des Artefakts.
|
||||
|
||||
Returns:
|
||||
Hash im Format "sha256:<hex-digest>".
|
||||
"""
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pfadauflösung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_artifact_path(scope: str, artifact_type: str, name: str) -> Path:
|
||||
"""Berechnet den Scope-basierten Pfad für ein Artefakt.
|
||||
|
||||
Struktur: {scope}/{type}/{name}.md
|
||||
|
||||
Args:
|
||||
scope: Der Arbeitskontext/Scope (z.B. "bahn", "privat").
|
||||
artifact_type: Der Artefakttyp (z.B. "decision", "note").
|
||||
name: Der Dateiname ohne Erweiterung (z.B. "api-design").
|
||||
|
||||
Returns:
|
||||
Relativer Pfad zum Artefakt.
|
||||
"""
|
||||
# Sicherstellen, dass der Name keine .md-Endung enthält
|
||||
if name.endswith(".md"):
|
||||
name = name[:-3]
|
||||
return Path(scope) / artifact_type / f"{name}.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML-Frontmatter Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pattern zum Erkennen von YAML-Frontmatter: beginnt mit --- am Dateianfang
|
||||
_FRONTMATTER_PATTERN = re.compile(
|
||||
r"\A---\s*\n(.*?)^---\s*$\n?",
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> date | None:
|
||||
"""Konvertiert einen Wert in ein date-Objekt."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_links(raw_links: Any) -> list[ArtifactLink]:
|
||||
"""Parsed die links-Liste aus dem YAML-Frontmatter."""
|
||||
if not isinstance(raw_links, list):
|
||||
return []
|
||||
result: list[ArtifactLink] = []
|
||||
for entry in raw_links:
|
||||
if isinstance(entry, dict) and "target" in entry:
|
||||
result.append(
|
||||
ArtifactLink(
|
||||
target=str(entry["target"]),
|
||||
relation=str(entry.get("relation", "")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_frontmatter(file_path: Path) -> Artifact:
|
||||
"""Parsed eine Markdown-Datei mit YAML-Frontmatter.
|
||||
|
||||
Erwartet das Format:
|
||||
---
|
||||
type: decision
|
||||
title: "Titel"
|
||||
...
|
||||
---
|
||||
# Markdown-Inhalt
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Markdown-Datei.
|
||||
|
||||
Returns:
|
||||
Ein Artifact mit geparsten Metadaten und Inhalt.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn die Datei nicht existiert.
|
||||
ValueError: Wenn kein gültiges YAML-Frontmatter gefunden wird.
|
||||
"""
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
|
||||
match = _FRONTMATTER_PATTERN.match(text)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"Kein gültiges YAML-Frontmatter in {file_path} gefunden. "
|
||||
"Datei muss mit '---' beginnen und ein schließendes '---' enthalten."
|
||||
)
|
||||
|
||||
yaml_text = match.group(1)
|
||||
content = text[match.end():]
|
||||
|
||||
# YAML parsen
|
||||
raw: dict[str, Any] = yaml.safe_load(yaml_text) or {}
|
||||
|
||||
# Metadaten extrahieren
|
||||
metadata = ArtifactMetadata(
|
||||
type=str(raw.get("type", "")),
|
||||
title=str(raw.get("title", "")),
|
||||
tags=[str(t) for t in raw.get("tags", [])] if isinstance(raw.get("tags"), list) else [],
|
||||
source_context=str(raw.get("source_context", "")),
|
||||
created=_parse_date(raw.get("created")),
|
||||
updated=_parse_date(raw.get("updated")),
|
||||
shareable=bool(raw.get("shareable", False)),
|
||||
links=_parse_links(raw.get("links")),
|
||||
content_hash=str(raw.get("content_hash", "")),
|
||||
)
|
||||
|
||||
return Artifact(metadata=metadata, content=content, file_path=file_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML-Frontmatter Generierung & Schreiben
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_metadata(metadata: ArtifactMetadata) -> dict[str, Any]:
|
||||
"""Konvertiert ArtifactMetadata in ein YAML-serialisierbares dict."""
|
||||
data: dict[str, Any] = {
|
||||
"type": metadata.type,
|
||||
"title": metadata.title,
|
||||
}
|
||||
|
||||
if metadata.tags:
|
||||
data["tags"] = metadata.tags
|
||||
|
||||
if metadata.source_context:
|
||||
data["source_context"] = metadata.source_context
|
||||
|
||||
if metadata.created is not None:
|
||||
data["created"] = metadata.created.isoformat()
|
||||
|
||||
if metadata.updated is not None:
|
||||
data["updated"] = metadata.updated.isoformat()
|
||||
|
||||
data["shareable"] = metadata.shareable
|
||||
|
||||
if metadata.links:
|
||||
data["links"] = [
|
||||
{"target": link.target, "relation": link.relation}
|
||||
for link in metadata.links
|
||||
]
|
||||
|
||||
if metadata.content_hash:
|
||||
data["content_hash"] = metadata.content_hash
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _generate_frontmatter(metadata: ArtifactMetadata) -> str:
|
||||
"""Generiert YAML-Frontmatter-String aus Metadaten."""
|
||||
data = _serialize_metadata(metadata)
|
||||
yaml_str = yaml.dump(
|
||||
data,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
|
||||
def write_artifact(artifact: Artifact, base_path: Path) -> Path:
|
||||
"""Schreibt ein Artefakt in die Scope-basierte Ordnerstruktur.
|
||||
|
||||
Berechnet den Zielpfad als {base_path}/{scope}/{type}/{name}.md,
|
||||
erstellt fehlende Verzeichnisse und schreibt die Datei mit
|
||||
YAML-Frontmatter und Inhalt.
|
||||
|
||||
Der Content-Hash wird automatisch aktualisiert.
|
||||
|
||||
Args:
|
||||
artifact: Das zu schreibende Artefakt.
|
||||
base_path: Basis-Verzeichnis des Wissensspeichers.
|
||||
|
||||
Returns:
|
||||
Der vollständige Pfad der geschriebenen Datei.
|
||||
"""
|
||||
# Content-Hash aktualisieren
|
||||
artifact.metadata.content_hash = compute_content_hash(artifact.content)
|
||||
|
||||
# Zielpfad berechnen
|
||||
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
|
||||
relative_path = resolve_artifact_path(
|
||||
scope=artifact.metadata.source_context,
|
||||
artifact_type=artifact.metadata.type,
|
||||
name=name,
|
||||
)
|
||||
full_path = base_path / relative_path
|
||||
|
||||
# Verzeichnisse erstellen
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Frontmatter + Content zusammenbauen
|
||||
frontmatter = _generate_frontmatter(artifact.metadata)
|
||||
file_content = frontmatter + artifact.content
|
||||
|
||||
# Schreiben
|
||||
full_path.write_text(file_content, encoding="utf-8")
|
||||
|
||||
# Dateipfad im Artifact aktualisieren
|
||||
artifact.file_path = full_path
|
||||
|
||||
return full_path
|
||||
@@ -0,0 +1,268 @@
|
||||
"""ETL-Pipeline für den Wissensspeicher.
|
||||
|
||||
Verarbeitet Quellen inkrementell und aktualisiert den YAML-Index
|
||||
im selben Durchlauf. Basiert auf der DB-Wissensdatenbank-ETL-Architektur.
|
||||
|
||||
Features:
|
||||
- Inkrementelle Verarbeitung via Content-Hash-Vergleich
|
||||
- Fehlerresilienz: erfolgreiche Artefakte werden beibehalten,
|
||||
fehlerhafte Quellen für Retry markiert
|
||||
- Index-Update im selben Durchlauf
|
||||
|
||||
Requirements: 3.2, 3.6, 3.10, 3.12, 3.20, 3.21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.knowledge.artifact import (
|
||||
Artifact,
|
||||
ArtifactMetadata,
|
||||
compute_content_hash,
|
||||
write_artifact,
|
||||
)
|
||||
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ergebnis-Datenmodell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestError:
|
||||
"""Fehler bei der Ingestion eines einzelnen Artefakts."""
|
||||
|
||||
file_path: str
|
||||
error: str
|
||||
retry: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestResult:
|
||||
"""Ergebnis eines ETL-Ingestion-Durchlaufs.
|
||||
|
||||
Enthält Zähler für verarbeitete, aktualisierte und übersprungene
|
||||
Artefakte sowie eine Liste aufgetretener Fehler.
|
||||
"""
|
||||
|
||||
processed: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[IngestError] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""True wenn mindestens ein Artefakt verarbeitet wurde und keine fatalen Fehler."""
|
||||
return self.processed > 0 or (self.processed == 0 and len(self.errors) == 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ETL-Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ETLPipeline:
|
||||
"""ETL-Pipeline für den Wissensspeicher.
|
||||
|
||||
Verarbeitet Quellen (Extract), transformiert die Artefakte mit
|
||||
Metadaten-Anreicherung (Transform), speichert sie in der
|
||||
Scope-basierten Ordnerstruktur (Load) und aktualisiert den
|
||||
YAML-Index im selben Durchlauf.
|
||||
|
||||
Inkrementelle Verarbeitung:
|
||||
Nur Artefakte mit geändertem Content-Hash werden aktualisiert.
|
||||
Unveränderte Artefakte werden übersprungen.
|
||||
|
||||
Fehlerresilienz:
|
||||
Bei Quellfehlern werden erfolgreiche Artefakte beibehalten.
|
||||
Fehlgeschlagene Quellen werden für Retry markiert.
|
||||
"""
|
||||
|
||||
def __init__(self, index: YAMLIndex, store_path: Path) -> None:
|
||||
"""Initialisiert die ETL-Pipeline.
|
||||
|
||||
Args:
|
||||
index: Der YAML-Index für Progressive Disclosure.
|
||||
store_path: Basispfad des Wissensspeichers (für Artefakt-Ablage).
|
||||
"""
|
||||
self.index = index
|
||||
self.store_path = store_path
|
||||
|
||||
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
|
||||
"""Führt einen vollständigen ETL-Durchlauf für eine Quelle durch.
|
||||
|
||||
1. Extract: Artefakte aus der Quelle extrahieren
|
||||
2. Transform: Metadaten anreichern (Scope, Datum, Content-Hash)
|
||||
3. Load: Nur geänderte Artefakte in die Ordnerstruktur schreiben
|
||||
4. Index-Update: YAML-Index im selben Durchlauf aktualisieren
|
||||
|
||||
Args:
|
||||
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
|
||||
context: Der Arbeitskontext der Quelle (z.B. "bahn", "privat").
|
||||
|
||||
Returns:
|
||||
IngestResult mit Statistiken und aufgetretenen Fehlern.
|
||||
"""
|
||||
result = IngestResult()
|
||||
|
||||
# --- Extract ---
|
||||
try:
|
||||
artifacts = source.extract()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Fataler Fehler bei der Extraktion aus %s: %s",
|
||||
source.directory,
|
||||
e,
|
||||
)
|
||||
result.errors.append(
|
||||
IngestError(
|
||||
file_path=str(source.directory),
|
||||
error=f"Extraktion fehlgeschlagen: {e}",
|
||||
retry=True,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# Quellfehler als IngestErrors übernehmen
|
||||
for src_error in source.errors:
|
||||
result.errors.append(
|
||||
IngestError(
|
||||
file_path=src_error.file_path,
|
||||
error=src_error.error,
|
||||
retry=src_error.retry,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Transform & Load (pro Artefakt) ---
|
||||
for artifact in artifacts:
|
||||
try:
|
||||
was_updated = self._process_artifact(artifact, context)
|
||||
result.processed += 1
|
||||
if was_updated:
|
||||
result.updated += 1
|
||||
else:
|
||||
result.skipped += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Fehler bei der Verarbeitung von %s: %s",
|
||||
artifact.file_path,
|
||||
e,
|
||||
)
|
||||
result.errors.append(
|
||||
IngestError(
|
||||
file_path=str(artifact.file_path),
|
||||
error=str(e),
|
||||
retry=True,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Index-Update (im selben Durchlauf) ---
|
||||
if result.processed > 0:
|
||||
try:
|
||||
self.index.save()
|
||||
except Exception as e:
|
||||
logger.error("Fehler beim Speichern des Index: %s", e)
|
||||
result.errors.append(
|
||||
IngestError(
|
||||
file_path=str(self.index.index_path),
|
||||
error=f"Index-Speicherung fehlgeschlagen: {e}",
|
||||
retry=True,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _process_artifact(self, artifact: Artifact, context: str) -> bool:
|
||||
"""Verarbeitet ein einzelnes Artefakt (Transform + Load + Index).
|
||||
|
||||
Returns:
|
||||
True wenn das Artefakt aktualisiert wurde,
|
||||
False wenn es übersprungen wurde (unverändert).
|
||||
"""
|
||||
# --- Transform: Metadaten anreichern ---
|
||||
self._enrich_metadata(artifact, context)
|
||||
|
||||
# Content-Hash berechnen
|
||||
new_hash = compute_content_hash(artifact.content)
|
||||
|
||||
# Artefakt-ID bestimmen
|
||||
artifact_id = self._compute_artifact_id(artifact, context)
|
||||
|
||||
# --- Inkrementelle Verarbeitung: Hash-Vergleich ---
|
||||
existing_entry = self.index.get_entry(artifact_id)
|
||||
if existing_entry is not None and existing_entry.content_hash == new_hash:
|
||||
# Inhalt unverändert → überspringen
|
||||
return False
|
||||
|
||||
# --- Load: Artefakt in Ordnerstruktur schreiben ---
|
||||
artifact.metadata.content_hash = new_hash
|
||||
written_path = write_artifact(artifact, self.store_path)
|
||||
|
||||
# --- Index-Update ---
|
||||
entry = IndexEntry(
|
||||
id=artifact_id,
|
||||
title=artifact.metadata.title,
|
||||
type=artifact.metadata.type,
|
||||
tags=artifact.metadata.tags,
|
||||
scope=context,
|
||||
summary=self._generate_summary(artifact.content),
|
||||
path=str(written_path.relative_to(self.store_path)),
|
||||
content_hash=new_hash,
|
||||
links=[link.target for link in artifact.metadata.links],
|
||||
)
|
||||
self.index.update_entry(entry)
|
||||
|
||||
return True
|
||||
|
||||
def _enrich_metadata(self, artifact: Artifact, context: str) -> None:
|
||||
"""Reichert die Artefakt-Metadaten mit Kontext und Datum an.
|
||||
|
||||
Setzt source_context und created/updated-Datum falls nicht vorhanden.
|
||||
"""
|
||||
if not artifact.metadata.source_context:
|
||||
artifact.metadata.source_context = context
|
||||
|
||||
today = date.today()
|
||||
if artifact.metadata.created is None:
|
||||
artifact.metadata.created = today
|
||||
if artifact.metadata.updated is None:
|
||||
artifact.metadata.updated = today
|
||||
else:
|
||||
# Bei Update das Aktualisierungsdatum auf heute setzen
|
||||
artifact.metadata.updated = today
|
||||
|
||||
def _compute_artifact_id(self, artifact: Artifact, context: str) -> str:
|
||||
"""Berechnet die eindeutige ID eines Artefakts.
|
||||
|
||||
Format: {context}/{type}/{filename_without_extension}
|
||||
"""
|
||||
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
|
||||
artifact_type = artifact.metadata.type or "note"
|
||||
return f"{context}/{artifact_type}/{name}"
|
||||
|
||||
def _generate_summary(self, content: str, max_length: int = 150) -> str:
|
||||
"""Erzeugt eine Kurzbeschreibung aus dem Inhalt.
|
||||
|
||||
Nimmt die erste nicht-leere Zeile (ohne Markdown-Heading-Marker)
|
||||
und kürzt sie auf max_length Zeichen.
|
||||
"""
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
# Heading-Marker entfernen
|
||||
if stripped.startswith("#"):
|
||||
stripped = stripped.lstrip("#").strip()
|
||||
if stripped:
|
||||
if len(stripped) > max_length:
|
||||
return stripped[:max_length - 3] + "..."
|
||||
return stripped
|
||||
return ""
|
||||
@@ -0,0 +1,263 @@
|
||||
"""YAML-Index für den Wissensspeicher (Progressive Disclosure – Schicht 1).
|
||||
|
||||
Der Index enthält ausschließlich kompakte Metadaten-Einträge (Titel, Tags,
|
||||
Beziehungen, Kurzbeschreibungen, Pfade) – niemals den vollständigen
|
||||
Dokumentinhalt. Agenten lesen zuerst den Index und laden bei Bedarf
|
||||
gezielt einzelne Dateien nach.
|
||||
|
||||
Format:
|
||||
version: "1.0"
|
||||
last_updated: "2025-01-15T10:30:00Z"
|
||||
artifacts:
|
||||
- id: "bahn/decisions/api-design"
|
||||
title: "..."
|
||||
type: decision
|
||||
tags: [...]
|
||||
scope: bahn
|
||||
summary: "..."
|
||||
path: "..."
|
||||
content_hash: "sha256:..."
|
||||
links: [...]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexEntry:
|
||||
"""Ein einzelner Eintrag im YAML-Index (Progressive Disclosure).
|
||||
|
||||
Enthält nur kompakte Metadaten – kein vollständiger Dokumentinhalt.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
type: str
|
||||
tags: list[str] = field(default_factory=list)
|
||||
scope: str = ""
|
||||
summary: str = ""
|
||||
path: str = ""
|
||||
content_hash: str = ""
|
||||
links: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Serialisiert den Eintrag als dict für YAML-Export."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"type": self.type,
|
||||
"tags": self.tags,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"path": self.path,
|
||||
"content_hash": self.content_hash,
|
||||
"links": self.links,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> IndexEntry:
|
||||
"""Erstellt einen IndexEntry aus einem dict (YAML-Import)."""
|
||||
return cls(
|
||||
id=str(data.get("id", "")),
|
||||
title=str(data.get("title", "")),
|
||||
type=str(data.get("type", "")),
|
||||
tags=list(data.get("tags", [])), # type: ignore[arg-type]
|
||||
scope=str(data.get("scope", "")),
|
||||
summary=str(data.get("summary", "")),
|
||||
path=str(data.get("path", "")),
|
||||
content_hash=str(data.get("content_hash", "")),
|
||||
links=list(data.get("links", [])), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class YAMLIndex:
|
||||
"""Progressive-Disclosure-Index für den Wissensspeicher.
|
||||
|
||||
Verwaltet eine YAML-Datei mit kompakten Metadaten-Einträgen.
|
||||
Erzwingt, dass niemals vollständiger Dokumentinhalt im Index landet.
|
||||
"""
|
||||
|
||||
VERSION = "1.0"
|
||||
|
||||
def __init__(self, index_path: Path) -> None:
|
||||
"""Initialisiert den YAMLIndex.
|
||||
|
||||
Args:
|
||||
index_path: Pfad zur YAML-Index-Datei (_index.yaml).
|
||||
"""
|
||||
self.index_path = index_path
|
||||
self._version: str = self.VERSION
|
||||
self._last_updated: str = ""
|
||||
self._entries: dict[str, IndexEntry] = {}
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Aktuelle Index-Version."""
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def last_updated(self) -> str:
|
||||
"""Zeitstempel der letzten Aktualisierung (ISO 8601)."""
|
||||
return self._last_updated
|
||||
|
||||
@property
|
||||
def entries(self) -> dict[str, IndexEntry]:
|
||||
"""Alle Index-Einträge, indexiert nach ID."""
|
||||
return dict(self._entries)
|
||||
|
||||
def load(self) -> None:
|
||||
"""Lädt den YAML-Index von der Festplatte.
|
||||
|
||||
Wenn die Datei nicht existiert, wird ein leerer Index initialisiert.
|
||||
|
||||
Raises:
|
||||
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
|
||||
ValueError: Wenn das Format ungültig ist.
|
||||
"""
|
||||
if not self.index_path.exists():
|
||||
self._entries = {}
|
||||
self._last_updated = ""
|
||||
self._version = self.VERSION
|
||||
return
|
||||
|
||||
with open(self.index_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
self._entries = {}
|
||||
self._last_updated = ""
|
||||
self._version = self.VERSION
|
||||
return
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(
|
||||
f"Ungültiges Index-Format: Erwartet dict, erhalten {type(data).__name__}"
|
||||
)
|
||||
|
||||
self._version = str(data.get("version", self.VERSION))
|
||||
self._last_updated = str(data.get("last_updated", ""))
|
||||
|
||||
self._entries = {}
|
||||
for artifact_data in data.get("artifacts", []):
|
||||
if isinstance(artifact_data, dict):
|
||||
entry = IndexEntry.from_dict(artifact_data)
|
||||
self._entries[entry.id] = entry
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persistiert den Index auf die Festplatte.
|
||||
|
||||
Aktualisiert den last_updated-Zeitstempel und schreibt alle
|
||||
Einträge im definierten YAML-Format.
|
||||
"""
|
||||
self._last_updated = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
data = {
|
||||
"version": self._version,
|
||||
"last_updated": self._last_updated,
|
||||
"artifacts": [entry.to_dict() for entry in self._entries.values()],
|
||||
}
|
||||
|
||||
# Sicherstellen, dass das Verzeichnis existiert
|
||||
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.index_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
|
||||
def update_entry(self, entry: IndexEntry) -> None:
|
||||
"""Fügt einen Eintrag hinzu oder aktualisiert einen bestehenden.
|
||||
|
||||
Progressive Disclosure wird erzwungen: Der Eintrag darf nur
|
||||
kompakte Metadaten enthalten (kein vollständiger Inhalt).
|
||||
|
||||
Args:
|
||||
entry: Der hinzuzufügende oder zu aktualisierende IndexEntry.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Eintrag keine gültige ID hat.
|
||||
"""
|
||||
if not entry.id:
|
||||
raise ValueError("IndexEntry muss eine nicht-leere ID haben.")
|
||||
|
||||
self._entries[entry.id] = entry
|
||||
|
||||
def search(self, query: str, allowed_scopes: list[str]) -> list[IndexEntry]:
|
||||
"""Textsuche über den Index mit Scope-Filterung.
|
||||
|
||||
Durchsucht Titel, Tags, Summary und ID nach dem Suchbegriff.
|
||||
Liefert nur Einträge aus erlaubten Scopes zurück – Einträge
|
||||
aus nicht-autorisierten Scopes werden weder zurückgegeben
|
||||
noch wird deren Existenz offengelegt.
|
||||
|
||||
Args:
|
||||
query: Suchbegriff (case-insensitive Teilstring-Suche).
|
||||
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
|
||||
zurückgegeben werden dürfen.
|
||||
|
||||
Returns:
|
||||
Liste passender IndexEntry-Objekte, gefiltert nach Scope.
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
query_lower = query.lower()
|
||||
results: list[IndexEntry] = []
|
||||
|
||||
for entry in self._entries.values():
|
||||
# Scope-Filter: Nur autorisierte Scopes
|
||||
if entry.scope not in allowed_scopes:
|
||||
continue
|
||||
|
||||
# Textsuche über relevante Felder
|
||||
searchable_text = " ".join([
|
||||
entry.title,
|
||||
entry.id,
|
||||
entry.summary,
|
||||
" ".join(entry.tags),
|
||||
]).lower()
|
||||
|
||||
if query_lower in searchable_text:
|
||||
results.append(entry)
|
||||
|
||||
return results
|
||||
|
||||
def get_by_scope(self, scope: str) -> list[IndexEntry]:
|
||||
"""Liefert alle Einträge eines bestimmten Scopes.
|
||||
|
||||
Args:
|
||||
scope: Der Scope, nach dem gefiltert wird (z.B. "bahn", "privat").
|
||||
|
||||
Returns:
|
||||
Liste aller IndexEntry-Objekte mit dem angegebenen Scope.
|
||||
"""
|
||||
return [entry for entry in self._entries.values() if entry.scope == scope]
|
||||
|
||||
def remove_entry(self, entry_id: str) -> bool:
|
||||
"""Entfernt einen Eintrag aus dem Index.
|
||||
|
||||
Args:
|
||||
entry_id: Die ID des zu entfernenden Eintrags.
|
||||
|
||||
Returns:
|
||||
True wenn der Eintrag entfernt wurde, False wenn er nicht existierte.
|
||||
"""
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_entry(self, entry_id: str) -> IndexEntry | None:
|
||||
"""Liefert einen einzelnen Eintrag nach ID.
|
||||
|
||||
Args:
|
||||
entry_id: Die ID des gesuchten Eintrags.
|
||||
|
||||
Returns:
|
||||
Der IndexEntry oder None, wenn nicht gefunden.
|
||||
"""
|
||||
return self._entries.get(entry_id)
|
||||
@@ -0,0 +1,486 @@
|
||||
"""NoteGraph-Migration für den Wissensspeicher.
|
||||
|
||||
Migriert eine bestehende NoteGraph-Verzeichnisstruktur (decisions, inbox,
|
||||
meetings, people, projects) in den KnowledgeStore. Nutzt die ETLPipeline
|
||||
für die Ingestion und erweitert Artefakte mit fehlenden Metadaten.
|
||||
|
||||
Scope-Konfiguration: Mapping Arbeitskontext → Scope wird aus scopes.yaml geladen.
|
||||
|
||||
Requirements: 3.5, 3.14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.knowledge.artifact import (
|
||||
Artifact,
|
||||
ArtifactMetadata,
|
||||
parse_frontmatter,
|
||||
)
|
||||
from monorepo.knowledge.etl import ETLPipeline, IngestResult
|
||||
from monorepo.knowledge.index import YAMLIndex
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource
|
||||
from monorepo.knowledge.store import KnowledgeStore
|
||||
from monorepo.models import ScopeConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NoteGraph-Verzeichnis → ArtifactType Mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOTEGRAPH_DIR_TYPE_MAP: dict[str, str] = {
|
||||
"decisions": "decision",
|
||||
"inbox": "note",
|
||||
"meetings": "meeting",
|
||||
"people": "reference",
|
||||
"projects": "project",
|
||||
}
|
||||
|
||||
"""Standard-NoteGraph-Verzeichnisse und ihr zugeordneter Artefakt-Typ."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ergebnis-Datenmodelle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationResult:
|
||||
"""Ergebnis einer NoteGraph-Migration.
|
||||
|
||||
Attributes:
|
||||
migrated_count: Anzahl erfolgreich migrierter Artefakte.
|
||||
skipped_count: Anzahl übersprungener Artefakte (unverändert oder ohne Frontmatter).
|
||||
errors: Liste der aufgetretenen Fehler mit Details.
|
||||
"""
|
||||
|
||||
migrated_count: int = 0
|
||||
skipped_count: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""True wenn Migration durchgeführt wurde ohne fatale Fehler."""
|
||||
return self.migrated_count > 0 or (
|
||||
self.migrated_count == 0 and len(self.errors) == 0
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationDetail:
|
||||
"""Detail zu einem fehlenden oder gefundenen Artefakt bei der Validierung."""
|
||||
|
||||
file_name: str
|
||||
expected_type: str
|
||||
found_in_index: bool
|
||||
artifact_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Ergebnis der Migrations-Validierung.
|
||||
|
||||
Attributes:
|
||||
valid: True wenn alle Artefakte im Index auffindbar sind.
|
||||
total: Gesamtanzahl erwarteter Artefakte.
|
||||
found_in_index: Anzahl der im Index gefundenen Artefakte.
|
||||
missing: Liste fehlender Artefakt-IDs.
|
||||
details: Detaillierte Ergebnisse pro Artefakt.
|
||||
"""
|
||||
|
||||
valid: bool = True
|
||||
total: int = 0
|
||||
found_in_index: int = 0
|
||||
missing: list[str] = field(default_factory=list)
|
||||
details: list[ValidationDetail] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope-Konfiguration laden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_scope_config(scope_config_path: Path) -> ScopeConfig:
|
||||
"""Lädt die Scope-Konfiguration aus einer YAML-Datei.
|
||||
|
||||
Die Datei definiert das Mapping Arbeitskontext → Scope für den
|
||||
Wissensspeicher (Requirement 3.14).
|
||||
|
||||
Args:
|
||||
scope_config_path: Pfad zur scopes.yaml Datei.
|
||||
|
||||
Returns:
|
||||
ScopeConfig mit dem Scope-Mapping.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn die Datei nicht existiert.
|
||||
ValueError: Wenn das Format ungültig ist.
|
||||
"""
|
||||
if not scope_config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Scope-Konfiguration nicht gefunden: {scope_config_path}"
|
||||
)
|
||||
|
||||
with open(scope_config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict) or "scopes" not in data:
|
||||
raise ValueError(
|
||||
f"Ungültiges Scope-Konfigurationsformat in {scope_config_path}"
|
||||
)
|
||||
|
||||
scopes: dict[str, dict[str, object]] = {}
|
||||
for entry in data["scopes"]:
|
||||
if isinstance(entry, dict) and "name" in entry:
|
||||
context = str(entry.get("context", entry["name"]))
|
||||
scopes[context] = {
|
||||
"scope": str(entry["name"]),
|
||||
"description": str(entry.get("description", "")),
|
||||
"knowledge_path": str(entry.get("knowledge_path", "")),
|
||||
}
|
||||
|
||||
return ScopeConfig(scopes=scopes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NoteGraph-Migrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NoteGraphMigrator:
|
||||
"""Migriert NoteGraph-Verzeichnisstrukturen in den KnowledgeStore.
|
||||
|
||||
Der NoteGraph hat folgende Verzeichnisstruktur:
|
||||
decisions/ → Typ: decision
|
||||
inbox/ → Typ: note
|
||||
meetings/ → Typ: meeting
|
||||
people/ → Typ: reference
|
||||
projects/ → Typ: project
|
||||
|
||||
Jedes Verzeichnis enthält .md-Dateien mit optionalem YAML-Frontmatter.
|
||||
Der Migrator:
|
||||
- Mappt NoteGraph-Verzeichnisse auf Artefakttypen
|
||||
- Nutzt die ETLPipeline für die Ingestion
|
||||
- Ergänzt fehlendes Frontmatter (Typ, source_context)
|
||||
- Bewahrt vorhandene Metadaten
|
||||
- Validiert, dass alle Artefakte im Zielsystem vorhanden und auffindbar sind
|
||||
|
||||
Requirements: 3.5, 3.14
|
||||
"""
|
||||
|
||||
def __init__(self, store: KnowledgeStore, scope_config_path: Path) -> None:
|
||||
"""Initialisiert den NoteGraphMigrator.
|
||||
|
||||
Args:
|
||||
store: Der KnowledgeStore, in den migriert wird.
|
||||
scope_config_path: Pfad zur scopes.yaml für Scope-Mapping.
|
||||
"""
|
||||
self.store = store
|
||||
self.scope_config_path = scope_config_path
|
||||
self.scope_config = load_scope_config(scope_config_path)
|
||||
|
||||
def migrate(self, source_path: Path, context: str) -> MigrationResult:
|
||||
"""Migriert eine NoteGraph-Verzeichnisstruktur in den KnowledgeStore.
|
||||
|
||||
Durchsucht die NoteGraph-Verzeichnisse (decisions, inbox, meetings,
|
||||
people, projects) und migriert alle .md-Dateien. Dateien ohne
|
||||
YAML-Frontmatter werden mit generiertem Frontmatter versehen.
|
||||
|
||||
Args:
|
||||
source_path: Pfad zum NoteGraph-Wurzelverzeichnis.
|
||||
context: Arbeitskontext für die migrierten Artefakte
|
||||
(z.B. "privat", "bahn").
|
||||
|
||||
Returns:
|
||||
MigrationResult mit Statistiken und Fehlerdetails.
|
||||
"""
|
||||
result = MigrationResult()
|
||||
|
||||
if not source_path.exists():
|
||||
result.errors.append(
|
||||
f"NoteGraph-Verzeichnis existiert nicht: {source_path}"
|
||||
)
|
||||
return result
|
||||
|
||||
if not source_path.is_dir():
|
||||
result.errors.append(
|
||||
f"NoteGraph-Pfad ist kein Verzeichnis: {source_path}"
|
||||
)
|
||||
return result
|
||||
|
||||
# Jedes NoteGraph-Unterverzeichnis verarbeiten
|
||||
for dir_name, artifact_type in NOTEGRAPH_DIR_TYPE_MAP.items():
|
||||
sub_dir = source_path / dir_name
|
||||
if not sub_dir.exists() or not sub_dir.is_dir():
|
||||
logger.debug(
|
||||
"NoteGraph-Unterverzeichnis existiert nicht, überspringe: %s",
|
||||
sub_dir,
|
||||
)
|
||||
continue
|
||||
|
||||
sub_result = self._migrate_directory(sub_dir, context, artifact_type)
|
||||
result.migrated_count += sub_result.migrated_count
|
||||
result.skipped_count += sub_result.skipped_count
|
||||
result.errors.extend(sub_result.errors)
|
||||
|
||||
return result
|
||||
|
||||
def _migrate_directory(
|
||||
self, directory: Path, context: str, artifact_type: str
|
||||
) -> MigrationResult:
|
||||
"""Migriert alle .md-Dateien eines NoteGraph-Unterverzeichnisses.
|
||||
|
||||
Bereitet Dateien ohne Frontmatter vor und nutzt dann die ETLPipeline.
|
||||
|
||||
Args:
|
||||
directory: Das zu migrierende Verzeichnis.
|
||||
context: Arbeitskontext.
|
||||
artifact_type: Der zugeordnete Artefakt-Typ.
|
||||
|
||||
Returns:
|
||||
MigrationResult für dieses Verzeichnis.
|
||||
"""
|
||||
result = MigrationResult()
|
||||
|
||||
md_files = sorted(directory.rglob("*.md"))
|
||||
if not md_files:
|
||||
return result
|
||||
|
||||
# Dateien ohne Frontmatter vorbereiten (temporär Frontmatter hinzufügen)
|
||||
prepared_files = self._prepare_files(md_files, context, artifact_type)
|
||||
|
||||
# ETLPipeline für die vorbereiteten Dateien verwenden
|
||||
source = MarkdownSource(directory=directory)
|
||||
# Wir nutzen den Store.ingest direkt, da die Dateien nun Frontmatter haben
|
||||
ingest_result = self.store.ingest(source, context)
|
||||
|
||||
result.migrated_count = ingest_result.updated
|
||||
result.skipped_count = ingest_result.skipped
|
||||
|
||||
for error in ingest_result.errors:
|
||||
result.errors.append(f"{error.file_path}: {error.error}")
|
||||
|
||||
return result
|
||||
|
||||
def _prepare_files(
|
||||
self,
|
||||
md_files: list[Path],
|
||||
context: str,
|
||||
artifact_type: str,
|
||||
) -> list[Path]:
|
||||
"""Bereitet .md-Dateien für die Migration vor.
|
||||
|
||||
Dateien ohne YAML-Frontmatter erhalten generiertes Frontmatter
|
||||
mit dem korrekten Typ und Kontext. Dateien mit existierendem
|
||||
Frontmatter erhalten fehlende Metadaten (type, source_context).
|
||||
|
||||
Args:
|
||||
md_files: Liste der zu verarbeitenden Markdown-Dateien.
|
||||
context: Arbeitskontext.
|
||||
artifact_type: Der Artefakt-Typ basierend auf dem NoteGraph-Verzeichnis.
|
||||
|
||||
Returns:
|
||||
Liste der vorbereiteten Dateipfade.
|
||||
"""
|
||||
prepared: list[Path] = []
|
||||
|
||||
for md_file in md_files:
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning("Kann Datei nicht lesen: %s – %s", md_file, e)
|
||||
continue
|
||||
|
||||
updated_content = self._ensure_frontmatter(
|
||||
content, md_file.stem, context, artifact_type
|
||||
)
|
||||
|
||||
if updated_content != content:
|
||||
md_file.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
prepared.append(md_file)
|
||||
|
||||
return prepared
|
||||
|
||||
def _ensure_frontmatter(
|
||||
self,
|
||||
content: str,
|
||||
file_stem: str,
|
||||
context: str,
|
||||
artifact_type: str,
|
||||
) -> str:
|
||||
"""Stellt sicher, dass der Inhalt gültiges YAML-Frontmatter enthält.
|
||||
|
||||
Wenn kein Frontmatter vorhanden ist, wird eines generiert.
|
||||
Wenn Frontmatter existiert, werden fehlende Felder ergänzt
|
||||
(type, source_context).
|
||||
|
||||
Args:
|
||||
content: Der aktuelle Dateiinhalt.
|
||||
file_stem: Dateiname ohne Erweiterung (für den Titel).
|
||||
context: Arbeitskontext.
|
||||
artifact_type: Der Artefakt-Typ.
|
||||
|
||||
Returns:
|
||||
Der (möglicherweise aktualisierte) Dateiinhalt.
|
||||
"""
|
||||
if content.startswith("---"):
|
||||
# Frontmatter existiert – fehlende Felder ergänzen
|
||||
return self._enrich_existing_frontmatter(
|
||||
content, context, artifact_type
|
||||
)
|
||||
else:
|
||||
# Kein Frontmatter – generieren
|
||||
return self._generate_frontmatter(
|
||||
content, file_stem, context, artifact_type
|
||||
)
|
||||
|
||||
def _enrich_existing_frontmatter(
|
||||
self, content: str, context: str, artifact_type: str
|
||||
) -> str:
|
||||
"""Ergänzt fehlende Felder in existierendem YAML-Frontmatter.
|
||||
|
||||
Setzt 'type' und 'source_context' falls nicht vorhanden.
|
||||
|
||||
Args:
|
||||
content: Dateiinhalt mit existierendem Frontmatter.
|
||||
context: Arbeitskontext.
|
||||
artifact_type: Der zu setzende Artefakt-Typ.
|
||||
|
||||
Returns:
|
||||
Aktualisierter Dateiinhalt.
|
||||
"""
|
||||
import re
|
||||
|
||||
pattern = re.compile(
|
||||
r"\A---\s*\n(.*?)^---\s*$\n?",
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
match = pattern.match(content)
|
||||
if not match:
|
||||
return content
|
||||
|
||||
yaml_text = match.group(1)
|
||||
body = content[match.end():]
|
||||
|
||||
try:
|
||||
metadata: dict[str, Any] = yaml.safe_load(yaml_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return content
|
||||
|
||||
modified = False
|
||||
|
||||
# Typ ergänzen falls fehlend
|
||||
if not metadata.get("type"):
|
||||
metadata["type"] = artifact_type
|
||||
modified = True
|
||||
|
||||
# source_context ergänzen falls fehlend
|
||||
if not metadata.get("source_context"):
|
||||
metadata["source_context"] = context
|
||||
modified = True
|
||||
|
||||
if not modified:
|
||||
return content
|
||||
|
||||
# Frontmatter neu generieren
|
||||
new_yaml = yaml.dump(
|
||||
metadata,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
return f"---\n{new_yaml}---\n{body}"
|
||||
|
||||
def _generate_frontmatter(
|
||||
self, content: str, file_stem: str, context: str, artifact_type: str
|
||||
) -> str:
|
||||
"""Generiert neues YAML-Frontmatter für eine Datei ohne Frontmatter.
|
||||
|
||||
Erstellt minimales Frontmatter mit Typ, Titel und Quellkontext.
|
||||
|
||||
Args:
|
||||
content: Der Dateiinhalt (ohne Frontmatter).
|
||||
file_stem: Dateiname ohne Erweiterung.
|
||||
context: Arbeitskontext.
|
||||
artifact_type: Der Artefakt-Typ.
|
||||
|
||||
Returns:
|
||||
Dateiinhalt mit vorangestelltem Frontmatter.
|
||||
"""
|
||||
# Titel aus Dateiname ableiten (kebab-case → Title Case)
|
||||
title = file_stem.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
metadata = {
|
||||
"type": artifact_type,
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"source_context": context,
|
||||
"shareable": False,
|
||||
}
|
||||
|
||||
yaml_str = yaml.dump(
|
||||
metadata,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
return f"---\n{yaml_str}---\n{content}"
|
||||
|
||||
def validate(self, context: str) -> ValidationResult:
|
||||
"""Validiert, dass alle migrierten Artefakte im Index auffindbar sind.
|
||||
|
||||
Prüft für den angegebenen Kontext:
|
||||
- Alle erwarteten Artefakte sind im YAML-Index vorhanden
|
||||
- Metadaten (Typ, Scope) sind korrekt gesetzt
|
||||
- Verknüpfungen sind intakt
|
||||
|
||||
Args:
|
||||
context: Der Arbeitskontext, für den validiert wird.
|
||||
|
||||
Returns:
|
||||
ValidationResult mit detaillierten Ergebnissen.
|
||||
"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Alle Einträge für diesen Scope aus dem Index holen
|
||||
index_entries = self.store.get_index(scope=context)
|
||||
index_ids = {entry.id for entry in index_entries}
|
||||
|
||||
result.total = len(index_entries)
|
||||
result.found_in_index = len(index_entries)
|
||||
|
||||
# Prüfe ob jeder Index-Eintrag eine gültige Datei referenziert
|
||||
for entry in index_entries:
|
||||
artifact_path = self.store.base_path / entry.path if entry.path else None
|
||||
file_exists = artifact_path.exists() if artifact_path else False
|
||||
|
||||
detail = ValidationDetail(
|
||||
file_name=entry.path,
|
||||
expected_type=entry.type,
|
||||
found_in_index=True,
|
||||
artifact_id=entry.id,
|
||||
)
|
||||
result.details.append(detail)
|
||||
|
||||
if not file_exists:
|
||||
result.missing.append(entry.id)
|
||||
result.valid = False
|
||||
|
||||
# Zusätzlich prüfen: Haben alle Dateien einen korrekten Scope?
|
||||
for entry in index_entries:
|
||||
if entry.scope != context:
|
||||
result.missing.append(
|
||||
f"{entry.id} (falscher Scope: {entry.scope}, erwartet: {context})"
|
||||
)
|
||||
result.valid = False
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Quellstrategien für die ETL-Pipeline des Wissensspeichers.
|
||||
|
||||
Jede Quellstrategie implementiert das Extrahieren von Wissensartefakten
|
||||
aus einem bestimmten Format/Medium (Markdown, Confluence, PDF, etc.).
|
||||
"""
|
||||
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource
|
||||
|
||||
__all__ = [
|
||||
"MarkdownSource",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Markdown-Quellstrategie für die ETL-Pipeline.
|
||||
|
||||
Scannt ein Verzeichnis rekursiv nach .md-Dateien mit YAML-Frontmatter
|
||||
und liefert eine Liste von Artifacts zurück.
|
||||
|
||||
Requirements: 3.2, 3.10, 3.12, 3.19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceError:
|
||||
"""Fehler bei der Verarbeitung einer einzelnen Quelldatei."""
|
||||
|
||||
file_path: str
|
||||
error: str
|
||||
retry: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownSource:
|
||||
"""Quellstrategie: Rekursives Scannen eines Verzeichnisses nach Markdown-Dateien.
|
||||
|
||||
Findet alle .md-Dateien im angegebenen Verzeichnis (rekursiv),
|
||||
parsed jede Datei mit parse_frontmatter und liefert die resultierenden
|
||||
Artifacts. Dateien ohne gültiges YAML-Frontmatter werden übersprungen
|
||||
und als Warning geloggt.
|
||||
"""
|
||||
|
||||
directory: Path
|
||||
errors: list[SourceError] = field(default_factory=list)
|
||||
|
||||
def extract(self) -> list[Artifact]:
|
||||
"""Extrahiert alle Artefakte aus dem Quellverzeichnis.
|
||||
|
||||
Durchsucht das Verzeichnis rekursiv nach .md-Dateien,
|
||||
parsed jede Datei und sammelt Fehler ohne abzubrechen.
|
||||
|
||||
Returns:
|
||||
Liste erfolgreich geparster Artifacts.
|
||||
"""
|
||||
self.errors = []
|
||||
artifacts: list[Artifact] = []
|
||||
|
||||
if not self.directory.exists():
|
||||
logger.warning(
|
||||
"Quellverzeichnis existiert nicht: %s", self.directory
|
||||
)
|
||||
self.errors.append(
|
||||
SourceError(
|
||||
file_path=str(self.directory),
|
||||
error=f"Verzeichnis existiert nicht: {self.directory}",
|
||||
retry=True,
|
||||
)
|
||||
)
|
||||
return artifacts
|
||||
|
||||
if not self.directory.is_dir():
|
||||
logger.warning(
|
||||
"Quellpfad ist kein Verzeichnis: %s", self.directory
|
||||
)
|
||||
self.errors.append(
|
||||
SourceError(
|
||||
file_path=str(self.directory),
|
||||
error=f"Pfad ist kein Verzeichnis: {self.directory}",
|
||||
retry=False,
|
||||
)
|
||||
)
|
||||
return artifacts
|
||||
|
||||
md_files = sorted(self.directory.rglob("*.md"))
|
||||
|
||||
for md_file in md_files:
|
||||
try:
|
||||
artifact = parse_frontmatter(md_file)
|
||||
artifacts.append(artifact)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Überspringe Datei ohne gültiges Frontmatter: %s – %s",
|
||||
md_file,
|
||||
e,
|
||||
)
|
||||
self.errors.append(
|
||||
SourceError(
|
||||
file_path=str(md_file),
|
||||
error=str(e),
|
||||
retry=False,
|
||||
)
|
||||
)
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
"Fehler beim Lesen der Datei: %s – %s", md_file, e
|
||||
)
|
||||
self.errors.append(
|
||||
SourceError(
|
||||
file_path=str(md_file),
|
||||
error=str(e),
|
||||
retry=True,
|
||||
)
|
||||
)
|
||||
|
||||
return artifacts
|
||||
@@ -0,0 +1,352 @@
|
||||
"""KnowledgeStore – Zentraler Wissensspeicher mit ETL-Pipeline.
|
||||
|
||||
Basiert auf der DB-Wissensdatenbank-ETL-Architektur und bietet:
|
||||
- Ingestion von Wissensartefakten aus verschiedenen Quellen
|
||||
- Progressive-Disclosure-Index (kompakte YAML-Metadaten, Schicht 1)
|
||||
- Volltextsuche mit Scope-basierter Zugriffskontrolle und Relevanz-Sortierung
|
||||
- Graph-Verknüpfungen zwischen Artefakten
|
||||
- Timeout-Handling für Suchanfragen (5 Sekunden)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.knowledge.artifact import ArtifactLink, parse_frontmatter, write_artifact
|
||||
from monorepo.knowledge.etl import ETLPipeline, IngestResult
|
||||
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
||||
from monorepo.knowledge.sources.markdown import MarkdownSource
|
||||
from monorepo.models import ScopeConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default-Timeout für Suchanfragen in Sekunden
|
||||
SEARCH_TIMEOUT_SECONDS: float = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Ergebnis einer Volltextsuche im Wissensspeicher.
|
||||
|
||||
Enthält den gefundenen Index-Eintrag, einen Relevanz-Score und
|
||||
eine Information, ob die Suche wegen Timeout abgebrochen wurde.
|
||||
|
||||
Attributes:
|
||||
entry: Der gefundene IndexEntry mit kompakten Metadaten.
|
||||
relevance_score: Relevanz-Score zwischen 0.0 und 1.0.
|
||||
Höhere Werte bedeuten bessere Treffer.
|
||||
Scoring-Hierarchie: title > tags > summary > id.
|
||||
partial_results: True wenn die Suche wegen Timeout abgebrochen
|
||||
wurde und möglicherweise nicht alle Ergebnisse enthalten sind.
|
||||
"""
|
||||
|
||||
entry: IndexEntry
|
||||
relevance_score: float = 0.0
|
||||
partial_results: bool = False
|
||||
|
||||
|
||||
def _compute_relevance(query_lower: str, entry: IndexEntry) -> float:
|
||||
"""Berechnet den Relevanz-Score für einen Index-Eintrag.
|
||||
|
||||
Scoring-Hierarchie (von hoch nach niedrig):
|
||||
1. Title-Match (0.8 - 1.0): Query kommt im Titel vor
|
||||
2. Tag-Match (0.6 - 0.79): Query kommt in einem Tag vor
|
||||
3. Summary-Match (0.4 - 0.59): Query kommt im Summary vor
|
||||
4. ID-Match (0.2 - 0.39): Query kommt in der ID vor
|
||||
|
||||
Innerhalb jeder Kategorie wird ein Bonus für exakte Übereinstimmung
|
||||
oder einen höheren Anteil des Matchs vergeben.
|
||||
|
||||
Args:
|
||||
query_lower: Suchbegriff in Kleinbuchstaben.
|
||||
entry: Der zu bewertende IndexEntry.
|
||||
|
||||
Returns:
|
||||
Relevanz-Score zwischen 0.0 und 1.0.
|
||||
"""
|
||||
title_lower = entry.title.lower()
|
||||
tags_lower = [t.lower() for t in entry.tags]
|
||||
summary_lower = entry.summary.lower()
|
||||
id_lower = entry.id.lower()
|
||||
|
||||
score = 0.0
|
||||
|
||||
# 1. Title match (highest priority: 0.8 - 1.0)
|
||||
if query_lower in title_lower:
|
||||
# Bonus for exact match or high coverage
|
||||
if title_lower == query_lower:
|
||||
score = max(score, 1.0)
|
||||
else:
|
||||
coverage = len(query_lower) / max(len(title_lower), 1)
|
||||
score = max(score, 0.8 + coverage * 0.19)
|
||||
|
||||
# 2. Tag match (0.6 - 0.79)
|
||||
for tag in tags_lower:
|
||||
if query_lower in tag:
|
||||
if tag == query_lower:
|
||||
score = max(score, 0.79)
|
||||
else:
|
||||
score = max(score, 0.6 + len(query_lower) / max(len(tag), 1) * 0.18)
|
||||
|
||||
# 3. Summary match (0.4 - 0.59)
|
||||
if query_lower in summary_lower:
|
||||
coverage = len(query_lower) / max(len(summary_lower), 1)
|
||||
score = max(score, 0.4 + coverage * 0.19)
|
||||
|
||||
# 4. ID match (0.2 - 0.39)
|
||||
if query_lower in id_lower:
|
||||
coverage = len(query_lower) / max(len(id_lower), 1)
|
||||
score = max(score, 0.2 + coverage * 0.19)
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
class KnowledgeStore:
|
||||
"""Zentraler Wissensspeicher mit ETL-Pipeline.
|
||||
|
||||
Der KnowledgeStore verwaltet Wissensartefakte aus allen Arbeitskontexten,
|
||||
indexiert sie im YAML-Index und stellt Suchmechanismen bereit.
|
||||
|
||||
Progressive Disclosure:
|
||||
- Schicht 1 (Index): Kompakte Metadaten (Titel, Tags, Pfade, Summary)
|
||||
- Schicht 2 (Dokument): Vollständige Markdown-Datei mit YAML-Frontmatter
|
||||
|
||||
Agenten lesen zuerst den Index und laden nur bei Bedarf einzelne Dokumente.
|
||||
"""
|
||||
|
||||
INDEX_FILENAME = "_index.yaml"
|
||||
|
||||
def __init__(self, base_path: Path, scope_config: ScopeConfig) -> None:
|
||||
"""Initialisiert den KnowledgeStore.
|
||||
|
||||
Args:
|
||||
base_path: Basispfad des Wissensspeichers
|
||||
(z.B. shared/knowledge-store/).
|
||||
scope_config: Konfiguration des Scope-Mappings
|
||||
(Arbeitskontext → Scope).
|
||||
"""
|
||||
self.base_path = base_path
|
||||
self.scope_config = scope_config
|
||||
self.index = YAMLIndex(base_path / self.INDEX_FILENAME)
|
||||
|
||||
# Index beim Start laden, sofern vorhanden
|
||||
self.index.load()
|
||||
|
||||
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
|
||||
"""Verarbeitet eine Quelle und legt Artefakte im Wissensspeicher ab.
|
||||
|
||||
Delegiert an die ETLPipeline, die:
|
||||
- Artefakte aus der Quelle extrahiert
|
||||
- Metadaten anreichert (Scope, Datum, Content-Hash)
|
||||
- Inkrementell verarbeitet (nur geänderte Artefakte aktualisiert)
|
||||
- Den YAML-Index im selben Durchlauf aktualisiert
|
||||
- Bei Fehlern erfolgreiche Artefakte behält und Fehler protokolliert
|
||||
|
||||
Args:
|
||||
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
|
||||
context: Der Arbeitskontext, aus dem die Quelle stammt
|
||||
(z.B. "bahn", "privat").
|
||||
|
||||
Returns:
|
||||
IngestResult mit Statistiken (processed, updated, skipped, errors).
|
||||
"""
|
||||
pipeline = ETLPipeline(index=self.index, store_path=self.base_path)
|
||||
return pipeline.ingest(source, context)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
allowed_scopes: list[str],
|
||||
timeout: float | None = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Volltextsuche über den Index mit Relevanz-Sortierung und Timeout.
|
||||
|
||||
Durchsucht den YAML-Index nach dem Suchbegriff und liefert
|
||||
nur Ergebnisse aus autorisierten Scopes, sortiert nach Relevanz.
|
||||
Nicht-autorisierte Artefakte werden weder zurückgegeben noch
|
||||
wird deren Existenz offengelegt.
|
||||
|
||||
Relevanz-Scoring-Hierarchie (höchste zuerst):
|
||||
1. Title-Match
|
||||
2. Tag-Match
|
||||
3. Summary-Match
|
||||
4. ID-Match
|
||||
|
||||
Timeout-Handling:
|
||||
Die Suche bricht nach dem konfigurierten Timeout (Standard:
|
||||
5 Sekunden) ab und liefert die bis dahin gefundenen
|
||||
Teilergebnisse mit partial_results=True.
|
||||
|
||||
Args:
|
||||
query: Suchbegriff für die Volltextsuche.
|
||||
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
|
||||
geliefert werden dürfen.
|
||||
timeout: Timeout in Sekunden (None = SEARCH_TIMEOUT_SECONDS).
|
||||
|
||||
Returns:
|
||||
Liste von SearchResult-Objekten, sortiert nach Relevanz
|
||||
(höchster Score zuerst). Jedes Ergebnis enthält nur Pfade
|
||||
und Metadaten – kein vollständiger Inhalt (Progressive Disclosure).
|
||||
|
||||
Note:
|
||||
Requirements: 3.3, 3.7, 3.9
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
effective_timeout = timeout if timeout is not None else SEARCH_TIMEOUT_SECONDS
|
||||
query_lower = query.lower()
|
||||
results: list[SearchResult] = []
|
||||
timed_out = False
|
||||
start_time = time.monotonic()
|
||||
|
||||
for entry in self.index.entries.values():
|
||||
# Timeout-Check
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= effective_timeout:
|
||||
timed_out = True
|
||||
break
|
||||
|
||||
# Scope-Filter: Nur autorisierte Scopes (niemals Existenz
|
||||
# nicht-autorisierter Artefakte offenlegen)
|
||||
if entry.scope not in allowed_scopes:
|
||||
continue
|
||||
|
||||
# Textsuche über relevante Felder
|
||||
searchable_text = " ".join([
|
||||
entry.title,
|
||||
entry.id,
|
||||
entry.summary,
|
||||
" ".join(entry.tags),
|
||||
]).lower()
|
||||
|
||||
if query_lower in searchable_text:
|
||||
relevance = _compute_relevance(query_lower, entry)
|
||||
results.append(SearchResult(
|
||||
entry=entry,
|
||||
relevance_score=relevance,
|
||||
partial_results=False,
|
||||
))
|
||||
|
||||
# Bei Timeout: Alle bisherigen Ergebnisse als partial markieren
|
||||
if timed_out:
|
||||
for result in results:
|
||||
result.partial_results = True
|
||||
|
||||
# Nach Relevanz sortieren (höchster Score zuerst)
|
||||
results.sort(key=lambda r: r.relevance_score, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
def get_index(self, scope: str | None = None) -> list[IndexEntry]:
|
||||
"""Liefert den kompakten YAML-Index (Progressive Disclosure Schicht 1).
|
||||
|
||||
Gibt nur Metadaten-Einträge zurück – niemals den vollständigen
|
||||
Dokumentinhalt. Agenten können über die Pfade in den Einträgen
|
||||
bei Bedarf einzelne Dokumente nachladen.
|
||||
|
||||
Args:
|
||||
scope: Optionaler Scope-Filter. Wenn None, werden alle
|
||||
Einträge zurückgegeben.
|
||||
|
||||
Returns:
|
||||
Liste von IndexEntry-Objekten mit kompakten Metadaten.
|
||||
"""
|
||||
if scope is not None:
|
||||
return self.index.get_by_scope(scope)
|
||||
return list(self.index.entries.values())
|
||||
|
||||
def link_artifacts(self, source_id: str, target_id: str, relation: str) -> None:
|
||||
"""Verknüpft zwei Artefakte als gerichtete Graph-Kante.
|
||||
|
||||
Erstellt eine bidirektionale Verknüpfung zwischen zwei Artefakten:
|
||||
- Im Quell-Artefakt wird die Beziehung zum Ziel eingetragen
|
||||
- Im Ziel-Artefakt wird die inverse Beziehung zur Quelle eingetragen
|
||||
- Der YAML-Index wird mit den neuen Link-Informationen aktualisiert
|
||||
- Die YAML-Frontmatter beider Artefakt-Dateien werden aktualisiert
|
||||
|
||||
Args:
|
||||
source_id: ID des Quell-Artefakts.
|
||||
target_id: ID des Ziel-Artefakts.
|
||||
relation: Art der Beziehung (z.B. "implements", "references").
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn source_id oder target_id nicht im Index existiert.
|
||||
"""
|
||||
source_entry = self.index.get_entry(source_id)
|
||||
target_entry = self.index.get_entry(target_id)
|
||||
|
||||
if source_entry is None:
|
||||
raise ValueError(f"Quell-Artefakt nicht im Index gefunden: {source_id}")
|
||||
if target_entry is None:
|
||||
raise ValueError(f"Ziel-Artefakt nicht im Index gefunden: {target_id}")
|
||||
|
||||
# Bidirektionale Verlinkung im Index
|
||||
if target_id not in source_entry.links:
|
||||
source_entry.links.append(target_id)
|
||||
self.index.update_entry(source_entry)
|
||||
|
||||
if source_id not in target_entry.links:
|
||||
target_entry.links.append(source_id)
|
||||
self.index.update_entry(target_entry)
|
||||
|
||||
# YAML-Frontmatter beider Artefakt-Dateien aktualisieren
|
||||
self._update_artifact_frontmatter_link(
|
||||
source_entry, target_entry.path, relation
|
||||
)
|
||||
self._update_artifact_frontmatter_link(
|
||||
target_entry, source_entry.path, relation
|
||||
)
|
||||
|
||||
def _update_artifact_frontmatter_link(
|
||||
self, entry: IndexEntry, link_target_path: str, relation: str
|
||||
) -> None:
|
||||
"""Aktualisiert das YAML-Frontmatter einer Artefakt-Datei mit einem neuen Link.
|
||||
|
||||
Liest die Artefakt-Datei, prüft ob der Link bereits vorhanden ist,
|
||||
fügt ihn bei Bedarf hinzu und schreibt die Datei zurück.
|
||||
|
||||
Wenn die Datei nicht existiert, wird nur der Index aktualisiert
|
||||
(kein Fehler – die Datei wird ggf. später erstellt).
|
||||
|
||||
Args:
|
||||
entry: Der IndexEntry des Artefakts, dessen Datei aktualisiert werden soll.
|
||||
link_target_path: Der Pfad des Ziel-Artefakts (für den links-Eintrag).
|
||||
relation: Art der Beziehung.
|
||||
"""
|
||||
if not entry.path:
|
||||
return
|
||||
|
||||
artifact_path = self.base_path / entry.path
|
||||
if not artifact_path.exists():
|
||||
logger.debug(
|
||||
"Artefakt-Datei existiert nicht, überspringe Frontmatter-Update: %s",
|
||||
artifact_path,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
artifact = parse_frontmatter(artifact_path)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
logger.warning(
|
||||
"Konnte Artefakt-Datei nicht parsen, überspringe Frontmatter-Update: %s (%s)",
|
||||
artifact_path,
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
# Prüfe ob Link bereits vorhanden (Idempotenz)
|
||||
for existing_link in artifact.metadata.links:
|
||||
if existing_link.target == link_target_path and existing_link.relation == relation:
|
||||
return # Link existiert bereits
|
||||
|
||||
# Neuen Link hinzufügen
|
||||
artifact.metadata.links.append(
|
||||
ArtifactLink(target=link_target_path, relation=relation)
|
||||
)
|
||||
|
||||
# Datei zurückschreiben (mit aktualisiertem Frontmatter)
|
||||
write_artifact(artifact, self.base_path)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
"""Gemeinsame Datenmodelle für das Monorepo-CLI.
|
||||
|
||||
Definiert Enums und Dataclasses für alle Komponenten:
|
||||
- Ordnerstruktur (ProjectInfo)
|
||||
- Repo-Verwaltung (RepoEntry, MigrationPlan)
|
||||
- Sicherheit (SecurityEvent)
|
||||
- Verschlüsselung (MachineContext, EncryptionKey, PasswordManagerConfig)
|
||||
- Wissensspeicher (ScopeConfig)
|
||||
- Föderation (TeamRepoEntry, SharedMirrorConfig, SyncResult, ConflictInfo, IsolationReport, IsolationLeak)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Context(str, Enum):
|
||||
"""Arbeitskontexte im Monorepo."""
|
||||
|
||||
PRIVAT = "privat"
|
||||
DHIVE = "dhive"
|
||||
BAHN = "bahn"
|
||||
SHARED = "shared"
|
||||
|
||||
|
||||
class RepoMode(str, Enum):
|
||||
"""Einbindungsmodus für externe Repositories."""
|
||||
|
||||
READ_ONLY = "read-only"
|
||||
UPSTREAM = "upstream"
|
||||
|
||||
|
||||
class ArtifactType(str, Enum):
|
||||
"""Typen von Wissensartefakten im Knowledge Store."""
|
||||
|
||||
DECISION = "decision"
|
||||
NOTE = "note"
|
||||
MEETING = "meeting"
|
||||
REFERENCE = "reference"
|
||||
PATTERN = "pattern"
|
||||
|
||||
|
||||
class KeySource(str, Enum):
|
||||
"""Quelle für Verschlüsselungsschlüssel."""
|
||||
|
||||
KEYRING = "keyring"
|
||||
FILE = "file"
|
||||
PASSWORD_MANAGER = "password-manager"
|
||||
|
||||
|
||||
class SyncDirection(str, Enum):
|
||||
"""Synchronisationsrichtung für Team-Repos."""
|
||||
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
HUB_TO_SPOKE = "hub-to-spoke"
|
||||
SPOKE_TO_HUB = "spoke-to-hub"
|
||||
|
||||
|
||||
class SyncFrequency(str, Enum):
|
||||
"""Frequenz der Synchronisation mit Team-Repos."""
|
||||
|
||||
ON_PUSH = "on-push"
|
||||
HOURLY = "hourly"
|
||||
DAILY = "daily"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class ConflictStrategy(str, Enum):
|
||||
"""Strategie zur Konfliktauflösung bei Synchronisation."""
|
||||
|
||||
TEAM_WINS = "team-wins"
|
||||
HUB_WINS = "hub-wins"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Ordnerstruktur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectInfo:
|
||||
"""Informationen über ein Projekt im Monorepo."""
|
||||
|
||||
name: str
|
||||
context: str
|
||||
path: Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Repo-Verwaltung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoEntry:
|
||||
"""Konfigurationseintrag für ein eingebundenes externes Repository."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
mode: Literal["read-only", "upstream"]
|
||||
target: str
|
||||
pinned: str
|
||||
mechanism: Literal["subtree", "submodule"] = "subtree"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationPlan:
|
||||
"""Plan für die Migration eines Repositories ins Monorepo."""
|
||||
|
||||
source_repo: str
|
||||
target_context: str
|
||||
target_name: str
|
||||
mode: Literal["direct", "subtree", "upstream"]
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
order: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Sicherheit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityEvent:
|
||||
"""Protokolleintrag für einen Sicherheitsvorfall (Zugriffsverletzung)."""
|
||||
|
||||
timestamp: datetime
|
||||
requesting_context: str
|
||||
target_context: str
|
||||
resource: str
|
||||
action: Literal["read", "write", "execute"]
|
||||
outcome: Literal["denied", "allowed"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Wissensspeicher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopeConfig:
|
||||
"""Mapping von Arbeitskontext zu Scope im Wissensspeicher.
|
||||
|
||||
Beispiel: {"privat": {"scope": "privat", "paths": ["privat/"]}}
|
||||
"""
|
||||
|
||||
scopes: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Verschlüsselung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordManagerConfig:
|
||||
"""Konfiguration für die Passwort-Manager-Integration."""
|
||||
|
||||
type: Literal["bitwarden", "1password", "keepass"]
|
||||
vault: str
|
||||
entry_prefix: str = "monorepo-key-"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MachineContext:
|
||||
"""Maschinenkontext: Zuordnung eines Rechners zu autorisierten Kontexten."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
authorized_contexts: list[str] = field(default_factory=list)
|
||||
key_source: Literal["keyring", "file", "password-manager"] = "keyring"
|
||||
password_manager: PasswordManagerConfig | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionKey:
|
||||
"""Verschlüsselungsschlüssel für einen Arbeitskontext."""
|
||||
|
||||
context: str
|
||||
key_id: str
|
||||
key_type: Literal["gpg", "symmetric"]
|
||||
source: Literal["keyring", "file", "password-manager"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnboardingResult:
|
||||
"""Ergebnis der Maschineneinrichtung (Onboarding).
|
||||
|
||||
Beschreibt welche Schlüssel erfolgreich installiert und welche
|
||||
Kontexte für die Maschine freigeschaltet wurden.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
machine_name: str
|
||||
authorized_contexts: list[str] = field(default_factory=list)
|
||||
installed_keys: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses – Föderation (Team-Repos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedMirrorConfig:
|
||||
"""Konfiguration für gespiegelte shared-Dateien in einem Team-Repo."""
|
||||
|
||||
enabled: bool
|
||||
paths: list[str] = field(default_factory=list)
|
||||
mode: Literal["read-only"] = "read-only"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamRepoEntry:
|
||||
"""Konfigurationseintrag für ein Team-Repository (Spoke)."""
|
||||
|
||||
context: str
|
||||
url: str
|
||||
branch: str
|
||||
sync_direction: Literal["bidirectional", "hub-to-spoke", "spoke-to-hub"]
|
||||
sync_frequency: Literal["on-push", "hourly", "daily", "manual"]
|
||||
shared_mirror: SharedMirrorConfig | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Ergebnis einer Synchronisationsoperation."""
|
||||
|
||||
success: bool
|
||||
context: str
|
||||
direction: Literal["pull", "push", "full"]
|
||||
commits_synced: int
|
||||
conflicts: list[ConflictInfo] = field(default_factory=list)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConflictInfo:
|
||||
"""Details zu einem Merge-Konflikt bei der Synchronisation."""
|
||||
|
||||
file_path: str
|
||||
conflict_type: Literal["content", "rename", "delete-modify"]
|
||||
source: Literal["monorepo", "team-repo"]
|
||||
details: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class IsolationReport:
|
||||
"""Ergebnis der Isolationsprüfung eines Team-Repos."""
|
||||
|
||||
context: str
|
||||
is_isolated: bool
|
||||
leaks: list[IsolationLeak] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IsolationLeak:
|
||||
"""Gefundene Referenz auf einen fremden Kontext in einem Team-Repo."""
|
||||
|
||||
file_path: str
|
||||
line_number: int
|
||||
leaked_context: str
|
||||
leak_type: Literal["path", "env_var", "config_ref", "comment"]
|
||||
@@ -0,0 +1,552 @@
|
||||
"""OrchestratorAdapter – Integration des AI-Orchestrators mit der Monorepo-Struktur.
|
||||
|
||||
Adaptiert den bestehenden AI-Orchestrator für kontextgebundenes Arbeiten:
|
||||
- Ermittlung des Arbeitskontexts aus Task-Metadaten (Labels/Projektzuordnung)
|
||||
- Erstellung kontextgebundener Workspaces
|
||||
- Prompt-Aufbau mit Wissenskontext aus dem YAML-Index
|
||||
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
|
||||
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
|
||||
- Fehlerbehandlung bei fehlendem Kontext oder Out-of-Context-Zugriff
|
||||
|
||||
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from monorepo.audit import AuditLogger
|
||||
from monorepo.models import Context, SecurityEvent
|
||||
from monorepo.security import ContextGuard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from monorepo.encryption import SecretEncryptionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContextResolutionError(Exception):
|
||||
"""Kein Arbeitskontext konnte aus den Task-Metadaten ermittelt werden.
|
||||
|
||||
Requirement 7.6: Task wird nicht gestartet, Fehlermeldung an den Nutzer.
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str, message: str) -> None:
|
||||
self.task_id = task_id
|
||||
self.message = message
|
||||
super().__init__(f"[{task_id}] {message}")
|
||||
|
||||
|
||||
class ContextViolationError(Exception):
|
||||
"""Zugriff außerhalb des zugewiesenen Kontexts erkannt.
|
||||
|
||||
Requirement 7.5: Task wird abgebrochen, Vorfall protokolliert,
|
||||
Nutzer wird benachrichtigt.
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str, context: str, access_path: str) -> None:
|
||||
self.task_id = task_id
|
||||
self.context = context
|
||||
self.access_path = access_path
|
||||
super().__init__(
|
||||
f"[{task_id}] Kontextverletzung: Zugriff auf '{access_path}' "
|
||||
f"aus Kontext '{context}' nicht erlaubt"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task-Datenmodell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Repräsentiert einen Task aus dem Orchestrator.
|
||||
|
||||
Attributes:
|
||||
id: Eindeutiger Task-Identifier.
|
||||
title: Titel/Zusammenfassung des Tasks.
|
||||
labels: Labels zur Kontextzuordnung (z.B. ["dhive"], ["bahn"]).
|
||||
project: Projektzuordnung (z.B. "dhive/my-project").
|
||||
description: Optionale Aufgabenbeschreibung.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
labels: list[str] = field(default_factory=list)
|
||||
project: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExecutionEnvironment – Vollständige Ausführungsumgebung für einen Task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionEnvironment:
|
||||
"""Vollständige Ausführungsumgebung für einen kontextgebundenen Task.
|
||||
|
||||
Kombiniert Workspace-Pfad, entschlüsselte Umgebungsvariablen,
|
||||
relevante Wissensartefakte und den zusammengesetzten Prompt.
|
||||
|
||||
Attributes:
|
||||
workspace_path: Pfad zum kontextgebundenen Arbeitsverzeichnis.
|
||||
context: Der zugewiesene Arbeitskontext (z.B. "dhive", "bahn").
|
||||
env_vars: Entschlüsselte Umgebungsvariablen des Kontexts.
|
||||
knowledge_artifacts: Pfade zu relevanten Wissensartefakten.
|
||||
prompt: Vollständiger Agenten-Prompt mit Wissenskontext.
|
||||
"""
|
||||
|
||||
workspace_path: Path
|
||||
context: str
|
||||
env_vars: dict[str, str] = field(default_factory=dict)
|
||||
knowledge_artifacts: list[str] = field(default_factory=list)
|
||||
prompt: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kontextmapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Bekannte Kontexte für die Auflösung aus Labels/Projektzuordnung
|
||||
KNOWN_CONTEXTS: set[str] = {ctx.value for ctx in Context if ctx != Context.SHARED}
|
||||
# Shared ist ebenfalls ein gültiger Kontext für Tasks
|
||||
ALL_CONTEXTS: set[str] = {ctx.value for ctx in Context}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OrchestratorAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrchestratorAdapter:
|
||||
"""Adaptiert den AI-Orchestrator für die Monorepo-Struktur.
|
||||
|
||||
Stellt kontextgebundene Arbeitsumgebungen bereit:
|
||||
- Workspace-Erstellung unter dem korrekten Kontextordner
|
||||
- Prompt-Aufbau mit relevantem Wissenskontext aus dem YAML-Index
|
||||
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
|
||||
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
|
||||
- Sicherheitsprüfung bei Dateizugriffen über ContextGuard
|
||||
|
||||
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_path: Path,
|
||||
context_guard: ContextGuard,
|
||||
audit_logger: AuditLogger | None = None,
|
||||
knowledge_index_path: Path | None = None,
|
||||
encryption_manager: SecretEncryptionManager | None = None,
|
||||
workflow_config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialisiert den OrchestratorAdapter.
|
||||
|
||||
Args:
|
||||
root_path: Wurzelverzeichnis des Monorepos.
|
||||
context_guard: ContextGuard-Instanz für Zugriffskontrolle.
|
||||
audit_logger: Optionaler AuditLogger für Verletzungs-Protokollierung.
|
||||
Falls None, wird ein Standard-Logger mit Default-Pfad erzeugt.
|
||||
knowledge_index_path: Pfad zur YAML-Index-Datei des Wissensspeichers.
|
||||
Falls None, wird `root_path / shared/knowledge-store/_index.yaml` verwendet.
|
||||
encryption_manager: Optionaler SecretEncryptionManager für die
|
||||
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
|
||||
direkt gelesen (unverschlüsselt oder bereits entschlüsselt).
|
||||
workflow_config_path: Optionaler Pfad zur WORKFLOW.md-Konfiguration.
|
||||
Falls None, wird `root_path / WORKFLOW.md` verwendet.
|
||||
"""
|
||||
self.root_path = root_path.resolve()
|
||||
self.context_guard = context_guard
|
||||
self.audit_logger = audit_logger or AuditLogger(
|
||||
self.root_path / ".audit" / "access.log"
|
||||
)
|
||||
self.encryption_manager = encryption_manager
|
||||
|
||||
if knowledge_index_path is None:
|
||||
knowledge_index_path = (
|
||||
self.root_path / "shared" / "knowledge-store" / "_index.yaml"
|
||||
)
|
||||
self._knowledge_index_path = knowledge_index_path
|
||||
|
||||
if workflow_config_path is None:
|
||||
workflow_config_path = self.root_path / "WORKFLOW.md"
|
||||
self._workflow_config_path = workflow_config_path
|
||||
|
||||
def resolve_context(self, task: Task) -> str:
|
||||
"""Ermittelt den Arbeitskontext aus Task-Metadaten.
|
||||
|
||||
Prüft in folgender Reihenfolge:
|
||||
1. Labels: Sucht nach bekannten Kontextnamen in den Task-Labels.
|
||||
2. Projektzuordnung: Extrahiert den Kontext aus dem Projektpfad
|
||||
(erster Pfadteil, z.B. "dhive/my-project" → "dhive").
|
||||
|
||||
Requirement 7.1: Kontext aus Task-Metadaten ermitteln.
|
||||
Requirement 7.6: Kein Kontext → Task nicht starten, Fehlermeldung.
|
||||
|
||||
Args:
|
||||
task: Der zu verarbeitende Task.
|
||||
|
||||
Returns:
|
||||
Name des ermittelten Arbeitskontexts (z.B. "dhive", "bahn", "privat").
|
||||
|
||||
Raises:
|
||||
ContextResolutionError: Wenn kein Kontext ermittelt werden kann.
|
||||
"""
|
||||
# 1. Labels prüfen
|
||||
for label in task.labels:
|
||||
label_lower = label.lower().strip()
|
||||
if label_lower in ALL_CONTEXTS:
|
||||
logger.info(
|
||||
"Task '%s': Kontext '%s' aus Label ermittelt", task.id, label_lower
|
||||
)
|
||||
return label_lower
|
||||
|
||||
# 2. Projektzuordnung prüfen
|
||||
if task.project:
|
||||
# Projekt kann als "kontext/projektname" oder direkt als Kontextname vorliegen
|
||||
project_parts = task.project.strip().split("/")
|
||||
first_part = project_parts[0].lower().strip()
|
||||
if first_part in ALL_CONTEXTS:
|
||||
logger.info(
|
||||
"Task '%s': Kontext '%s' aus Projektzuordnung ermittelt",
|
||||
task.id,
|
||||
first_part,
|
||||
)
|
||||
return first_part
|
||||
|
||||
# Kein Kontext ermittelbar → Req 7.6
|
||||
raise ContextResolutionError(
|
||||
task_id=task.id,
|
||||
message=(
|
||||
f"Kein Arbeitskontext aus Task-Metadaten ermittelbar. "
|
||||
f"Labels: {task.labels}, Projekt: '{task.project}'. "
|
||||
f"Erwartet wird ein Label oder Projektpräfix aus: {sorted(ALL_CONTEXTS)}"
|
||||
),
|
||||
)
|
||||
|
||||
def create_workspace(self, task: Task, context: str) -> Path:
|
||||
"""Erstellt ein kontextgebundenes Arbeitsverzeichnis.
|
||||
|
||||
Erstellt das Workspace-Verzeichnis unter dem ermittelten Kontextordner:
|
||||
`<monorepo_root>/<context>/.workspaces/<task_id>/`
|
||||
|
||||
Requirement 7.2: Workspace-Root unter dem Kontextordner.
|
||||
|
||||
Args:
|
||||
task: Der Task, für den das Workspace erstellt wird.
|
||||
context: Der ermittelte Arbeitskontext.
|
||||
|
||||
Returns:
|
||||
Pfad zum erstellten Workspace-Verzeichnis.
|
||||
|
||||
Raises:
|
||||
ContextResolutionError: Wenn der Kontext ungültig ist.
|
||||
"""
|
||||
if context not in ALL_CONTEXTS:
|
||||
raise ContextResolutionError(
|
||||
task_id=task.id,
|
||||
message=f"Ungültiger Kontext '{context}'. Gültig: {sorted(ALL_CONTEXTS)}",
|
||||
)
|
||||
|
||||
# Workspace-Verzeichnis: <root>/<kontext>/.workspaces/<task_id>/
|
||||
# Sanitize task ID für Verzeichnisnamen
|
||||
safe_task_id = self._sanitize_dirname(task.id)
|
||||
workspace_path = self.root_path / context / ".workspaces" / safe_task_id
|
||||
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(
|
||||
"Task '%s': Workspace erstellt unter '%s'", task.id, workspace_path
|
||||
)
|
||||
|
||||
return workspace_path
|
||||
|
||||
def build_prompt(self, task: Task, context: str) -> str:
|
||||
"""Erstellt den Agenten-Prompt mit Wissenskontext.
|
||||
|
||||
Der Prompt enthält:
|
||||
- Workspace-Pfad und Kontextinformationen
|
||||
- Verfügbare Wissensartefakte aus dem YAML-Index (für den Kontext)
|
||||
- Sicherheitsbeschränkungen (nur Zugriff innerhalb des Kontexts)
|
||||
- Aufgabenbeschreibung aus dem Task
|
||||
|
||||
Requirement 7.1: Arbeitsverzeichnis auf Kontextordner beschränken.
|
||||
Requirement 7.3: Nur .env des eigenen Kontexts laden.
|
||||
|
||||
Args:
|
||||
task: Der zu bearbeitende Task.
|
||||
context: Der ermittelte Arbeitskontext.
|
||||
|
||||
Returns:
|
||||
Vollständiger Agenten-Prompt als String.
|
||||
"""
|
||||
workspace_path = self.root_path / context / ".workspaces" / self._sanitize_dirname(task.id)
|
||||
context_root = self.root_path / context
|
||||
|
||||
# Basis-Prompt aufbauen
|
||||
prompt_parts: list[str] = [
|
||||
f"# Task: {task.title}",
|
||||
"",
|
||||
"## Arbeitskontext",
|
||||
f"- Kontext: {context}",
|
||||
f"- Workspace: {workspace_path}",
|
||||
f"- Kontextordner: {context_root}",
|
||||
"",
|
||||
"## Sicherheitsbeschränkungen",
|
||||
f"- Du darfst NUR auf Dateien innerhalb von '{context_root}' und "
|
||||
f"'{self.root_path / 'shared'}' zugreifen.",
|
||||
f"- Lade ausschließlich die .env-Datei des Kontexts '{context}'.",
|
||||
"- Zugriff auf andere Kontextordner ist VERBOTEN und führt zum Task-Abbruch.",
|
||||
"",
|
||||
]
|
||||
|
||||
# Wissenskontext einfügen
|
||||
knowledge_section = self.inject_knowledge(context)
|
||||
if knowledge_section:
|
||||
prompt_parts.append("## Verfügbare Wissensartefakte")
|
||||
prompt_parts.append(knowledge_section)
|
||||
prompt_parts.append("")
|
||||
|
||||
# Aufgabenbeschreibung
|
||||
if task.description:
|
||||
prompt_parts.append("## Aufgabe")
|
||||
prompt_parts.append(task.description)
|
||||
prompt_parts.append("")
|
||||
|
||||
return "\n".join(prompt_parts)
|
||||
|
||||
def inject_knowledge(self, context: str) -> str:
|
||||
"""Fügt relevante Artefakt-Pfade aus dem YAML-Index hinzu.
|
||||
|
||||
Liest den YAML-Index und liefert Pfade zu Artefakten, die
|
||||
im aktuellen Kontext oder im shared-Scope verfügbar sind.
|
||||
|
||||
Args:
|
||||
context: Der aktive Arbeitskontext.
|
||||
|
||||
Returns:
|
||||
Formatierter String mit verfügbaren Artefakt-Pfaden oder
|
||||
leerer String wenn kein Index vorhanden ist.
|
||||
"""
|
||||
if not self._knowledge_index_path.exists():
|
||||
logger.debug("Kein Wissens-Index gefunden unter: %s", self._knowledge_index_path)
|
||||
return ""
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
with open(self._knowledge_index_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict) or "artifacts" not in data:
|
||||
return ""
|
||||
|
||||
# Nur Artefakte aus dem eigenen Kontext und shared anzeigen
|
||||
allowed_scopes = [context, Context.SHARED.value]
|
||||
relevant_artifacts: list[str] = []
|
||||
|
||||
for artifact in data.get("artifacts", []):
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
scope = artifact.get("scope", "")
|
||||
if scope in allowed_scopes:
|
||||
title = artifact.get("title", "Unbenannt")
|
||||
path = artifact.get("path", "")
|
||||
tags = artifact.get("tags", [])
|
||||
tags_str = f" [{', '.join(tags)}]" if tags else ""
|
||||
relevant_artifacts.append(f"- {title}{tags_str}: {path}")
|
||||
|
||||
if not relevant_artifacts:
|
||||
return ""
|
||||
|
||||
return "\n".join(relevant_artifacts)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Fehler beim Lesen des Wissens-Index: %s", e)
|
||||
return ""
|
||||
|
||||
def validate_access(self, task: Task, context: str, target_path: Path) -> None:
|
||||
"""Prüft ob ein Dateizugriff innerhalb des zugewiesenen Kontexts liegt.
|
||||
|
||||
Verwendet den ContextGuard zur Zugriffsprüfung. Bei Verletzung:
|
||||
- Task wird abgebrochen (Exception)
|
||||
- Vorfall wird im AuditLog protokolliert
|
||||
- Nutzer wird über die Exception informiert
|
||||
|
||||
Requirement 7.5: Out-of-Context → abbrechen, protokollieren, benachrichtigen.
|
||||
|
||||
Args:
|
||||
task: Der laufende Task.
|
||||
context: Der zugewiesene Arbeitskontext.
|
||||
target_path: Der Pfad, auf den zugegriffen werden soll.
|
||||
|
||||
Raises:
|
||||
ContextViolationError: Wenn der Zugriff außerhalb des Kontexts liegt.
|
||||
"""
|
||||
if not self.context_guard.check_access(context, target_path):
|
||||
# Zugriffsverletzung protokollieren
|
||||
target_path_str = str(target_path)
|
||||
event = SecurityEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
requesting_context=context,
|
||||
target_context=self._detect_target_context(target_path),
|
||||
resource=target_path_str,
|
||||
action="read",
|
||||
outcome="denied",
|
||||
)
|
||||
self.audit_logger.log_violation(event)
|
||||
|
||||
logger.error(
|
||||
"Task '%s': Kontextverletzung! Zugriff auf '%s' aus Kontext '%s' verweigert.",
|
||||
task.id,
|
||||
target_path_str,
|
||||
context,
|
||||
)
|
||||
|
||||
raise ContextViolationError(
|
||||
task_id=task.id,
|
||||
context=context,
|
||||
access_path=target_path_str,
|
||||
)
|
||||
|
||||
def load_context_env(self, context: str) -> dict[str, str]:
|
||||
"""Lädt ausschließlich die .env-Datei des zugewiesenen Kontexts.
|
||||
|
||||
Bei konfiguriertem SecretEncryptionManager wird die .env-Datei
|
||||
entschlüsselt gelesen. Ohne Manager wird sie direkt gelesen
|
||||
(unverschlüsselt oder bereits durch git-crypt entschlüsselt).
|
||||
|
||||
Requirement 7.3: Nur .env des eigenen Kontexts laden, keine
|
||||
Umgebungsvariablen anderer Kontexte. Entschlüsselung via
|
||||
SecretEncryptionManager.
|
||||
|
||||
Args:
|
||||
context: Der zugewiesene Arbeitskontext.
|
||||
|
||||
Returns:
|
||||
Dict mit den Umgebungsvariablen des Kontexts.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Kontext ungültig ist.
|
||||
FileNotFoundError: Wenn die .env-Datei nicht existiert.
|
||||
PermissionError: Wenn die Entschlüsselung fehlschlägt
|
||||
(Maschinenkontext nicht autorisiert).
|
||||
"""
|
||||
if self.encryption_manager is not None:
|
||||
return self._load_env_with_decryption(context)
|
||||
return self.context_guard.load_env(context)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private Hilfsmethoden
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_dirname(name: str) -> str:
|
||||
"""Macht einen String sicher für die Verwendung als Verzeichnisname.
|
||||
|
||||
Ersetzt ungültige Zeichen durch Unterstriche.
|
||||
|
||||
Args:
|
||||
name: Der zu bereinigende String.
|
||||
|
||||
Returns:
|
||||
Sicherer Verzeichnisname.
|
||||
"""
|
||||
# Einfache Sanitierung: Nur alphanumerische Zeichen, Bindestrich, Unterstrich
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
||||
return safe or "unnamed"
|
||||
|
||||
def _detect_target_context(self, target_path: Path) -> str:
|
||||
"""Ermittelt den Kontext eines Zielpfads für die Protokollierung.
|
||||
|
||||
Args:
|
||||
target_path: Der Pfad, dessen Kontext ermittelt werden soll.
|
||||
|
||||
Returns:
|
||||
Name des Zielkontexts oder "unknown".
|
||||
"""
|
||||
try:
|
||||
resolved = target_path.resolve() if target_path.is_absolute() else target_path
|
||||
rel_path = resolved.relative_to(self.root_path)
|
||||
parts = rel_path.parts
|
||||
if parts and parts[0] in ALL_CONTEXTS:
|
||||
return parts[0]
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
def _load_env_with_decryption(self, context: str) -> dict[str, str]:
|
||||
"""Lädt die .env-Datei des Kontexts mit Entschlüsselung via SecretEncryptionManager.
|
||||
|
||||
Ermittelt den Pfad zur .env-Datei aus der ContextGuard-Konfiguration,
|
||||
prüft die Autorisierung des Maschinenkontexts und entschlüsselt die
|
||||
Datei bei Bedarf.
|
||||
|
||||
Requirement 7.3: Nur .env des eigenen Kontexts laden, entschlüsselt
|
||||
via SecretEncryptionManager.
|
||||
Requirement 9.3: Kontext-eigener Schlüssel für Entschlüsselung.
|
||||
Requirement 9.4: Maschinenkontext-basierte Autorisierung.
|
||||
|
||||
Args:
|
||||
context: Der Arbeitskontext, dessen .env geladen werden soll.
|
||||
|
||||
Returns:
|
||||
Dict mit den entschlüsselten Umgebungsvariablen.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Kontext ungültig ist.
|
||||
FileNotFoundError: Wenn die .env-Datei nicht existiert.
|
||||
PermissionError: Wenn die Entschlüsselung fehlschlägt
|
||||
(Maschinenkontext nicht autorisiert).
|
||||
"""
|
||||
assert self.encryption_manager is not None # Caller garantiert
|
||||
|
||||
# Autorisierungsprüfung
|
||||
if not self.encryption_manager.is_authorized(context):
|
||||
raise PermissionError(
|
||||
f"Maschinenkontext '{self.encryption_manager.machine_context.name}' "
|
||||
f"ist nicht für Kontext '{context}' autorisiert. "
|
||||
f"Env-Loading verweigert."
|
||||
)
|
||||
|
||||
# .env-Pfad aus der ContextGuard-Konfiguration ermitteln
|
||||
ctx_config = self.context_guard.get_context_config(context)
|
||||
env_file_rel = ctx_config.get("env_file")
|
||||
if not env_file_rel:
|
||||
raise ValueError(
|
||||
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
|
||||
)
|
||||
|
||||
env_path = self.root_path / env_file_rel
|
||||
if not env_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
|
||||
)
|
||||
|
||||
# Entschlüsselung via SecretEncryptionManager
|
||||
result = self.encryption_manager.decrypt_file(env_path)
|
||||
|
||||
if result.success and result.content is not None:
|
||||
# Entschlüsselter Inhalt als Text parsen
|
||||
content_text = result.content.decode("utf-8", errors="replace")
|
||||
return ContextGuard._parse_env_content(content_text)
|
||||
|
||||
# Entschlüsselung fehlgeschlagen – Fallback auf ContextGuard.load_env
|
||||
# (Datei könnte bereits im Klartext vorliegen nach git-crypt unlock)
|
||||
logger.debug(
|
||||
"Entschlüsselung für Kontext '%s' nicht erfolgreich (%s). "
|
||||
"Fallback auf ContextGuard.load_env.",
|
||||
context,
|
||||
result.error or "unbekannter Fehler",
|
||||
)
|
||||
return self.context_guard.load_env(context)
|
||||
@@ -0,0 +1,835 @@
|
||||
"""Externes-Repo-Manager für das Monorepo.
|
||||
|
||||
Verwaltet die Einbindung externer Repositories via Git-Subtree (Standard)
|
||||
oder Git-Submodule, sowie die Synchronisation mit Upstream-Remotes.
|
||||
|
||||
Verantwortung: Einbindung, Synchronisation und Schutzmechanismen für
|
||||
externe Repositories (Read-Only und Upstream).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import stat
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.models import ConflictInfo, RepoEntry, SyncResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template für den pre-commit Hook, der Read-Only-Verzeichnisse schützt.
|
||||
# Platzhalter: {protected_paths} wird durch eine Shell-Array-Definition ersetzt.
|
||||
_PRE_COMMIT_HOOK_TEMPLATE = """\
|
||||
#!/bin/sh
|
||||
# --- Monorepo Read-Only-Schutz (auto-generiert) ---
|
||||
# Verhindert Commits, die Dateien in geschützten Read-Only-Verzeichnissen ändern.
|
||||
|
||||
{protected_paths}
|
||||
|
||||
staged_files=$(git diff --cached --name-only)
|
||||
|
||||
for file in $staged_files; do
|
||||
for protected in "${{PROTECTED_PATHS[@]}}"; do
|
||||
case "$file" in
|
||||
"$protected"/*)
|
||||
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
# --- Ende Read-Only-Schutz ---
|
||||
"""
|
||||
|
||||
# Marker-Kommentare für den Read-Only-Abschnitt im Hook
|
||||
_HOOK_START_MARKER = "# --- Monorepo Read-Only-Schutz (auto-generiert) ---"
|
||||
_HOOK_END_MARKER = "# --- Ende Read-Only-Schutz ---"
|
||||
|
||||
|
||||
class RepoManager:
|
||||
"""Verwaltet externe und Upstream-Repository-Einbindungen.
|
||||
|
||||
Unterstützt zwei Einbindungsmechanismen:
|
||||
- Git-Subtree (Standard): Flache Checkouts, bidirektionale Sync möglich
|
||||
- Git-Submodule: Separate Repository-Referenz
|
||||
|
||||
Und zwei Betriebsmodi:
|
||||
- read-only: Nur Pull von Remote, lokale Änderungen werden blockiert
|
||||
- upstream: Bidirektionale Synchronisation (Pull/Push)
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Path, monorepo_root: Path | None = None) -> None:
|
||||
"""Initialisiert den RepoManager.
|
||||
|
||||
Args:
|
||||
config_path: Pfad zur repos.yaml Konfigurationsdatei.
|
||||
monorepo_root: Wurzelverzeichnis des Monorepos. Wenn None,
|
||||
wird das übergeordnete Verzeichnis der Config verwendet.
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self.monorepo_root = monorepo_root or config_path.parent.parent.parent
|
||||
self.repos = self._load_repos_config(config_path)
|
||||
|
||||
def _load_repos_config(self, config_path: Path) -> list[RepoEntry]:
|
||||
"""Lädt die Repo-Konfiguration aus repos.yaml.
|
||||
|
||||
Args:
|
||||
config_path: Pfad zur repos.yaml.
|
||||
|
||||
Returns:
|
||||
Liste aller konfigurierten RepoEntry-Objekte.
|
||||
"""
|
||||
if not config_path.exists():
|
||||
logger.warning("Repo-Konfiguration nicht gefunden: %s", config_path)
|
||||
return []
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
|
||||
entries: list[RepoEntry] = []
|
||||
for item in data.get("repos", []):
|
||||
entries.append(
|
||||
RepoEntry(
|
||||
name=item["name"],
|
||||
url=item["url"],
|
||||
mode=item.get("mode", "read-only"),
|
||||
target=item["target"],
|
||||
pinned=item.get("pinned", "main"),
|
||||
mechanism=item.get("mechanism", "subtree"),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
def _find_repo(self, repo_name: str) -> RepoEntry | None:
|
||||
"""Findet einen Repo-Eintrag anhand des Namens.
|
||||
|
||||
Args:
|
||||
repo_name: Name des gesuchten Repos.
|
||||
|
||||
Returns:
|
||||
Der RepoEntry oder None wenn nicht gefunden.
|
||||
"""
|
||||
for entry in self.repos:
|
||||
if entry.name == repo_name:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _run_git(
|
||||
self, args: list[str], cwd: Path | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Führt einen Git-Befehl aus.
|
||||
|
||||
Args:
|
||||
args: Git-Argumente (ohne 'git' Präfix).
|
||||
cwd: Arbeitsverzeichnis für den Befehl.
|
||||
|
||||
Returns:
|
||||
CompletedProcess mit stdout/stderr.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
logger.debug("Ausführen: %s (cwd=%s)", " ".join(cmd), cwd or self.monorepo_root)
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd or self.monorepo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def add_repo(self, entry: RepoEntry) -> None:
|
||||
"""Bindet ein externes Repo via Git-Subtree oder Submodule ein.
|
||||
|
||||
Subtree (Standard):
|
||||
git subtree add --prefix=<target> <url> <pinned> --squash
|
||||
|
||||
Submodule:
|
||||
git submodule add <url> <target>
|
||||
git -C <target> checkout <pinned>
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit URL, Zielverzeichnis, Mechanismus, etc.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn das Zielverzeichnis bereits existiert.
|
||||
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
|
||||
"""
|
||||
target_path = self.monorepo_root / entry.target
|
||||
|
||||
if target_path.exists():
|
||||
raise ValueError(
|
||||
f"Zielverzeichnis '{entry.target}' existiert bereits. "
|
||||
f"Repo '{entry.name}' kann nicht eingebunden werden."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Binde Repo '%s' ein via %s → %s",
|
||||
entry.name,
|
||||
entry.mechanism,
|
||||
entry.target,
|
||||
)
|
||||
|
||||
if entry.mechanism == "subtree":
|
||||
self._add_subtree(entry)
|
||||
elif entry.mechanism == "submodule":
|
||||
self._add_submodule(entry)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unbekannter Mechanismus '{entry.mechanism}'. "
|
||||
f"Erlaubt: 'subtree', 'submodule'."
|
||||
)
|
||||
|
||||
# Repo in Konfiguration aufnehmen (falls noch nicht vorhanden)
|
||||
if not self._find_repo(entry.name):
|
||||
self.repos.append(entry)
|
||||
self._save_repos_config()
|
||||
|
||||
logger.info("Repo '%s' erfolgreich eingebunden.", entry.name)
|
||||
|
||||
def _add_subtree(self, entry: RepoEntry) -> None:
|
||||
"""Bindet ein Repo als Git-Subtree ein.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit den Subtree-Parametern.
|
||||
"""
|
||||
self._run_git([
|
||||
"subtree",
|
||||
"add",
|
||||
f"--prefix={entry.target}",
|
||||
entry.url,
|
||||
entry.pinned,
|
||||
"--squash",
|
||||
])
|
||||
|
||||
def _add_submodule(self, entry: RepoEntry) -> None:
|
||||
"""Bindet ein Repo als Git-Submodule ein.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit den Submodule-Parametern.
|
||||
"""
|
||||
self._run_git([
|
||||
"submodule",
|
||||
"add",
|
||||
entry.url,
|
||||
entry.target,
|
||||
])
|
||||
|
||||
# Auf gepinnte Version auschecken
|
||||
target_path = self.monorepo_root / entry.target
|
||||
self._run_git(["checkout", entry.pinned], cwd=target_path)
|
||||
|
||||
def sync(self, repo_name: str) -> SyncResult:
|
||||
"""Synchronisiert ein Repo mit seinem Remote.
|
||||
|
||||
Für Read-Only-Repos: Pull von Upstream auf gepinnte Version.
|
||||
Für Upstream-Repos: Bidirektionale Synchronisation (Pull dann Push).
|
||||
|
||||
Bei Merge-Konflikten wird die Synchronisation abgebrochen,
|
||||
der Konflikt protokolliert und manuelle Auflösung ermöglicht.
|
||||
|
||||
Args:
|
||||
repo_name: Name des zu synchronisierenden Repos.
|
||||
|
||||
Returns:
|
||||
SyncResult mit Status (Erfolg/Misserfolg/unbekannt),
|
||||
Richtung, Anzahl synchronisierter Commits und ggf. Konflikte.
|
||||
"""
|
||||
entry = self._find_repo(repo_name)
|
||||
if entry is None:
|
||||
logger.error("Repo '%s' nicht in Konfiguration gefunden.", repo_name)
|
||||
return SyncResult(
|
||||
success=False,
|
||||
context=repo_name,
|
||||
direction="pull",
|
||||
commits_synced=0,
|
||||
conflicts=[
|
||||
ConflictInfo(
|
||||
file_path="",
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details=f"Repo '{repo_name}' nicht in Konfiguration gefunden.",
|
||||
)
|
||||
],
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
if entry.mode == "upstream":
|
||||
return self._sync_upstream(entry)
|
||||
else:
|
||||
return self._sync_readonly(entry)
|
||||
|
||||
def _sync_readonly(self, entry: RepoEntry) -> SyncResult:
|
||||
"""Synchronisiert ein Read-Only-Repo (nur Pull).
|
||||
|
||||
Args:
|
||||
entry: RepoEntry des zu synchronisierenden Repos.
|
||||
|
||||
Returns:
|
||||
SyncResult mit Ergebnis der Pull-Operation.
|
||||
"""
|
||||
logger.info("Synchronisiere Read-Only-Repo '%s' auf '%s'.", entry.name, entry.pinned)
|
||||
|
||||
try:
|
||||
if entry.mechanism == "subtree":
|
||||
result = self._subtree_pull(entry)
|
||||
else:
|
||||
result = self._submodule_pull(entry)
|
||||
|
||||
commits = self._count_commits_from_output(result.stdout)
|
||||
logger.info(
|
||||
"Repo '%s' erfolgreich synchronisiert (%d Commits).",
|
||||
entry.name,
|
||||
commits,
|
||||
)
|
||||
return SyncResult(
|
||||
success=True,
|
||||
context=entry.name,
|
||||
direction="pull",
|
||||
commits_synced=commits,
|
||||
conflicts=[],
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return self._handle_sync_error(entry, e, direction="pull")
|
||||
|
||||
def _sync_upstream(self, entry: RepoEntry) -> SyncResult:
|
||||
"""Synchronisiert ein Upstream-Repo bidirektional (Pull + Push).
|
||||
|
||||
Ablauf:
|
||||
1. Pull von Upstream (Subtree-Pull oder Submodule-Update)
|
||||
2. Bei Merge-Konflikt: Abbruch, Protokollierung, manuelle Auflösung
|
||||
3. Push lokaler Änderungen zu Upstream
|
||||
|
||||
Args:
|
||||
entry: RepoEntry des Upstream-Repos.
|
||||
|
||||
Returns:
|
||||
SyncResult mit Gesamtergebnis der bidirektionalen Sync.
|
||||
"""
|
||||
logger.info("Bidirektionale Synchronisation für Upstream-Repo '%s'.", entry.name)
|
||||
total_commits = 0
|
||||
|
||||
# Phase 1: Pull von Upstream
|
||||
try:
|
||||
if entry.mechanism == "subtree":
|
||||
pull_result = self._subtree_pull(entry)
|
||||
else:
|
||||
pull_result = self._submodule_pull(entry)
|
||||
|
||||
total_commits += self._count_commits_from_output(pull_result.stdout)
|
||||
logger.info("Pull von '%s' erfolgreich.", entry.name)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Prüfe ob es ein Merge-Konflikt ist
|
||||
if self._is_merge_conflict(e):
|
||||
return self._handle_merge_conflict(entry, e)
|
||||
return self._handle_sync_error(entry, e, direction="full")
|
||||
|
||||
# Phase 2: Push lokaler Änderungen
|
||||
try:
|
||||
if entry.mechanism == "subtree":
|
||||
push_result = self._subtree_push(entry)
|
||||
else:
|
||||
push_result = self._submodule_push(entry)
|
||||
|
||||
total_commits += self._count_commits_from_output(push_result.stdout)
|
||||
logger.info("Push zu '%s' erfolgreich.", entry.name)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
if self._is_merge_conflict(e):
|
||||
return self._handle_merge_conflict(entry, e)
|
||||
return self._handle_sync_error(entry, e, direction="push")
|
||||
|
||||
return SyncResult(
|
||||
success=True,
|
||||
context=entry.name,
|
||||
direction="full",
|
||||
commits_synced=total_commits,
|
||||
conflicts=[],
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
def _subtree_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
|
||||
"""Führt einen git subtree pull aus.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit Subtree-Parametern.
|
||||
|
||||
Returns:
|
||||
CompletedProcess mit Ergebnis.
|
||||
"""
|
||||
return self._run_git([
|
||||
"subtree",
|
||||
"pull",
|
||||
f"--prefix={entry.target}",
|
||||
entry.url,
|
||||
entry.pinned,
|
||||
"--squash",
|
||||
])
|
||||
|
||||
def _subtree_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
|
||||
"""Führt einen git subtree push aus.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit Subtree-Parametern.
|
||||
|
||||
Returns:
|
||||
CompletedProcess mit Ergebnis.
|
||||
"""
|
||||
return self._run_git([
|
||||
"subtree",
|
||||
"push",
|
||||
f"--prefix={entry.target}",
|
||||
entry.url,
|
||||
entry.pinned,
|
||||
])
|
||||
|
||||
def _submodule_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
|
||||
"""Aktualisiert ein Submodule auf die gepinnte Version.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit Submodule-Parametern.
|
||||
|
||||
Returns:
|
||||
CompletedProcess mit Ergebnis.
|
||||
"""
|
||||
target_path = self.monorepo_root / entry.target
|
||||
|
||||
# Fetch latest from remote
|
||||
self._run_git(["fetch", "origin"], cwd=target_path)
|
||||
|
||||
# Checkout pinned version
|
||||
return self._run_git(["checkout", entry.pinned], cwd=target_path)
|
||||
|
||||
def _submodule_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
|
||||
"""Pusht lokale Änderungen in einem Submodule zu Upstream.
|
||||
|
||||
Args:
|
||||
entry: RepoEntry mit Submodule-Parametern.
|
||||
|
||||
Returns:
|
||||
CompletedProcess mit Ergebnis.
|
||||
"""
|
||||
target_path = self.monorepo_root / entry.target
|
||||
return self._run_git(["push", "origin", entry.pinned], cwd=target_path)
|
||||
|
||||
def _is_merge_conflict(self, error: subprocess.CalledProcessError) -> bool:
|
||||
"""Prüft ob ein Git-Fehler ein Merge-Konflikt ist.
|
||||
|
||||
Args:
|
||||
error: Der aufgetretene CalledProcessError.
|
||||
|
||||
Returns:
|
||||
True wenn es sich um einen Merge-Konflikt handelt.
|
||||
"""
|
||||
conflict_indicators = [
|
||||
"CONFLICT",
|
||||
"merge conflict",
|
||||
"Automatic merge failed",
|
||||
"fix conflicts",
|
||||
]
|
||||
combined_output = (error.stdout or "") + (error.stderr or "")
|
||||
return any(indicator in combined_output for indicator in conflict_indicators)
|
||||
|
||||
def _handle_merge_conflict(
|
||||
self, entry: RepoEntry, error: subprocess.CalledProcessError
|
||||
) -> SyncResult:
|
||||
"""Behandelt einen Merge-Konflikt: Abbruch, Protokollierung.
|
||||
|
||||
Bei Merge-Konflikten wird:
|
||||
1. Die Merge-Operation abgebrochen (git merge --abort)
|
||||
2. Der Konflikt protokolliert
|
||||
3. Ein SyncResult mit Konfliktdetails zurückgegeben
|
||||
|
||||
Args:
|
||||
entry: RepoEntry des betroffenen Repos.
|
||||
error: Der CalledProcessError mit Konfliktdetails.
|
||||
|
||||
Returns:
|
||||
SyncResult mit success=False und Konfliktinformationen.
|
||||
"""
|
||||
logger.warning(
|
||||
"Merge-Konflikt bei Synchronisation von '%s'. Breche ab.",
|
||||
entry.name,
|
||||
)
|
||||
|
||||
# Merge abbrechen um sauberen Zustand herzustellen
|
||||
try:
|
||||
self._run_git(["merge", "--abort"])
|
||||
except subprocess.CalledProcessError:
|
||||
# Falls kein Merge aktiv ist, ignorieren
|
||||
logger.debug("Kein aktiver Merge zum Abbrechen.")
|
||||
|
||||
# Konfliktdetails aus Fehlerausgabe extrahieren
|
||||
combined_output = (error.stdout or "") + (error.stderr or "")
|
||||
conflict_files = self._extract_conflict_files(combined_output)
|
||||
|
||||
conflicts = [
|
||||
ConflictInfo(
|
||||
file_path=f,
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details=f"Merge-Konflikt in '{f}' bei Sync von '{entry.name}'.",
|
||||
)
|
||||
for f in conflict_files
|
||||
] or [
|
||||
ConflictInfo(
|
||||
file_path="",
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details=f"Merge-Konflikt bei Synchronisation von '{entry.name}': "
|
||||
f"{combined_output[:200]}",
|
||||
)
|
||||
]
|
||||
|
||||
return SyncResult(
|
||||
success=False,
|
||||
context=entry.name,
|
||||
direction="full" if entry.mode == "upstream" else "pull",
|
||||
commits_synced=0,
|
||||
conflicts=conflicts,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
def _handle_sync_error(
|
||||
self,
|
||||
entry: RepoEntry,
|
||||
error: subprocess.CalledProcessError,
|
||||
direction: str,
|
||||
) -> SyncResult:
|
||||
"""Behandelt einen allgemeinen Sync-Fehler.
|
||||
|
||||
Protokolliert den Fehler in der Sync-Error-Log-Datei und gibt
|
||||
ein SyncResult mit success=False zurück. Der lokale Stand wird
|
||||
nicht verändert (Req 4.9).
|
||||
|
||||
Args:
|
||||
entry: RepoEntry des betroffenen Repos.
|
||||
error: Der aufgetretene Fehler.
|
||||
direction: Richtung der fehlgeschlagenen Operation.
|
||||
|
||||
Returns:
|
||||
SyncResult mit success=False und Fehlerdetails.
|
||||
"""
|
||||
combined_output = (error.stdout or "") + (error.stderr or "")
|
||||
error_detail = combined_output.strip()[:300] or "Unbekannter Fehler"
|
||||
|
||||
logger.error(
|
||||
"Synchronisation von '%s' fehlgeschlagen: %s",
|
||||
entry.name,
|
||||
error_detail,
|
||||
)
|
||||
|
||||
# Fehler in Audit-Datei protokollieren (Req 4.9)
|
||||
self.log_sync_error(
|
||||
repo_name=entry.name,
|
||||
error_reason=error_detail,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
return SyncResult(
|
||||
success=False,
|
||||
context=entry.name,
|
||||
direction=direction, # type: ignore[arg-type]
|
||||
commits_synced=0,
|
||||
conflicts=[
|
||||
ConflictInfo(
|
||||
file_path="",
|
||||
conflict_type="content",
|
||||
source="monorepo",
|
||||
details=f"Synchronisation fehlgeschlagen: {error_detail}",
|
||||
)
|
||||
],
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
def _extract_conflict_files(self, output: str) -> list[str]:
|
||||
"""Extrahiert Dateinamen aus Git-Merge-Konflikt-Ausgabe.
|
||||
|
||||
Args:
|
||||
output: Die kombinierte stdout/stderr-Ausgabe von Git.
|
||||
|
||||
Returns:
|
||||
Liste der Dateipfade mit Konflikten.
|
||||
"""
|
||||
conflict_files: list[str] = []
|
||||
for line in output.splitlines():
|
||||
# Git zeigt "CONFLICT (content): Merge conflict in <file>"
|
||||
if "CONFLICT" in line and "Merge conflict in" in line:
|
||||
parts = line.split("Merge conflict in")
|
||||
if len(parts) > 1:
|
||||
file_path = parts[1].strip().rstrip(".")
|
||||
conflict_files.append(file_path)
|
||||
# Git zeigt auch "CONFLICT (modify/delete): <file> deleted in ..."
|
||||
elif "CONFLICT" in line and ":" in line:
|
||||
parts = line.split(":")
|
||||
if len(parts) > 1:
|
||||
# Versuche Dateiname zu extrahieren
|
||||
segment = parts[1].strip()
|
||||
tokens = segment.split()
|
||||
if tokens:
|
||||
conflict_files.append(tokens[0])
|
||||
return conflict_files
|
||||
|
||||
def _count_commits_from_output(self, output: str) -> int:
|
||||
"""Zählt synchronisierte Commits anhand der Git-Ausgabe.
|
||||
|
||||
Sucht nach typischen Git-Meldungen wie "X commits" oder
|
||||
Shortstat-Zeilen. Gibt 1 zurück wenn der Output auf
|
||||
Aktivität hindeutet, sonst 0.
|
||||
|
||||
Args:
|
||||
output: Die stdout-Ausgabe des Git-Befehls.
|
||||
|
||||
Returns:
|
||||
Geschätzte Anzahl synchronisierter Commits.
|
||||
"""
|
||||
if not output:
|
||||
return 0
|
||||
|
||||
# Suche nach "X files changed" als Indikator für Änderungen
|
||||
for line in output.splitlines():
|
||||
if "files changed" in line or "file changed" in line:
|
||||
return 1
|
||||
if "Already up to date" in line or "Already up-to-date" in line:
|
||||
return 0
|
||||
|
||||
# Wenn es Output gibt aber keine klaren Indikatoren,
|
||||
# gehen wir von mindestens einer Änderung aus
|
||||
if output.strip():
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def _save_repos_config(self) -> None:
|
||||
"""Speichert die aktuelle Repo-Konfiguration in repos.yaml."""
|
||||
data = {
|
||||
"repos": [
|
||||
{
|
||||
"name": entry.name,
|
||||
"url": entry.url,
|
||||
"mode": entry.mode,
|
||||
"target": entry.target,
|
||||
"pinned": entry.pinned,
|
||||
"mechanism": entry.mechanism,
|
||||
}
|
||||
for entry in self.repos
|
||||
]
|
||||
}
|
||||
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
logger.debug("Repo-Konfiguration gespeichert: %s", self.config_path)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Read-Only-Schutzmechanismus (Req 4.2, 4.5)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def protect_readonly(self, repo_path: Path) -> None:
|
||||
"""Installiert einen Git pre-commit Hook zum Schutz eines Read-Only-Verzeichnisses.
|
||||
|
||||
Der Hook prüft bei jedem Commit, ob gestagte Dateien innerhalb des
|
||||
geschützten Verzeichnisses liegen. Ist das der Fall, wird der Commit
|
||||
mit einer Fehlermeldung abgebrochen, die den Repo-Namen und den
|
||||
Read-Only-Status benennt.
|
||||
|
||||
Wenn bereits ein pre-commit Hook existiert, wird der Schutzabschnitt
|
||||
angehängt bzw. aktualisiert. Die geschützten Pfade werden als
|
||||
Shell-Array in den Hook geschrieben.
|
||||
|
||||
Args:
|
||||
repo_path: Absoluter Pfad zum geschützten Verzeichnis (Target-Pfad
|
||||
relativ zum Monorepo-Root wird automatisch berechnet).
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der repo_path nicht innerhalb des Monorepo-Roots liegt.
|
||||
"""
|
||||
# Berechne den relativen Pfad zum Monorepo-Root
|
||||
try:
|
||||
relative_target = repo_path.relative_to(self.monorepo_root)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Pfad '{repo_path}' liegt nicht innerhalb des "
|
||||
f"Monorepo-Roots '{self.monorepo_root}'."
|
||||
)
|
||||
|
||||
target_str = str(relative_target).replace("\\", "/")
|
||||
|
||||
# Git-Hook-Verzeichnis ermitteln
|
||||
hooks_dir = self.monorepo_root / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
hook_path = hooks_dir / "pre-commit"
|
||||
|
||||
# Geschützte Pfade sammeln (aus bestehendem Hook + neuer Pfad)
|
||||
protected_paths = self._get_existing_protected_paths(hook_path)
|
||||
if target_str not in protected_paths:
|
||||
protected_paths.append(target_str)
|
||||
|
||||
# Hook-Script generieren und schreiben
|
||||
self._write_pre_commit_hook(hook_path, protected_paths)
|
||||
|
||||
logger.info(
|
||||
"Read-Only-Schutz installiert für '%s' (Hook: %s).",
|
||||
target_str,
|
||||
hook_path,
|
||||
)
|
||||
|
||||
def install_all_readonly_hooks(self) -> None:
|
||||
"""Installiert Read-Only-Schutz für alle als read-only konfigurierten Repos.
|
||||
|
||||
Iteriert über alle konfigurierten Repos und installiert für jene mit
|
||||
mode="read-only" den pre-commit-Hook-Schutz. Bereits geschützte Pfade
|
||||
werden nicht doppelt eingetragen.
|
||||
"""
|
||||
readonly_repos = [entry for entry in self.repos if entry.mode == "read-only"]
|
||||
|
||||
if not readonly_repos:
|
||||
logger.info("Keine Read-Only-Repos konfiguriert – kein Hook notwendig.")
|
||||
return
|
||||
|
||||
for entry in readonly_repos:
|
||||
repo_path = self.monorepo_root / entry.target
|
||||
self.protect_readonly(repo_path)
|
||||
|
||||
logger.info(
|
||||
"Read-Only-Schutz für %d Repos installiert: %s",
|
||||
len(readonly_repos),
|
||||
", ".join(e.name for e in readonly_repos),
|
||||
)
|
||||
|
||||
def _get_existing_protected_paths(self, hook_path: Path) -> list[str]:
|
||||
"""Extrahiert bereits geschützte Pfade aus einem bestehenden Hook.
|
||||
|
||||
Args:
|
||||
hook_path: Pfad zur pre-commit Hook-Datei.
|
||||
|
||||
Returns:
|
||||
Liste der bereits geschützten Pfade (relativ zum Monorepo-Root).
|
||||
"""
|
||||
if not hook_path.exists():
|
||||
return []
|
||||
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
# Suche nach PROTECTED_PATHS-Array im bestehenden Hook
|
||||
paths: list[str] = []
|
||||
in_array = False
|
||||
for line in content.splitlines():
|
||||
if "PROTECTED_PATHS=(" in line:
|
||||
in_array = True
|
||||
# Pfade in derselben Zeile: PROTECTED_PATHS=("path1" "path2")
|
||||
inner = line.split("(", 1)[1].rstrip(")")
|
||||
for token in inner.split():
|
||||
cleaned = token.strip('"').strip("'")
|
||||
if cleaned:
|
||||
paths.append(cleaned)
|
||||
if ")" in line:
|
||||
in_array = False
|
||||
continue
|
||||
if in_array:
|
||||
if ")" in line:
|
||||
inner = line.split(")")[0]
|
||||
for token in inner.split():
|
||||
cleaned = token.strip('"').strip("'")
|
||||
if cleaned:
|
||||
paths.append(cleaned)
|
||||
in_array = False
|
||||
else:
|
||||
for token in line.split():
|
||||
cleaned = token.strip('"').strip("'")
|
||||
if cleaned:
|
||||
paths.append(cleaned)
|
||||
|
||||
return paths
|
||||
|
||||
def _write_pre_commit_hook(self, hook_path: Path, protected_paths: list[str]) -> None:
|
||||
"""Schreibt oder aktualisiert den pre-commit Hook mit Schutzmechanismus.
|
||||
|
||||
Wenn bereits ein Hook existiert, wird der Read-Only-Abschnitt
|
||||
(zwischen Start- und End-Marker) ersetzt. Andernfalls wird ein
|
||||
neuer Hook erstellt.
|
||||
|
||||
Args:
|
||||
hook_path: Pfad zur Hook-Datei.
|
||||
protected_paths: Liste der zu schützenden relativen Pfade.
|
||||
"""
|
||||
# Generiere Shell-Array-Definition
|
||||
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
|
||||
paths_definition = f'PROTECTED_PATHS=({quoted_paths})'
|
||||
|
||||
# Generiere neuen Hook-Abschnitt
|
||||
new_section = _PRE_COMMIT_HOOK_TEMPLATE.format(protected_paths=paths_definition)
|
||||
|
||||
if hook_path.exists():
|
||||
existing_content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
if _HOOK_START_MARKER in existing_content:
|
||||
# Ersetze den bestehenden Abschnitt
|
||||
start_idx = existing_content.index(_HOOK_START_MARKER)
|
||||
end_idx = existing_content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
|
||||
updated_content = (
|
||||
existing_content[:start_idx]
|
||||
+ new_section.strip()
|
||||
+ existing_content[end_idx:]
|
||||
)
|
||||
else:
|
||||
# Hänge den Abschnitt an den bestehenden Hook an
|
||||
updated_content = existing_content.rstrip() + "\n\n" + new_section
|
||||
else:
|
||||
updated_content = new_section
|
||||
|
||||
hook_path.write_text(updated_content, encoding="utf-8")
|
||||
|
||||
# Hook ausführbar machen (wichtig für Unix-Systeme)
|
||||
current_mode = hook_path.stat().st_mode
|
||||
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Sync-Fehlerprotokollierung (Req 4.9)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def log_sync_error(
|
||||
self,
|
||||
repo_name: str,
|
||||
error_reason: str,
|
||||
direction: str = "pull",
|
||||
) -> None:
|
||||
"""Protokolliert einen Synchronisationsfehler in der Audit-Datei.
|
||||
|
||||
Schreibt einen strukturierten Eintrag (Zeitstempel, Repo-Name,
|
||||
Richtung, Fehlergrund) in `.audit/sync-errors.log`. Der lokale
|
||||
Stand des Repos wird dabei nicht verändert.
|
||||
|
||||
Args:
|
||||
repo_name: Name des betroffenen Repos.
|
||||
error_reason: Beschreibung des Fehlergrunds (Netzwerk, Auth, Merge).
|
||||
direction: Richtung der fehlgeschlagenen Operation (pull/push/full).
|
||||
"""
|
||||
audit_dir = self.monorepo_root / ".audit"
|
||||
audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = audit_dir / "sync-errors.log"
|
||||
|
||||
timestamp = datetime.now().isoformat(timespec="seconds")
|
||||
log_entry = (
|
||||
f"[{timestamp}] repo={repo_name} direction={direction} "
|
||||
f"error={error_reason}\n"
|
||||
)
|
||||
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry)
|
||||
|
||||
logger.error(
|
||||
"Sync-Fehler protokolliert: repo=%s, direction=%s, error=%s",
|
||||
repo_name,
|
||||
direction,
|
||||
error_reason,
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Sicherheits-Guard für die Zugriffskontrolle zwischen Arbeitskontexten.
|
||||
|
||||
Implementiert den ContextGuard, der Sicherheitsgrenzen zwischen den
|
||||
Arbeitskontexten (privat, dhive, bahn, shared) erzwingt. Die Zugriffskontrolle
|
||||
basiert auf der zentralen access-config.yaml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from monorepo.models import Context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContextGuard:
|
||||
"""Erzwingt Sicherheitsgrenzen zwischen Arbeitskontexten.
|
||||
|
||||
Prüft ob ein anfragender Kontext auf einen gegebenen Pfad zugreifen darf,
|
||||
basierend auf den Regeln in access-config.yaml:
|
||||
- Ein Kontext darf immer auf eigene Dateien zugreifen.
|
||||
- Ein Kontext darf auf shared-Pfade zugreifen, die in allowed_shared gelistet sind.
|
||||
- Der shared-Kontext mit ["*"] darf auf alles in shared zugreifen.
|
||||
- Alle anderen kontextübergreifenden Zugriffe werden verweigert.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_path: Path,
|
||||
access_config_path: Path | None = None,
|
||||
encryption_manager: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialisiert den ContextGuard.
|
||||
|
||||
Args:
|
||||
root_path: Wurzelverzeichnis des Monorepos.
|
||||
access_config_path: Pfad zur access-config.yaml.
|
||||
Falls None, wird `root_path / shared/config/access-config.yaml` verwendet.
|
||||
encryption_manager: Optionaler SecretEncryptionManager für die
|
||||
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
|
||||
direkt gelesen (unverschlüsselt oder bereits durch git-crypt entschlüsselt).
|
||||
"""
|
||||
self.root_path = root_path.resolve()
|
||||
if access_config_path is None:
|
||||
access_config_path = self.root_path / "shared" / "config" / "access-config.yaml"
|
||||
self.access_config_path = access_config_path.resolve()
|
||||
self.encryption_manager = encryption_manager
|
||||
self._config: dict[str, Any] = self._load_access_config()
|
||||
|
||||
def _load_access_config(self) -> dict[str, Any]:
|
||||
"""Lädt die access-config.yaml.
|
||||
|
||||
Returns:
|
||||
Dict mit der Zugriffskonfiguration pro Kontext.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
|
||||
ValueError: Wenn das YAML-Format ungültig ist.
|
||||
"""
|
||||
if not self.access_config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Access-Konfiguration nicht gefunden: {self.access_config_path}"
|
||||
)
|
||||
|
||||
with open(self.access_config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict) or "contexts" not in data:
|
||||
raise ValueError(
|
||||
f"Ungültiges access-config.yaml-Format: "
|
||||
f"Erwartet dict mit 'contexts'-Schlüssel in {self.access_config_path}"
|
||||
)
|
||||
|
||||
return data["contexts"]
|
||||
|
||||
def check_access(self, requesting_context: str, target_path: Path) -> bool:
|
||||
"""Prüft ob der Zugriff auf target_path vom requesting_context erlaubt ist.
|
||||
|
||||
Regeln:
|
||||
1. Ein Kontext darf immer auf eigene Dateien zugreifen (Pfad unter eigenem Kontextordner).
|
||||
2. Ein Kontext darf auf shared-Pfade zugreifen, wenn diese in allowed_shared gelistet sind.
|
||||
3. Der shared-Kontext mit allowed_shared: ["*"] darf auf alles in shared zugreifen.
|
||||
4. Alle anderen kontextübergreifenden Zugriffe werden verweigert.
|
||||
|
||||
Args:
|
||||
requesting_context: Name des anfragenden Kontexts (z.B. "privat", "dhive", "bahn", "shared").
|
||||
target_path: Pfad auf den zugegriffen werden soll (absolut oder relativ zum root_path).
|
||||
|
||||
Returns:
|
||||
True wenn der Zugriff erlaubt ist, False wenn verweigert.
|
||||
"""
|
||||
# Pfad relativ zum Root normalisieren
|
||||
rel_path = self._resolve_relative_path(target_path)
|
||||
rel_path_str = rel_path.as_posix()
|
||||
|
||||
# Kontext des Zielpfads ermitteln
|
||||
target_context = self._get_path_context(rel_path_str)
|
||||
|
||||
# Regel 1: Zugriff auf eigenen Kontext immer erlaubt
|
||||
if target_context == requesting_context:
|
||||
return True
|
||||
|
||||
# Regel 2+3: Zugriff auf shared-Bereich prüfen
|
||||
if target_context == Context.SHARED.value:
|
||||
return self._check_shared_access(requesting_context, rel_path_str)
|
||||
|
||||
# Regel 4: Kontextübergreifender Zugriff verweigert
|
||||
return False
|
||||
|
||||
def load_env(self, context: str) -> dict[str, str]:
|
||||
"""Lädt die .env-Datei des gegebenen Kontexts (mit optionaler Entschlüsselung).
|
||||
|
||||
Wenn ein SecretEncryptionManager konfiguriert ist und der Maschinenkontext
|
||||
für den angefragten Kontext autorisiert ist, wird die .env-Datei via
|
||||
decrypt_file entschlüsselt. Andernfalls wird die Datei direkt gelesen
|
||||
(unverschlüsselt oder bereits durch git-crypt entschlüsselt im Working Tree).
|
||||
|
||||
Parst eine Standard-.env-Datei (KEY=VALUE-Format) und gibt die
|
||||
Umgebungsvariablen als Dict zurück. Kommentare (#) und Leerzeilen
|
||||
werden übersprungen.
|
||||
|
||||
Args:
|
||||
context: Name des Kontexts, dessen .env geladen werden soll.
|
||||
|
||||
Returns:
|
||||
Dict mit den Umgebungsvariablen (Schlüssel → Wert).
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
|
||||
FileNotFoundError: Wenn die .env-Datei nicht existiert.
|
||||
PermissionError: Wenn die Entschlüsselung fehlschlägt
|
||||
(Maschinenkontext nicht autorisiert).
|
||||
"""
|
||||
if context not in self._config:
|
||||
raise ValueError(
|
||||
f"Unbekannter Kontext '{context}'. "
|
||||
f"Gültige Kontexte: {list(self._config.keys())}"
|
||||
)
|
||||
|
||||
env_file_rel = self._config[context].get("env_file")
|
||||
if not env_file_rel:
|
||||
raise ValueError(
|
||||
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
|
||||
)
|
||||
|
||||
env_path = self.root_path / env_file_rel
|
||||
|
||||
if not env_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
|
||||
)
|
||||
|
||||
# Integration mit SecretEncryptionManager für Entschlüsselung (Req 2.2, 9.3).
|
||||
if self.encryption_manager is not None:
|
||||
return self._load_env_with_decryption(env_path, context)
|
||||
|
||||
# Fallback: Datei direkt lesen (unverschlüsselt oder bereits entschlüsselt)
|
||||
return self._parse_env_file(env_path)
|
||||
|
||||
def _load_env_with_decryption(
|
||||
self, env_path: Path, context: str
|
||||
) -> dict[str, str]:
|
||||
"""Lädt und entschlüsselt eine .env-Datei via SecretEncryptionManager.
|
||||
|
||||
Prüft zuerst die Autorisierung des Maschinenkontexts. Wenn autorisiert,
|
||||
wird die Datei entschlüsselt und geparst. Andernfalls wird ein
|
||||
PermissionError ausgelöst.
|
||||
|
||||
Args:
|
||||
env_path: Absoluter Pfad zur .env-Datei.
|
||||
context: Arbeitskontext der .env-Datei.
|
||||
|
||||
Returns:
|
||||
Dict mit den entschlüsselten Umgebungsvariablen.
|
||||
|
||||
Raises:
|
||||
PermissionError: Wenn der Maschinenkontext nicht autorisiert ist.
|
||||
"""
|
||||
encryption_mgr = self.encryption_manager
|
||||
|
||||
# Autorisierungsprüfung
|
||||
if not encryption_mgr.is_authorized(context):
|
||||
raise PermissionError(
|
||||
f"Maschinenkontext '{encryption_mgr.machine_context.name}' ist nicht "
|
||||
f"für Kontext '{context}' autorisiert. Entschlüsselung verweigert."
|
||||
)
|
||||
|
||||
# Entschlüsselung versuchen
|
||||
result = encryption_mgr.decrypt_file(env_path)
|
||||
|
||||
if result.success and result.content is not None:
|
||||
# Entschlüsselter Inhalt als Text parsen
|
||||
content_text = result.content.decode("utf-8", errors="replace")
|
||||
return self._parse_env_content(content_text)
|
||||
|
||||
# Entschlüsselung fehlgeschlagen – Fallback auf direkte Leseoperation.
|
||||
# Dies tritt auf wenn die Datei bereits im Klartext vorliegt (z.B.
|
||||
# nach git-crypt unlock) oder wenn git-crypt nicht verfügbar ist.
|
||||
logger.debug(
|
||||
"Entschlüsselung für '%s' nicht erfolgreich (%s). "
|
||||
"Fallback auf direktes Lesen.",
|
||||
env_path,
|
||||
result.error or "unbekannter Fehler",
|
||||
)
|
||||
return self._parse_env_file(env_path)
|
||||
|
||||
def get_context_config(self, context: str) -> dict[str, Any]:
|
||||
"""Gibt die Konfiguration für einen bestimmten Kontext zurück.
|
||||
|
||||
Args:
|
||||
context: Name des Kontexts.
|
||||
|
||||
Returns:
|
||||
Dict mit env_file und allowed_shared für den Kontext.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
|
||||
"""
|
||||
if context not in self._config:
|
||||
raise ValueError(
|
||||
f"Unbekannter Kontext '{context}'. "
|
||||
f"Gültige Kontexte: {list(self._config.keys())}"
|
||||
)
|
||||
return self._config[context]
|
||||
|
||||
def _resolve_relative_path(self, target_path: Path) -> Path:
|
||||
"""Löst einen Pfad relativ zum Monorepo-Root auf.
|
||||
|
||||
Args:
|
||||
target_path: Absoluter oder relativer Pfad.
|
||||
|
||||
Returns:
|
||||
Pfad relativ zum Root (immer mit Forward-Slashes via as_posix).
|
||||
"""
|
||||
resolved = Path(target_path).resolve() if target_path.is_absolute() else target_path
|
||||
try:
|
||||
return resolved.relative_to(self.root_path)
|
||||
except ValueError:
|
||||
# Pfad ist bereits relativ oder liegt außerhalb des Roots
|
||||
return target_path
|
||||
|
||||
def _get_path_context(self, rel_path_str: str) -> str:
|
||||
"""Ermittelt den Kontext eines relativen Pfads.
|
||||
|
||||
Der Kontext wird anhand des obersten Verzeichnisses bestimmt.
|
||||
Gültige Kontexte: privat, dhive, bahn, shared.
|
||||
|
||||
Args:
|
||||
rel_path_str: Relativer Pfad als String (mit Forward-Slashes).
|
||||
|
||||
Returns:
|
||||
Name des Kontexts oder leerer String wenn nicht zuordenbar.
|
||||
"""
|
||||
parts = rel_path_str.split("/")
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
first_part = parts[0]
|
||||
valid_contexts = {ctx.value for ctx in Context}
|
||||
if first_part in valid_contexts:
|
||||
return first_part
|
||||
|
||||
return ""
|
||||
|
||||
def _check_shared_access(self, requesting_context: str, rel_path_str: str) -> bool:
|
||||
"""Prüft ob ein Kontext auf einen shared-Pfad zugreifen darf.
|
||||
|
||||
Args:
|
||||
requesting_context: Name des anfragenden Kontexts.
|
||||
rel_path_str: Relativer Pfad (beginnt mit "shared/").
|
||||
|
||||
Returns:
|
||||
True wenn der Zugriff erlaubt ist.
|
||||
"""
|
||||
if requesting_context not in self._config:
|
||||
return False
|
||||
|
||||
allowed_shared = self._config[requesting_context].get("allowed_shared", [])
|
||||
|
||||
# Wildcard: Zugriff auf alles in shared erlaubt
|
||||
if allowed_shared == ["*"]:
|
||||
return True
|
||||
|
||||
# Prüfe ob der Pfad unter einem der erlaubten shared-Pfade liegt
|
||||
for allowed_path in allowed_shared:
|
||||
# Normalisiere: Stelle sicher dass der Pfad mit / endet für Prefix-Matching
|
||||
allowed_normalized = allowed_path.rstrip("/") + "/"
|
||||
# Pfad liegt unter dem erlaubten Verzeichnis oder ist es selbst
|
||||
if rel_path_str.startswith(allowed_normalized) or rel_path_str.rstrip("/") + "/" == allowed_normalized:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_env_file(env_path: Path) -> dict[str, str]:
|
||||
"""Parst eine .env-Datei im Standard-Format.
|
||||
|
||||
Format:
|
||||
- KEY=VALUE (ein Paar pro Zeile)
|
||||
- Zeilen die mit # beginnen sind Kommentare
|
||||
- Leerzeilen werden übersprungen
|
||||
- Führende/nachfolgende Leerzeichen werden entfernt
|
||||
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
|
||||
|
||||
Args:
|
||||
env_path: Pfad zur .env-Datei.
|
||||
|
||||
Returns:
|
||||
Dict mit geparsten Umgebungsvariablen.
|
||||
"""
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return ContextGuard._parse_env_content(content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_env_content(content: str) -> dict[str, str]:
|
||||
"""Parst .env-Inhalt im Standard-Format aus einem String.
|
||||
|
||||
Format:
|
||||
- KEY=VALUE (ein Paar pro Zeile)
|
||||
- Zeilen die mit # beginnen sind Kommentare
|
||||
- Leerzeilen werden übersprungen
|
||||
- Führende/nachfolgende Leerzeichen werden entfernt
|
||||
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
|
||||
|
||||
Args:
|
||||
content: Textinhalt der .env-Datei.
|
||||
|
||||
Returns:
|
||||
Dict mit geparsten Umgebungsvariablen.
|
||||
"""
|
||||
env_vars: dict[str, str] = {}
|
||||
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
|
||||
# Leerzeilen und Kommentare überspringen
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# KEY=VALUE aufteilen (nur beim ersten = splitten)
|
||||
if "=" not in line:
|
||||
continue
|
||||
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# Anführungszeichen entfernen
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
value = value[1:-1]
|
||||
|
||||
if key:
|
||||
env_vars[key] = value
|
||||
|
||||
return env_vars
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
"""Ordnerstruktur-Manager für das Monorepo.
|
||||
|
||||
Verwaltet die 3-Ebenen-Ordnerhierarchie:
|
||||
Ebene 1 (Kontextordner) → Ebene 2 (Projektordner) → Ebene 3 (Modulordner)
|
||||
|
||||
Verantwortung: Anlegen, Validieren und Verwalten der Monorepo-Ordnerstruktur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from monorepo.models import Context, ProjectInfo
|
||||
|
||||
|
||||
class StructureManager:
|
||||
"""Verwaltet die 3-Ebenen-Ordnerhierarchie des Monorepos."""
|
||||
|
||||
CONTEXTS = ("privat", "dhive", "bahn", "shared")
|
||||
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$")
|
||||
|
||||
def __init__(self, root_path: Path) -> None:
|
||||
"""Initialisiert den StructureManager.
|
||||
|
||||
Args:
|
||||
root_path: Wurzelverzeichnis des Monorepos.
|
||||
"""
|
||||
self.root_path = root_path
|
||||
|
||||
def validate_name(self, name: str) -> bool:
|
||||
"""Prüft ob ein Name der kebab-case-Konvention entspricht.
|
||||
|
||||
Regeln:
|
||||
- Ausschließlich Kleinbuchstaben, Ziffern und Bindestriche
|
||||
- Zwischen 2 und 50 Zeichen lang
|
||||
- Beginnt und endet nicht mit einem Bindestrich
|
||||
|
||||
Args:
|
||||
name: Der zu prüfende Projektname.
|
||||
|
||||
Returns:
|
||||
True wenn der Name gültig ist, False sonst.
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
return self.NAME_PATTERN.match(name) is not None
|
||||
|
||||
def create_project(self, context: str, name: str) -> Path:
|
||||
"""Erstellt ein neues Projekt im gegebenen Kontext.
|
||||
|
||||
Validiert den Namen, prüft auf Namenskonflikte und erstellt
|
||||
den Projektordner im korrekten Kontextordner.
|
||||
|
||||
Args:
|
||||
context: Der Arbeitskontext (privat, dhive, bahn, shared).
|
||||
name: Der Projektname in kebab-case.
|
||||
|
||||
Returns:
|
||||
Pfad zum erstellten Projektordner.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Kontext ungültig ist, der Name die
|
||||
Namenskonvention verletzt, oder ein Projekt mit
|
||||
diesem Namen bereits existiert.
|
||||
"""
|
||||
# Kontext validieren
|
||||
if context not in self.CONTEXTS:
|
||||
raise ValueError(
|
||||
f"Ungültiger Kontext '{context}'. "
|
||||
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
|
||||
)
|
||||
|
||||
# Name validieren
|
||||
if not self.validate_name(name):
|
||||
raise ValueError(
|
||||
f"Ungültiger Projektname '{name}'. "
|
||||
f"Der Name muss kebab-case sein (Kleinbuchstaben, Ziffern, "
|
||||
f"Bindestriche), zwischen 2 und 50 Zeichen lang, und darf "
|
||||
f"nicht mit einem Bindestrich beginnen oder enden."
|
||||
)
|
||||
|
||||
# Namenskonflikt prüfen
|
||||
project_path = self.root_path / context / name
|
||||
if project_path.exists():
|
||||
raise ValueError(
|
||||
f"Namenskonflikt: Ein Projekt mit dem Namen '{name}' "
|
||||
f"existiert bereits im Kontext '{context}' "
|
||||
f"(Pfad: {project_path})."
|
||||
)
|
||||
|
||||
# Projektordner erstellen
|
||||
project_path.mkdir(parents=True, exist_ok=False)
|
||||
return project_path
|
||||
|
||||
def list_projects(self, context: str | None = None) -> list[ProjectInfo]:
|
||||
"""Listet alle Projekte, optional gefiltert nach Kontext.
|
||||
|
||||
Args:
|
||||
context: Optionaler Kontext-Filter. Wenn None, werden alle
|
||||
Projekte aus allen Kontexten aufgelistet.
|
||||
|
||||
Returns:
|
||||
Liste von ProjectInfo-Objekten für alle gefundenen Projekte.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn ein ungültiger Kontext angegeben wird.
|
||||
"""
|
||||
if context is not None and context not in self.CONTEXTS:
|
||||
raise ValueError(
|
||||
f"Ungültiger Kontext '{context}'. "
|
||||
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
|
||||
)
|
||||
|
||||
contexts_to_scan = (context,) if context else self.CONTEXTS
|
||||
projects: list[ProjectInfo] = []
|
||||
|
||||
for ctx in contexts_to_scan:
|
||||
context_dir = self.root_path / ctx
|
||||
if not context_dir.exists():
|
||||
continue
|
||||
|
||||
for entry in sorted(context_dir.iterdir()):
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
projects.append(
|
||||
ProjectInfo(
|
||||
name=entry.name,
|
||||
context=ctx,
|
||||
path=entry,
|
||||
)
|
||||
)
|
||||
|
||||
return projects
|
||||
|
||||
def resolve_context(self, project_path: Path) -> str:
|
||||
"""Ermittelt den Arbeitskontext eines Projekts anhand seines Pfads.
|
||||
|
||||
Analysiert den Pfad relativ zum Monorepo-Root und extrahiert
|
||||
den Kontextordner (erste Ebene).
|
||||
|
||||
Args:
|
||||
project_path: Absoluter oder relativer Pfad zum Projekt.
|
||||
|
||||
Returns:
|
||||
Der ermittelte Arbeitskontext als String.
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn der Pfad nicht innerhalb eines bekannten
|
||||
Kontextordners liegt.
|
||||
"""
|
||||
# Pfad auflösen (absolut machen)
|
||||
resolved = project_path.resolve()
|
||||
root_resolved = self.root_path.resolve()
|
||||
|
||||
# Prüfen ob der Pfad innerhalb des Monorepos liegt
|
||||
try:
|
||||
relative = resolved.relative_to(root_resolved)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Der Pfad '{project_path}' liegt nicht innerhalb "
|
||||
f"des Monorepos ({self.root_path})."
|
||||
)
|
||||
|
||||
# Ersten Pfadteil (Kontextordner) extrahieren
|
||||
parts = relative.parts
|
||||
if not parts:
|
||||
raise ValueError(
|
||||
f"Der Pfad '{project_path}' zeigt auf das Monorepo-Root, "
|
||||
f"nicht auf ein Projekt in einem Kontext."
|
||||
)
|
||||
|
||||
context_name = parts[0]
|
||||
if context_name not in self.CONTEXTS:
|
||||
raise ValueError(
|
||||
f"Der Pfad '{project_path}' liegt nicht in einem bekannten "
|
||||
f"Kontextordner. Gefunden: '{context_name}', "
|
||||
f"erwartet: {', '.join(self.CONTEXTS)}."
|
||||
)
|
||||
|
||||
return context_name
|
||||
Reference in New Issue
Block a user