feat: Complete Temporal migration cleanup and fixes

- Remove obsolete docker_logs.py module and container diagnostics from SDK
- Fix security_assessment workflow metadata (vertical: rust -> python)
- Remove all Prefect references from documentation
- Add SDK exception handling test suite
- Clean up old test artifacts
This commit is contained in:
tduhamel42
2025-10-14 15:02:52 +02:00
parent ec812461d6
commit 7a260bd3ee
27 changed files with 379 additions and 9627 deletions
-387
View File
@@ -1,387 +0,0 @@
"""
Docker log integration for enhanced error reporting.
This module provides functionality to fetch and parse Docker container logs
to provide better context for deployment and workflow execution errors.
"""
# Copyright (c) 2025 FuzzingLabs
#
# Licensed under the Business Source License 1.1 (BSL). See the LICENSE file
# at the root of this repository for details.
#
# After the Change Date (four years from publication), this version of the
# Licensed Work will be made available under the Apache License, Version 2.0.
# See the LICENSE-APACHE file or http://www.apache.org/licenses/LICENSE-2.0
#
# Additional attribution and requirements are provided in the NOTICE file.
import logging
import re
import subprocess
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class ContainerLogEntry:
"""A single log entry from a container."""
timestamp: datetime
level: str
message: str
stream: str # 'stdout' or 'stderr'
raw: str
@dataclass
class ContainerDiagnostics:
"""Complete diagnostics for a container."""
container_id: Optional[str]
status: str
exit_code: Optional[int]
error: Optional[str]
logs: List[ContainerLogEntry]
resource_usage: Dict[str, Any]
volume_mounts: List[Dict[str, str]]
class DockerLogIntegration:
"""
Integration with Docker to fetch container logs and diagnostics.
This class provides methods to fetch container logs, parse common error
patterns, and extract meaningful diagnostic information from Docker
containers related to FuzzForge workflow execution.
"""
def __init__(self):
self.docker_available = self._check_docker_availability()
# Common error patterns in container logs
self.error_patterns = {
'permission_denied': [
r'permission denied',
r'operation not permitted',
r'cannot access.*permission denied'
],
'out_of_memory': [
r'out of memory',
r'oom killed',
r'cannot allocate memory'
],
'image_pull_failed': [
r'failed to pull image',
r'pull access denied',
r'image not found'
],
'volume_mount_failed': [
r'invalid mount config',
r'mount denied',
r'no such file or directory.*mount'
],
'network_error': [
r'network is unreachable',
r'connection refused',
r'timeout.*connect'
]
}
def _check_docker_availability(self) -> bool:
"""Check if Docker is available and accessible."""
try:
result = subprocess.run(['docker', 'version', '--format', 'json'],
capture_output=True, text=True, timeout=5)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
return False
def get_container_logs(self, container_name_or_id: str, tail: int = 100) -> List[ContainerLogEntry]:
"""
Fetch logs from a Docker container.
Args:
container_name_or_id: Container name or ID
tail: Number of log lines to retrieve
Returns:
List of parsed log entries
"""
if not self.docker_available:
logger.warning("Docker not available, cannot fetch container logs")
return []
try:
cmd = ['docker', 'logs', '--timestamps', '--tail', str(tail), container_name_or_id]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
logger.error(f"Failed to fetch logs for container {container_name_or_id}: {result.stderr}")
return []
return self._parse_docker_logs(result.stdout + result.stderr)
except subprocess.TimeoutExpired:
logger.error(f"Timeout fetching logs for container {container_name_or_id}")
return []
except Exception as e:
logger.error(f"Error fetching container logs: {e}")
return []
def _parse_docker_logs(self, raw_logs: str) -> List[ContainerLogEntry]:
"""Parse raw Docker logs into structured entries."""
entries = []
for line in raw_logs.strip().split('\n'):
if not line.strip():
continue
entry = self._parse_log_line(line)
if entry:
entries.append(entry)
return entries
def _parse_log_line(self, line: str) -> Optional[ContainerLogEntry]:
"""Parse a single log line with timestamp."""
# Docker log format: 2023-10-01T12:00:00.000000000Z message
timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)', line)
if timestamp_match:
timestamp_str, message = timestamp_match.groups()
try:
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
except ValueError:
timestamp = datetime.now(timezone.utc)
else:
timestamp = datetime.now(timezone.utc)
message = line
# Determine log level from message content
level = self._extract_log_level(message)
# Determine stream (simplified - Docker doesn't clearly separate in combined output)
stream = 'stderr' if any(keyword in message.lower() for keyword in ['error', 'failed', 'exception']) else 'stdout'
return ContainerLogEntry(
timestamp=timestamp,
level=level,
message=message.strip(),
stream=stream,
raw=line
)
def _extract_log_level(self, message: str) -> str:
"""Extract log level from message content."""
message_lower = message.lower()
if any(keyword in message_lower for keyword in ['error', 'failed', 'exception', 'fatal']):
return 'ERROR'
elif any(keyword in message_lower for keyword in ['warning', 'warn']):
return 'WARNING'
elif any(keyword in message_lower for keyword in ['info', 'information']):
return 'INFO'
elif any(keyword in message_lower for keyword in ['debug']):
return 'DEBUG'
else:
return 'INFO'
def get_container_diagnostics(self, container_name_or_id: str) -> ContainerDiagnostics:
"""
Get complete diagnostics for a container including logs, status, and resource usage.
Args:
container_name_or_id: Container name or ID
Returns:
Complete container diagnostics
"""
if not self.docker_available:
return ContainerDiagnostics(
container_id=None,
status="unknown",
exit_code=None,
error="Docker not available",
logs=[],
resource_usage={},
volume_mounts=[]
)
# Get container inspect data
inspect_data = self._get_container_inspect(container_name_or_id)
# Get logs
logs = self.get_container_logs(container_name_or_id)
# Extract key information
if inspect_data:
state = inspect_data.get('State', {})
config = inspect_data.get('Config', {})
host_config = inspect_data.get('HostConfig', {})
status = state.get('Status', 'unknown')
exit_code = state.get('ExitCode')
error = state.get('Error', '')
# Get volume mounts
mounts = inspect_data.get('Mounts', [])
volume_mounts = [
{
'source': mount.get('Source', ''),
'destination': mount.get('Destination', ''),
'mode': mount.get('Mode', ''),
'type': mount.get('Type', '')
}
for mount in mounts
]
# Get resource limits
resource_usage = {
'memory_limit': host_config.get('Memory', 0),
'cpu_limit': host_config.get('CpuQuota', 0),
'cpu_period': host_config.get('CpuPeriod', 0)
}
else:
status = "not_found"
exit_code = None
error = f"Container {container_name_or_id} not found"
volume_mounts = []
resource_usage = {}
return ContainerDiagnostics(
container_id=container_name_or_id,
status=status,
exit_code=exit_code,
error=error,
logs=logs,
resource_usage=resource_usage,
volume_mounts=volume_mounts
)
def _get_container_inspect(self, container_name_or_id: str) -> Optional[Dict[str, Any]]:
"""Get container inspection data."""
try:
cmd = ['docker', 'inspect', container_name_or_id]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
return data[0] if data else None
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e:
logger.debug(f"Failed to inspect container {container_name_or_id}: {e}")
return None
def analyze_error_patterns(self, logs: List[ContainerLogEntry]) -> Dict[str, List[str]]:
"""
Analyze logs for common error patterns.
Args:
logs: List of log entries to analyze
Returns:
Dictionary mapping error types to matching log messages
"""
detected_errors = {}
for error_type, patterns in self.error_patterns.items():
matches = []
for log_entry in logs:
for pattern in patterns:
if re.search(pattern, log_entry.message, re.IGNORECASE):
matches.append(log_entry.message)
break # Don't match the same message multiple times
if matches:
detected_errors[error_type] = matches
return detected_errors
def get_container_names_by_label(self, label_filter: str) -> List[str]:
"""
Get container names that match a specific label filter.
Args:
label_filter: Label filter (e.g., "prefect.flow-run-id=12345")
Returns:
List of container names
"""
if not self.docker_available:
return []
try:
cmd = ['docker', 'ps', '-a', '--filter', f'label={label_filter}', '--format', '{{.Names}}']
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode != 0:
return []
return [name.strip() for name in result.stdout.strip().split('\n') if name.strip()]
except Exception as e:
logger.debug(f"Failed to get containers by label {label_filter}: {e}")
return []
def suggest_fixes(self, error_analysis: Dict[str, List[str]]) -> List[str]:
"""
Suggest fixes based on detected error patterns.
Args:
error_analysis: Result from analyze_error_patterns()
Returns:
List of suggested fixes
"""
suggestions = []
if 'permission_denied' in error_analysis:
suggestions.extend([
"Check file permissions on the target path",
"Ensure the Docker daemon has access to the mounted volumes",
"Try running with elevated privileges or adjust volume ownership"
])
if 'out_of_memory' in error_analysis:
suggestions.extend([
"Increase memory limits for the workflow",
"Check if the target files are too large for available memory",
"Consider using streaming processing for large datasets"
])
if 'image_pull_failed' in error_analysis:
suggestions.extend([
"Check network connectivity to Docker registry",
"Verify image name and tag are correct",
"Ensure Docker registry credentials are configured"
])
if 'volume_mount_failed' in error_analysis:
suggestions.extend([
"Verify the target path exists and is accessible",
"Check volume mount syntax and permissions",
"Ensure the path is not already in use by another process"
])
if 'network_error' in error_analysis:
suggestions.extend([
"Check network connectivity",
"Verify backend services are running (docker-compose up -d)",
"Check firewall settings and port availability"
])
if not suggestions:
suggestions.append("Review the container logs above for specific error details")
return suggestions
# Global instance for easy access
docker_integration = DockerLogIntegration()
+12 -87
View File
@@ -1,8 +1,9 @@
"""
Enhanced exceptions for FuzzForge SDK with rich context and Docker integration.
Enhanced exceptions for FuzzForge SDK with rich context.
Provides comprehensive error information including container logs, diagnostics,
and actionable suggestions for troubleshooting.
Provides comprehensive error information and actionable suggestions for troubleshooting.
Note: Container diagnostics are not available in Temporal architecture as workflows
run in long-lived worker containers rather than ephemeral per-workflow containers.
"""
# Copyright (c) 2025 FuzzingLabs
#
@@ -21,8 +22,6 @@ import re
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, asdict
from .docker_logs import docker_integration, ContainerDiagnostics
@dataclass
class ErrorContext:
@@ -31,7 +30,6 @@ class ErrorContext:
request_method: Optional[str] = None
request_data: Optional[Dict[str, Any]] = None
response_data: Optional[Dict[str, Any]] = None
container_diagnostics: Optional[ContainerDiagnostics] = None
suggested_fixes: List[str] = None
error_patterns: Dict[str, List[str]] = None
related_run_id: Optional[str] = None
@@ -62,49 +60,10 @@ class FuzzForgeError(Exception):
self.context = context or ErrorContext()
self.original_exception = original_exception
# Auto-populate container diagnostics if we have a run ID
if self.context.related_run_id and not self.context.container_diagnostics:
self._fetch_container_diagnostics()
def _fetch_container_diagnostics(self):
"""Fetch container diagnostics for the related run."""
if not self.context.related_run_id:
return
try:
# Try to find containers by Prefect run ID label
label_filter = f"prefect.flow-run-id={self.context.related_run_id}"
container_names = docker_integration.get_container_names_by_label(label_filter)
if container_names:
# Use the most recent container
container_name = container_names[0]
diagnostics = docker_integration.get_container_diagnostics(container_name)
# Analyze error patterns in logs
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
self.context.container_diagnostics = diagnostics
self.context.error_patterns = error_analysis
self.context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail the main error because of diagnostics issues
pass
def get_summary(self) -> str:
"""Get a summary of the error with key details."""
parts = [self.message]
if self.context.container_diagnostics:
diag = self.context.container_diagnostics
if diag.status != 'running':
parts.append(f"Container status: {diag.status}")
if diag.exit_code is not None:
parts.append(f"Exit code: {diag.exit_code}")
if self.context.error_patterns:
detected = list(self.context.error_patterns.keys())
parts.append(f"Detected issues: {', '.join(detected)}")
@@ -153,18 +112,11 @@ class FuzzForgeHTTPError(FuzzForgeError):
self.response_text = response_text
def get_summary(self) -> str:
base = f"HTTP {self.status_code}: {self.message}"
if self.context.container_diagnostics:
diag = self.context.container_diagnostics
if diag.exit_code is not None and diag.exit_code != 0:
base += f" (Container exit code: {diag.exit_code})"
return base
return f"HTTP {self.status_code}: {self.message}"
class DeploymentError(FuzzForgeHTTPError):
"""Enhanced deployment errors with container diagnostics."""
"""Enhanced deployment errors."""
def __init__(
self,
@@ -181,23 +133,9 @@ class DeploymentError(FuzzForgeHTTPError):
context.workflow_name = workflow_name
# If we have a container name, get its diagnostics immediately
if container_name:
try:
diagnostics = docker_integration.get_container_diagnostics(container_name)
context.container_diagnostics = diagnostics
# Analyze logs for error patterns
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
context.error_patterns = error_analysis
context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail on diagnostics
pass
# Note: Container diagnostics are not fetched in Temporal architecture.
# Workflows run in long-lived worker containers, not per-workflow containers.
# The container_name parameter is kept for backward compatibility but not used.
full_message = f"Deployment failed for workflow '{workflow_name}': {message}"
super().__init__(full_message, status_code, response_text, context)
@@ -292,22 +230,9 @@ class ContainerError(FuzzForgeError):
if context is None:
context = ErrorContext()
# Immediately fetch container diagnostics
try:
diagnostics = docker_integration.get_container_diagnostics(container_name)
context.container_diagnostics = diagnostics
# Analyze logs for patterns
if diagnostics.logs:
error_analysis = docker_integration.analyze_error_patterns(diagnostics.logs)
suggestions = docker_integration.suggest_fixes(error_analysis)
context.error_patterns = error_analysis
context.suggested_fixes.extend(suggestions)
except Exception:
# Don't fail on diagnostics
pass
# Note: Container diagnostics are not fetched in Temporal architecture.
# Workflows run in long-lived worker containers, not per-workflow containers.
# The container_name parameter is kept for backward compatibility but not used.
full_message = f"Container error ({container_name}): {message}"
if exit_code is not None:
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Quick smoke test for SDK exception handling after exceptions.py modifications.
Tests that the modified _fetch_container_diagnostics() no-op doesn't break exception flows.
"""
import sys
from pathlib import Path
# Add SDK to path
sdk_path = Path(__file__).parent / "src"
sys.path.insert(0, str(sdk_path))
from fuzzforge_sdk.exceptions import (
FuzzForgeError,
FuzzForgeHTTPError,
WorkflowNotFoundError,
RunNotFoundError,
ErrorContext,
DeploymentError,
WorkflowExecutionError,
ValidationError,
)
def test_basic_import():
"""Test that all exception classes can be imported."""
print("✓ All exception classes imported successfully")
def test_error_context():
"""Test ErrorContext instantiation."""
context = ErrorContext(
url="http://localhost:8000/test",
related_run_id="test-run-123",
workflow_name="test_workflow"
)
assert context.url == "http://localhost:8000/test"
assert context.related_run_id == "test-run-123"
assert context.workflow_name == "test_workflow"
print("✓ ErrorContext instantiation works")
def test_base_exception():
"""Test base FuzzForgeError."""
context = ErrorContext(related_run_id="test-run-456")
error = FuzzForgeError("Test error message", context=context)
assert error.message == "Test error message"
assert error.context.related_run_id == "test-run-456"
print("✓ FuzzForgeError creation works")
def test_http_error():
"""Test HTTP error creation."""
error = FuzzForgeHTTPError(
message="Test HTTP error",
status_code=500,
response_text='{"error": "Internal server error"}'
)
assert error.status_code == 500
assert error.message == "Test HTTP error"
assert error.context.response_data == {"error": "Internal server error"}
print("✓ FuzzForgeHTTPError creation works")
def test_workflow_not_found():
"""Test WorkflowNotFoundError with suggestions."""
error = WorkflowNotFoundError(
workflow_name="nonexistent_workflow",
available_workflows=["security_assessment", "secret_detection"]
)
assert error.workflow_name == "nonexistent_workflow"
assert len(error.context.suggested_fixes) > 0
print("✓ WorkflowNotFoundError with suggestions works")
def test_run_not_found():
"""Test RunNotFoundError."""
error = RunNotFoundError(run_id="missing-run-123")
assert error.run_id == "missing-run-123"
assert error.context.related_run_id == "missing-run-123"
assert len(error.context.suggested_fixes) > 0
print("✓ RunNotFoundError creation works")
def test_deployment_error():
"""Test DeploymentError."""
error = DeploymentError(
workflow_name="test_workflow",
message="Deployment failed",
deployment_id="deploy-123",
container_name="test-container-456" # Kept for backward compatibility
)
assert error.workflow_name == "test_workflow"
assert error.deployment_id == "deploy-123"
print("✓ DeploymentError creation works")
def test_workflow_execution_error():
"""Test WorkflowExecutionError."""
error = WorkflowExecutionError(
workflow_name="security_assessment",
run_id="run-789",
message="Execution timeout"
)
assert error.workflow_name == "security_assessment"
assert error.run_id == "run-789"
assert error.context.related_run_id == "run-789"
print("✓ WorkflowExecutionError creation works")
def test_validation_error():
"""Test ValidationError."""
error = ValidationError(
field_name="target_path",
message="Path does not exist",
provided_value="/nonexistent/path",
expected_format="Valid directory path"
)
assert error.field_name == "target_path"
assert error.provided_value == "/nonexistent/path"
assert len(error.context.suggested_fixes) > 0
print("✓ ValidationError with suggestions works")
def test_exception_string_representation():
"""Test exception summary and string conversion."""
error = FuzzForgeHTTPError(
message="Test error",
status_code=404,
response_text="Not found"
)
summary = error.get_summary()
assert "404" in summary
assert "Test error" in summary
str_repr = str(error)
assert str_repr == summary
print("✓ Exception string representation works")
def test_exception_detailed_info():
"""Test detailed error information."""
context = ErrorContext(
url="http://localhost:8000/test",
workflow_name="test_workflow"
)
error = FuzzForgeError("Test error", context=context)
info = error.get_detailed_info()
assert info["message"] == "Test error"
assert info["type"] == "FuzzForgeError"
assert info["url"] == "http://localhost:8000/test"
assert info["workflow_name"] == "test_workflow"
print("✓ Exception detailed info works")
def main():
"""Run all tests."""
print("\n" + "="*60)
print("SDK Exception Handling Smoke Tests")
print("="*60 + "\n")
tests = [
test_basic_import,
test_error_context,
test_base_exception,
test_http_error,
test_workflow_not_found,
test_run_not_found,
test_deployment_error,
test_workflow_execution_error,
test_validation_error,
test_exception_string_representation,
test_exception_detailed_info,
]
passed = 0
failed = 0
for test_func in tests:
try:
test_func()
passed += 1
except Exception as e:
print(f"{test_func.__name__} FAILED: {e}")
failed += 1
print("\n" + "="*60)
print(f"Results: {passed} passed, {failed} failed")
print("="*60 + "\n")
if failed > 0:
print("❌ SDK exception handling has issues")
return 1
else:
print("✅ SDK exception handling works correctly")
print("✅ The no-op _fetch_container_diagnostics() doesn't break exception flows")
return 0
if __name__ == "__main__":
sys.exit(main())