cloudwright-ai-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudwright_ai_cli-0.1.0.dist-info/METADATA +42 -0
- cloudwright_ai_cli-0.1.0.dist-info/RECORD +28 -0
- cloudwright_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- cloudwright_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cloudwright_cli/__init__.py +1 -0
- cloudwright_cli/__main__.py +5 -0
- cloudwright_cli/commands/__init__.py +0 -0
- cloudwright_cli/commands/analyze_cmd.py +117 -0
- cloudwright_cli/commands/catalog_cmd.py +148 -0
- cloudwright_cli/commands/chat.py +164 -0
- cloudwright_cli/commands/compare.py +75 -0
- cloudwright_cli/commands/cost.py +112 -0
- cloudwright_cli/commands/design.py +84 -0
- cloudwright_cli/commands/diff.py +88 -0
- cloudwright_cli/commands/drift_cmd.py +98 -0
- cloudwright_cli/commands/export.py +65 -0
- cloudwright_cli/commands/import_cmd.py +69 -0
- cloudwright_cli/commands/init_cmd.py +135 -0
- cloudwright_cli/commands/lint_cmd.py +91 -0
- cloudwright_cli/commands/modify_cmd.py +125 -0
- cloudwright_cli/commands/policy.py +88 -0
- cloudwright_cli/commands/refresh_cmd.py +104 -0
- cloudwright_cli/commands/score_cmd.py +80 -0
- cloudwright_cli/commands/validate.py +106 -0
- cloudwright_cli/main.py +66 -0
- cloudwright_cli/project.py +50 -0
- cloudwright_cli/py.typed +0 -0
- cloudwright_cli/utils.py +37 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Lint an architecture spec for anti-patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from cloudwright_cli.utils import handle_error
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lint(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
spec_file: Annotated[Path, typer.Argument(help="Architecture spec YAML file", exists=True)],
|
|
22
|
+
output: Annotated[str, typer.Option(help="Output format: text, json")] = "text",
|
|
23
|
+
strict: Annotated[bool, typer.Option(help="Fail on warnings too")] = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Detect architecture anti-patterns in a spec file."""
|
|
26
|
+
try:
|
|
27
|
+
from cloudwright import ArchSpec
|
|
28
|
+
from cloudwright.linter import lint as run_lint
|
|
29
|
+
|
|
30
|
+
spec = ArchSpec.from_file(spec_file)
|
|
31
|
+
warnings = run_lint(spec)
|
|
32
|
+
|
|
33
|
+
if output == "json":
|
|
34
|
+
result = [
|
|
35
|
+
{
|
|
36
|
+
"rule": w.rule,
|
|
37
|
+
"severity": w.severity,
|
|
38
|
+
"component": w.component,
|
|
39
|
+
"message": w.message,
|
|
40
|
+
"recommendation": w.recommendation,
|
|
41
|
+
}
|
|
42
|
+
for w in warnings
|
|
43
|
+
]
|
|
44
|
+
print(json.dumps(result, indent=2))
|
|
45
|
+
else:
|
|
46
|
+
if not warnings:
|
|
47
|
+
console.print(f"[green][PASS][/green] No anti-patterns detected in {spec.name}")
|
|
48
|
+
else:
|
|
49
|
+
table = Table(title=f"Lint Results: {spec.name}", show_lines=True)
|
|
50
|
+
table.add_column("Severity", width=9)
|
|
51
|
+
table.add_column("Rule", style="cyan")
|
|
52
|
+
table.add_column("Component")
|
|
53
|
+
table.add_column("Message")
|
|
54
|
+
table.add_column("Recommendation")
|
|
55
|
+
|
|
56
|
+
for w in warnings:
|
|
57
|
+
if w.severity == "error":
|
|
58
|
+
sev = Text("error", style="bold red")
|
|
59
|
+
elif w.severity == "warning":
|
|
60
|
+
sev = Text("warning", style="yellow")
|
|
61
|
+
else:
|
|
62
|
+
sev = Text("info", style="blue")
|
|
63
|
+
|
|
64
|
+
table.add_row(
|
|
65
|
+
sev,
|
|
66
|
+
w.rule,
|
|
67
|
+
w.component or "—",
|
|
68
|
+
w.message,
|
|
69
|
+
w.recommendation,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
console.print(table)
|
|
73
|
+
|
|
74
|
+
errors = [w for w in warnings if w.severity == "error"]
|
|
75
|
+
warns = [w for w in warnings if w.severity == "warning"]
|
|
76
|
+
console.print(
|
|
77
|
+
f"\n[bold]{len(warnings)} finding(s)[/bold]: "
|
|
78
|
+
f"[red]{len(errors)} error(s)[/red], "
|
|
79
|
+
f"[yellow]{len(warns)} warning(s)[/yellow]"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
has_errors = any(w.severity == "error" for w in warnings)
|
|
83
|
+
has_warnings = any(w.severity == "warning" for w in warnings)
|
|
84
|
+
|
|
85
|
+
if has_errors or (strict and has_warnings):
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
|
|
88
|
+
except typer.Exit:
|
|
89
|
+
raise
|
|
90
|
+
except Exception as e:
|
|
91
|
+
handle_error(ctx, e)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Structured modify — modify a spec with natural language and show the diff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.rule import Rule
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from cloudwright_cli.utils import handle_error
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def modify(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
spec_file: Annotated[str, typer.Argument(help="Path to the ArchSpec YAML to modify")],
|
|
23
|
+
instruction: Annotated[str, typer.Argument(help="Natural language modification instruction")],
|
|
24
|
+
output: Annotated[str | None, typer.Option("--output", "-o", help="Output file (default: overwrite input)")] = None,
|
|
25
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show changes without writing")] = False,
|
|
26
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Modify an ArchSpec with natural language and show the diff."""
|
|
29
|
+
try:
|
|
30
|
+
from cloudwright import ArchSpec, Differ
|
|
31
|
+
from cloudwright.architect import Architect
|
|
32
|
+
from cloudwright.cost import CostEngine
|
|
33
|
+
|
|
34
|
+
spec_path = Path(spec_file)
|
|
35
|
+
if not spec_path.exists():
|
|
36
|
+
console.print(f"[red]Error:[/red] Spec file not found: {spec_file}")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
original = ArchSpec.from_file(spec_path)
|
|
40
|
+
|
|
41
|
+
console.print(f"Modifying [cyan]{spec_file}[/cyan]: [yellow]{instruction}[/yellow]\n")
|
|
42
|
+
|
|
43
|
+
with console.status("Applying modification..."):
|
|
44
|
+
architect = Architect()
|
|
45
|
+
modified = architect.modify(original, instruction)
|
|
46
|
+
|
|
47
|
+
# Price both versions; ignore errors (catalog may not have all services)
|
|
48
|
+
cost_engine = CostEngine()
|
|
49
|
+
try:
|
|
50
|
+
original_costed = cost_engine.price(original)
|
|
51
|
+
modified_costed = cost_engine.price(modified)
|
|
52
|
+
except Exception:
|
|
53
|
+
original_costed = original
|
|
54
|
+
modified_costed = modified
|
|
55
|
+
|
|
56
|
+
diff_result = Differ().diff(original_costed, modified_costed)
|
|
57
|
+
|
|
58
|
+
if json_output:
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
result = {
|
|
62
|
+
"original": original.model_dump(),
|
|
63
|
+
"modified": modified.model_dump(),
|
|
64
|
+
"diff": diff_result.model_dump(),
|
|
65
|
+
}
|
|
66
|
+
console.print_json(json.dumps(result, default=str))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
console.print(Rule("[bold]Changes[/bold]"))
|
|
70
|
+
|
|
71
|
+
if not diff_result.added and not diff_result.removed and not diff_result.changed:
|
|
72
|
+
console.print("[dim]No structural changes detected.[/dim]")
|
|
73
|
+
else:
|
|
74
|
+
if diff_result.added:
|
|
75
|
+
console.print(f"[bold green]Added ({len(diff_result.added)})[/bold green]")
|
|
76
|
+
for comp in diff_result.added:
|
|
77
|
+
console.print(f" [green]+[/green] {comp.id} ({comp.service}) — {comp.label}")
|
|
78
|
+
console.print()
|
|
79
|
+
|
|
80
|
+
if diff_result.removed:
|
|
81
|
+
console.print(f"[bold red]Removed ({len(diff_result.removed)})[/bold red]")
|
|
82
|
+
for comp in diff_result.removed:
|
|
83
|
+
console.print(f" [red]-[/red] {comp.id} ({comp.service}) — {comp.label}")
|
|
84
|
+
console.print()
|
|
85
|
+
|
|
86
|
+
if diff_result.changed:
|
|
87
|
+
table = Table(title="Changed")
|
|
88
|
+
table.add_column("Component", style="cyan")
|
|
89
|
+
table.add_column("Field")
|
|
90
|
+
table.add_column("Before", style="red")
|
|
91
|
+
table.add_column("After", style="green")
|
|
92
|
+
for change in diff_result.changed:
|
|
93
|
+
table.add_row(change.component_id, change.field, change.old_value, change.new_value)
|
|
94
|
+
console.print(table)
|
|
95
|
+
console.print()
|
|
96
|
+
|
|
97
|
+
if diff_result.cost_delta != 0.0:
|
|
98
|
+
sign = "+" if diff_result.cost_delta > 0 else ""
|
|
99
|
+
color = "red" if diff_result.cost_delta > 0 else "green"
|
|
100
|
+
console.print(f"Cost delta: [{color}]{sign}${diff_result.cost_delta:,.2f}/mo[/{color}]")
|
|
101
|
+
|
|
102
|
+
if diff_result.compliance_impact:
|
|
103
|
+
console.print("\n[bold red]Compliance Impact[/bold red]")
|
|
104
|
+
for impact in diff_result.compliance_impact:
|
|
105
|
+
console.print(f" [red]![/red] {impact}")
|
|
106
|
+
|
|
107
|
+
console.print()
|
|
108
|
+
console.print(
|
|
109
|
+
Panel(
|
|
110
|
+
Syntax(modified.to_yaml(), "yaml", theme="monokai", word_wrap=True),
|
|
111
|
+
title="[bold cyan]Modified Spec[/bold cyan]",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if not dry_run:
|
|
116
|
+
out_path = Path(output) if output else spec_path
|
|
117
|
+
out_path.write_text(modified.to_yaml())
|
|
118
|
+
console.print(f"\n[green]Written to {out_path}[/green]")
|
|
119
|
+
else:
|
|
120
|
+
console.print("\n[dim]Dry run — no files written.[/dim]")
|
|
121
|
+
|
|
122
|
+
except typer.Exit:
|
|
123
|
+
raise
|
|
124
|
+
except Exception as e:
|
|
125
|
+
handle_error(ctx, e)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from cloudwright import ArchSpec
|
|
9
|
+
from cloudwright.cost import CostEngine
|
|
10
|
+
from cloudwright.policy import PolicyEngine
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def policy(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
|
|
20
|
+
rules: Annotated[Path, typer.Option("--rules", "-r", help="Path to policy rules YAML file", exists=True)],
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Evaluate an architecture spec against policy rules."""
|
|
23
|
+
try:
|
|
24
|
+
spec = ArchSpec.from_file(spec_file)
|
|
25
|
+
engine = PolicyEngine()
|
|
26
|
+
|
|
27
|
+
cost_estimate = spec.cost_estimate
|
|
28
|
+
if not cost_estimate:
|
|
29
|
+
cost_engine = CostEngine()
|
|
30
|
+
cost_estimate = cost_engine.estimate(spec)
|
|
31
|
+
|
|
32
|
+
result = engine.evaluate_from_file(spec, rules, cost_estimate=cost_estimate)
|
|
33
|
+
|
|
34
|
+
json_mode = ctx.obj.get("json", False) if ctx.obj else False
|
|
35
|
+
if json_mode:
|
|
36
|
+
print(json.dumps(result.model_dump(), default=str))
|
|
37
|
+
if not result.passed:
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
table = Table(title="Policy Evaluation")
|
|
42
|
+
table.add_column("Status", width=6)
|
|
43
|
+
table.add_column("Rule", style="cyan")
|
|
44
|
+
table.add_column("Severity")
|
|
45
|
+
table.add_column("Message", style="dim")
|
|
46
|
+
|
|
47
|
+
for check in result.results:
|
|
48
|
+
if check.passed:
|
|
49
|
+
status = "[green]PASS[/green]"
|
|
50
|
+
elif check.severity == "deny":
|
|
51
|
+
status = "[red]DENY[/red]"
|
|
52
|
+
elif check.severity == "warn":
|
|
53
|
+
status = "[yellow]WARN[/yellow]"
|
|
54
|
+
else:
|
|
55
|
+
status = "[blue]INFO[/blue]"
|
|
56
|
+
|
|
57
|
+
sev_colors = {"deny": "red", "warn": "yellow", "info": "blue"}
|
|
58
|
+
color = sev_colors.get(check.severity, "")
|
|
59
|
+
severity_display = f"[{color}]{check.severity.upper()}[/{color}]" if color else check.severity.upper()
|
|
60
|
+
|
|
61
|
+
table.add_row(status, check.rule, severity_display, check.message)
|
|
62
|
+
|
|
63
|
+
console.print(table)
|
|
64
|
+
console.print()
|
|
65
|
+
|
|
66
|
+
if result.passed:
|
|
67
|
+
console.print("[green]All policies passed.[/green]")
|
|
68
|
+
else:
|
|
69
|
+
parts = []
|
|
70
|
+
if result.deny_count:
|
|
71
|
+
parts.append(f"[red]{result.deny_count} denied[/red]")
|
|
72
|
+
if result.warn_count:
|
|
73
|
+
parts.append(f"[yellow]{result.warn_count} warnings[/yellow]")
|
|
74
|
+
console.print(f"Policy evaluation: {', '.join(parts)}")
|
|
75
|
+
if result.deny_count > 0:
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
except typer.Exit:
|
|
79
|
+
raise
|
|
80
|
+
except FileNotFoundError as e:
|
|
81
|
+
console.print(f"[red]Error:[/red] File not found: {e}")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
verbose = ctx.obj.get("verbose", False) if ctx.obj else False
|
|
85
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
86
|
+
if verbose:
|
|
87
|
+
console.print_exception()
|
|
88
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Refresh live pricing data from cloud provider APIs into the catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from cloudwright_cli.utils import handle_error
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def refresh(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
provider: Annotated[
|
|
20
|
+
str | None,
|
|
21
|
+
typer.Option("--provider", "-p", help="Provider to refresh: aws, gcp, azure (default: all)"),
|
|
22
|
+
] = None,
|
|
23
|
+
category: Annotated[
|
|
24
|
+
str | None,
|
|
25
|
+
typer.Option("--category", "-c", help="Filter to a category: compute or a service name"),
|
|
26
|
+
] = None,
|
|
27
|
+
region: Annotated[
|
|
28
|
+
str | None,
|
|
29
|
+
typer.Option("--region", "-r", help="Override the default region for pricing lookups"),
|
|
30
|
+
] = None,
|
|
31
|
+
dry_run: Annotated[
|
|
32
|
+
bool,
|
|
33
|
+
typer.Option("--dry-run", help="Fetch but don't write to catalog DB"),
|
|
34
|
+
] = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Refresh live pricing data from cloud provider APIs into the local catalog."""
|
|
37
|
+
try:
|
|
38
|
+
from cloudwright.catalog.refresh import refresh_catalog
|
|
39
|
+
|
|
40
|
+
providers_label = provider or "aws, gcp, azure"
|
|
41
|
+
with console.status(f"Fetching pricing for {providers_label}..."):
|
|
42
|
+
summary = refresh_catalog(
|
|
43
|
+
provider=provider,
|
|
44
|
+
category=category,
|
|
45
|
+
region=region,
|
|
46
|
+
dry_run=dry_run,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
json_mode = ctx.obj and ctx.obj.get("json")
|
|
50
|
+
|
|
51
|
+
if json_mode:
|
|
52
|
+
data = {
|
|
53
|
+
"total_fetched": summary.total_fetched,
|
|
54
|
+
"total_errors": summary.total_errors,
|
|
55
|
+
"results": [
|
|
56
|
+
{
|
|
57
|
+
"provider": r.provider,
|
|
58
|
+
"category": r.category,
|
|
59
|
+
"instances_fetched": r.instances_fetched,
|
|
60
|
+
"managed_services_fetched": r.managed_services_fetched,
|
|
61
|
+
"errors": r.errors,
|
|
62
|
+
"dry_run": r.dry_run,
|
|
63
|
+
}
|
|
64
|
+
for r in summary.results
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
print(json.dumps(data, indent=2))
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
table = Table(show_header=True, header_style="bold")
|
|
71
|
+
table.add_column("Provider")
|
|
72
|
+
table.add_column("Category")
|
|
73
|
+
table.add_column("Instances", justify="right")
|
|
74
|
+
table.add_column("Managed", justify="right")
|
|
75
|
+
table.add_column("Errors", justify="right")
|
|
76
|
+
|
|
77
|
+
for r in summary.results:
|
|
78
|
+
error_str = str(len(r.errors)) if r.errors else "[green]0[/green]"
|
|
79
|
+
table.add_row(
|
|
80
|
+
r.provider,
|
|
81
|
+
r.category or "all",
|
|
82
|
+
str(r.instances_fetched),
|
|
83
|
+
str(r.managed_services_fetched),
|
|
84
|
+
error_str,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
console.print(table)
|
|
88
|
+
|
|
89
|
+
suffix = " [dim](dry run)[/dim]" if dry_run else ""
|
|
90
|
+
if summary.total_errors == 0:
|
|
91
|
+
console.print(f"[green]Done.[/green] {summary.total_fetched} pricing records fetched{suffix}")
|
|
92
|
+
else:
|
|
93
|
+
console.print(
|
|
94
|
+
f"[yellow]Done with errors.[/yellow] "
|
|
95
|
+
f"{summary.total_fetched} fetched, {summary.total_errors} error(s){suffix}"
|
|
96
|
+
)
|
|
97
|
+
for r in summary.results:
|
|
98
|
+
for err in r.errors:
|
|
99
|
+
console.print(f" [red]{r.provider}:[/red] {err}")
|
|
100
|
+
|
|
101
|
+
except typer.Exit:
|
|
102
|
+
raise
|
|
103
|
+
except Exception as e:
|
|
104
|
+
handle_error(ctx, e)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Score an architecture's quality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from cloudwright_cli.utils import handle_error
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def score(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
spec_file: Annotated[str, typer.Argument(help="Path to ArchSpec YAML/JSON file")],
|
|
21
|
+
with_cost: Annotated[bool, typer.Option("--with-cost", help="Run cost analysis before scoring")] = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Score an architecture's quality on 5 dimensions (0-100)."""
|
|
24
|
+
try:
|
|
25
|
+
from cloudwright import ArchSpec
|
|
26
|
+
from cloudwright.scorer import Scorer
|
|
27
|
+
|
|
28
|
+
spec = ArchSpec.from_file(spec_file)
|
|
29
|
+
|
|
30
|
+
if with_cost:
|
|
31
|
+
from cloudwright.cost import CostEngine
|
|
32
|
+
|
|
33
|
+
engine = CostEngine()
|
|
34
|
+
spec = engine.price(spec)
|
|
35
|
+
|
|
36
|
+
scorer = Scorer()
|
|
37
|
+
result = scorer.score(spec)
|
|
38
|
+
|
|
39
|
+
if ctx.obj and ctx.obj.get("json"):
|
|
40
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
border = "green" if result.overall >= 70 else "yellow" if result.overall >= 50 else "red"
|
|
44
|
+
console.print(
|
|
45
|
+
Panel(
|
|
46
|
+
f"[bold]Overall Score: {result.overall:.0f}/100[/bold] Grade: [bold]{result.grade}[/bold]",
|
|
47
|
+
title=f"Architecture Quality: {spec.name}",
|
|
48
|
+
border_style=border,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
table = Table(title="Dimension Breakdown")
|
|
53
|
+
table.add_column("Dimension", style="cyan")
|
|
54
|
+
table.add_column("Score", justify="right")
|
|
55
|
+
table.add_column("Weight", justify="right")
|
|
56
|
+
table.add_column("Weighted", justify="right")
|
|
57
|
+
table.add_column("Details")
|
|
58
|
+
|
|
59
|
+
for d in result.dimensions:
|
|
60
|
+
weighted = d.score * d.weight
|
|
61
|
+
color = "green" if d.score >= 70 else "yellow" if d.score >= 50 else "red"
|
|
62
|
+
table.add_row(
|
|
63
|
+
d.name,
|
|
64
|
+
f"[{color}]{d.score:.0f}[/{color}]",
|
|
65
|
+
f"{d.weight:.0%}",
|
|
66
|
+
f"{weighted:.1f}",
|
|
67
|
+
"; ".join(d.details[:2]) if d.details else "",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
console.print(table)
|
|
71
|
+
|
|
72
|
+
if result.recommendations:
|
|
73
|
+
console.print("\n[bold]Top Recommendations:[/bold]")
|
|
74
|
+
for i, rec in enumerate(result.recommendations, 1):
|
|
75
|
+
console.print(f" {i}. {rec}")
|
|
76
|
+
|
|
77
|
+
except typer.Exit:
|
|
78
|
+
raise
|
|
79
|
+
except Exception as e:
|
|
80
|
+
handle_error(ctx, e)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from cloudwright import ArchSpec, Validator
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.rule import Rule
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
|
|
18
|
+
compliance: Annotated[
|
|
19
|
+
str | None, typer.Option(help="Comma-separated compliance frameworks (hipaa, pci-dss, soc2)")
|
|
20
|
+
] = None,
|
|
21
|
+
well_architected: Annotated[bool, typer.Option("--well-architected", help="Run well-architected review")] = False,
|
|
22
|
+
report: Annotated[
|
|
23
|
+
Path | None, typer.Option("--report", help="Write a markdown compliance report to this path")
|
|
24
|
+
] = None,
|
|
25
|
+
pdf_report: Annotated[Path | None, typer.Option("--pdf", help="Write a PDF compliance report to this path")] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Validate an architecture spec against compliance frameworks or well-architected principles."""
|
|
28
|
+
if not compliance and not well_architected:
|
|
29
|
+
console.print("[yellow]Specify --compliance and/or --well-architected.[/yellow]")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
spec = ArchSpec.from_file(spec_file)
|
|
33
|
+
frameworks: list[str] = []
|
|
34
|
+
|
|
35
|
+
if compliance:
|
|
36
|
+
frameworks.extend(f.strip() for f in compliance.split(",") if f.strip())
|
|
37
|
+
if well_architected:
|
|
38
|
+
frameworks.append("well-architected")
|
|
39
|
+
|
|
40
|
+
wa = "well-architected" in frameworks
|
|
41
|
+
compliance_only = [f for f in frameworks if f != "well-architected"]
|
|
42
|
+
|
|
43
|
+
with console.status("Running validation..."):
|
|
44
|
+
results = Validator().validate(spec, compliance_only, well_architected=wa)
|
|
45
|
+
|
|
46
|
+
if report and results:
|
|
47
|
+
from cloudwright.exporter.compliance_report import render as render_report
|
|
48
|
+
|
|
49
|
+
# Generate one report per framework; if multiple, use the first or merge
|
|
50
|
+
report_text = "\n\n---\n\n".join(render_report(spec, r) for r in results)
|
|
51
|
+
report.write_text(report_text)
|
|
52
|
+
console.print(f"[green]Compliance report written to {report}[/green]")
|
|
53
|
+
|
|
54
|
+
if pdf_report and results:
|
|
55
|
+
from cloudwright.exporter.compliance_report import render_pdf
|
|
56
|
+
|
|
57
|
+
for r in results:
|
|
58
|
+
if len(results) > 1:
|
|
59
|
+
out_path = pdf_report.parent / f"{pdf_report.stem}_{r.framework}{pdf_report.suffix}"
|
|
60
|
+
else:
|
|
61
|
+
out_path = pdf_report
|
|
62
|
+
render_pdf(spec, r, str(out_path))
|
|
63
|
+
console.print(f"[green]PDF compliance report written to {out_path}[/green]")
|
|
64
|
+
|
|
65
|
+
if ctx.obj and ctx.obj.get("json"):
|
|
66
|
+
import json
|
|
67
|
+
|
|
68
|
+
print(json.dumps({"results": [r.model_dump() for r in results]}, default=str))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
any_failed = False
|
|
72
|
+
for result in results:
|
|
73
|
+
title = _framework_title(result.framework)
|
|
74
|
+
console.print(Rule(f"[bold]{title}[/bold]"))
|
|
75
|
+
|
|
76
|
+
passed_count = sum(1 for c in result.checks if c.passed)
|
|
77
|
+
total = len(result.checks)
|
|
78
|
+
|
|
79
|
+
for check in result.checks:
|
|
80
|
+
status = Text("[PASS]", style="green") if check.passed else Text("[FAIL]", style="red")
|
|
81
|
+
line = Text()
|
|
82
|
+
line.append_text(status)
|
|
83
|
+
line.append(f" {check.name}")
|
|
84
|
+
if check.detail:
|
|
85
|
+
line.append(f" — {check.detail}", style="dim")
|
|
86
|
+
console.print(line)
|
|
87
|
+
|
|
88
|
+
if not check.passed and check.recommendation:
|
|
89
|
+
console.print(f" [dim]Recommendation: {check.recommendation}[/dim]")
|
|
90
|
+
any_failed = True
|
|
91
|
+
|
|
92
|
+
pct = int(result.score * 100) if result.score <= 1 else int(result.score)
|
|
93
|
+
console.print(f"Score: {passed_count}/{total} ({pct}%)\n")
|
|
94
|
+
|
|
95
|
+
if any_failed:
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _framework_title(framework: str) -> str:
|
|
100
|
+
titles = {
|
|
101
|
+
"hipaa": "HIPAA Compliance Review",
|
|
102
|
+
"pci-dss": "PCI-DSS Compliance Review",
|
|
103
|
+
"soc2": "SOC 2 Compliance Review",
|
|
104
|
+
"well-architected": "Well-Architected Review",
|
|
105
|
+
}
|
|
106
|
+
return titles.get(framework.lower(), f"{framework.upper()} Review")
|
cloudwright_cli/main.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from cloudwright_cli import __version__
|
|
4
|
+
from cloudwright_cli.commands.analyze_cmd import analyze
|
|
5
|
+
from cloudwright_cli.commands.catalog_cmd import catalog_app
|
|
6
|
+
from cloudwright_cli.commands.chat import chat
|
|
7
|
+
from cloudwright_cli.commands.compare import compare
|
|
8
|
+
from cloudwright_cli.commands.cost import cost
|
|
9
|
+
from cloudwright_cli.commands.design import design
|
|
10
|
+
from cloudwright_cli.commands.diff import diff
|
|
11
|
+
from cloudwright_cli.commands.drift_cmd import drift
|
|
12
|
+
from cloudwright_cli.commands.export import export
|
|
13
|
+
from cloudwright_cli.commands.import_cmd import import_infra
|
|
14
|
+
from cloudwright_cli.commands.init_cmd import init
|
|
15
|
+
from cloudwright_cli.commands.lint_cmd import lint
|
|
16
|
+
from cloudwright_cli.commands.modify_cmd import modify
|
|
17
|
+
from cloudwright_cli.commands.policy import policy
|
|
18
|
+
from cloudwright_cli.commands.refresh_cmd import refresh
|
|
19
|
+
from cloudwright_cli.commands.score_cmd import score
|
|
20
|
+
from cloudwright_cli.commands.validate import validate
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version_callback(value: bool) -> None:
|
|
24
|
+
if value:
|
|
25
|
+
print(f"cloudwright {__version__}")
|
|
26
|
+
raise typer.Exit()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="cloudwright",
|
|
31
|
+
help="Architecture intelligence for cloud engineers",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def main(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
version: bool = typer.Option(
|
|
40
|
+
False, "--version", "-V", help="Show version", callback=_version_callback, is_eager=True
|
|
41
|
+
),
|
|
42
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
43
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
44
|
+
) -> None:
|
|
45
|
+
ctx.ensure_object(dict)
|
|
46
|
+
ctx.obj["verbose"] = verbose
|
|
47
|
+
ctx.obj["json"] = json_output
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app.command()(design)
|
|
51
|
+
app.command()(cost)
|
|
52
|
+
app.command()(compare)
|
|
53
|
+
app.command()(validate)
|
|
54
|
+
app.command()(export)
|
|
55
|
+
app.command()(diff)
|
|
56
|
+
app.command()(drift)
|
|
57
|
+
app.command()(modify)
|
|
58
|
+
app.command(name="import")(import_infra)
|
|
59
|
+
app.command()(chat)
|
|
60
|
+
app.command()(init)
|
|
61
|
+
app.command()(policy)
|
|
62
|
+
app.command()(score)
|
|
63
|
+
app.command()(analyze)
|
|
64
|
+
app.command()(refresh)
|
|
65
|
+
app.command()(lint)
|
|
66
|
+
app.add_typer(catalog_app, name="catalog")
|