Files
Orchestrator/shared/tools/monorepo-cli/src/monorepo/federation.py
T
2026-06-30 20:37:40 +02:00

1638 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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(),
)