Skip to content
Merged
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
37 changes: 9 additions & 28 deletions .github/actions/cloudwright/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ runs:
HAS_ERRORS=0
HAS_WARNINGS=0

_append() { RESULTS="$RESULTS"$'\n'"## $1"$'\n'"$2"$'\n'; }

# Validate
if echo "$CHECKS" | grep -q "validate"; then
echo "::group::Validation"
Expand All @@ -71,10 +73,7 @@ runs:
fi
VALIDATE_OUT=$(cloudwright validate "$SPEC_FILE" $COMPLIANCE_ARGS --json 2>&1) || true
echo "$VALIDATE_OUT"
RESULTS="${RESULTS}
## Validation
${VALIDATE_OUT}
"
_append "Validation" "$VALIDATE_OUT"
if echo "$VALIDATE_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); exit(0 if d.get('passed', True) else 1)" 2>/dev/null; then
:
else
Expand All @@ -88,10 +87,7 @@ ${VALIDATE_OUT}
echo "::group::Cost Estimation"
COST_OUT=$(cloudwright cost "$SPEC_FILE" --json 2>&1) || true
echo "$COST_OUT"
RESULTS="${RESULTS}
## Cost Estimate
${COST_OUT}
"
_append "Cost Estimate" "$COST_OUT"
echo "::endgroup::"
fi

Expand All @@ -100,10 +96,7 @@ ${COST_OUT}
echo "::group::Score"
SCORE_OUT=$(cloudwright score "$SPEC_FILE" --json 2>&1) || true
echo "$SCORE_OUT"
RESULTS="${RESULTS}
## Score
${SCORE_OUT}
"
_append "Score" "$SCORE_OUT"
echo "::endgroup::"
fi

Expand All @@ -112,10 +105,7 @@ ${SCORE_OUT}
echo "::group::Lint"
LINT_OUT=$(cloudwright lint "$SPEC_FILE" --json 2>&1) || true
echo "$LINT_OUT"
RESULTS="${RESULTS}
## Lint
${LINT_OUT}
"
_append "Lint" "$LINT_OUT"
if echo "$LINT_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); issues=d if isinstance(d,list) else d.get('issues',[]); exit(1 if any(i.get('severity')=='error' for i in issues if isinstance(i,dict)) else 0)" 2>/dev/null; then
:
else
Expand All @@ -129,10 +119,7 @@ ${LINT_OUT}
echo "::group::Blast Radius Analysis"
ANALYZE_OUT=$(cloudwright analyze "$SPEC_FILE" --json 2>&1) || true
echo "$ANALYZE_OUT"
RESULTS="${RESULTS}
## Analyze
${ANALYZE_OUT}
"
_append "Analyze" "$ANALYZE_OUT"
echo "::endgroup::"
fi

Expand All @@ -141,10 +128,7 @@ ${ANALYZE_OUT}
echo "::group::Diff"
DIFF_OUT=$(cloudwright diff "$OLD_SPEC_FILE" "$SPEC_FILE" --json 2>&1) || true
echo "$DIFF_OUT"
RESULTS="${RESULTS}
## Diff
${DIFF_OUT}
"
_append "Diff" "$DIFF_OUT"
echo "::endgroup::"
fi

