Skip to content

Commit 1240bee

Browse files
committed
Add Phase 2: policy engine, canopy plan, GCP support, CI integration
- Policy engine with budget/carbon/region/tagging/EcoWeight rules and block/warn/info severity levels (canopy-policy.yaml) - Terraform/OpenTofu plan JSON parser - Pulumi preview JSON parser with auto-detection - canopy plan command with cost/carbon delta estimation - GCP provider with static pricing for 21 machine types - GitHub Actions workflow for PR cost/carbon comments - GitLab CI template - 52 new tests (110 total)
1 parent 099b87e commit 1240bee

17 files changed

Lines changed: 2340 additions & 2 deletions

File tree

.github/workflows/canopy-plan.yml

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
name: Canopy Plan
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
permissions:
8+
pull-requests: write
9+
contents: read
10+
11+
jobs:
12+
canopy-plan:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
21+
- name: Install Canopy
22+
run: pip install -e .
23+
24+
- name: Setup Terraform
25+
uses: hashicorp/setup-terraform@v3
26+
with:
27+
terraform_wrapper: false
28+
29+
- name: Terraform Init & Plan
30+
id: tf-plan
31+
working-directory: ${{ inputs.working_directory || '.' }}
32+
env:
33+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
34+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
35+
AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION || 'us-east-1' }}
36+
run: |
37+
if [ -f "main.tf" ] || [ -f "*.tf" ]; then
38+
terraform init -input=false
39+
terraform plan -out=tfplan -input=false
40+
terraform show -json tfplan > plan.json
41+
echo "has_plan=true" >> "$GITHUB_OUTPUT"
42+
else
43+
echo "has_plan=false" >> "$GITHUB_OUTPUT"
44+
fi
45+
46+
- name: Run Canopy Plan
47+
id: canopy
48+
if: steps.tf-plan.outputs.has_plan == 'true'
49+
working-directory: ${{ inputs.working_directory || '.' }}
50+
run: |
51+
set +e
52+
OUTPUT=$(canopy plan plan.json --output json 2>&1)
53+
EXIT_CODE=$?
54+
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
55+
56+
# Write output for the comment step
57+
echo "$OUTPUT" > canopy-output.json
58+
59+
# Generate markdown summary
60+
python3 -c "
61+
import json, sys
62+
63+
try:
64+
data = json.loads(open('canopy-output.json').read())
65+
except (json.JSONDecodeError, FileNotFoundError):
66+
print('Canopy plan produced no parseable output.')
67+
sys.exit(0)
68+
69+
summary = data.get('summary', {})
70+
resources = data.get('resources', [])
71+
violations = data.get('violations', [])
72+
73+
lines = ['## Canopy Plan — Cost & Carbon Impact', '']
74+
75+
if resources:
76+
lines.append('| Resource | Action | Instance | Cost/mo | Cost Delta | Carbon/mo | Carbon Delta |')
77+
lines.append('|----------|--------|----------|---------|------------|-----------|--------------|')
78+
for r in resources:
79+
cost_d = r.get('cost_delta_usd', 0)
80+
carbon_d = r.get('carbon_delta_kg', 0)
81+
cost_sign = '+' if cost_d > 0 else ''
82+
carbon_sign = '+' if carbon_d > 0 else ''
83+
lines.append(
84+
f\"| \`{r['address']}\` | {r['action'].upper()} | {r.get('instance_type') or '—'} \"
85+
f\"| \\\${r.get('monthly_cost_usd', 0):,.2f} | {cost_sign}\\\${cost_d:,.2f} \"
86+
f\"| {r.get('monthly_carbon_kg_co2', 0):,.1f} kg | {carbon_sign}{carbon_d:,.1f} kg |\"
87+
)
88+
lines.append('')
89+
90+
total_cost = summary.get('total_monthly_cost_usd', 0)
91+
cost_delta = summary.get('total_cost_delta_usd', 0)
92+
total_carbon = summary.get('total_monthly_carbon_kg', 0)
93+
carbon_delta = summary.get('total_carbon_delta_kg', 0)
94+
cost_sign = '+' if cost_delta > 0 else ''
95+
carbon_sign = '+' if carbon_delta > 0 else ''
96+
97+
lines.append(f'**Total:** \\\${total_cost:,.2f}/mo ({cost_sign}\\\${cost_delta:,.2f}) | {total_carbon:,.1f} kg CO₂/mo ({carbon_sign}{carbon_delta:,.1f} kg)')
98+
lines.append('')
99+
100+
if violations:
101+
lines.append('### Policy Violations')
102+
lines.append('')
103+
for v in violations:
104+
icon = '🚫' if v['severity'] == 'block' else '⚠️' if v['severity'] == 'warn' else 'ℹ️'
105+
lines.append(f'- {icon} **{v[\"severity\"].upper()}** ({v[\"policy_name\"]}): {v[\"message\"]}')
106+
lines.append('')
107+
108+
lines.append('---')
109+
lines.append('*Generated by [Canopy](https://github.com/mahabubul470/canopy-cloud)*')
110+
111+
with open('canopy-comment.md', 'w') as f:
112+
f.write('\n'.join(lines))
113+
" 2>&1 || true
114+
115+
- name: Comment on PR
116+
if: steps.tf-plan.outputs.has_plan == 'true'
117+
uses: actions/github-script@v7
118+
with:
119+
script: |
120+
const fs = require('fs');
121+
const commentPath = '${{ inputs.working_directory || '.' }}/canopy-comment.md';
122+
let body = '## Canopy Plan\n\nNo cost/carbon impact detected.';
123+
try {
124+
body = fs.readFileSync(commentPath, 'utf8');
125+
} catch (e) {}
126+
127+
// Find existing Canopy comment
128+
const { data: comments } = await github.rest.issues.listComments({
129+
owner: context.repo.owner,
130+
repo: context.repo.repo,
131+
issue_number: context.issue.number,
132+
});
133+
const existing = comments.find(c => c.body.includes('Canopy Plan'));
134+
135+
if (existing) {
136+
await github.rest.issues.updateComment({
137+
owner: context.repo.owner,
138+
repo: context.repo.repo,
139+
comment_id: existing.id,
140+
body: body,
141+
});
142+
} else {
143+
await github.rest.issues.createComment({
144+
owner: context.repo.owner,
145+
repo: context.repo.repo,
146+
issue_number: context.issue.number,
147+
body: body,
148+
});
149+
}
150+
151+
- name: Fail on blocking violations
152+
if: steps.canopy.outputs.exit_code == '1'
153+
run: exit 1

