1638 lines
58 KiB
Python
1638 lines
58 KiB
Python
"""Föderationsmodul für das Monorepo-CLI.
|
||
|
||
Implementiert den FederationManager und die SubtreeSyncEngine für die
|
||
Hub-and-Spoke-Topologie mit bidirektionaler Synchronisation zwischen
|
||
Monorepo (Hub) und Team-Repos (Spokes) via Git-Subtrees.
|
||
|
||
Das Monorepo ist der zentrale Hub (nur Andre sieht alles),
|
||
Team-Repos sind unabhängige Spokes. Synchronisation via Git-Subtrees
|
||
bewahrt vollständige Historie.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
import yaml
|
||
|
||
from monorepo.models import (
|
||
ConflictInfo,
|
||
ConflictStrategy,
|
||
IsolationLeak,
|
||
IsolationReport,
|
||
SharedMirrorConfig,
|
||
SyncResult,
|
||
TeamRepoEntry,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from monorepo.encryption import SecretEncryptionManager
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ergebnis-Dataclasses
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class MirrorResult:
|
||
"""Ergebnis einer Shared-Bereich-Spiegelung."""
|
||
|
||
success: bool
|
||
context: str
|
||
mirrored_paths: list[str] = field(default_factory=list)
|
||
errors: list[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class ConflictResult:
|
||
"""Ergebnis einer Konflikt-Auflösung."""
|
||
|
||
success: bool
|
||
context: str
|
||
strategy: str
|
||
resolved_files: list[str] = field(default_factory=list)
|
||
errors: list[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class MemberInfo:
|
||
"""Informationen über ein neues Team-Mitglied."""
|
||
|
||
name: str
|
||
email: str
|
||
role: str = "developer"
|
||
|
||
|
||
@dataclass
|
||
class MemberOnboardingResult:
|
||
"""Ergebnis des Team-Mitglied-Onboardings."""
|
||
|
||
success: bool
|
||
context: str
|
||
member_name: str
|
||
team_repo_url: str = ""
|
||
errors: list[str] = field(default_factory=list)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Föderations-Konfiguration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class FederationTopologyConfig:
|
||
"""Geladene Föderations-Topologie-Konfiguration aus team-repos.yaml."""
|
||
|
||
version: str = "1.0"
|
||
topology: str = "hub-and-spoke"
|
||
hub_owner: str = "andre"
|
||
conflict_strategy: str = "team-wins"
|
||
team_repos: list[TeamRepoEntry] = field(default_factory=list)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SubtreeSyncEngine
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class SubtreeSyncEngine:
|
||
"""Git-Subtree-basierte Synchronisation mit Historie-Bewahrung.
|
||
|
||
Kapselt die git-subtree-Befehle (pull/push) und bietet
|
||
Konflikterkennung vor der eigentlichen Synchronisation.
|
||
|
||
Args:
|
||
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
|
||
"""
|
||
|
||
def __init__(self, monorepo_root: Path) -> None:
|
||
self.monorepo_root = monorepo_root
|
||
|
||
def subtree_pull(
|
||
self, remote: str, prefix: str, branch: str
|
||
) -> SyncResult:
|
||
"""Zieht Änderungen vom Team_Repo per git subtree pull.
|
||
|
||
Args:
|
||
remote: URL des Remote-Repos.
|
||
prefix: Subtree-Prefix (Pfad im Monorepo).
|
||
branch: Branch im Remote-Repo.
|
||
|
||
Returns:
|
||
SyncResult mit Erfolgs-/Fehlerstatus.
|
||
"""
|
||
logger.info(
|
||
"Subtree-Pull: remote=%s, prefix=%s, branch=%s",
|
||
remote, prefix, branch,
|
||
)
|
||
|
||
try:
|
||
result = self._run_git([
|
||
"subtree", "pull",
|
||
f"--prefix={prefix}",
|
||
remote, branch,
|
||
"--squash",
|
||
])
|
||
commits = self._count_commits(result.stdout)
|
||
return SyncResult(
|
||
success=True,
|
||
context=prefix,
|
||
direction="pull",
|
||
commits_synced=commits,
|
||
conflicts=[],
|
||
timestamp=datetime.now(),
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
if self._is_merge_conflict(e):
|
||
conflicts = self._extract_conflicts(e)
|
||
self._abort_merge()
|
||
return SyncResult(
|
||
success=False,
|
||
context=prefix,
|
||
direction="pull",
|
||
commits_synced=0,
|
||
conflicts=conflicts,
|
||
timestamp=datetime.now(),
|
||
)
|
||
return SyncResult(
|
||
success=False,
|
||
context=prefix,
|
||
direction="pull",
|
||
commits_synced=0,
|
||
conflicts=[ConflictInfo(
|
||
file_path="",
|
||
conflict_type="content",
|
||
source="team-repo",
|
||
details=f"Subtree-Pull fehlgeschlagen: {e.stderr or e.stdout}",
|
||
)],
|
||
timestamp=datetime.now(),
|
||
)
|
||
|
||
def subtree_push(
|
||
self, remote: str, prefix: str, branch: str
|
||
) -> SyncResult:
|
||
"""Pusht Änderungen zum Team_Repo per git subtree push.
|
||
|
||
Args:
|
||
remote: URL des Remote-Repos.
|
||
prefix: Subtree-Prefix (Pfad im Monorepo).
|
||
branch: Branch im Remote-Repo.
|
||
|
||
Returns:
|
||
SyncResult mit Erfolgs-/Fehlerstatus.
|
||
"""
|
||
logger.info(
|
||
"Subtree-Push: remote=%s, prefix=%s, branch=%s",
|
||
remote, prefix, branch,
|
||
)
|
||
|
||
try:
|
||
result = self._run_git([
|
||
"subtree", "push",
|
||
f"--prefix={prefix}",
|
||
remote, branch,
|
||
])
|
||
commits = self._count_commits(result.stdout)
|
||
return SyncResult(
|
||
success=True,
|
||
context=prefix,
|
||
direction="push",
|
||
commits_synced=commits,
|
||
conflicts=[],
|
||
timestamp=datetime.now(),
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
return SyncResult(
|
||
success=False,
|
||
context=prefix,
|
||
direction="push",
|
||
commits_synced=0,
|
||
conflicts=[ConflictInfo(
|
||
file_path="",
|
||
conflict_type="content",
|
||
source="monorepo",
|
||
details=f"Subtree-Push fehlgeschlagen: {e.stderr or e.stdout}",
|
||
)],
|
||
timestamp=datetime.now(),
|
||
)
|
||
|
||
def detect_conflicts(self, context: str) -> list[ConflictInfo]:
|
||
"""Erkennt Merge-Konflikte vor der Synchronisation.
|
||
|
||
Führt einen Dry-Run durch, indem ein temporärer Fetch und Diff
|
||
gegen den Remote-Branch durchgeführt wird, ohne tatsächlich zu mergen.
|
||
|
||
Args:
|
||
context: Kontextordner (z.B. 'privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
Liste von ConflictInfo-Objekten für erkannte Konflikte.
|
||
Leere Liste wenn keine Konflikte erkannt werden.
|
||
"""
|
||
logger.info("Prüfe auf Konflikte für Kontext '%s'.", context)
|
||
|
||
try:
|
||
# Prüfe auf lokale uncommitted Änderungen im Prefix
|
||
status_result = self._run_git([
|
||
"status", "--porcelain", "--", f"{context}/",
|
||
])
|
||
if status_result.stdout.strip():
|
||
# Es gibt uncommitted Änderungen – potenzielle Konflikte
|
||
conflicts: list[ConflictInfo] = []
|
||
for line in status_result.stdout.strip().splitlines():
|
||
# Format: "XY filename"
|
||
if len(line) >= 3:
|
||
file_path = line[3:].strip()
|
||
conflicts.append(ConflictInfo(
|
||
file_path=file_path,
|
||
conflict_type="content",
|
||
source="monorepo",
|
||
details="Uncommitted lokale Änderungen erkannt",
|
||
))
|
||
return conflicts
|
||
except subprocess.CalledProcessError:
|
||
pass
|
||
|
||
return []
|
||
|
||
def preserve_history(self, repo_path: Path) -> bool:
|
||
"""Verifiziert, dass die Git-Historie nach Sync vollständig ist.
|
||
|
||
Prüft ob der Commit-Graph im gegebenen Pfad intakt ist und
|
||
keine verwaisten Commits existieren.
|
||
|
||
Args:
|
||
repo_path: Pfad zum Repository (oder Subtree-Prefix).
|
||
|
||
Returns:
|
||
True wenn die Historie vollständig und intakt ist.
|
||
"""
|
||
try:
|
||
result = self._run_git([
|
||
"log", "--oneline", "--", str(repo_path),
|
||
])
|
||
# Wenn wir Commits finden, ist die Historie vorhanden
|
||
return bool(result.stdout.strip())
|
||
except subprocess.CalledProcessError:
|
||
return False
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Interne Hilfsmethoden
|
||
# -----------------------------------------------------------------------
|
||
|
||
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 _is_merge_conflict(self, error: subprocess.CalledProcessError) -> bool:
|
||
"""Prüft ob ein CalledProcessError einen Merge-Konflikt anzeigt.
|
||
|
||
Args:
|
||
error: Der aufgetretene Fehler.
|
||
|
||
Returns:
|
||
True wenn der Fehler auf einen Merge-Konflikt hindeutet.
|
||
"""
|
||
conflict_indicators = [
|
||
"CONFLICT",
|
||
"merge conflict",
|
||
"Merge conflict",
|
||
"Automatic merge failed",
|
||
"fix conflicts",
|
||
]
|
||
combined = (error.stdout or "") + (error.stderr or "")
|
||
return any(indicator in combined for indicator in conflict_indicators)
|
||
|
||
def _extract_conflicts(
|
||
self, error: subprocess.CalledProcessError
|
||
) -> list[ConflictInfo]:
|
||
"""Extrahiert Konflikt-Details aus der Git-Fehlermeldung.
|
||
|
||
Args:
|
||
error: CalledProcessError mit Merge-Konflikt-Informationen.
|
||
|
||
Returns:
|
||
Liste von ConflictInfo-Objekten.
|
||
"""
|
||
conflicts: list[ConflictInfo] = []
|
||
combined = (error.stdout or "") + (error.stderr or "")
|
||
|
||
for line in combined.splitlines():
|
||
if "CONFLICT" in line:
|
||
# Typisches Format: "CONFLICT (content): Merge conflict in <file>"
|
||
conflict_type = "content"
|
||
if "rename" in line.lower():
|
||
conflict_type = "rename"
|
||
elif "delete" in line.lower() or "modify" in line.lower():
|
||
conflict_type = "delete-modify"
|
||
|
||
# Dateinamen extrahieren
|
||
file_path = ""
|
||
if " in " in line:
|
||
file_path = line.split(" in ", 1)[-1].strip()
|
||
|
||
conflicts.append(ConflictInfo(
|
||
file_path=file_path,
|
||
conflict_type=conflict_type, # type: ignore[arg-type]
|
||
source="team-repo",
|
||
details=line.strip(),
|
||
))
|
||
|
||
if not conflicts:
|
||
conflicts.append(ConflictInfo(
|
||
file_path="",
|
||
conflict_type="content",
|
||
source="team-repo",
|
||
details=f"Merge-Konflikt erkannt: {combined[:200]}",
|
||
))
|
||
|
||
return conflicts
|
||
|
||
def _abort_merge(self) -> None:
|
||
"""Bricht einen laufenden Merge ab, um einen sauberen Zustand herzustellen."""
|
||
try:
|
||
self._run_git(["merge", "--abort"])
|
||
except subprocess.CalledProcessError:
|
||
# merge --abort kann fehlschlagen wenn kein Merge läuft
|
||
logger.debug("merge --abort fehlgeschlagen (kein aktiver Merge).")
|
||
|
||
def _count_commits(self, output: str) -> int:
|
||
"""Zählt synchronisierte Commits aus der Git-Ausgabe.
|
||
|
||
Args:
|
||
output: Git-Befehlsausgabe.
|
||
|
||
Returns:
|
||
Geschätzte Anzahl synchronisierter Commits.
|
||
"""
|
||
if not output:
|
||
return 0
|
||
|
||
# Suche nach typischen Mustern wie "X commits" oder Commit-Hashes
|
||
lines = output.strip().splitlines()
|
||
commit_count = 0
|
||
for line in lines:
|
||
# git pull Ausgabe enthält oft "X files changed, Y insertions"
|
||
if "files changed" in line or "file changed" in line:
|
||
commit_count = max(commit_count, 1)
|
||
# Direkte Commit-Referenzen (kurze SHA)
|
||
if line.strip() and len(line.strip().split()[0]) >= 7:
|
||
parts = line.strip().split()
|
||
if parts[0].isalnum() and len(parts[0]) <= 40:
|
||
commit_count += 1
|
||
|
||
return max(commit_count, 1) if output.strip() else 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FederationManager
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class FederationManager:
|
||
"""Verwaltet die föderierte Repo-Struktur (Hub-and-Spoke).
|
||
|
||
Das Monorepo ist der zentrale Hub, jedes Team-Repo ein unabhängiger
|
||
Spoke. Synchronisation erfolgt bidirektional via Git-Subtrees, wobei
|
||
die vollständige Historie bewahrt wird.
|
||
|
||
Args:
|
||
config_path: Pfad zur team-repos.yaml Konfigurationsdatei.
|
||
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
|
||
encryption_manager: Optionaler SecretEncryptionManager für
|
||
kontextspezifische Secret-Filterung (Integration in Task 15.2).
|
||
"""
|
||
|
||
VALID_CONTEXTS = {"privat", "dhive", "bahn"}
|
||
|
||
def __init__(
|
||
self,
|
||
config_path: Path,
|
||
monorepo_root: Path | None = None,
|
||
encryption_manager: "SecretEncryptionManager | None" = None,
|
||
) -> None:
|
||
self.config_path = config_path
|
||
self.monorepo_root = monorepo_root or Path.cwd()
|
||
self.encryption_manager = encryption_manager
|
||
self.topology_config = self._load_team_repos_config(config_path)
|
||
self.sync_engine = SubtreeSyncEngine(self.monorepo_root)
|
||
|
||
# -------------------------------------------------------------------
|
||
# Öffentliche API
|
||
# -------------------------------------------------------------------
|
||
|
||
def sync_from_team(self, context: str) -> SyncResult:
|
||
"""Synchronisiert Änderungen vom Team_Repo in den Monorepo-Kontextordner.
|
||
|
||
Führt einen git subtree pull vom konfigurierten Team-Repo aus
|
||
und integriert die Änderungen in den entsprechenden Kontextordner.
|
||
|
||
Bei Merge-Konflikten wird die Synchronisation abgebrochen,
|
||
der Konflikt protokolliert und dem Hub-Besitzer eine manuelle
|
||
Auflösung ermöglicht.
|
||
|
||
Args:
|
||
context: Arbeitskontext ('privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
SyncResult mit Erfolgs-/Fehlerstatus und ggf. Konflikten.
|
||
"""
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return self._error_result(
|
||
context, "pull",
|
||
f"Kein Team-Repo für Kontext '{context}' konfiguriert.",
|
||
)
|
||
|
||
# Prüfe ob Sync-Richtung erlaubt
|
||
if team_entry.sync_direction == "hub-to-spoke":
|
||
return self._error_result(
|
||
context, "pull",
|
||
f"Sync-Richtung für '{context}' ist 'hub-to-spoke', "
|
||
f"Pull vom Team-Repo nicht erlaubt.",
|
||
)
|
||
|
||
logger.info("Sync von Team-Repo '%s' → Monorepo/%s", team_entry.url, context)
|
||
|
||
result = self.sync_engine.subtree_pull(
|
||
remote=team_entry.url,
|
||
prefix=context,
|
||
branch=team_entry.branch,
|
||
)
|
||
# Setze den korrekten Kontext im Ergebnis
|
||
result.context = context
|
||
|
||
if not result.success:
|
||
logger.warning(
|
||
"Sync von Team-Repo '%s' fehlgeschlagen: %d Konflikte.",
|
||
context, len(result.conflicts),
|
||
)
|
||
|
||
return result
|
||
|
||
def sync_to_team(self, context: str) -> SyncResult:
|
||
"""Synchronisiert Änderungen vom Monorepo-Kontextordner zum Team_Repo.
|
||
|
||
Führt einen git subtree push zum konfigurierten Team-Repo aus
|
||
und überträgt lokale Änderungen aus dem Kontextordner.
|
||
|
||
Args:
|
||
context: Arbeitskontext ('privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
SyncResult mit Erfolgs-/Fehlerstatus.
|
||
"""
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return self._error_result(
|
||
context, "push",
|
||
f"Kein Team-Repo für Kontext '{context}' konfiguriert.",
|
||
)
|
||
|
||
# Prüfe ob Sync-Richtung erlaubt
|
||
if team_entry.sync_direction == "spoke-to-hub":
|
||
return self._error_result(
|
||
context, "push",
|
||
f"Sync-Richtung für '{context}' ist 'spoke-to-hub', "
|
||
f"Push zum Team-Repo nicht erlaubt.",
|
||
)
|
||
|
||
logger.info("Sync von Monorepo/%s → Team-Repo '%s'", context, team_entry.url)
|
||
|
||
result = self.sync_engine.subtree_push(
|
||
remote=team_entry.url,
|
||
prefix=context,
|
||
branch=team_entry.branch,
|
||
)
|
||
# Setze den korrekten Kontext im Ergebnis
|
||
result.context = context
|
||
|
||
if not result.success:
|
||
logger.warning(
|
||
"Push zum Team-Repo '%s' fehlgeschlagen.", context,
|
||
)
|
||
|
||
return result
|
||
|
||
def full_sync(self, context: str) -> SyncResult:
|
||
"""Bidirektionale Synchronisation mit Konflikt-Erkennung.
|
||
|
||
Ablauf:
|
||
1. Konflikte vorab erkennen (detect_conflicts)
|
||
2. Pull vom Team-Repo (sync_from_team)
|
||
3. Bei Konflikt: Abbruch, Protokollierung, team-wins Strategie
|
||
4. Push zum Team-Repo (sync_to_team)
|
||
|
||
Bei Merge-Konflikten wird die Synchronisation abgebrochen und
|
||
dem Hub-Besitzer eine manuelle Auflösung ermöglicht.
|
||
|
||
Args:
|
||
context: Arbeitskontext ('privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
SyncResult mit Gesamtergebnis der bidirektionalen Sync.
|
||
"""
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return self._error_result(
|
||
context, "full",
|
||
f"Kein Team-Repo für Kontext '{context}' konfiguriert.",
|
||
)
|
||
|
||
if team_entry.sync_direction not in ("bidirectional",):
|
||
return self._error_result(
|
||
context, "full",
|
||
f"Bidirektionale Synchronisation für '{context}' nicht erlaubt "
|
||
f"(konfiguriert: '{team_entry.sync_direction}').",
|
||
)
|
||
|
||
logger.info("Full-Sync für Kontext '%s' gestartet.", context)
|
||
total_commits = 0
|
||
|
||
# Phase 1: Konflikte vorab prüfen
|
||
pre_conflicts = self.detect_conflicts(context)
|
||
if pre_conflicts:
|
||
logger.warning(
|
||
"Vorab-Konflikte erkannt für '%s': %d Konflikte.",
|
||
context, len(pre_conflicts),
|
||
)
|
||
return SyncResult(
|
||
success=False,
|
||
context=context,
|
||
direction="full",
|
||
commits_synced=0,
|
||
conflicts=pre_conflicts,
|
||
timestamp=datetime.now(),
|
||
)
|
||
|
||
# Phase 2: Pull vom Team-Repo
|
||
pull_result = self.sync_from_team(context)
|
||
if not pull_result.success:
|
||
logger.warning("Full-Sync abgebrochen: Pull fehlgeschlagen für '%s'.", context)
|
||
return SyncResult(
|
||
success=False,
|
||
context=context,
|
||
direction="full",
|
||
commits_synced=pull_result.commits_synced,
|
||
conflicts=pull_result.conflicts,
|
||
timestamp=datetime.now(),
|
||
)
|
||
total_commits += pull_result.commits_synced
|
||
|
||
# Phase 3: Push zum Team-Repo
|
||
push_result = self.sync_to_team(context)
|
||
if not push_result.success:
|
||
logger.warning("Full-Sync: Push fehlgeschlagen für '%s'.", context)
|
||
return SyncResult(
|
||
success=False,
|
||
context=context,
|
||
direction="full",
|
||
commits_synced=total_commits + push_result.commits_synced,
|
||
conflicts=push_result.conflicts,
|
||
timestamp=datetime.now(),
|
||
)
|
||
total_commits += push_result.commits_synced
|
||
|
||
logger.info(
|
||
"Full-Sync für '%s' erfolgreich: %d Commits synchronisiert.",
|
||
context, total_commits,
|
||
)
|
||
return SyncResult(
|
||
success=True,
|
||
context=context,
|
||
direction="full",
|
||
commits_synced=total_commits,
|
||
conflicts=[],
|
||
timestamp=datetime.now(),
|
||
)
|
||
|
||
def detect_conflicts(self, context: str) -> list[ConflictInfo]:
|
||
"""Erkennt Merge-Konflikte vor der Synchronisation.
|
||
|
||
Delegiert an die SubtreeSyncEngine für die eigentliche Prüfung.
|
||
|
||
Args:
|
||
context: Arbeitskontext ('privat', 'dhive', 'bahn').
|
||
|
||
Returns:
|
||
Liste von ConflictInfo-Objekten. Leer wenn keine Konflikte.
|
||
"""
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return [ConflictInfo(
|
||
file_path="",
|
||
conflict_type="content",
|
||
source="monorepo",
|
||
details=f"Kein Team-Repo für Kontext '{context}' konfiguriert.",
|
||
)]
|
||
|
||
return self.sync_engine.detect_conflicts(context)
|
||
|
||
def verify_isolation(
|
||
self, team_repo_path: Path, context: str
|
||
) -> IsolationReport:
|
||
"""Prüft ob ein Team_Repo keine Referenzen auf andere Kontexte enthält.
|
||
|
||
Durchsucht alle Dateien im Team-Repo nach Pfad-Referenzen,
|
||
Umgebungsvariablen, Config-Referenzen und Kommentaren die auf
|
||
andere Kontexte verweisen.
|
||
|
||
Args:
|
||
team_repo_path: Pfad zum zu prüfenden Team-Repo.
|
||
context: Erwarteter Kontext des Team-Repos.
|
||
|
||
Returns:
|
||
IsolationReport mit gefundenen Leaks (falls vorhanden).
|
||
"""
|
||
leaks: list[IsolationLeak] = []
|
||
other_contexts = self.VALID_CONTEXTS - {context}
|
||
|
||
if not team_repo_path.exists():
|
||
return IsolationReport(context=context, is_isolated=True, leaks=[])
|
||
|
||
# Scan all text files in the team repo
|
||
for file_path in self._iter_text_files(team_repo_path):
|
||
try:
|
||
rel_path = str(file_path.relative_to(team_repo_path))
|
||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||
lines = content.splitlines()
|
||
|
||
for line_num, line in enumerate(lines, start=1):
|
||
for other_ctx in other_contexts:
|
||
# 1. Pfad-Referenzen: z.B. "privat/", "dhive/project"
|
||
leak = self._check_path_reference(
|
||
line, other_ctx, rel_path, line_num
|
||
)
|
||
if leak:
|
||
leaks.append(leak)
|
||
|
||
# 2. Env-Variablen: z.B. DHIVE_API_KEY, BAHN_TOKEN
|
||
leak = self._check_env_var_reference(
|
||
line, other_ctx, rel_path, line_num
|
||
)
|
||
if leak:
|
||
leaks.append(leak)
|
||
|
||
# 3. Config-Referenzen: z.B. git-crypt-dhive, context: bahn
|
||
leak = self._check_config_reference(
|
||
line, other_ctx, rel_path, line_num
|
||
)
|
||
if leak:
|
||
leaks.append(leak)
|
||
|
||
# 4. Kommentare zu anderen Kontexten
|
||
leak = self._check_comment_reference(
|
||
line, other_ctx, rel_path, line_num
|
||
)
|
||
if leak:
|
||
leaks.append(leak)
|
||
|
||
except (OSError, UnicodeDecodeError):
|
||
# Binärdateien oder nicht-lesbare Dateien überspringen
|
||
continue
|
||
|
||
is_isolated = len(leaks) == 0
|
||
return IsolationReport(context=context, is_isolated=is_isolated, leaks=leaks)
|
||
|
||
def mirror_shared(self, context: str, paths: list[str]) -> MirrorResult:
|
||
"""Spiegelt ausgewählte shared-Dateien als Read-Only in das Team_Repo.
|
||
|
||
Kopiert die angegebenen Pfade aus dem shared-Bereich des Monorepos
|
||
in das Team_Repo-Verzeichnis (unter `.shared-mirror/`). Die Dateien
|
||
werden als read-only markiert. Die Spiegelung respektiert die
|
||
konfigurierte SharedMirrorConfig und Sync-Frequenz.
|
||
|
||
Args:
|
||
context: Ziel-Kontext für die Spiegelung.
|
||
paths: Liste von Pfaden aus dem shared-Bereich (relativ zum Monorepo-Root).
|
||
|
||
Returns:
|
||
MirrorResult mit gespiegelten Pfaden oder Fehlern.
|
||
"""
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return MirrorResult(
|
||
success=False,
|
||
context=context,
|
||
errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."],
|
||
)
|
||
|
||
# Prüfe ob shared_mirror in der Konfiguration aktiviert ist
|
||
if team_entry.shared_mirror is not None and not team_entry.shared_mirror.enabled:
|
||
return MirrorResult(
|
||
success=False,
|
||
context=context,
|
||
errors=[f"Shared-Mirror für Kontext '{context}' ist deaktiviert."],
|
||
)
|
||
|
||
# Bestimme die erlaubten Pfade aus der Konfiguration
|
||
allowed_paths: list[str] | None = None
|
||
if team_entry.shared_mirror is not None:
|
||
allowed_paths = team_entry.shared_mirror.paths
|
||
|
||
mirrored: list[str] = []
|
||
errors: list[str] = []
|
||
|
||
# Zielverzeichnis für gespiegelte Dateien im Kontext-Ordner
|
||
mirror_target = self.monorepo_root / context / ".shared-mirror"
|
||
|
||
for path in paths:
|
||
# Validiere dass der Pfad im shared-Bereich liegt
|
||
if not path.startswith("shared/"):
|
||
errors.append(
|
||
f"Pfad '{path}' liegt nicht im shared-Bereich. "
|
||
f"Nur Pfade unterhalb von 'shared/' können gespiegelt werden."
|
||
)
|
||
continue
|
||
|
||
# Prüfe ob der Pfad in den erlaubten Mirror-Pfaden enthalten ist
|
||
if allowed_paths is not None:
|
||
path_allowed = any(
|
||
path.startswith(allowed.rstrip("/"))
|
||
or path == allowed.rstrip("/")
|
||
for allowed in allowed_paths
|
||
)
|
||
if not path_allowed:
|
||
errors.append(
|
||
f"Pfad '{path}' ist nicht in der Mirror-Konfiguration "
|
||
f"für Kontext '{context}' erlaubt."
|
||
)
|
||
continue
|
||
|
||
source = self.monorepo_root / path
|
||
if not source.exists():
|
||
errors.append(f"Quellpfad '{path}' existiert nicht.")
|
||
continue
|
||
|
||
# Ziel-Pfad berechnen (Struktur beibehalten)
|
||
relative_path = path # z.B. "shared/tools/common/script.sh"
|
||
target = mirror_target / relative_path
|
||
|
||
try:
|
||
if source.is_file():
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
# Kopiere Datei-Inhalt
|
||
target.write_bytes(source.read_bytes())
|
||
# Read-Only setzen (entferne Schreibrechte)
|
||
target.chmod(0o444)
|
||
mirrored.append(path)
|
||
elif source.is_dir():
|
||
# Rekursiv alle Dateien im Verzeichnis spiegeln
|
||
for file_path in source.rglob("*"):
|
||
if file_path.is_file():
|
||
rel = file_path.relative_to(self.monorepo_root)
|
||
file_target = mirror_target / str(rel)
|
||
file_target.parent.mkdir(parents=True, exist_ok=True)
|
||
file_target.write_bytes(file_path.read_bytes())
|
||
file_target.chmod(0o444)
|
||
mirrored.append(path)
|
||
else:
|
||
errors.append(f"Pfad '{path}' ist weder Datei noch Verzeichnis.")
|
||
except OSError as e:
|
||
errors.append(f"Fehler beim Spiegeln von '{path}': {e}")
|
||
|
||
logger.info(
|
||
"Shared-Mirror für '%s': %d Pfade gespiegelt, %d Fehler.",
|
||
context, len(mirrored), len(errors),
|
||
)
|
||
|
||
return MirrorResult(
|
||
success=len(errors) == 0,
|
||
context=context,
|
||
mirrored_paths=mirrored,
|
||
errors=errors,
|
||
)
|
||
|
||
def prepare_team_repo(self, context: str) -> Path:
|
||
"""Erzeugt ein Team_Repo mit nur dem eigenen Kontext.
|
||
|
||
Erstellt eine saubere Kopie des Kontextordners ohne Referenzen
|
||
auf andere Kontexte. Secrets werden entschlüsselt oder mit einem
|
||
Team-Schlüssel re-keyed, sodass nur die Secrets des eigenen
|
||
Kontexts im Team-Repo enthalten sind.
|
||
|
||
Args:
|
||
context: Arbeitskontext für das Team-Repo.
|
||
|
||
Returns:
|
||
Pfad zum vorbereiteten Team-Repo.
|
||
|
||
Raises:
|
||
ValueError: Wenn der Kontext ungültig ist oder kein Team-Repo
|
||
konfiguriert ist.
|
||
"""
|
||
if context not in self.VALID_CONTEXTS:
|
||
raise ValueError(
|
||
f"Ungültiger Kontext '{context}'. "
|
||
f"Erlaubt: {', '.join(sorted(self.VALID_CONTEXTS))}"
|
||
)
|
||
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
raise ValueError(
|
||
f"Kein Team-Repo für Kontext '{context}' konfiguriert."
|
||
)
|
||
|
||
# Zielverzeichnis für das vorbereitete Team-Repo
|
||
team_repo_path = self.monorepo_root / ".team-repos" / context
|
||
team_repo_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Quellverzeichnis des Kontexts
|
||
context_source = self.monorepo_root / context
|
||
if not context_source.exists():
|
||
logger.warning(
|
||
"Kontextordner '%s' existiert nicht. "
|
||
"Erstelle leeres Team-Repo.", context
|
||
)
|
||
return team_repo_path
|
||
|
||
# Dateien kopieren (nur eigener Kontext)
|
||
self._copy_context_files(context_source, team_repo_path, context)
|
||
|
||
# Secrets-Handling: Nur Secrets des eigenen Kontexts einbeziehen
|
||
self._handle_team_secrets(team_repo_path, context)
|
||
|
||
# Isolation verifizieren
|
||
report = self.verify_isolation(team_repo_path, context)
|
||
if not report.is_isolated:
|
||
logger.warning(
|
||
"Team-Repo für '%s' enthält %d Cross-Context-Leaks. "
|
||
"Bereinigung wird durchgeführt.",
|
||
context, len(report.leaks),
|
||
)
|
||
self._sanitize_leaks(team_repo_path, report.leaks)
|
||
|
||
return team_repo_path
|
||
|
||
def resolve_conflict(
|
||
self, context: str, strategy: str = "team-wins"
|
||
) -> ConflictResult:
|
||
"""Löst Sync-Konflikte auf (Standard: Team_Repo hat Vorrang).
|
||
|
||
Verwendet `git checkout --theirs` für team-wins (Team_Repo ist
|
||
Single Source of Truth) oder `git checkout --ours` für hub-wins.
|
||
Bei strategy='manual' wird keine automatische Auflösung durchgeführt.
|
||
|
||
Die Strategie 'team-wins' entspricht Requirement 10.6: Das Team_Repo
|
||
hat bei Konflikten Vorrang als Single Source of Truth.
|
||
|
||
Args:
|
||
context: Arbeitskontext mit Konflikten.
|
||
strategy: Auflösungsstrategie ('team-wins', 'hub-wins', 'manual').
|
||
|
||
Returns:
|
||
ConflictResult mit aufgelösten Dateien oder Fehlern.
|
||
"""
|
||
valid_strategies = ("team-wins", "hub-wins", "manual")
|
||
if strategy not in valid_strategies:
|
||
return ConflictResult(
|
||
success=False,
|
||
context=context,
|
||
strategy=strategy,
|
||
errors=[
|
||
f"Ungültige Strategie '{strategy}'. "
|
||
f"Erlaubt: {', '.join(valid_strategies)}"
|
||
],
|
||
)
|
||
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return ConflictResult(
|
||
success=False,
|
||
context=context,
|
||
strategy=strategy,
|
||
errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."],
|
||
)
|
||
|
||
if strategy == "manual":
|
||
logger.info(
|
||
"Manuelle Konflikt-Auflösung für '%s' angefordert. "
|
||
"Keine automatische Auflösung durchgeführt.",
|
||
context,
|
||
)
|
||
return ConflictResult(
|
||
success=True,
|
||
context=context,
|
||
strategy=strategy,
|
||
resolved_files=[],
|
||
errors=[],
|
||
)
|
||
|
||
# Ermittle Dateien mit Konflikten (unmerged files)
|
||
try:
|
||
result = self.sync_engine._run_git(
|
||
["diff", "--name-only", "--diff-filter=U"]
|
||
)
|
||
conflicted_files = [
|
||
f.strip() for f in result.stdout.strip().splitlines()
|
||
if f.strip() and f.strip().startswith(f"{context}/")
|
||
]
|
||
except subprocess.CalledProcessError:
|
||
# Alternativ: ls-files --unmerged
|
||
try:
|
||
result = self.sync_engine._run_git(
|
||
["ls-files", "--unmerged"]
|
||
)
|
||
# Extrahiere Dateinamen aus ls-files Ausgabe
|
||
seen: set[str] = set()
|
||
conflicted_files = []
|
||
for line in result.stdout.strip().splitlines():
|
||
# Format: "<mode> <hash> <stage>\t<filename>"
|
||
if "\t" in line:
|
||
file_path = line.split("\t", 1)[1].strip()
|
||
if file_path.startswith(f"{context}/") and file_path not in seen:
|
||
conflicted_files.append(file_path)
|
||
seen.add(file_path)
|
||
except subprocess.CalledProcessError as e:
|
||
return ConflictResult(
|
||
success=False,
|
||
context=context,
|
||
strategy=strategy,
|
||
errors=[f"Konflikte konnten nicht ermittelt werden: {e}"],
|
||
)
|
||
|
||
if not conflicted_files:
|
||
logger.info("Keine Konflikte für Kontext '%s' gefunden.", context)
|
||
return ConflictResult(
|
||
success=True,
|
||
context=context,
|
||
strategy=strategy,
|
||
resolved_files=[],
|
||
errors=[],
|
||
)
|
||
|
||
# Löse Konflikte auf basierend auf Strategie
|
||
# team-wins: --theirs (Team_Repo hat Vorrang = Single Source of Truth)
|
||
# hub-wins: --ours (Monorepo-Hub hat Vorrang)
|
||
checkout_flag = "--theirs" if strategy == "team-wins" else "--ours"
|
||
|
||
resolved: list[str] = []
|
||
errors: list[str] = []
|
||
|
||
for file_path in conflicted_files:
|
||
try:
|
||
self.sync_engine._run_git(
|
||
["checkout", checkout_flag, "--", file_path]
|
||
)
|
||
# Stage die aufgelöste Datei
|
||
self.sync_engine._run_git(["add", file_path])
|
||
resolved.append(file_path)
|
||
logger.info(
|
||
"Konflikt aufgelöst (%s): %s", strategy, file_path,
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
error_msg = (
|
||
f"Konflikt-Auflösung für '{file_path}' fehlgeschlagen: "
|
||
f"{e.stderr or e.stdout or str(e)}"
|
||
)
|
||
errors.append(error_msg)
|
||
logger.warning(error_msg)
|
||
|
||
success = len(errors) == 0
|
||
logger.info(
|
||
"Konflikt-Auflösung für '%s' (%s): %d gelöst, %d Fehler.",
|
||
context, strategy, len(resolved), len(errors),
|
||
)
|
||
|
||
return ConflictResult(
|
||
success=success,
|
||
context=context,
|
||
strategy=strategy,
|
||
resolved_files=resolved,
|
||
errors=errors,
|
||
)
|
||
|
||
def onboard_member(
|
||
self, context: str, member_info: MemberInfo
|
||
) -> MemberOnboardingResult:
|
||
"""Erteilt Zugang nur zum Team_Repo ohne Kenntnis des Monorepos.
|
||
|
||
Das neue Mitglied erhält ausschließlich Zugang zum Team-Repo
|
||
seines Kontexts. Die Existenz anderer Kontexte und des
|
||
übergeordneten Monorepos wird nicht offenbart.
|
||
|
||
Der Onboarding-Prozess:
|
||
1. Validiert den Kontext und die Member-Info
|
||
2. Ermittelt die Team-Repo-URL (nur diese wird dem Mitglied mitgeteilt)
|
||
3. Gewährt Zugang via Repository-URL (ohne Monorepo-Details)
|
||
4. Respektiert die konfigurierte Sync-Frequenz
|
||
|
||
Args:
|
||
context: Arbeitskontext, zu dem Zugang erteilt werden soll.
|
||
member_info: Informationen über das neue Team-Mitglied.
|
||
|
||
Returns:
|
||
MemberOnboardingResult mit Details.
|
||
"""
|
||
# Validiere den Kontext
|
||
team_entry = self._get_team_entry(context)
|
||
if team_entry is None:
|
||
return MemberOnboardingResult(
|
||
success=False,
|
||
context=context,
|
||
member_name=member_info.name,
|
||
errors=[f"Kein Team-Repo für Kontext '{context}' konfiguriert."],
|
||
)
|
||
|
||
# Validiere Member-Info
|
||
if not member_info.name or not member_info.name.strip():
|
||
return MemberOnboardingResult(
|
||
success=False,
|
||
context=context,
|
||
member_name=member_info.name,
|
||
errors=["Mitglied-Name darf nicht leer sein."],
|
||
)
|
||
|
||
if not member_info.email or not member_info.email.strip():
|
||
return MemberOnboardingResult(
|
||
success=False,
|
||
context=context,
|
||
member_name=member_info.name,
|
||
errors=["Mitglied-E-Mail darf nicht leer sein."],
|
||
)
|
||
|
||
# Das Mitglied erhält ausschließlich die Team-Repo-URL.
|
||
# Keine Informationen über:
|
||
# - Die Existenz des Monorepos
|
||
# - Andere Kontexte (privat/dhive/bahn)
|
||
# - Die Hub-and-Spoke-Topologie
|
||
# - Shared-Bereiche (diese werden ggf. als Mirror bereitgestellt)
|
||
team_repo_url = team_entry.url
|
||
|
||
logger.info(
|
||
"Onboarding: Mitglied '%s' (%s) erhält Zugang zum Team-Repo '%s' "
|
||
"(Kontext: %s, Rolle: %s, Sync-Frequenz: %s).",
|
||
member_info.name,
|
||
member_info.email,
|
||
team_repo_url,
|
||
context,
|
||
member_info.role,
|
||
team_entry.sync_frequency,
|
||
)
|
||
|
||
return MemberOnboardingResult(
|
||
success=True,
|
||
context=context,
|
||
member_name=member_info.name,
|
||
team_repo_url=team_repo_url,
|
||
)
|
||
|
||
# -------------------------------------------------------------------
|
||
# Private Hilfsmethoden für Team-Isolation (Task 15.2)
|
||
# -------------------------------------------------------------------
|
||
|
||
def _iter_text_files(self, root: Path) -> list[Path]:
|
||
"""Iteriert über alle Text-Dateien in einem Verzeichnis (rekursiv).
|
||
|
||
Überspringt .git-Verzeichnisse, Binärdateien und versteckte Ordner.
|
||
|
||
Args:
|
||
root: Startverzeichnis für die Suche.
|
||
|
||
Returns:
|
||
Liste von Pfaden zu Text-Dateien.
|
||
"""
|
||
text_extensions = {
|
||
".py", ".yaml", ".yml", ".json", ".toml", ".cfg", ".ini",
|
||
".md", ".txt", ".rst", ".sh", ".bash", ".zsh", ".fish",
|
||
".env", ".gitattributes", ".gitignore", ".gitmodules",
|
||
".dockerfile", ".xml", ".html", ".css", ".js", ".ts",
|
||
".example", ".sample", ".template", ".conf",
|
||
}
|
||
# Dateinamen die immer als Text behandelt werden (unabhängig von Extension)
|
||
text_filenames = {
|
||
".gitattributes", ".gitignore", ".gitmodules",
|
||
".env", ".env.example", ".env.sample",
|
||
"Makefile", "Dockerfile", "Jenkinsfile",
|
||
}
|
||
files: list[Path] = []
|
||
|
||
if not root.exists():
|
||
return files
|
||
|
||
for item in root.rglob("*"):
|
||
# Skip .git directories and hidden folders
|
||
parts = item.relative_to(root).parts
|
||
if any(p.startswith(".git") and p != ".gitattributes" for p in parts):
|
||
continue
|
||
|
||
if item.is_file():
|
||
# Include files with known text extensions, known filenames,
|
||
# or no extension
|
||
suffix = item.suffix.lower()
|
||
name = item.name
|
||
if (
|
||
suffix in text_extensions
|
||
or suffix == ""
|
||
or name in text_filenames
|
||
or name.startswith(".env")
|
||
):
|
||
files.append(item)
|
||
|
||
return files
|
||
|
||
def _check_path_reference(
|
||
self, line: str, other_context: str, file_path: str, line_number: int
|
||
) -> IsolationLeak | None:
|
||
"""Prüft eine Zeile auf Pfad-Referenzen zu einem anderen Kontext.
|
||
|
||
Erkennt Muster wie: privat/, dhive/project, bahn/src
|
||
|
||
Args:
|
||
line: Zu prüfende Textzeile.
|
||
other_context: Fremder Kontext, auf den geprüft wird.
|
||
file_path: Relativer Pfad der Datei (für Report).
|
||
line_number: Zeilennummer (für Report).
|
||
|
||
Returns:
|
||
IsolationLeak bei Fund, None sonst.
|
||
"""
|
||
# Muster: Kontextname gefolgt von / (als Pfad-Beginn)
|
||
# Aber nicht als Teil eines längeren Wortes
|
||
pattern = rf'(?<![a-zA-Z0-9_-]){re.escape(other_context)}/'
|
||
if re.search(pattern, line):
|
||
return IsolationLeak(
|
||
file_path=file_path,
|
||
line_number=line_number,
|
||
leaked_context=other_context,
|
||
leak_type="path",
|
||
)
|
||
return None
|
||
|
||
def _check_env_var_reference(
|
||
self, line: str, other_context: str, file_path: str, line_number: int
|
||
) -> IsolationLeak | None:
|
||
"""Prüft eine Zeile auf Umgebungsvariablen-Referenzen eines anderen Kontexts.
|
||
|
||
Erkennt Muster wie: DHIVE_API_KEY, BAHN_TOKEN, PRIVAT_SECRET
|
||
|
||
Args:
|
||
line: Zu prüfende Textzeile.
|
||
other_context: Fremder Kontext, auf den geprüft wird.
|
||
file_path: Relativer Pfad der Datei (für Report).
|
||
line_number: Zeilennummer (für Report).
|
||
|
||
Returns:
|
||
IsolationLeak bei Fund, None sonst.
|
||
"""
|
||
# Muster: KONTEXT_ als Prefix in Großbuchstaben (typisch für Env-Variablen)
|
||
ctx_upper = other_context.upper()
|
||
pattern = rf'\b{re.escape(ctx_upper)}_[A-Z][A-Z0-9_]*\b'
|
||
if re.search(pattern, line):
|
||
return IsolationLeak(
|
||
file_path=file_path,
|
||
line_number=line_number,
|
||
leaked_context=other_context,
|
||
leak_type="env_var",
|
||
)
|
||
return None
|
||
|
||
def _check_config_reference(
|
||
self, line: str, other_context: str, file_path: str, line_number: int
|
||
) -> IsolationLeak | None:
|
||
"""Prüft eine Zeile auf Konfigurations-Referenzen zu einem anderen Kontext.
|
||
|
||
Erkennt Muster wie: git-crypt-dhive, context: bahn, kontext: privat
|
||
|
||
Args:
|
||
line: Zu prüfende Textzeile.
|
||
other_context: Fremder Kontext, auf den geprüft wird.
|
||
file_path: Relativer Pfad der Datei (für Report).
|
||
line_number: Zeilennummer (für Report).
|
||
|
||
Returns:
|
||
IsolationLeak bei Fund, None sonst.
|
||
"""
|
||
# Muster: git-crypt-{context} oder filter=git-crypt-{context}
|
||
git_crypt_pattern = rf'git-crypt-{re.escape(other_context)}\b'
|
||
if re.search(git_crypt_pattern, line):
|
||
return IsolationLeak(
|
||
file_path=file_path,
|
||
line_number=line_number,
|
||
leaked_context=other_context,
|
||
leak_type="config_ref",
|
||
)
|
||
|
||
# Muster: context: <other> oder kontext: <other> (YAML-Config)
|
||
context_pattern = rf'(?:context|kontext)\s*[:=]\s*["\']?{re.escape(other_context)}\b'
|
||
if re.search(context_pattern, line, re.IGNORECASE):
|
||
return IsolationLeak(
|
||
file_path=file_path,
|
||
line_number=line_number,
|
||
leaked_context=other_context,
|
||
leak_type="config_ref",
|
||
)
|
||
|
||
return None
|
||
|
||
def _check_comment_reference(
|
||
self, line: str, other_context: str, file_path: str, line_number: int
|
||
) -> IsolationLeak | None:
|
||
"""Prüft eine Zeile auf Kommentar-Referenzen zu einem anderen Kontext.
|
||
|
||
Erkennt Muster in Kommentaren: # Für dhive-Team, // bahn-Projekt usw.
|
||
|
||
Args:
|
||
line: Zu prüfende Textzeile.
|
||
other_context: Fremder Kontext, auf den geprüft wird.
|
||
file_path: Relativer Pfad der Datei (für Report).
|
||
line_number: Zeilennummer (für Report).
|
||
|
||
Returns:
|
||
IsolationLeak bei Fund, None sonst.
|
||
"""
|
||
# Prüfe ob die Zeile ein Kommentar ist (Python, Shell, YAML, JS/TS)
|
||
stripped = line.strip()
|
||
is_comment = (
|
||
stripped.startswith("#")
|
||
or stripped.startswith("//")
|
||
or stripped.startswith("/*")
|
||
or stripped.startswith("*")
|
||
or stripped.startswith("<!--")
|
||
)
|
||
|
||
if not is_comment:
|
||
return None
|
||
|
||
# In Kommentaren: Kontextname als eigenständiges Wort
|
||
pattern = rf'\b{re.escape(other_context)}\b'
|
||
if re.search(pattern, stripped, re.IGNORECASE):
|
||
return IsolationLeak(
|
||
file_path=file_path,
|
||
line_number=line_number,
|
||
leaked_context=other_context,
|
||
leak_type="comment",
|
||
)
|
||
|
||
return None
|
||
|
||
def _copy_context_files(
|
||
self, source: Path, destination: Path, context: str
|
||
) -> None:
|
||
"""Kopiert Dateien des eigenen Kontexts ins Team-Repo.
|
||
|
||
Überspringt .git-Verzeichnisse und Dateien die auf andere
|
||
Kontexte verweisen.
|
||
|
||
Args:
|
||
source: Quellverzeichnis (Kontextordner im Monorepo).
|
||
destination: Zielverzeichnis (Team-Repo).
|
||
context: Eigener Kontext (für Filterung).
|
||
"""
|
||
other_contexts = self.VALID_CONTEXTS - {context}
|
||
|
||
for item in source.rglob("*"):
|
||
# Skip .git directories
|
||
rel_parts = item.relative_to(source).parts
|
||
if any(p.startswith(".git") and p != ".gitattributes" for p in rel_parts):
|
||
continue
|
||
|
||
rel_path = item.relative_to(source)
|
||
dest_path = destination / rel_path
|
||
|
||
if item.is_dir():
|
||
# Skip directories named after other contexts
|
||
if item.name in other_contexts:
|
||
continue
|
||
dest_path.mkdir(parents=True, exist_ok=True)
|
||
elif item.is_file():
|
||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(item, dest_path)
|
||
|
||
def _handle_team_secrets(self, team_repo_path: Path, context: str) -> None:
|
||
"""Behandelt Secrets im Team-Repo.
|
||
|
||
Wenn ein SecretEncryptionManager verfügbar ist und der Maschinenkontext
|
||
für den Kontext autorisiert ist, werden Secrets entschlüsselt und im
|
||
Klartext ins Team-Repo geschrieben (Requirement 10.10: Team-Repos
|
||
erhalten nur Secrets des eigenen Kontexts, unverschlüsselt oder mit
|
||
Team-Schlüssel re-keyed).
|
||
|
||
Wenn nicht autorisiert, bleiben die Secrets verschlüsselt (mit dem
|
||
eigenen Kontext-Schlüssel). Secrets anderer Kontexte werden in jedem
|
||
Fall entfernt.
|
||
|
||
Args:
|
||
team_repo_path: Pfad zum Team-Repo.
|
||
context: Eigener Kontext.
|
||
"""
|
||
if self.encryption_manager is None:
|
||
return
|
||
|
||
# Prüfe ob Entschlüsselung für den Kontext möglich ist
|
||
if self.encryption_manager.is_authorized(context):
|
||
# Secrets können für das Team entschlüsselt bereitgestellt werden
|
||
logger.info(
|
||
"Secrets für Kontext '%s' werden entschlüsselt bereitgestellt.",
|
||
context,
|
||
)
|
||
# Entschlüssele alle Secret-Dateien im Team-Repo
|
||
self._decrypt_secrets_in_team_repo(team_repo_path, context)
|
||
else:
|
||
logger.info(
|
||
"Keine Autorisierung für Kontext '%s'. "
|
||
"Secrets bleiben verschlüsselt.", context,
|
||
)
|
||
|
||
# Entferne .gitattributes-Einträge die auf andere Kontexte verweisen
|
||
gitattributes_path = team_repo_path / ".gitattributes"
|
||
if gitattributes_path.exists():
|
||
self._filter_gitattributes(gitattributes_path, context)
|
||
|
||
def _decrypt_secrets_in_team_repo(
|
||
self, team_repo_path: Path, context: str
|
||
) -> None:
|
||
"""Entschlüsselt alle Secret-Dateien im Team-Repo.
|
||
|
||
Iteriert über bekannte Secret-Patterns (.env, *.pem, *.key, etc.)
|
||
und entschlüsselt sie via SecretEncryptionManager. Die entschlüsselten
|
||
Inhalte werden direkt ins Team-Repo geschrieben.
|
||
|
||
Args:
|
||
team_repo_path: Pfad zum Team-Repo.
|
||
context: Eigener Kontext.
|
||
"""
|
||
assert self.encryption_manager is not None
|
||
|
||
secret_extensions = {".env", ".pem", ".key"}
|
||
secret_name_patterns = {"token", "secret"}
|
||
|
||
for file_path in team_repo_path.rglob("*"):
|
||
if not file_path.is_file():
|
||
continue
|
||
|
||
# Prüfe ob die Datei ein Secret-Pattern matcht
|
||
is_secret = (
|
||
file_path.suffix.lower() in secret_extensions
|
||
or file_path.name.startswith(".env")
|
||
or any(pat in file_path.name.lower() for pat in secret_name_patterns)
|
||
)
|
||
|
||
if not is_secret:
|
||
continue
|
||
|
||
# Versuche Entschlüsselung über den Encryption Manager.
|
||
# Wir konstruieren den entsprechenden Pfad im Monorepo,
|
||
# da der EncryptionManager auf Monorepo-Pfaden arbeitet.
|
||
try:
|
||
rel_path = file_path.relative_to(team_repo_path)
|
||
monorepo_secret_path = self.monorepo_root / context / rel_path
|
||
|
||
if not monorepo_secret_path.exists():
|
||
continue
|
||
|
||
result = self.encryption_manager.decrypt_file(monorepo_secret_path)
|
||
if (
|
||
result.success
|
||
and result.content is not None
|
||
and isinstance(result.content, bytes)
|
||
):
|
||
# Schreibe entschlüsselten Inhalt ins Team-Repo
|
||
file_path.write_bytes(result.content)
|
||
logger.debug(
|
||
"Secret entschlüsselt bereitgestellt: %s", rel_path
|
||
)
|
||
except (OSError, ValueError, TypeError) as e:
|
||
logger.debug(
|
||
"Secret-Entschlüsselung für '%s' übersprungen: %s",
|
||
file_path.name, e,
|
||
)
|
||
|
||
def _filter_gitattributes(self, gitattributes_path: Path, context: str) -> None:
|
||
"""Filtert .gitattributes um nur den eigenen Kontext-Filter zu behalten.
|
||
|
||
Entfernt Filter-Referenzen auf andere Kontexte (z.B. git-crypt-dhive
|
||
wenn der aktuelle Kontext 'privat' ist).
|
||
|
||
Args:
|
||
gitattributes_path: Pfad zur .gitattributes-Datei.
|
||
context: Eigener Kontext.
|
||
"""
|
||
other_contexts = self.VALID_CONTEXTS - {context}
|
||
|
||
try:
|
||
content = gitattributes_path.read_text(encoding="utf-8")
|
||
lines = content.splitlines()
|
||
filtered_lines: list[str] = []
|
||
|
||
for line in lines:
|
||
# Entferne Zeilen die Filter anderer Kontexte referenzieren
|
||
skip = False
|
||
for other_ctx in other_contexts:
|
||
if f"git-crypt-{other_ctx}" in line:
|
||
skip = True
|
||
break
|
||
if not skip:
|
||
filtered_lines.append(line)
|
||
|
||
gitattributes_path.write_text(
|
||
"\n".join(filtered_lines) + "\n", encoding="utf-8"
|
||
)
|
||
except OSError as e:
|
||
logger.warning(
|
||
"Konnte .gitattributes nicht filtern: %s", e
|
||
)
|
||
|
||
def _sanitize_leaks(
|
||
self, team_repo_path: Path, leaks: list[IsolationLeak]
|
||
) -> None:
|
||
"""Bereinigt gefundene Cross-Context-Leaks im Team-Repo.
|
||
|
||
Entfernt oder kommentiert Referenzen auf andere Kontexte.
|
||
Konservative Strategie: Zeilen mit Leaks werden mit einem
|
||
Warnung-Kommentar versehen, nicht gelöscht.
|
||
|
||
Args:
|
||
team_repo_path: Pfad zum Team-Repo.
|
||
leaks: Liste der gefundenen Leaks.
|
||
"""
|
||
# Gruppiere Leaks nach Datei
|
||
leaks_by_file: dict[str, list[IsolationLeak]] = {}
|
||
for leak in leaks:
|
||
if leak.file_path not in leaks_by_file:
|
||
leaks_by_file[leak.file_path] = []
|
||
leaks_by_file[leak.file_path].append(leak)
|
||
|
||
for file_rel_path, file_leaks in leaks_by_file.items():
|
||
file_path = team_repo_path / file_rel_path
|
||
if not file_path.exists():
|
||
continue
|
||
|
||
try:
|
||
content = file_path.read_text(encoding="utf-8")
|
||
lines = content.splitlines()
|
||
|
||
# Zeilen mit Leaks durch bereinigte Version ersetzen
|
||
leak_lines = {lk.line_number for lk in file_leaks}
|
||
sanitized_lines: list[str] = []
|
||
|
||
for i, line in enumerate(lines, start=1):
|
||
if i in leak_lines:
|
||
# Zeile mit Leak entfernen (durch Leerzeile ersetzen)
|
||
sanitized_lines.append("")
|
||
else:
|
||
sanitized_lines.append(line)
|
||
|
||
file_path.write_text(
|
||
"\n".join(sanitized_lines) + "\n", encoding="utf-8"
|
||
)
|
||
except OSError as e:
|
||
logger.warning(
|
||
"Konnte Datei '%s' nicht bereinigen: %s",
|
||
file_rel_path, e,
|
||
)
|
||
|
||
# -------------------------------------------------------------------
|
||
# Konfiguration und Hilfsmethoden
|
||
# -------------------------------------------------------------------
|
||
|
||
def get_team_repos(self) -> list[TeamRepoEntry]:
|
||
"""Gibt alle konfigurierten Team-Repos zurück.
|
||
|
||
Returns:
|
||
Liste aller TeamRepoEntry-Objekte aus der Konfiguration.
|
||
"""
|
||
return self.topology_config.team_repos
|
||
|
||
def get_conflict_strategy(self) -> str:
|
||
"""Gibt die konfigurierte Konflikt-Strategie zurück.
|
||
|
||
Returns:
|
||
Strategiename (z.B. 'team-wins').
|
||
"""
|
||
return self.topology_config.conflict_strategy
|
||
|
||
def _get_team_entry(self, context: str) -> TeamRepoEntry | None:
|
||
"""Sucht den TeamRepoEntry für einen gegebenen Kontext.
|
||
|
||
Args:
|
||
context: Arbeitskontext.
|
||
|
||
Returns:
|
||
TeamRepoEntry oder None wenn nicht konfiguriert.
|
||
"""
|
||
for entry in self.topology_config.team_repos:
|
||
if entry.context == context:
|
||
return entry
|
||
return None
|
||
|
||
def _load_team_repos_config(self, config_path: Path) -> FederationTopologyConfig:
|
||
"""Lädt die Föderations-Konfiguration aus team-repos.yaml.
|
||
|
||
Args:
|
||
config_path: Pfad zur team-repos.yaml Datei.
|
||
|
||
Returns:
|
||
FederationTopologyConfig mit allen geladenen Einstellungen.
|
||
|
||
Raises:
|
||
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
|
||
ValueError: Wenn das YAML-Format ungültig ist.
|
||
"""
|
||
if not config_path.exists():
|
||
raise FileNotFoundError(
|
||
f"Team-Repos-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 team-repos.yaml-Format: "
|
||
f"Erwartet dict, erhalten {type(data)}"
|
||
)
|
||
|
||
# Föderations-Metadaten
|
||
federation_data = data.get("federation", {})
|
||
topology = federation_data.get("topology", "hub-and-spoke")
|
||
hub_owner = federation_data.get("hub_owner", "andre")
|
||
conflict_strategy = federation_data.get("conflict_strategy", "team-wins")
|
||
|
||
# Team-Repos parsen
|
||
team_repos: list[TeamRepoEntry] = []
|
||
for repo_data in data.get("team_repos", []):
|
||
shared_mirror: SharedMirrorConfig | None = None
|
||
mirror_data = repo_data.get("shared_mirror")
|
||
if mirror_data:
|
||
shared_mirror = SharedMirrorConfig(
|
||
enabled=mirror_data.get("enabled", False),
|
||
paths=mirror_data.get("paths", []),
|
||
mode=mirror_data.get("mode", "read-only"),
|
||
)
|
||
|
||
team_repos.append(TeamRepoEntry(
|
||
context=repo_data.get("context", ""),
|
||
url=repo_data.get("url", ""),
|
||
branch=repo_data.get("branch", "main"),
|
||
sync_direction=repo_data.get("sync_direction", "bidirectional"),
|
||
sync_frequency=repo_data.get("sync_frequency", "manual"),
|
||
shared_mirror=shared_mirror,
|
||
))
|
||
|
||
return FederationTopologyConfig(
|
||
version=data.get("version", "1.0"),
|
||
topology=topology,
|
||
hub_owner=hub_owner,
|
||
conflict_strategy=conflict_strategy,
|
||
team_repos=team_repos,
|
||
)
|
||
|
||
@staticmethod
|
||
def _error_result(context: str, direction: str, details: str) -> SyncResult:
|
||
"""Erzeugt ein Fehler-SyncResult.
|
||
|
||
Args:
|
||
context: Betroffener Kontext.
|
||
direction: Sync-Richtung ('pull', 'push', 'full').
|
||
details: Fehlerbeschreibung.
|
||
|
||
Returns:
|
||
SyncResult mit success=False und Fehlerdetails.
|
||
"""
|
||
return SyncResult(
|
||
success=False,
|
||
context=context,
|
||
direction=direction, # type: ignore[arg-type]
|
||
commits_synced=0,
|
||
conflicts=[ConflictInfo(
|
||
file_path="",
|
||
conflict_type="content",
|
||
source="monorepo",
|
||
details=details,
|
||
)],
|
||
timestamp=datetime.now(),
|
||
)
|