Expand All @@ -154,10 +138,7 @@ ${DIFF_OUT}
echo "::group::Policy Check"
POLICY_OUT=$(cloudwright policy "$SPEC_FILE" "$POLICY_FILE" --json 2>&1) || true
echo "$POLICY_OUT"
RESULTS="${RESULTS}
## Policy
${POLICY_OUT}
"
_append "Policy" "$POLICY_OUT"
if echo "$POLICY_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); exit(0 if d.get('passed', True) else 1)" 2>/dev/null; then
:
else
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/architecture-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Find changed specs
id: specs
run: |
SPECS=$(git diff --name-only HEAD~1 HEAD -- '*.yaml' '*.yml' | grep -E '(spec|arch)' | head -1 || true)
SPECS=$(git diff --name-only HEAD~1 HEAD -- '*.yaml' '*.yml' | grep -v '^\.github/' | grep -E '(spec|arch)' | head -1 || true)
echo "file=$SPECS" >> "$GITHUB_OUTPUT"
echo "found=$([ -n "$SPECS" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
pip install -e ./packages/mcp
pip install pytest pytest-timeout pytest-cov
- name: Run core tests
run: pytest packages/core/tests/ -x -q --timeout=30 -m "not slow" --cov=cloudwright --cov-report=term-missing
run: pytest packages/core/tests/ -x -q --timeout=30 -m "not slow" --cov=cloudwright --cov-report=term-missing --cov-fail-under=70
- name: Run web tests
run: pytest packages/web/tests/ -x -q --timeout=60
- name: Run CLI tests
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ on:
tags: ["v*"]

jobs:
test:
uses: ./.github/workflows/ci.yml

publish:
needs: [test]
runs-on: ubuntu-latest
environment: release
permissions:
Expand Down
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM python:3.12-slim

WORKDIR /app

COPY packages/core/pyproject.toml packages/core/pyproject.toml
COPY packages/core/cloudwright/ packages/core/cloudwright/
COPY packages/web/pyproject.toml packages/web/pyproject.toml
COPY packages/web/cloudwright_web/ packages/web/cloudwright_web/

RUN pip install --no-cache-dir ./packages/core ./packages/web

EXPOSE 8000