.gitlab-ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
stages:
2+
- plan
3+
4+
canopy-plan:
5+
stage: plan
6+
image: python:3.12-slim
7+
rules:
8+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
9+
variables:
10+
AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-us-east-1}
11+
before_script:
12+
- pip install -e .
13+
- |
14+
if command -v terraform &> /dev/null; then
15+
echo "Terraform already installed"
16+
else
17+
apt-get update -qq && apt-get install -y -qq unzip curl > /dev/null
18+
curl -fsSL https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip -o tf.zip
19+
unzip -q tf.zip -d /usr/local/bin && rm tf.zip
20+
fi
21+
script:
22+
- |
23+
if [ -f "main.tf" ] || ls *.tf 1>/dev/null 2>&1; then
24+
terraform init -input=false
25+
terraform plan -out=tfplan -input=false
26+
terraform show -json tfplan > plan.json
27+
canopy plan plan.json --output json > canopy-output.json || true
28+
echo "--- Canopy Plan Results ---"
29+
cat canopy-output.json | python3 -m json.tool
30+
else
31+
echo "No Terraform files found, skipping."
32+
fi
33+
artifacts:
34+
paths:
35+
- canopy-output.json
36+
when: always
37+
expire_in: 7 days

canopy/cli/main.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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()
134141
def 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()

canopy/engine/audit.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from canopy.engine.detectors import detect_idle, detect_region_move, detect_rightsize
99
from canopy.engine.providers.aws import AWSProvider
1010
from canopy.engine.providers.base import CloudProvider
11+
from canopy.engine.providers.gcp import GCPProvider
1112
from canopy.models.core import EcoWeight, Recommendation, SavingsSummary
1213

1314
# Default budget allocations when no policy is configured
@@ -18,6 +19,7 @@
1819
def get_provider(name: str) -> CloudProvider:
1920
providers: dict[str, type[CloudProvider]] = {
2021
"aws": AWSProvider,
22+
"gcp": GCPProvider,
2123
}
2224
provider_cls = providers.get(name)
2325
if not provider_cls:

canopy/engine/iac/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Infrastructure as Code parsers for Canopy."""

0 commit comments

Comments
 (0)