Skip to content

Commit 981048a

Browse files
committed
Add Phase 3: agentic orchestration, MCP servers, dashboard
Implement apply engine with AWS/GCP executors (terminate, rightsize, region move stub), approval workflows (CLI, Slack, GitHub), CARL carbon-aware scheduler, audit log (JSON Lines), 5 MCP servers (billing-aws, billing-gcp, electricity, slack, github), FastAPI dashboard with Chart.js, and updated docs/roadmap. 187 tests passing.
1 parent 1240bee commit 981048a

39 files changed

Lines changed: 3054 additions & 14 deletions

.pre-commit-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repos:
1010
rev: v1.13.0
1111
hooks:
1212
- id: mypy
13+
exclude: ^tests/
1314
additional_dependencies:
1415
- pydantic>=2.0.0
1516
- boto3-stubs[ec2,cloudwatch,ce,pricing]>=1.35.0
@@ -18,6 +19,8 @@ repos:
1819
- rich>=13.0.0
1920
- pyyaml>=6.0
2021
- types-PyYAML>=6.0
22+
- "mcp[cli]>=1.0.0"
23+
- fastapi>=0.115.0
2124
args: [--strict]
2225

2326
- repo: local

README.md

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Organizations waste 30–35% of their cloud spend on overprovisioned resources.
88

99
Unlike tools that are either cost-aware (Kubecost, Infracost) or carbon-aware (Cloud Carbon Footprint), Canopy optimizes both in a single loop.
1010

11-
Currently supports **AWS** (EC2). GCP and Azure coming in future phases.
11+
Supports **AWS** and **GCP**. Azure planned for a future phase.
1212

1313
## Features
1414

@@ -17,8 +17,14 @@ Currently supports **AWS** (EC2). GCP and Azure coming in future phases.
1717
- **Right-sizing** — suggests instance downgrades for under-utilized workloads (< 15% CPU)
1818
- **Region migration** — recommends greener regions when 50%+ carbon reduction is possible
1919
- **Region tiers** — Platinum/Gold/Silver/Bronze classification for 48 AWS + GCP regions
20+
- **Shift-left**`canopy plan` previews cost/carbon impact of Terraform and Pulumi changes before deploy
21+
- **Apply engine**`canopy apply` executes approved optimizations (terminate idle, rightsize instances) with interactive, Slack, or GitHub approval workflows
22+
- **CARL scheduler** — Carbon-Aware Resource Launcher defers or throttles workloads based on grid carbon intensity
23+
- **MCP servers** — expose billing, carbon, Slack, and GitHub tools for LLM hosts via the Model Context Protocol
24+
- **Web dashboard** — optional FastAPI UI with EcoWeight cards, workload tables, region intensity charts, and audit log
25+
- **Audit log** — JSON Lines audit trail of all actions (audits, applies, approvals, CARL decisions)
2026
- **Reports** — JSON and CSV export for dashboards and compliance reporting
21-
- **Configuration** — YAML-based budgets, weights, and thresholds
27+
- **Configuration** — YAML-based budgets, weights, thresholds, and approval settings
2228

2329
## Installation
2430

@@ -38,6 +44,19 @@ source .venv/bin/activate # or: . .venv/bin/activate.fish
3844
pip install -e ".[dev]"
3945
```
4046

47+
### Optional extras
48+
49+
```bash
50+
# MCP servers (for LLM tool integration)
51+
pip install -e ".[mcp]"
52+
53+
# Web dashboard
54+
pip install -e ".[dashboard]"
55+
56+
# Everything
57+
pip install -e ".[dev,mcp,dashboard]"
58+
```
59+
4160
### Verify installation
4261

4362
```bash
@@ -59,9 +78,21 @@ canopy audit --provider aws --region us-east-1
5978
# Get JSON output
6079
canopy audit --provider aws --output json
6180

