Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ var/
sdist/
develop-eggs/
.installed.cfg
lib/
lib64/
/lib/
/lib64/
wheels/
.pytest_cache/
.coverage
Expand Down
15 changes: 15 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,25 @@ APP_ENV=development
DEBUG=true
HOST=0.0.0.0
PORT=8000

# CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:3001"]

# Ollama (local LLM)
OLLAMA_BASE_URL=http://localhost:11434
QWEN_MODEL=qwen3.5:latest
LLM_TEMPERATURE=0.2
LLM_TIMEOUT_SECONDS=90

# Anthropic (Terraform generation)
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-20250514

# Deploy
DEPLOY_WORKSPACE_ROOT=/tmp/cloudforge
DEPLOY_DRY_RUN=true

# Agents
ENABLE_WEB_SEARCH=true
MAX_CLARIFICATION_ROUNDS=3
MAX_RESEARCH_ROUNDS=3
15 changes: 15 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@ class Settings(BaseSettings):
debug: bool = True
host: str = "0.0.0.0"
port: int = 8000

# CORS
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:3001"]

# LLM — Ollama (local)
ollama_base_url: str = "http://localhost:11434"
qwen_model: str = "qwen3.5:latest"
llm_temperature: float = 0.2
llm_timeout_seconds: int = 90

# LLM — Anthropic (for Terraform generation)
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-20250514"

# Deploy settings
deploy_workspace_root: str = "/tmp/cloudforge"
deploy_dry_run: bool = True # When True, simulates terraform commands

# Agents
enable_web_search: bool = True
max_clarification_rounds: int = 6
max_research_rounds: int = 3
Expand Down
13 changes: 12 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import health, workflows
from app.routers import health, workflows, deploy

app = FastAPI(
title=settings.app_name,
debug=settings.debug,
)

# CORS — allow the Next.js frontend to reach the API
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(health.router)
app.include_router(workflows.router)
app.include_router(deploy.router)


@app.get("/")
Expand Down
167 changes: 167 additions & 0 deletions backend/app/routers/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""
Deploy router — SSE streaming deployment endpoints.

Endpoints:
POST /deploy/start → Start a new deployment, returns deployment_id
GET /deploy/{id}/stream → SSE stream of deployment events
GET /deploy/{id}/status → Current deployment status (JSON)
POST /deploy/{id}/rollback → Rollback a deployment
POST /deploy/{id}/cancel → Cancel a running deployment
GET /deploy/list → List all deployments
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse

from app.schemas.deploy import (
DeployListItem,
DeployStartResponse,
DeployStatusResponse,
RollbackRequest,
RollbackResponse,
StartDeployRequest,
)
from app.services.deploy_orchestrator import DeploymentOrchestrator

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/deploy", tags=["deploy"])

# Shared orchestrator instance
_orchestrator = DeploymentOrchestrator()


@router.post("/start", response_model=DeployStartResponse)
async def start_deployment(payload: StartDeployRequest) -> DeployStartResponse:
"""
Start a new infrastructure deployment.

Accepts the architecture data (nodes + edges) from the forge pipeline
and kicks off the Terraform generation → init → plan → apply pipeline.
"""
# Convert Pydantic models to dicts for the orchestrator
arch_data = {
"nodes": [node.model_dump(by_alias=True) for node in payload.architecture_data.nodes],
"edges": [edge.model_dump(by_alias=True) for edge in payload.architecture_data.edges],
}

deployment_id = await _orchestrator.start_deployment(
architecture_data=arch_data,
project_name=payload.project_name,
region=payload.region,
environment=payload.environment,
aws_credentials=payload.aws_credentials,
)

return DeployStartResponse(
deployment_id=deployment_id,
status="accepted",
message=f"Deployment {deployment_id} started — stream events at /deploy/{deployment_id}/stream",
)


@router.get("/{deployment_id}/stream")
async def stream_deployment(deployment_id: str) -> StreamingResponse:
"""
SSE stream of deployment events.

Connect via EventSource or fetch with streaming.
Events: log, node_status, stage_change, terraform_output, error, complete.
"""
deployment = await _orchestrator.get_deployment_status(deployment_id)
if not deployment:
raise HTTPException(status_code=404, detail="Deployment not found")

async def event_generator():
try:
async for event in _orchestrator.stream_events(deployment_id):
yield event.to_sse()
except Exception as e:
logger.error("SSE stream error for %s: %s", deployment_id, str(e))
yield f"data: {{\"type\": \"error\", \"message\": \"{str(e)}\"}}\n\n"

Comment on lines +83 to +86

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the SSE stream exception handler, the JSON is built via string interpolation ("{str(e)}"). If the exception message contains quotes/newlines, this produces invalid JSON and can break client parsing. Build the payload with json.dumps(...) (and include the expected data/timestamp fields) before formatting the SSE line.

Copilot uses AI. Check for mistakes.
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)


@router.get("/{deployment_id}/status", response_model=DeployStatusResponse)
async def get_deployment_status(deployment_id: str) -> DeployStatusResponse:
"""Get the current status of a deployment."""
deployment = await _orchestrator.get_deployment_status(deployment_id)
if not deployment:
raise HTTPException(status_code=404, detail="Deployment not found")

