@@ -130,12 +130,154 @@ def audit(
130130 )
131131
132132
133+ SEVERITY_COLORS : dict [str , str ] = {
134+ "block" : "bold red" ,
135+ "warn" : "yellow" ,
136+ "info" : "blue" ,
137+ }
138+
139+
133140@app .command ()
134141def plan (
135- stack : Annotated [str , typer .Argument (help = "IaC stack to analyze" )] = "." ,
142+ plan_file : Annotated [str , typer .Argument (help = "Terraform/Pulumi plan JSON file" )],
143+ source : Annotated [str , typer .Option (help = "IaC source (auto, terraform, pulumi)" )] = "auto" ,
144+ policy : Annotated [str | None , typer .Option (help = "Path to canopy-policy.yaml" )] = None ,
145+ region : Annotated [str , typer .Option (help = "Default region for resources" )] = "us-east-1" ,
146+ output : Annotated [str , typer .Option (help = "Output format (table, json)" )] = "table" ,
136147) -> None :
137148 """Preview cost and carbon impact of infrastructure changes."""
138- console .print ("[yellow]canopy plan is coming in v0.2[/yellow]" )
149+ import json as json_mod
150+
151+ from canopy .engine .iac .pulumi import parse_preview_dict
152+ from canopy .engine .iac .terraform import parse_plan_json
153+ from canopy .engine .plan import estimate_plan
154+ from canopy .engine .policy import load_policy
155+
156+ plan_path = Path (plan_file )
157+ if not plan_path .is_file ():
158+ console .print (f"[red]Plan file not found: { plan_file } [/red]" )
159+ raise typer .Exit (1 )
160+
161+ # Auto-detect IaC source or use explicit override
162+ if source == "auto" :
163+ raw = json_mod .loads (plan_path .read_text (encoding = "utf-8" ))
164+ if "resource_changes" in raw :
165+ parsed = parse_plan_json (plan_path )
166+ elif "steps" in raw :
167+ parsed = parse_preview_dict (raw )
168+ else :
169+ console .print ("[red]Could not detect plan format. Use --source.[/red]" )
170+ raise typer .Exit (1 )
171+ elif source == "pulumi" :
172+ raw = json_mod .loads (plan_path .read_text (encoding = "utf-8" ))
173+ parsed = parse_preview_dict (raw )
174+ else :
175+ parsed = parse_plan_json (plan_path )
176+
177+ pol = load_policy (Path (policy ) if policy else None )
178+ estimate = estimate_plan (parsed , policy = pol , default_region = region )
179+
180+ if output == "json" :
181+ data = {
182+ "source" : estimate .source ,
183+ "resources" : [r .model_dump (mode = "json" ) for r in estimate .resources ],
184+ "violations" : [v .model_dump (mode = "json" ) for v in estimate .violations ],
185+ "summary" : {
186+ "total_monthly_cost_usd" : round (estimate .total_monthly_cost_usd , 2 ),
187+ "total_cost_delta_usd" : round (estimate .total_cost_delta_usd , 2 ),
188+ "total_monthly_carbon_kg" : round (estimate .total_monthly_carbon_kg , 3 ),
189+ "total_carbon_delta_kg" : round (estimate .total_carbon_delta_kg , 3 ),
190+ },
191+ }
192+ console .print_json (json_mod .dumps (data , indent = 2 , default = str ))
193+ return
194+
195+ if not estimate .resources :
196+ console .print ("[yellow]No compute resource changes found in plan.[/yellow]" )
197+ return
198+
199+ # Resource changes table
200+ table = Table (title = "Infrastructure Changes — Cost & Carbon Impact" , show_lines = True )
201+ table .add_column ("Resource" , style = "cyan" )
202+ table .add_column ("Action" )
203+ table .add_column ("Instance" , justify = "center" )
204+ table .add_column ("Region" )
205+ table .add_column ("Cost/mo" , justify = "right" , style = "yellow" )
206+ table .add_column ("Cost Delta" , justify = "right" )
207+ table .add_column ("Carbon/mo" , justify = "right" , style = "green" )
208+ table .add_column ("Carbon Delta" , justify = "right" )
209+
210+ action_colors : dict [str , str ] = {
211+ "create" : "green" ,
212+ "update" : "yellow" ,
213+ "delete" : "red" ,
214+ "no-op" : "dim" ,
215+ }
216+
217+ for r in estimate .resources :
218+ a_color = action_colors .get (r .action .value , "white" )
219+ cost_delta_str = _format_delta (r .cost_delta_usd , "$" , 2 )
220+ carbon_delta_str = _format_delta (r .carbon_delta_kg , "" , 1 , " kg" )
221+
222+ table .add_row (
223+ r .address ,
224+ f"[{ a_color } ]{ r .action .value .upper ()} [/{ a_color } ]" ,
225+ r .instance_type or "—" ,
226+ r .region or "—" ,
227+ f"${ r .monthly_cost_usd :,.2f} " ,
228+ cost_delta_str ,
229+ f"{ r .monthly_carbon_kg_co2 :,.1f} kg" ,
230+ carbon_delta_str ,
231+ )
232+
233+ console .print (table )
234+
235+ # Summary line
236+ console .print ()
237+ cost_delta = _format_delta (estimate .total_cost_delta_usd , "$" , 2 )
238+ carbon_delta = _format_delta (estimate .total_carbon_delta_kg , "" , 1 , " kg CO₂" )
239+ console .print (
240+ f"[bold]Total monthly impact:[/bold] "
241+ f"[yellow]${ estimate .total_monthly_cost_usd :,.2f} /mo[/yellow] ({ cost_delta } )"
242+ f" | [green]{ estimate .total_monthly_carbon_kg :,.1f} kg CO₂/mo[/green] ({ carbon_delta } )"
243+ )
244+
245+ # Policy violations
246+ if estimate .violations :
247+ console .print ()
248+ viol_table = Table (title = "Policy Violations" , show_lines = True )
249+ viol_table .add_column ("Severity" )
250+ viol_table .add_column ("Policy" )
251+ viol_table .add_column ("Resource" , style = "cyan" )
252+ viol_table .add_column ("Message" )
253+
254+ for v in estimate .violations :
255+ sev_color = SEVERITY_COLORS .get (v .severity .value , "white" )
256+ viol_table .add_row (
257+ f"[{ sev_color } ]{ v .severity .value .upper ()} [/{ sev_color } ]" ,
258+ v .policy_name ,
259+ v .resource_name or v .resource_id or "—" ,
260+ v .message ,
261+ )
262+
263+ console .print (viol_table )
264+
265+ blocking = sum (1 for v in estimate .violations if v .severity .value == "block" )
266+ if blocking :
267+ console .print (
268+ f"\n [bold red]{ blocking } blocking violation(s)"
269+ f" — this plan should not be applied.[/bold red]"
270+ )
271+ raise typer .Exit (1 )
272+
273+
274+ def _format_delta (value : float , prefix : str , decimals : int , suffix : str = "" ) -> str :
275+ """Format a delta value with color and sign."""
276+ if abs (value ) < 0.005 :
277+ return "[dim]—[/dim]"
278+ if value > 0 :
279+ return f"[red]+{ prefix } { value :,.{decimals }f} { suffix } [/red]"
280+ return f"[green]-{ prefix } { abs (value ):,.{decimals }f} { suffix } [/green]"
139281
140282
141283@app .command ()
0 commit comments