@@ -282,10 +282,112 @@ def _format_delta(value: float, prefix: str, decimals: int, suffix: str = "") ->
282282
283283@app .command ()
284284def apply (
285+ provider : Annotated [str , typer .Option (help = "Cloud provider (aws, gcp)" )] = "aws" ,
286+ region : Annotated [str | None , typer .Option (help = "Filter by region" )] = None ,
287+ config : Annotated [str | None , typer .Option (help = "Path to canopy.yaml config file" )] = None ,
285288 auto_approve : Annotated [bool , typer .Option ("--yes" , help = "Skip confirmation" )] = False ,
289+ approval : Annotated [str , typer .Option (help = "Approval method (cli, slack, github)" )] = "cli" ,
290+ dry_run : Annotated [bool , typer .Option ("--dry-run" , help = "Show what would be done" )] = False ,
286291) -> None :
287- """Apply recommended optimizations."""
288- console .print ("[yellow]canopy apply is coming in v0.3[/yellow]" )
292+ """Apply recommended optimizations to running infrastructure."""
293+ from canopy .config import load_config
294+ from canopy .engine .apply .aws_executor import AWSApplyExecutor
295+ from canopy .engine .apply .executor import ApplyStatus , execute_recommendation
296+ from canopy .engine .apply .gcp_executor import GCPApplyExecutor
297+ from canopy .engine .audit import run_audit_with_recommendations
298+ from canopy .engine .audit_log .writer import AuditLogWriter
299+ from canopy .models .audit_log import ActionType
300+
301+ cfg = load_config (Path (config ) if config else None )
302+ results , summary = run_audit_with_recommendations (provider = provider , region = region , config = cfg )
303+
304+ if not summary .recommendations :
305+ console .print ("[green]No optimizations to apply — infrastructure looks good![/green]" )
306+ return
307+
308+ # Select recommendations to apply
309+ recs = summary .recommendations
310+ if not auto_approve and not dry_run :
311+ if approval == "slack" and cfg .slack_webhook_url :
312+ from canopy .engine .apply .approval import request_slack_approval
313+
314+ ok = request_slack_approval (recs , cfg .slack_webhook_url , cfg .approval_channel )
315+ if ok :
316+ console .print ("[green]Approval request sent to Slack.[/green]" )
317+ else :
318+ console .print ("[red]Failed to send Slack approval request.[/red]" )
319+ return
320+ if approval == "github" and cfg .github_token and cfg .github_repo :
321+ from canopy .engine .apply .approval import request_github_approval
322+
323+ url = request_github_approval (recs , cfg .github_token , cfg .github_repo )
324+ if url :
325+ console .print (f"[green]GitHub issue created: { url } [/green]" )
326+ else :
327+ console .print ("[red]Failed to create GitHub issue.[/red]" )
328+ return
329+ # Default: CLI interactive approval
330+ from canopy .engine .apply .approval import request_cli_approval
331+
332+ recs = request_cli_approval (recs , console )
333+ if not recs :
334+ console .print ("[yellow]No recommendations approved.[/yellow]" )
335+ return
336+
337+ # Create executor
338+ executor : AWSApplyExecutor | GCPApplyExecutor
339+ if provider == "gcp" :
340+ executor = GCPApplyExecutor ()
341+ else :
342+ executor = AWSApplyExecutor ()
343+
344+ log_dir = Path (cfg .audit_log_dir ) if cfg .audit_log_dir else None
345+ audit_writer = AuditLogWriter (base_dir = log_dir ) if log_dir else AuditLogWriter ()
346+
347+ # Execute
348+ table = Table (title = "Apply Results" , show_lines = True )
349+ table .add_column ("Workload" , style = "cyan" )
350+ table .add_column ("Type" )
351+ table .add_column ("Status" )
352+ table .add_column ("Message" )
353+
354+ for rec in recs :
355+ audit_writer .log_action (
356+ ActionType .APPLY_STARTED ,
357+ workload_id = rec .workload_id ,
358+ workload_name = rec .workload_name ,
359+ provider = provider ,
360+ dry_run = dry_run ,
361+ )
362+
363+ result = execute_recommendation (executor , rec , dry_run = dry_run )
364+
365+ status_color = "green" if result .status == ApplyStatus .SUCCESS else "yellow"
366+ if result .status == ApplyStatus .FAILED :
367+ status_color = "red"
368+
369+ table .add_row (
370+ rec .workload_name ,
371+ rec .recommendation_type .value .upper (),
372+ f"[{ status_color } ]{ result .status .value .upper ()} [/{ status_color } ]" ,
373+ result .message ,
374+ )
375+
376+ log_action = (
377+ ActionType .APPLY_COMPLETED
378+ if result .status in (ApplyStatus .SUCCESS , ApplyStatus .DRY_RUN )
379+ else ActionType .APPLY_FAILED
380+ )
381+ audit_writer .log_action (
382+ log_action ,
383+ workload_id = rec .workload_id ,
384+ workload_name = rec .workload_name ,
385+ provider = provider ,
386+ details = {"status" : result .status .value , "message" : result .message },
387+ dry_run = dry_run ,
388+ )
389+
390+ console .print (table )
289391
290392
291393@app .command ()
@@ -359,5 +461,82 @@ def regions(
359461 console .print (table )
360462
361463
464+ # --- MCP subcommand group ---
465+
466+ mcp_app = typer .Typer (name = "mcp" , help = "MCP server management" , no_args_is_help = True )
467+ app .add_typer (mcp_app , name = "mcp" )
468+
469+ _MCP_SERVERS = ["billing-aws" , "billing-gcp" , "electricity" , "slack" , "github" ]
470+
471+
472+ @mcp_app .command ("list" )
473+ def mcp_list () -> None :
474+ """List available MCP servers."""
475+ table = Table (title = "Available MCP Servers" , show_lines = True )
476+ table .add_column ("Server" , style = "cyan" )
477+ table .add_column ("Description" )
478+
479+ descriptions : dict [str , str ] = {
480+ "billing-aws" : "AWS cost and billing data" ,
481+ "billing-gcp" : "GCP cost and billing data" ,
482+ "electricity" : "Carbon intensity data via Electricity Maps" ,
483+ "slack" : "Slack notifications and approval requests" ,
484+ "github" : "GitHub issue creation for optimizations" ,
485+ }
486+
487+ for name in _MCP_SERVERS :
488+ table .add_row (name , descriptions .get (name , "" ))
489+
490+ console .print (table )
491+
492+
493+ @mcp_app .command ("serve" )
494+ def mcp_serve (
495+ server_name : Annotated [str , typer .Argument (help = "MCP server to start" )],
496+ ) -> None :
497+ """Start an MCP server (communicates over stdio)."""
498+ try :
499+ from canopy .mcp import get_server
500+ except ImportError :
501+ console .print (
502+ "[red]MCP dependencies not installed. Install with: pip install canopy-cloud[mcp][/red]"
503+ )
504+ raise typer .Exit (1 )
505+
506+ if server_name not in _MCP_SERVERS :
507+ console .print (
508+ f"[red]Unknown server: { server_name } . Available: { ', ' .join (_MCP_SERVERS )} [/red]"
509+ )
510+ raise typer .Exit (1 )
511+
512+ server = get_server (server_name )
513+ server .run ()
514+
515+
516+ # --- Dashboard command ---
517+
518+
519+ @app .command ()
520+ def dashboard (
521+ port : Annotated [int , typer .Option (help = "Port to serve on" )] = 8080 ,
522+ host : Annotated [str , typer .Option (help = "Host to bind to" )] = "127.0.0.1" ,
523+ ) -> None :
524+ """Launch the Canopy web dashboard."""
525+ try :
526+ import uvicorn # type: ignore[import-not-found,unused-ignore]
527+
528+ from canopy .dashboard .app import create_app
529+ except ImportError :
530+ console .print (
531+ "[red]Dashboard dependencies not installed. "
532+ "Install with: pip install canopy-cloud[dashboard][/red]"
533+ )
534+ raise typer .Exit (1 )
535+
536+ console .print (f"[green]Starting Canopy dashboard at http://{ host } :{ port } [/green]" )
537+ app_instance = create_app ()
538+ uvicorn .run (app_instance , host = host , port = port )
539+
540+
362541if __name__ == "__main__" :
363542 app ()
0 commit comments