return DeployStatusResponse(
deployment_id=deployment["deployment_id"],
project_name=deployment["project_name"],
status=deployment["status"].value if hasattr(deployment["status"], "value") else deployment["status"],
region=deployment["region"],
environment=deployment["environment"],
node_statuses=deployment.get("node_statuses", {}),
outputs=deployment.get("outputs", {}),
is_rollback=deployment.get("is_rollback", False),
created_at=deployment["created_at"],
updated_at=deployment["updated_at"],
)


@router.post("/{deployment_id}/rollback", response_model=RollbackResponse)
async def rollback_deployment(
deployment_id: str, payload: RollbackRequest
) -> RollbackResponse:
"""Rollback a completed or failed deployment using terraform destroy."""
if not payload.confirm:
raise HTTPException(
status_code=400, detail="Rollback not confirmed — set confirm=true"
)

try:
rollback_id = await _orchestrator.rollback_deployment(deployment_id)
return RollbackResponse(
rollback_id=rollback_id,
status="accepted",
message=f"Rollback {rollback_id} started for deployment {deployment_id}",
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))


@router.post("/{deployment_id}/cancel")
async def cancel_deployment(deployment_id: str) -> dict:
"""Cancel a running deployment."""
cancelled = await _orchestrator.cancel_deployment(deployment_id)
if not cancelled:
raise HTTPException(
status_code=409,
detail="Deployment is not running or already completed",
)
return {"deployment_id": deployment_id, "status": "cancelled"}


@router.get("/list", response_model=list[DeployListItem])
async def list_deployments() -> list[DeployListItem]:
"""List all deployments."""
deployments = await _orchestrator.state_manager.list_deployments()
return [
DeployListItem(
deployment_id=d["deployment_id"],
project_name=d["project_name"],
status=d["status"].value if hasattr(d["status"], "value") else d["status"],
region=d["region"],
environment=d["environment"],
is_rollback=d.get("is_rollback", False),
created_at=d["created_at"],
)
for d in deployments
]
98 changes: 98 additions & 0 deletions backend/app/schemas/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Pydantic schemas for the deploy API."""

from __future__ import annotations

from typing import Any, Literal

from pydantic import BaseModel, Field


# ── Request schemas ──────────────────────────────────────────────────

class ArchNodeSchema(BaseModel):
"""A single node from the CloudForge architecture."""
id: str
label: str
sublabel: str = ""
type: Literal["compute", "storage", "cache", "gateway", "queue", "auth"]
x: float = 0
y: float = 0
terraformResource: str = ""
estimatedCost: str = ""
config: dict[str, str] = Field(default_factory=dict)
whyChosen: str = ""
validates: list[str] = Field(default_factory=list)
blocks: list[str] = Field(default_factory=list)
deployStatus: str = "queued"


class ArchEdgeSchema(BaseModel):
"""An edge between two architecture nodes."""
source: str = Field(alias="from", default="")
target: str = Field(alias="to", default="")
Comment on lines +31 to +32

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ArchEdgeSchema provides default empty strings for both endpoints. This allows requests with missing/invalid edges to pass validation and later produce unclear behavior (e.g., empty from/to in Terraform connection wiring). Make these fields required (no default) and let validation return a 422 for malformed topology data.

Suggested change
source: str = Field(alias="from", default="")
target: str = Field(alias="to", default="")
source: str = Field(alias="from")
target: str = Field(alias="to")

Copilot uses AI. Check for mistakes.

model_config = {"populate_by_name": True}


class ArchitectureDataSchema(BaseModel):
"""Full architecture spec from the frontend."""
nodes: list[ArchNodeSchema] = Field(default_factory=list)
edges: list[ArchEdgeSchema] = Field(default_factory=list)


class StartDeployRequest(BaseModel):
"""Request body for POST /deploy/start."""
architecture_data: ArchitectureDataSchema
project_name: str = "cloudforge-project"
region: str = "us-east-1"
environment: str = "prod"
aws_credentials: dict[str, str] | None = Field(
default=None,
description="Optional AWS credentials (access_key_id, secret_access_key, session_token)",
)


class RollbackRequest(BaseModel):
"""Request body for POST /deploy/{deployment_id}/rollback."""
confirm: bool = True


# ── Response schemas ─────────────────────────────────────────────────

class DeployStartResponse(BaseModel):
"""Response for POST /deploy/start."""
deployment_id: str
status: str = "accepted"
message: str = "Deployment started"


class DeployStatusResponse(BaseModel):
"""Response for GET /deploy/{deployment_id}/status."""
deployment_id: str
project_name: str
status: str
region: str
environment: str
node_statuses: dict[str, str] = Field(default_factory=dict)
outputs: dict[str, Any] = Field(default_factory=dict)
is_rollback: bool = False
created_at: str
updated_at: str


class DeployListItem(BaseModel):
"""Summary item for GET /deploy/list."""
deployment_id: str
project_name: str
status: str
region: str
environment: str
is_rollback: bool = False
created_at: str


class RollbackResponse(BaseModel):
"""Response for POST /deploy/{deployment_id}/rollback."""
rollback_id: str
status: str = "accepted"
message: str = "Rollback started"
Loading
Loading