81+
# Preview what applying recommendations would do
82+
canopy apply --provider aws --dry-run
83+
84+
# Apply recommendations with interactive approval
85+
canopy apply --provider aws
86+
6287
# Export a report
6388
canopy report --provider aws --output json --out report.json
6489
canopy report --provider aws --output csv --out report.csv
90+
91+
# Launch the web dashboard
92+
canopy dashboard --port 8080
93+
94+
# List available MCP servers
95+
canopy mcp list
6596
```
6697

6798
See [docs/quickstart.md](docs/quickstart.md) for a full walkthrough.
@@ -86,6 +117,16 @@ rightsize_cpu_threshold: 15.0 # % — below this triggers right-sizing
86117
# Provider
87118
provider: aws
88119
regions: [] # empty = all regions
120+
121+
# Approval & notifications (Phase 3)
122+
approval_channel: "#cloud-ops"
123+
slack_webhook_url: https://hooks.slack.com/services/...
124+
github_token: ghp_...
125+
github_repo: org/infra-repo
126+
carl_urgency: normal # critical | normal | flexible
127+
128+
# Dashboard
129+
dashboard_port: 8080
89130
```
90131
91132
Pass a custom config path:
@@ -102,8 +143,11 @@ canopy audit --config path/to/canopy.yaml
102143
| `canopy regions` | List region efficiency tiers |
103144
| `canopy audit` | Scan infrastructure, compute EcoWeight scores, show recommendations |
104145
| `canopy report` | Export audit results as JSON or CSV |
105-
| `canopy plan` | *(Phase 2)* Preview cost/carbon impact of IaC changes |
106-
| `canopy apply` | *(Phase 3)* Apply recommended optimizations |
146+
| `canopy plan` | Preview cost/carbon impact of Terraform/Pulumi changes |
147+
| `canopy apply` | Apply recommended optimizations with approval workflow |
148+
| `canopy mcp list` | List available MCP servers |
149+
| `canopy mcp serve <name>` | Start an MCP server (stdio) |
150+
| `canopy dashboard` | Launch the web dashboard |
107151

108152
### `canopy audit`
109153

@@ -115,6 +159,18 @@ Options:
115159
--config TEXT Path to canopy.yaml config file
116160
```
117161

162+
### `canopy apply`
163+
164+
```
165+
Options:
166+
--provider TEXT Cloud provider (aws, gcp) [default: aws]
167+
--region TEXT Filter by region
168+
--config TEXT Path to canopy.yaml config file
169+
--yes Skip confirmation
170+
--approval TEXT Approval method (cli, slack, github) [default: cli]
171+
--dry-run Show what would be done without executing
172+
```
173+
118174
### `canopy report`
119175

120176
```
@@ -133,6 +189,26 @@ Options:
133189
--provider TEXT Cloud provider (aws, gcp, all) [default: all]
134190
```
135191

192+
### `canopy dashboard`
193+
194+
```
195+
Options:
196+
--port INTEGER Port to serve on [default: 8080]
197+
--host TEXT Host to bind to [default: 127.0.0.1]
198+
```
199+
200+
### `canopy mcp serve`
201+
202+
Starts an MCP server that communicates over stdio. Available servers:
203+
204+
| Server | Tools |
205+
|--------|-------|
206+
| `billing-aws` | `get_workload_costs()`, `get_cost_breakdown()` |
207+
| `billing-gcp` | `get_workload_costs()`, `get_cost_breakdown()` |
208+
| `electricity` | `get_carbon_intensity()`, `get_all_region_intensities()` |
209+
| `slack` | `send_notification()`, `send_approval_request()` |
210+
| `github` | `create_issue()`, `create_optimization_issue()` |
211+
136212
## How EcoWeight works
137213

138214
EcoWeight is a normalized score:
@@ -152,8 +228,8 @@ EcoWeight = α × (hourly_cost / budget_hourly_usd) + β × (hourly_carbon / car
152228
## Development
153229

154230
```bash
155-
# Install dev dependencies
156-
pip install -e ".[dev]"
231+
# Install all dependencies
232+
pip install -e ".[dev,mcp,dashboard]"
157233

158234
# Run tests
159235
pytest

canopy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Canopy: Budget & Carbon-Aware Infrastructure Architect."""
22

3-
__version__ = "0.2.0"
3+
__version__ = "0.3.0"

canopy/cli/main.py

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,112 @@ def _format_delta(value: float, prefix: str, decimals: int, suffix: str = "") ->
282282

283283
@app.command()
284284
def 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+
362541
if __name__ == "__main__":
363542
app()

canopy/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ class CanopyConfig(BaseModel):
1717
regions: list[str] = Field(default_factory=list)
1818
idle_cpu_threshold: float = Field(default=2.0, ge=0, le=100)
1919
rightsize_cpu_threshold: float = Field(default=15.0, ge=0, le=100)
20+
# Phase 3 — agentic orchestration
21+
audit_log_dir: str | None = None
22+
approval_channel: str | None = None
23+
slack_webhook_url: str | None = None
24+
github_token: str | None = None
25+
github_repo: str | None = None
26+
carl_urgency: str = "normal"
27+
dashboard_port: int = Field(default=8080, ge=1, le=65535)
2028

2129

2230
_SEARCH_PATHS = [

canopy/dashboard/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)