Merge pull request #20 from FuzzingLabs/dev

Release: v0.7.1 - Worker fixes, monitor consolidation, and findings improvements
This commit is contained in:
tduhamel42
2025-10-21 16:59:44 +02:00
committed by GitHub
9 changed files with 283 additions and 204 deletions
+8 -2
View File
@@ -55,9 +55,12 @@ async def get_run_status(
is_failed = workflow_status == "FAILED" is_failed = workflow_status == "FAILED"
is_running = workflow_status == "RUNNING" is_running = workflow_status == "RUNNING"
# Extract workflow name from run_id (format: workflow_name-unique_id)
workflow_name = run_id.rsplit('-', 1)[0] if '-' in run_id else "unknown"
return WorkflowStatus( return WorkflowStatus(
run_id=run_id, run_id=run_id,
workflow="unknown", # Temporal doesn't track workflow name in status workflow=workflow_name,
status=workflow_status, status=workflow_status,
is_completed=is_completed, is_completed=is_completed,
is_failed=is_failed, is_failed=is_failed,
@@ -123,6 +126,9 @@ async def get_run_findings(
else: else:
sarif = {} sarif = {}
# Extract workflow name from run_id (format: workflow_name-unique_id)
workflow_name = run_id.rsplit('-', 1)[0] if '-' in run_id else "unknown"
# Metadata # Metadata
metadata = { metadata = {
"completion_time": status.get("close_time"), "completion_time": status.get("close_time"),
@@ -130,7 +136,7 @@ async def get_run_findings(
} }
return WorkflowFindings( return WorkflowFindings(
workflow="unknown", workflow=workflow_name,
run_id=run_id, run_id=run_id,
sarif=sarif, sarif=sarif,
metadata=metadata metadata=metadata
-1
View File
@@ -566,7 +566,6 @@ async def get_workflow_worker_info(
return { return {
"workflow": workflow_name, "workflow": workflow_name,
"vertical": vertical, "vertical": vertical,
"worker_container": f"fuzzforge-worker-{vertical}",
"worker_service": f"worker-{vertical}", "worker_service": f"worker-{vertical}",
"task_queue": f"{vertical}-queue", "task_queue": f"{vertical}-queue",
"required": True "required": True
+149 -12
View File
@@ -140,11 +140,145 @@ def get_findings(
else: # table format else: # table format
display_findings_table(findings.sarif) display_findings_table(findings.sarif)
# Suggest export command and show command
console.print(f"\n💡 View full details of a finding: [bold cyan]ff finding show {run_id} --rule <rule-id>[/bold cyan]")
console.print(f"💡 Export these findings: [bold cyan]ff findings export {run_id} --format sarif[/bold cyan]")
console.print(" Supported formats: [cyan]sarif[/cyan] (standard), [cyan]json[/cyan], [cyan]csv[/cyan], [cyan]html[/cyan]")
except Exception as e: except Exception as e:
console.print(f"❌ Failed to get findings: {e}", style="red") console.print(f"❌ Failed to get findings: {e}", style="red")
raise typer.Exit(1) raise typer.Exit(1)
def show_finding(
run_id: str = typer.Argument(..., help="Run ID to get finding from"),
rule_id: str = typer.Option(..., "--rule", "-r", help="Rule ID of the specific finding to show")
):
"""
🔍 Show detailed information about a specific finding
This function is registered as a command in main.py under the finding (singular) command group.
"""
try:
require_project()
validate_run_id(run_id)
# Try to get from database first, fallback to API
db = get_project_db()
findings_data = None
if db:
findings_data = db.get_findings(run_id)
if not findings_data:
with get_client() as client:
console.print(f"🔍 Fetching findings for run: {run_id}")
findings = client.get_run_findings(run_id)
sarif_data = findings.sarif
else:
sarif_data = findings_data.sarif_data
# Find the specific finding by rule_id
runs = sarif_data.get("runs", [])
if not runs:
console.print("❌ No findings data available", style="red")
raise typer.Exit(1)
run_data = runs[0]
results = run_data.get("results", [])
tool = run_data.get("tool", {}).get("driver", {})
# Search for matching finding
matching_finding = None
for result in results:
if result.get("ruleId") == rule_id:
matching_finding = result
break
if not matching_finding:
console.print(f"❌ No finding found with rule ID: {rule_id}", style="red")
console.print(f"💡 Use [bold cyan]ff findings get {run_id}[/bold cyan] to see all findings", style="dim")
raise typer.Exit(1)
# Display detailed finding
display_finding_detail(matching_finding, tool, run_id)
except Exception as e:
console.print(f"❌ Failed to get finding: {e}", style="red")
raise typer.Exit(1)
def display_finding_detail(finding: Dict[str, Any], tool: Dict[str, Any], run_id: str):
"""Display detailed information about a single finding"""
rule_id = finding.get("ruleId", "unknown")
level = finding.get("level", "note")
message = finding.get("message", {})
message_text = message.get("text", "No summary available")
message_markdown = message.get("markdown", message_text)
# Get location
locations = finding.get("locations", [])
location_str = "Unknown location"
code_snippet = None
if locations:
physical_location = locations[0].get("physicalLocation", {})
artifact_location = physical_location.get("artifactLocation", {})
region = physical_location.get("region", {})
file_path = artifact_location.get("uri", "")
if file_path:
location_str = file_path
if region.get("startLine"):
location_str += f":{region['startLine']}"
if region.get("startColumn"):
location_str += f":{region['startColumn']}"
# Get code snippet if available
if region.get("snippet", {}).get("text"):
code_snippet = region["snippet"]["text"].strip()
# Get severity style
severity_color = {
"error": "red",
"warning": "yellow",
"note": "blue",
"info": "cyan"
}.get(level.lower(), "white")
# Build detailed content
content_lines = []
content_lines.append(f"[bold]Rule ID:[/bold] {rule_id}")
content_lines.append(f"[bold]Severity:[/bold] [{severity_color}]{level.upper()}[/{severity_color}]")
content_lines.append(f"[bold]Location:[/bold] {location_str}")
content_lines.append(f"[bold]Tool:[/bold] {tool.get('name', 'Unknown')} v{tool.get('version', 'unknown')}")
content_lines.append(f"[bold]Run ID:[/bold] {run_id}")
content_lines.append("")
content_lines.append(f"[bold]Summary:[/bold]")
content_lines.append(message_text)
content_lines.append("")
content_lines.append(f"[bold]Description:[/bold]")
content_lines.append(message_markdown)
if code_snippet:
content_lines.append("")
content_lines.append(f"[bold]Code Snippet:[/bold]")
content_lines.append(f"[dim]{code_snippet}[/dim]")
content = "\n".join(content_lines)
# Display in panel
console.print()
console.print(Panel(
content,
title=f"🔍 Finding Detail",
border_style=severity_color,
box=box.ROUNDED,
padding=(1, 2)
))
console.print()
console.print(f"💡 Export this run: [bold cyan]ff findings export {run_id} --format sarif[/bold cyan]")
def display_findings_table(sarif_data: Dict[str, Any]): def display_findings_table(sarif_data: Dict[str, Any]):
"""Display SARIF findings in a rich table format""" """Display SARIF findings in a rich table format"""
runs = sarif_data.get("runs", []) runs = sarif_data.get("runs", [])
@@ -195,8 +329,8 @@ def display_findings_table(sarif_data: Dict[str, Any]):
# Detailed results - Rich Text-based table with proper emoji alignment # Detailed results - Rich Text-based table with proper emoji alignment
results_table = Table(box=box.ROUNDED) results_table = Table(box=box.ROUNDED)
results_table.add_column("Severity", width=12, justify="left", no_wrap=True) results_table.add_column("Severity", width=12, justify="left", no_wrap=True)
results_table.add_column("Rule", width=25, justify="left", style="bold cyan", no_wrap=True) results_table.add_column("Rule", justify="left", style="bold cyan", no_wrap=True)
results_table.add_column("Message", width=55, justify="left", no_wrap=True) results_table.add_column("Message", width=45, justify="left", no_wrap=True)
results_table.add_column("Location", width=20, justify="left", style="dim", no_wrap=True) results_table.add_column("Location", width=20, justify="left", style="dim", no_wrap=True)
for result in results[:50]: # Limit to first 50 results for result in results[:50]: # Limit to first 50 results
@@ -224,18 +358,16 @@ def display_findings_table(sarif_data: Dict[str, Any]):
severity_text = Text(level.upper(), style=severity_style(level)) severity_text = Text(level.upper(), style=severity_style(level))
severity_text.truncate(12, overflow="ellipsis") severity_text.truncate(12, overflow="ellipsis")
rule_text = Text(rule_id) # Show full rule ID without truncation
rule_text.truncate(25, overflow="ellipsis")
message_text = Text(message) message_text = Text(message)
message_text.truncate(55, overflow="ellipsis") message_text.truncate(45, overflow="ellipsis")
location_text = Text(location_str) location_text = Text(location_str)
location_text.truncate(20, overflow="ellipsis") location_text.truncate(20, overflow="ellipsis")
results_table.add_row( results_table.add_row(
severity_text, severity_text,
rule_text, rule_id, # Pass string directly to show full UUID
message_text, message_text,
location_text location_text
) )
@@ -307,16 +439,20 @@ def findings_history(
def export_findings( def export_findings(
run_id: str = typer.Argument(..., help="Run ID to export findings for"), run_id: str = typer.Argument(..., help="Run ID to export findings for"),
format: str = typer.Option( format: str = typer.Option(
"json", "--format", "-f", "sarif", "--format", "-f",
help="Export format: json, csv, html, sarif" help="Export format: sarif (standard), json, csv, html"
), ),
output: Optional[str] = typer.Option( output: Optional[str] = typer.Option(
None, "--output", "-o", None, "--output", "-o",
help="Output file path (defaults to findings-<run-id>.<format>)" help="Output file path (defaults to findings-<run-id>-<timestamp>.<format>)"
) )
): ):
""" """
📤 Export security findings in various formats 📤 Export security findings in various formats
SARIF is the standard format for security findings and is recommended
for interoperability with other security tools. Filenames are automatically
made unique with timestamps to prevent overwriting previous exports.
""" """
db = get_project_db() db = get_project_db()
if not db: if not db:
@@ -334,9 +470,10 @@ def export_findings(
else: else:
sarif_data = findings_data.sarif_data sarif_data = findings_data.sarif_data
# Generate output filename # Generate output filename with timestamp for uniqueness
if not output: if not output:
output = f"findings-{run_id[:8]}.{format}" timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
output = f"findings-{run_id[:8]}-{timestamp}.{format}"
output_path = Path(output) output_path = Path(output)
+43 -69
View File
@@ -59,66 +59,6 @@ def format_number(num: int) -> str:
return str(num) return str(num)
@app.command("stats")
def fuzzing_stats(
run_id: str = typer.Argument(..., help="Run ID to get statistics for"),
refresh: int = typer.Option(
5, "--refresh", "-r",
help="Refresh interval in seconds"
),
once: bool = typer.Option(
False, "--once",
help="Show stats once and exit"
)
):
"""
📊 Show current fuzzing statistics for a run
"""
try:
with get_client() as client:
if once:
# Show stats once
stats = client.get_fuzzing_stats(run_id)
display_stats_table(stats)
else:
# Live updating stats
console.print(f"📊 [bold]Live Fuzzing Statistics[/bold] (Run: {run_id[:12]}...)")
console.print(f"Refreshing every {refresh}s. Press Ctrl+C to stop.\n")
with Live(auto_refresh=False, console=console) as live:
while True:
try:
# Check workflow status
run_status = client.get_run_status(run_id)
stats = client.get_fuzzing_stats(run_id)
table = create_stats_table(stats)
live.update(table, refresh=True)
# Exit if workflow completed or failed
if getattr(run_status, 'is_completed', False) or getattr(run_status, 'is_failed', False):
final_status = getattr(run_status, 'status', 'Unknown')
if getattr(run_status, 'is_completed', False):
console.print("\n✅ [bold green]Workflow completed[/bold green]", style="green")
else:
console.print(f"\n⚠️ [bold yellow]Workflow ended[/bold yellow] | Status: {final_status}", style="yellow")
break
time.sleep(refresh)
except KeyboardInterrupt:
console.print("\n📊 Monitoring stopped", style="yellow")
break
except Exception as e:
console.print(f"❌ Failed to get fuzzing stats: {e}", style="red")
raise typer.Exit(1)
def display_stats_table(stats):
"""Display stats in a simple table"""
table = create_stats_table(stats)
console.print(table)
def create_stats_table(stats) -> Panel: def create_stats_table(stats) -> Panel:
"""Create a rich table for fuzzing statistics""" """Create a rich table for fuzzing statistics"""
# Create main stats table # Create main stats table
@@ -266,8 +206,8 @@ def crash_reports(
raise typer.Exit(1) raise typer.Exit(1)
def _live_monitor(run_id: str, refresh: int): def _live_monitor(run_id: str, refresh: int, once: bool = False, style: str = "inline"):
"""Helper for live monitoring with inline real-time display""" """Helper for live monitoring with inline real-time display or table display"""
with get_client() as client: with get_client() as client:
start_time = time.time() start_time = time.time()
@@ -319,7 +259,6 @@ def _live_monitor(run_id: str, refresh: int):
self.elapsed_time = 0 self.elapsed_time = 0
self.last_crash_time = None self.last_crash_time = None
with Live(auto_refresh=False, console=console) as live:
# Initial fetch # Initial fetch
try: try:
run_status = client.get_run_status(run_id) run_status = client.get_run_status(run_id)
@@ -328,6 +267,20 @@ def _live_monitor(run_id: str, refresh: int):
stats = FallbackStats(run_id) stats = FallbackStats(run_id)
run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})() run_status = type("RS", (), {"status":"Unknown","is_completed":False,"is_failed":False})()
# Handle --once mode: show stats once and exit
if once:
if style == "table":
console.print(create_stats_table(stats))
else:
console.print(render_inline_stats(run_status, stats))
return
# Live monitoring mode
with Live(auto_refresh=False, console=console) as live:
# Render based on style
if style == "table":
live.update(create_stats_table(stats), refresh=True)
else:
live.update(render_inline_stats(run_status, stats), refresh=True) live.update(render_inline_stats(run_status, stats), refresh=True)
# Polling loop # Polling loop
@@ -354,7 +307,10 @@ def _live_monitor(run_id: str, refresh: int):
except Exception: except Exception:
stats = FallbackStats(run_id) stats = FallbackStats(run_id)
# Update display # Update display based on style
if style == "table":
live.update(create_stats_table(stats), refresh=True)
else:
live.update(render_inline_stats(run_status, stats), refresh=True) live.update(render_inline_stats(run_status, stats), refresh=True)
# Check if completed # Check if completed
@@ -386,17 +342,36 @@ def live_monitor(
refresh: int = typer.Option( refresh: int = typer.Option(
2, "--refresh", "-r", 2, "--refresh", "-r",
help="Refresh interval in seconds" help="Refresh interval in seconds"
),
once: bool = typer.Option(
False, "--once",
help="Show stats once and exit"
),
style: str = typer.Option(
"inline", "--style",
help="Display style: 'inline' (default) or 'table'"
) )
): ):
""" """
📺 Real-time inline monitoring with live statistics updates 📺 Real-time monitoring with live statistics updates
Display styles:
- inline: Visual inline display with emojis (default)
- table: Clean table-based display
Use --once to show stats once without continuous monitoring (useful for scripts)
""" """
try: try:
_live_monitor(run_id, refresh) # Validate style
if style not in ["inline", "table"]:
console.print(f"❌ Invalid style: {style}. Must be 'inline' or 'table'", style="red")
raise typer.Exit(1)
_live_monitor(run_id, refresh, once, style)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\n\n📊 Monitoring stopped by user.", style="yellow") console.print("\n\n📊 Monitoring stopped by user.", style="yellow")
except Exception as e: except Exception as e:
console.print(f"\n❌ Failed to start live monitoring: {e}", style="red") console.print(f"\n❌ Failed to start monitoring: {e}", style="red")
raise typer.Exit(1) raise typer.Exit(1)
@@ -423,6 +398,5 @@ def monitor_callback(ctx: typer.Context):
console = Console() console = Console()
console.print("📊 [bold cyan]Monitor Command[/bold cyan]") console.print("📊 [bold cyan]Monitor Command[/bold cyan]")
console.print("\nAvailable subcommands:") console.print("\nAvailable subcommands:")
console.print(" • [cyan]ff monitor stats <run-id>[/cyan] - Show execution statistics") console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Monitor with live updates (supports --once, --style)")
console.print(" • [cyan]ff monitor crashes <run-id>[/cyan] - Show crash reports") console.print(" • [cyan]ff monitor crashes <run-id>[/cyan] - Show crash reports")
console.print(" • [cyan]ff monitor live <run-id>[/cyan] - Real-time inline monitoring")
@@ -365,7 +365,7 @@ def execute_workflow(
should_auto_start = auto_start if auto_start is not None else config.workers.auto_start_workers should_auto_start = auto_start if auto_start is not None else config.workers.auto_start_workers
should_auto_stop = auto_stop if auto_stop is not None else config.workers.auto_stop_workers should_auto_stop = auto_stop if auto_stop is not None else config.workers.auto_stop_workers
worker_container = None # Track for cleanup worker_service = None # Track for cleanup
worker_mgr = None worker_mgr = None
wait_completed = False # Track if wait completed successfully wait_completed = False # Track if wait completed successfully
@@ -384,7 +384,6 @@ def execute_workflow(
) )
# Ensure worker is running # Ensure worker is running
worker_container = worker_info["worker_container"]
worker_service = worker_info.get("worker_service", f"worker-{worker_info['vertical']}") worker_service = worker_info.get("worker_service", f"worker-{worker_info['vertical']}")
if not worker_mgr.ensure_worker_running(worker_info, auto_start=should_auto_start): if not worker_mgr.ensure_worker_running(worker_info, auto_start=should_auto_start):
console.print( console.print(
@@ -434,7 +433,7 @@ def execute_workflow(
# Don't fail the whole operation if database save fails # Don't fail the whole operation if database save fails
console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow") console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow")
console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor stats {response.run_id}[/bold cyan]") console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]")
console.print(f"💡 Check status: [bold cyan]fuzzforge workflow status {response.run_id}[/bold cyan]") console.print(f"💡 Check status: [bold cyan]fuzzforge workflow status {response.run_id}[/bold cyan]")
# Suggest --live for fuzzing workflows # Suggest --live for fuzzing workflows
@@ -461,7 +460,7 @@ def execute_workflow(
console.print("\n⏹️ Live monitoring stopped (execution continues in background)", style="yellow") console.print("\n⏹️ Live monitoring stopped (execution continues in background)", style="yellow")
except Exception as e: except Exception as e:
console.print(f"⚠️ Failed to start live monitoring: {e}", style="yellow") console.print(f"⚠️ Failed to start live monitoring: {e}", style="yellow")
console.print(f"💡 You can still monitor manually: [bold cyan]fuzzforge monitor {response.run_id}[/bold cyan]") console.print(f"💡 You can still monitor manually: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]")
# Wait for completion if requested # Wait for completion if requested
elif wait: elif wait:
@@ -527,11 +526,11 @@ def execute_workflow(
handle_error(e, "executing workflow") handle_error(e, "executing workflow")
finally: finally:
# Stop worker if auto-stop is enabled and wait completed # Stop worker if auto-stop is enabled and wait completed
if should_auto_stop and worker_container and worker_mgr and wait_completed: if should_auto_stop and worker_service and worker_mgr and wait_completed:
try: try:
console.print("\n🛑 Stopping worker (auto-stop enabled)...") console.print("\n🛑 Stopping worker (auto-stop enabled)...")
if worker_mgr.stop_worker(worker_container): if worker_mgr.stop_worker(worker_service):
console.print(f"✅ Worker stopped: {worker_container}") console.print(f"✅ Worker stopped: {worker_service}")
except Exception as e: except Exception as e:
console.print( console.print(
f"⚠️ Failed to stop worker: {e}", f"⚠️ Failed to stop worker: {e}",
@@ -608,7 +607,7 @@ def workflow_status(
# Show next steps # Show next steps
if status.is_running: if status.is_running:
console.print(f"\n💡 Monitor live: [bold cyan]fuzzforge monitor {execution_id}[/bold cyan]") console.print(f"\n💡 Monitor live: [bold cyan]fuzzforge monitor live {execution_id}[/bold cyan]")
elif status.is_completed: elif status.is_completed:
console.print(f"💡 View findings: [bold cyan]fuzzforge finding {execution_id}[/bold cyan]") console.print(f"💡 View findings: [bold cyan]fuzzforge finding {execution_id}[/bold cyan]")
elif status.is_failed: elif status.is_failed:
@@ -770,7 +769,7 @@ def retry_workflow(
except Exception as e: except Exception as e:
console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow") console.print(f"⚠️ Failed to save execution to database: {e}", style="yellow")
console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor stats {response.run_id}[/bold cyan]") console.print(f"\n💡 Monitor progress: [bold cyan]fuzzforge monitor live {response.run_id}[/bold cyan]")
except Exception as e: except Exception as e:
handle_error(e, "retrying workflow") handle_error(e, "retrying workflow")
+9 -49
View File
@@ -260,57 +260,17 @@ def workflow_main():
# === Finding commands (singular) === # === Finding commands (singular) ===
@finding_app.command("export") @finding_app.command("show")
def export_finding( def show_finding_detail(
execution_id: Optional[str] = typer.Argument(None, help="Execution ID (defaults to latest)"), run_id: str = typer.Argument(..., help="Run ID to get finding from"),
format: str = typer.Option( rule_id: str = typer.Option(..., "--rule", "-r", help="Rule ID of the specific finding to show")
"sarif", "--format", "-f",
help="Export format: sarif, json, csv"
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help="Output file (defaults to stdout)"
)
): ):
""" """
📤 Export findings to file 🔍 Show detailed information about a specific finding
""" """
from .commands.findings import export_findings from .commands.findings import show_finding
from .database import get_project_db show_finding(run_id=run_id, rule_id=rule_id)
from .exceptions import require_project
try:
require_project()
# If no ID provided, get the latest
if not execution_id:
db = get_project_db()
if db:
recent_runs = db.list_runs(limit=1)
if recent_runs:
execution_id = recent_runs[0].run_id
console.print(f"🔍 Using most recent execution: {execution_id}")
else:
console.print("⚠️ No findings found in project database", style="yellow")
return
else:
console.print("❌ No project database found", style="red")
return
export_findings(run_id=execution_id, format=format, output=output)
except Exception as e:
console.print(f"❌ Failed to export findings: {e}", style="red")
@finding_app.command("analyze")
def analyze_finding(
finding_id: Optional[str] = typer.Argument(None, help="Finding ID to analyze")
):
"""
🤖 AI analysis of a finding
"""
from .commands.ai import analyze_finding as ai_analyze
ai_analyze(finding_id)
@finding_app.callback(invoke_without_command=True) @finding_app.callback(invoke_without_command=True)
def finding_main( def finding_main(
@@ -322,7 +282,7 @@ def finding_main(
Examples: Examples:
fuzzforge finding # Show latest finding fuzzforge finding # Show latest finding
fuzzforge finding <id> # Show specific finding fuzzforge finding <id> # Show specific finding
fuzzforge finding export # Export latest findings fuzzforge finding show <run-id> --rule <id> # Show specific finding detail
""" """
# Check if a subcommand is being invoked # Check if a subcommand is being invoked
if ctx.invoked_subcommand is not None: if ctx.invoked_subcommand is not None:
@@ -418,7 +378,7 @@ def main():
# Handle finding command with pattern recognition # Handle finding command with pattern recognition
if len(args) >= 2 and args[0] == 'finding': if len(args) >= 2 and args[0] == 'finding':
finding_subcommands = ['export', 'analyze'] finding_subcommands = ['show']
# Skip custom dispatching if help flags are present # Skip custom dispatching if help flags are present
if not any(arg in ['--help', '-h', '--version', '-v'] for arg in args): if not any(arg in ['--help', '-h', '--version', '-v'] for arg in args):
if args[1] not in finding_subcommands: if args[1] not in finding_subcommands:
+52 -46
View File
@@ -95,17 +95,30 @@ class WorkerManager:
check=True check=True
) )
def is_worker_running(self, container_name: str) -> bool: def _service_to_container_name(self, service_name: str) -> str:
""" """
Check if a worker container is running. Convert service name to container name based on docker-compose naming convention.
Args: Args:
container_name: Name of the Docker container (e.g., "fuzzforge-worker-ossfuzz") service_name: Docker Compose service name (e.g., "worker-python")
Returns:
Container name (e.g., "fuzzforge-worker-python")
"""
return f"fuzzforge-{service_name}"
def is_worker_running(self, service_name: str) -> bool:
"""
Check if a worker service is running.
Args:
service_name: Name of the Docker Compose service (e.g., "worker-ossfuzz")
Returns: Returns:
True if container is running, False otherwise True if container is running, False otherwise
""" """
try: try:
container_name = self._service_to_container_name(service_name)
result = subprocess.run( result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", container_name], ["docker", "inspect", "-f", "{{.State.Running}}", container_name],
capture_output=True, capture_output=True,
@@ -120,46 +133,42 @@ class WorkerManager:
logger.debug(f"Failed to check worker status: {e}") logger.debug(f"Failed to check worker status: {e}")
return False return False
def start_worker(self, container_name: str) -> bool: def start_worker(self, service_name: str) -> bool:
""" """
Start a worker container using docker. Start a worker service using docker-compose.
Args: Args:
container_name: Name of the Docker container to start service_name: Name of the Docker Compose service to start (e.g., "worker-python")
Returns: Returns:
True if started successfully, False otherwise True if started successfully, False otherwise
""" """
try: try:
console.print(f"🚀 Starting worker: {container_name}") console.print(f"🚀 Starting worker: {service_name}")
# Use docker start directly (works with container name) # Use docker-compose up to create and start the service
subprocess.run( result = self._run_docker_compose("up", "-d", service_name)
["docker", "start", container_name],
capture_output=True,
text=True,
check=True
)
logger.info(f"Worker {container_name} started") logger.info(f"Worker {service_name} started")
return True return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Failed to start worker {container_name}: {e.stderr}") logger.error(f"Failed to start worker {service_name}: {e.stderr}")
console.print(f"❌ Failed to start worker: {e.stderr}", style="red") console.print(f"❌ Failed to start worker: {e.stderr}", style="red")
console.print(f"💡 Start the worker manually: docker compose up -d {service_name}", style="yellow")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Unexpected error starting worker {container_name}: {e}") logger.error(f"Unexpected error starting worker {service_name}: {e}")
console.print(f"❌ Unexpected error: {e}", style="red") console.print(f"❌ Unexpected error: {e}", style="red")
return False return False
def wait_for_worker_ready(self, container_name: str, timeout: Optional[int] = None) -> bool: def wait_for_worker_ready(self, service_name: str, timeout: Optional[int] = None) -> bool:
""" """
Wait for a worker to be healthy and ready to process tasks. Wait for a worker to be healthy and ready to process tasks.
Args: Args:
container_name: Name of the Docker container service_name: Name of the Docker Compose service
timeout: Maximum seconds to wait (uses instance default if not specified) timeout: Maximum seconds to wait (uses instance default if not specified)
Returns: Returns:
@@ -170,13 +179,14 @@ class WorkerManager:
""" """
timeout = timeout or self.startup_timeout timeout = timeout or self.startup_timeout
start_time = time.time() start_time = time.time()
container_name = self._service_to_container_name(service_name)
console.print("⏳ Waiting for worker to be ready...") console.print("⏳ Waiting for worker to be ready...")
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
# Check if container is running # Check if container is running
if not self.is_worker_running(container_name): if not self.is_worker_running(service_name):
logger.debug(f"Worker {container_name} not running yet") logger.debug(f"Worker {service_name} not running yet")
time.sleep(self.health_check_interval) time.sleep(self.health_check_interval)
continue continue
@@ -193,16 +203,16 @@ class WorkerManager:
# If no health check is defined, assume healthy after running # If no health check is defined, assume healthy after running
if health_status == "<no value>" or health_status == "": if health_status == "<no value>" or health_status == "":
logger.info(f"Worker {container_name} is running (no health check)") logger.info(f"Worker {service_name} is running (no health check)")
console.print(f"✅ Worker ready: {container_name}") console.print(f"✅ Worker ready: {service_name}")
return True return True
if health_status == "healthy": if health_status == "healthy":
logger.info(f"Worker {container_name} is healthy") logger.info(f"Worker {service_name} is healthy")
console.print(f"✅ Worker ready: {container_name}") console.print(f"✅ Worker ready: {service_name}")
return True return True
logger.debug(f"Worker {container_name} health: {health_status}") logger.debug(f"Worker {service_name} health: {health_status}")
except Exception as e: except Exception as e:
logger.debug(f"Failed to check health: {e}") logger.debug(f"Failed to check health: {e}")
@@ -210,41 +220,36 @@ class WorkerManager:
time.sleep(self.health_check_interval) time.sleep(self.health_check_interval)
elapsed = time.time() - start_time elapsed = time.time() - start_time
logger.warning(f"Worker {container_name} did not become ready within {elapsed:.1f}s") logger.warning(f"Worker {service_name} did not become ready within {elapsed:.1f}s")
console.print(f"⚠️ Worker startup timeout after {elapsed:.1f}s", style="yellow") console.print(f"⚠️ Worker startup timeout after {elapsed:.1f}s", style="yellow")
return False return False
def stop_worker(self, container_name: str) -> bool: def stop_worker(self, service_name: str) -> bool:
""" """
Stop a worker container using docker. Stop a worker service using docker-compose.
Args: Args:
container_name: Name of the Docker container to stop service_name: Name of the Docker Compose service to stop
Returns: Returns:
True if stopped successfully, False otherwise True if stopped successfully, False otherwise
""" """
try: try:
console.print(f"🛑 Stopping worker: {container_name}") console.print(f"🛑 Stopping worker: {service_name}")
# Use docker stop directly (works with container name) # Use docker-compose down to stop and remove the service
subprocess.run( result = self._run_docker_compose("stop", service_name)
["docker", "stop", container_name],
capture_output=True,
text=True,
check=True
)
logger.info(f"Worker {container_name} stopped") logger.info(f"Worker {service_name} stopped")
return True return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Failed to stop worker {container_name}: {e.stderr}") logger.error(f"Failed to stop worker {service_name}: {e.stderr}")
console.print(f"❌ Failed to stop worker: {e.stderr}", style="red") console.print(f"❌ Failed to stop worker: {e.stderr}", style="red")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Unexpected error stopping worker {container_name}: {e}") logger.error(f"Unexpected error stopping worker {service_name}: {e}")
console.print(f"❌ Unexpected error: {e}", style="red") console.print(f"❌ Unexpected error: {e}", style="red")
return False return False
@@ -257,17 +262,18 @@ class WorkerManager:
Ensure a worker is running, starting it if necessary. Ensure a worker is running, starting it if necessary.
Args: Args:
worker_info: Worker information dict from API (contains worker_container, etc.) worker_info: Worker information dict from API (contains worker_service, etc.)
auto_start: Whether to automatically start the worker if not running auto_start: Whether to automatically start the worker if not running
Returns: Returns:
True if worker is running, False otherwise True if worker is running, False otherwise
""" """
container_name = worker_info["worker_container"] # Get worker_service (docker-compose service name)
service_name = worker_info.get("worker_service", f"worker-{worker_info['vertical']}")
vertical = worker_info["vertical"] vertical = worker_info["vertical"]
# Check if already running # Check if already running
if self.is_worker_running(container_name): if self.is_worker_running(service_name):
console.print(f"✓ Worker already running: {vertical}") console.print(f"✓ Worker already running: {vertical}")
return True return True
@@ -279,8 +285,8 @@ class WorkerManager:
return False return False
# Start the worker # Start the worker
if not self.start_worker(container_name): if not self.start_worker(service_name):
return False return False
# Wait for it to be ready # Wait for it to be ready
return self.wait_for_worker_ready(container_name) return self.wait_for_worker_ready(service_name)
Generated
+3 -3
View File
@@ -1257,7 +1257,7 @@ wheels = [
[[package]] [[package]]
name = "fuzzforge-ai" name = "fuzzforge-ai"
version = "0.6.0" version = "0.7.0"
source = { editable = "../ai" } source = { editable = "../ai" }
dependencies = [ dependencies = [
{ name = "a2a-sdk" }, { name = "a2a-sdk" },
@@ -1303,7 +1303,7 @@ dev = [
[[package]] [[package]]
name = "fuzzforge-cli" name = "fuzzforge-cli"
version = "0.6.0" version = "0.7.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fuzzforge-ai" }, { name = "fuzzforge-ai" },
@@ -1347,7 +1347,7 @@ provides-extras = ["dev"]
[[package]] [[package]]
name = "fuzzforge-sdk" name = "fuzzforge-sdk"
version = "0.6.0" version = "0.7.0"
source = { editable = "../sdk" } source = { editable = "../sdk" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
-2
View File
@@ -9,8 +9,6 @@
# Development: docker-compose -f docker-compose.temporal.yaml up # Development: docker-compose -f docker-compose.temporal.yaml up
# Production: docker-compose -f docker-compose.temporal.yaml -f docker-compose.temporal.prod.yaml up # Production: docker-compose -f docker-compose.temporal.yaml -f docker-compose.temporal.prod.yaml up
version: '3.8'
services: services:
# ============================================================================ # ============================================================================
# Temporal Server - Workflow Orchestration # Temporal Server - Workflow Orchestration