CMD ["uvicorn", "cloudwright_web.app:app", "--host", "0.0.0.0", "--port", "8000"]
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
web:
build: .
ports:
- "8000:8000"
environment:
- CLOUDWRIGHT_API_KEY=${CLOUDWRIGHT_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- CLOUDWRIGHT_CORS_ORIGINS=${CLOUDWRIGHT_CORS_ORIGINS:-http://localhost:5173}
- CLOUDWRIGHT_LOG_FORMAT=json
restart: unless-stopped
2 changes: 1 addition & 1 deletion packages/cli/cloudwright_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.1.0"
9 changes: 7 additions & 2 deletions packages/cli/cloudwright_cli/commands/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from rich.syntax import Syntax
from rich.table import Table

from cloudwright_cli.completions import complete_compliance as _complete_compliance
from cloudwright_cli.completions import complete_provider as _complete_provider
from cloudwright_cli.output import emit_dry_run, emit_error, emit_success, is_json_mode

console = Console()
Expand All @@ -21,11 +23,14 @@
def design(
ctx: typer.Context,
description: Annotated[str, typer.Argument(help="Natural language architecture description")],
provider: Annotated[str, typer.Option(help="Cloud provider")] = "aws",
provider: Annotated[str, typer.Option(help="Cloud provider", autocompletion=_complete_provider)] = "aws",
region: Annotated[str, typer.Option(help="Primary region")] = "us-east-1",
budget: Annotated[float | None, typer.Option(help="Monthly budget in USD")] = None,
compliance: Annotated[
list[str] | None, typer.Option(help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)")
list[str] | None,
typer.Option(
help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)", autocompletion=_complete_compliance
),
] = None,
output: Annotated[Path | None, typer.Option("--output", "-o", help="Write YAML to file")] = None,
yaml_output: Annotated[bool, typer.Option("--yaml")] = False,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/cloudwright_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def main(
dry_run: bool = typer.Option(False, "--dry-run", help="Preview LLM operations without calling the API"),
stream: bool = typer.Option(False, "--stream", help="NDJSON streaming output (one JSON line per item)"),
) -> None:
from cloudwright.logging import configure_logging

configure_logging()
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["json"] = json_output
Expand Down
2 changes: 1 addition & 1 deletion packages/core/cloudwright/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ValidationResult,
)

__version__ = "1.0.0"
__version__ = "1.1.0"

__all__ = [
"Alternative",
Expand Down
7 changes: 3 additions & 4 deletions packages/core/cloudwright/exporter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,9 @@ def export_spec(spec: ArchSpec, fmt: str, output: str | None = None, output_dir:
"""Export an ArchSpec to the given format. Returns the rendered string."""
fmt = fmt.lower().strip()

# Validate all component configs before exporting to IaC formats
if fmt in ("terraform", "cloudformation", "cfn"):
for comp in spec.components:
validate_export_config(comp.config, path=f"component[{comp.id}].config")
# Validate all component configs before exporting (prevents injection in any format)
for comp in spec.components:
validate_export_config(comp.config, path=f"component[{comp.id}].config")

if fmt == "terraform":
from cloudwright.exporter.terraform import render
Expand Down
7 changes: 6 additions & 1 deletion packages/core/cloudwright/exporter/cloudformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _build_properties(c: "Component") -> dict[str, Any]:
"DBInstanceClass": cfg.get("instance_class", "db.t3.medium"),
"Engine": cfg.get("engine", "mysql"),
"AllocatedStorage": str(cfg.get("allocated_storage", 20)),
"MasterUsername": "admin",
"MasterUsername": {"Ref": "DBUsername"},
"MasterUserPassword": {"Ref": "DBPassword"},
"Tags": tags,
}
Expand Down Expand Up @@ -201,6 +201,11 @@ def render(spec: "ArchSpec") -> str:
"Type": "String",
"Default": "production",
},
"DBUsername": {
"Type": "String",
"Description": "Database master username",
"Default": "dbadmin",
},
"DBPassword": {
"Type": "String",
"NoEcho": True,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/cloudwright/exporter/terraform/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str:
f' engine = "{engine}"',
f' instance_class = "{instance_class}"',
f" allocated_storage = {cfg.get('allocated_storage', 20)}",
' username = "admin"',
" username = var.db_username",
" password = var.db_password",
" skip_final_snapshot = true",
" skip_final_snapshot = false",
" tags = {",
f' Name = "{c.label}"',
" }",
Expand Down Expand Up @@ -317,7 +317,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str:
lines += [
f'resource "aws_ecr_repository" "{c.id}" {{',
f' name = "{c.id.replace("_", "-")}"',
' image_tag_mutability = "MUTABLE"',
' image_tag_mutability = "IMMUTABLE"',
" tags = {",
f' Name = "{c.label}"',
" }",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/cloudwright/exporter/terraform/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str:
f" resource_group_name = {_RG}",
f" location = {_LOCATION}",
f' size = "{cfg.get("size", "Standard_B2s")}"',
' admin_username = "adminuser"',
" admin_username = var.db_username",
f" network_interface_ids = [azurerm_network_interface.{c.id}_nic.id]",
" os_disk {",
' caching = "ReadWrite"',
Expand All @@ -71,7 +71,7 @@ def render_resource(c: "Component", spec: "ArchSpec") -> str:
f" resource_group_name = {_RG}",
f" location = {_LOCATION}",
' version = "12.0"',
' administrator_login = "sqladmin"',
" administrator_login = var.db_username",
" administrator_login_password = var.db_password",
" tags = {",
f' Name = "{c.label}"',
Expand Down
17 changes: 13 additions & 4 deletions packages/core/cloudwright/llm/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

log = get_logger(__name__)

GENERATE_MODEL = "claude-sonnet-4-6"
GENERATE_MODEL = os.environ.get("CLOUDWRIGHT_MODEL") or "claude-sonnet-4-6"
FAST_MODEL = "claude-haiku-4-5-20251001"
_MAX_RETRIES = int(os.environ.get("CLOUDWRIGHT_LLM_MAX_RETRIES", 3))

Expand Down Expand Up @@ -55,9 +55,18 @@ def generate_stream(
kwargs = dict(model=GENERATE_MODEL, max_tokens=max_tokens, system=system_block, messages=messages)
if timeout is not None:
kwargs["timeout"] = timeout
with self.client.messages.stream(**kwargs) as stream:
for text in stream.text_stream:
yield text
delay = 1.0
for attempt in range(_MAX_RETRIES):
try:
with self.client.messages.stream(**kwargs) as stream:
for text in stream.text_stream:
yield text
return
except _RETRYABLE:
if attempt == _MAX_RETRIES - 1:
raise
time.sleep(delay * (1 + random.uniform(0, 0.5)))
delay *= 2

def _call(
self, model: str, messages: list[dict], system: str, max_tokens: int, timeout: float | None = None
Expand Down
25 changes: 17 additions & 8 deletions packages/core/cloudwright/llm/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

log = get_logger(__name__)

GENERATE_MODEL = "gpt-5.2"
GENERATE_MODEL = os.environ.get("CLOUDWRIGHT_MODEL") or "gpt-5.2"
FAST_MODEL = "gpt-5-mini"
_MAX_RETRIES = int(os.environ.get("CLOUDWRIGHT_LLM_MAX_RETRIES", 3))

Expand Down Expand Up @@ -54,19 +54,28 @@ def generate_stream(
self, messages: list[dict], system: str, max_tokens: int = 2000, timeout: float | None = None
) -> Iterator[str]:
full_messages = [{"role": "system", "content": system}] + messages
kwargs = dict(model=GENERATE_MODEL, max_tokens=max_tokens, messages=full_messages, stream=True)
kwargs = dict(model=GENERATE_MODEL, max_completion_tokens=max_tokens, messages=full_messages, stream=True)
if timeout is not None:
kwargs["timeout"] = timeout
stream = self.client.chat.completions.create(**kwargs)
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
yield content
delay = 1.0
for attempt in range(_MAX_RETRIES):
try:
stream = self.client.chat.completions.create(**kwargs)
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
yield content
return
except _RETRYABLE:
if attempt == _MAX_RETRIES - 1:
raise
time.sleep(delay * (1 + random.uniform(0, 0.5)))
delay *= 2

def _call(
self, model: str, messages: list[dict], max_tokens: int, timeout: float | None = None
) -> tuple[str, dict]:
kwargs = dict(model=model, max_tokens=max_tokens, messages=messages)
kwargs = dict(model=model, max_completion_tokens=max_tokens, messages=messages)
if timeout is not None:
kwargs["timeout"] = timeout
delay = 1.0
Expand Down
21 changes: 21 additions & 0 deletions packages/core/cloudwright/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,18 @@

# -- Service normalization for LLM output drift ------------------------------------

# Provider-aware normalization: ambiguous service names resolve differently per provider.
# Keys that are unambiguous map to a single target. Keys that depend on provider
# are handled by normalize_service(raw, provider) below.
_PROVIDER_SPECIFIC_NORMALIZATION: dict[str, dict[str, str]] = {
"redis": {"aws": "elasticache", "gcp": "memorystore", "azure": "azure_cache"},
"postgres": {"aws": "rds", "gcp": "cloud_sql", "azure": "azure_sql"},
"mysql": {"aws": "rds", "gcp": "cloud_sql", "azure": "azure_sql"},
"mongodb": {"aws": "dynamodb", "gcp": "firestore", "azure": "cosmos_db"},
"kubernetes": {"aws": "eks", "gcp": "gke", "azure": "aks"},
"docker": {"aws": "ecs", "gcp": "cloud_run", "azure": "container_apps"},
}

SERVICE_NORMALIZATION: dict[str, str] = {
"aws_rds": "rds",
"aws_lambda": "lambda",
Expand Down Expand Up @@ -592,3 +604,12 @@
- Design a single provider-agnostic architecture using the primary provider's service keys
- Include realistic instance types and configurations for accurate pricing
- Respond with ONLY the JSON object"""


def normalize_service(raw: str, provider: str | None = None) -> str:
"""Normalize a raw service name, using provider context for ambiguous names."""
key = raw.lower().strip()
if provider and key in _PROVIDER_SPECIFIC_NORMALIZATION:
provider_map = _PROVIDER_SPECIFIC_NORMALIZATION[key]
return provider_map.get(provider.lower(), SERVICE_NORMALIZATION.get(key, key))
return SERVICE_NORMALIZATION.get(key, key)
Loading
Loading