stackfix 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.
- cloudgym/__init__.py +3 -0
- cloudgym/benchmark/__init__.py +0 -0
- cloudgym/benchmark/dataset.py +188 -0
- cloudgym/benchmark/evaluator.py +275 -0
- cloudgym/cli.py +61 -0
- cloudgym/fixer/__init__.py +1 -0
- cloudgym/fixer/cli.py +521 -0
- cloudgym/fixer/detector.py +81 -0
- cloudgym/fixer/formatter.py +55 -0
- cloudgym/fixer/lambda_handler.py +126 -0
- cloudgym/fixer/repairer.py +237 -0
- cloudgym/generator/__init__.py +0 -0
- cloudgym/generator/formatter.py +142 -0
- cloudgym/generator/pipeline.py +271 -0
- cloudgym/inverter/__init__.py +0 -0
- cloudgym/inverter/_cf_injectors.py +705 -0
- cloudgym/inverter/_cf_utils.py +202 -0
- cloudgym/inverter/_hcl_utils.py +182 -0
- cloudgym/inverter/_tf_injectors.py +641 -0
- cloudgym/inverter/_yaml_cf.py +84 -0
- cloudgym/inverter/agentic.py +90 -0
- cloudgym/inverter/engine.py +258 -0
- cloudgym/inverter/programmatic.py +95 -0
- cloudgym/scraper/__init__.py +0 -0
- cloudgym/scraper/aws_samples.py +159 -0
- cloudgym/scraper/github.py +238 -0
- cloudgym/scraper/registry.py +165 -0
- cloudgym/scraper/validator.py +116 -0
- cloudgym/taxonomy/__init__.py +10 -0
- cloudgym/taxonomy/base.py +102 -0
- cloudgym/taxonomy/cloudformation.py +258 -0
- cloudgym/taxonomy/terraform.py +274 -0
- cloudgym/utils/__init__.py +0 -0
- cloudgym/utils/config.py +57 -0
- cloudgym/utils/ollama.py +66 -0
- cloudgym/validator/__init__.py +0 -0
- cloudgym/validator/cloudformation.py +55 -0
- cloudgym/validator/opentofu.py +103 -0
- cloudgym/validator/terraform.py +115 -0
- stackfix-0.1.0.dist-info/METADATA +182 -0
- stackfix-0.1.0.dist-info/RECORD +44 -0
- stackfix-0.1.0.dist-info/WHEEL +4 -0
- stackfix-0.1.0.dist-info/entry_points.txt +3 -0
- stackfix-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudgym/fixer/cli.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""stackfix CLI: AI-powered Infrastructure-as-Code repair tool.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
stackfix check main.tf # validate and show errors
|
|
5
|
+
stackfix repair main.tf # validate, fix, show diff
|
|
6
|
+
stackfix repair main.tf --apply # validate, fix, write in place
|
|
7
|
+
stackfix repair main.tf --apply -o fixed.tf # write to different file
|
|
8
|
+
stackfix repair *.tf # fix multiple files
|
|
9
|
+
cat broken.tf | stackfix repair - # stdin/stdout mode
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from cloudgym.fixer.detector import IaCFormat, detect_format, validate_file_sync
|
|
24
|
+
from cloudgym.fixer.formatter import colorized_diff, unified_diff, write_repair
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
stderr_console = Console(stderr=True)
|
|
28
|
+
|
|
29
|
+
# Lazy-loaded repairer (avoids model load until needed)
|
|
30
|
+
_repairer = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_repairer(backend: str, model: str | None, adapter: str | None):
|
|
34
|
+
"""Get or create the repairer instance."""
|
|
35
|
+
global _repairer
|
|
36
|
+
if _repairer is not None:
|
|
37
|
+
return _repairer
|
|
38
|
+
|
|
39
|
+
if backend == "mlx":
|
|
40
|
+
from cloudgym.fixer.repairer import MLXRepairer, DEFAULT_BASE_MODEL, DEFAULT_ADAPTER_PATH
|
|
41
|
+
|
|
42
|
+
_repairer = MLXRepairer(
|
|
43
|
+
base_model=model or DEFAULT_BASE_MODEL,
|
|
44
|
+
adapter_path=adapter or DEFAULT_ADAPTER_PATH,
|
|
45
|
+
)
|
|
46
|
+
elif backend == "gguf":
|
|
47
|
+
from cloudgym.fixer.repairer import GGUFRepairer
|
|
48
|
+
|
|
49
|
+
if not model:
|
|
50
|
+
raise click.ClickException(
|
|
51
|
+
"GGUF backend requires --model pointing to a .gguf file. "
|
|
52
|
+
"Export one with: python scripts/export_gguf.py 0.5b"
|
|
53
|
+
)
|
|
54
|
+
_repairer = GGUFRepairer(model_path=model)
|
|
55
|
+
elif backend == "ollama":
|
|
56
|
+
from cloudgym.fixer.repairer import OllamaRepairer
|
|
57
|
+
|
|
58
|
+
_repairer = OllamaRepairer(model=model or "qwen2.5-coder:3b")
|
|
59
|
+
else:
|
|
60
|
+
raise click.ClickException(f"Unknown backend: {backend}")
|
|
61
|
+
|
|
62
|
+
return _repairer
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.group()
|
|
66
|
+
@click.version_option(version="0.1.0", prog_name="stackfix")
|
|
67
|
+
def cli():
|
|
68
|
+
"""AI-powered Infrastructure-as-Code repair.
|
|
69
|
+
|
|
70
|
+
Validates Terraform, CloudFormation, and OpenTofu configs,
|
|
71
|
+
then uses a fine-tuned local model to fix errors.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cli.command()
|
|
76
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path())
|
|
77
|
+
@click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
|
|
78
|
+
default=None, help="Override auto-detected format")
|
|
79
|
+
def check(files: tuple[str, ...], fmt: str | None):
|
|
80
|
+
"""Validate IaC files and report errors.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
stackfix check main.tf
|
|
84
|
+
stackfix check *.yaml
|
|
85
|
+
stackfix check --format cloudformation template.yaml
|
|
86
|
+
"""
|
|
87
|
+
iac_fmt = IaCFormat(fmt) if fmt else None
|
|
88
|
+
any_errors = False
|
|
89
|
+
|
|
90
|
+
for file_str in files:
|
|
91
|
+
path = Path(file_str)
|
|
92
|
+
if not path.exists():
|
|
93
|
+
console.print(f"[red]File not found: {path}[/red]")
|
|
94
|
+
any_errors = True
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
detected_fmt, result = validate_file_sync(path, iac_fmt)
|
|
98
|
+
|
|
99
|
+
if result.valid:
|
|
100
|
+
console.print(f"[green]PASS[/green] {path} ({detected_fmt.value})")
|
|
101
|
+
else:
|
|
102
|
+
any_errors = True
|
|
103
|
+
console.print(f"[red]FAIL[/red] {path} ({detected_fmt.value})")
|
|
104
|
+
for err in result.errors:
|
|
105
|
+
console.print(f" [red]error:[/red] {err}")
|
|
106
|
+
for warn in result.warnings:
|
|
107
|
+
console.print(f" [yellow]warning:[/yellow] {warn}")
|
|
108
|
+
|
|
109
|
+
sys.exit(1 if any_errors else 0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@cli.command()
|
|
113
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(allow_dash=True))
|
|
114
|
+
@click.option("--apply", is_flag=True, help="Write fixes in place (otherwise just show diff)")
|
|
115
|
+
@click.option("-o", "--output", type=click.Path(), help="Write fixed output to this file")
|
|
116
|
+
@click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
|
|
117
|
+
default=None, help="Override auto-detected format")
|
|
118
|
+
@click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx",
|
|
119
|
+
help="Model backend (default: mlx)")
|
|
120
|
+
@click.option("--model", default=None, help="Override base model")
|
|
121
|
+
@click.option("--adapter", default=None, help="Override adapter path")
|
|
122
|
+
@click.option("--no-verify", is_flag=True, help="Skip post-repair validation")
|
|
123
|
+
@click.option("--diff/--no-diff", default=True, help="Show diff output (default: on)")
|
|
124
|
+
@click.option("--color/--no-color", default=True, help="Colorize diff output")
|
|
125
|
+
def repair(
|
|
126
|
+
files: tuple[str, ...],
|
|
127
|
+
apply: bool,
|
|
128
|
+
output: str | None,
|
|
129
|
+
fmt: str | None,
|
|
130
|
+
backend: str,
|
|
131
|
+
model: str | None,
|
|
132
|
+
adapter: str | None,
|
|
133
|
+
no_verify: bool,
|
|
134
|
+
diff: bool,
|
|
135
|
+
color: bool,
|
|
136
|
+
):
|
|
137
|
+
"""Validate and repair IaC files using a fine-tuned AI model.
|
|
138
|
+
|
|
139
|
+
By default shows a diff of proposed changes. Use --apply to write fixes.
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
stackfix repair main.tf # show diff
|
|
143
|
+
stackfix repair main.tf --apply # fix in place
|
|
144
|
+
stackfix repair main.tf -o fixed.tf # write to new file
|
|
145
|
+
stackfix repair --backend ollama main.tf # use Ollama
|
|
146
|
+
cat broken.tf | stackfix repair - # stdin/stdout
|
|
147
|
+
"""
|
|
148
|
+
iac_fmt = IaCFormat(fmt) if fmt else None
|
|
149
|
+
stdin_mode = len(files) == 1 and files[0] == "-"
|
|
150
|
+
|
|
151
|
+
if stdin_mode:
|
|
152
|
+
_repair_stdin(iac_fmt, backend, model, adapter, no_verify, output)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
any_failed = False
|
|
156
|
+
for file_str in files:
|
|
157
|
+
path = Path(file_str)
|
|
158
|
+
if not path.exists():
|
|
159
|
+
console.print(f"[red]File not found: {path}[/red]")
|
|
160
|
+
any_failed = True
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
success = _repair_file(
|
|
164
|
+
path, iac_fmt, backend, model, adapter,
|
|
165
|
+
apply, output, no_verify, diff, color,
|
|
166
|
+
)
|
|
167
|
+
if not success:
|
|
168
|
+
any_failed = True
|
|
169
|
+
|
|
170
|
+
sys.exit(1 if any_failed else 0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _repair_file(
|
|
174
|
+
path: Path,
|
|
175
|
+
fmt: IaCFormat | None,
|
|
176
|
+
backend: str,
|
|
177
|
+
model: str | None,
|
|
178
|
+
adapter: str | None,
|
|
179
|
+
apply: bool,
|
|
180
|
+
output: str | None,
|
|
181
|
+
no_verify: bool,
|
|
182
|
+
show_diff: bool,
|
|
183
|
+
color: bool,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""Repair a single file. Returns True if successful."""
|
|
186
|
+
original = path.read_text()
|
|
187
|
+
|
|
188
|
+
# Step 1: Validate
|
|
189
|
+
detected_fmt, result = validate_file_sync(path, fmt)
|
|
190
|
+
|
|
191
|
+
if result.valid:
|
|
192
|
+
console.print(f"[green]PASS[/green] {path} — no errors to fix")
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
console.print(f"[yellow]FIXING[/yellow] {path} ({detected_fmt.value}) — {len(result.errors)} error(s)")
|
|
196
|
+
for err in result.errors:
|
|
197
|
+
console.print(f" [dim]{err}[/dim]")
|
|
198
|
+
|
|
199
|
+
# Step 2: Repair
|
|
200
|
+
repairer = _get_repairer(backend, model, adapter)
|
|
201
|
+
with console.status("[bold]Generating fix...", spinner="dots"):
|
|
202
|
+
repaired = repairer.repair(original, result.errors)
|
|
203
|
+
|
|
204
|
+
if not repaired or repaired.strip() == original.strip():
|
|
205
|
+
console.print(f" [yellow]No changes generated[/yellow]")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
# Step 3: Post-repair validation
|
|
209
|
+
if not no_verify:
|
|
210
|
+
verified = _verify_repair(repaired, detected_fmt, path.name)
|
|
211
|
+
if not verified:
|
|
212
|
+
console.print(f" [red]Fix did not pass validation — not applying[/red]")
|
|
213
|
+
if show_diff:
|
|
214
|
+
_show_diff(original, repaired, path.name, color)
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
# Step 4: Show diff
|
|
218
|
+
if show_diff:
|
|
219
|
+
_show_diff(original, repaired, path.name, color)
|
|
220
|
+
|
|
221
|
+
# Step 5: Apply
|
|
222
|
+
if apply:
|
|
223
|
+
out_path = Path(output) if output else path
|
|
224
|
+
write_repair(out_path, repaired)
|
|
225
|
+
console.print(f" [green]Fixed and saved to {out_path}[/green]")
|
|
226
|
+
elif output:
|
|
227
|
+
write_repair(Path(output), repaired)
|
|
228
|
+
console.print(f" [green]Fixed output written to {output}[/green]")
|
|
229
|
+
elif not apply:
|
|
230
|
+
console.print(f" [dim]Use --apply to write fix, or -o FILE to save elsewhere[/dim]")
|
|
231
|
+
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _repair_stdin(
|
|
236
|
+
fmt: IaCFormat | None,
|
|
237
|
+
backend: str,
|
|
238
|
+
model: str | None,
|
|
239
|
+
adapter: str | None,
|
|
240
|
+
no_verify: bool,
|
|
241
|
+
output: str | None,
|
|
242
|
+
):
|
|
243
|
+
"""Repair from stdin, output to stdout or file."""
|
|
244
|
+
original = sys.stdin.read()
|
|
245
|
+
|
|
246
|
+
# Write to temp file for validation
|
|
247
|
+
suffix = ".tf" if fmt != IaCFormat.CLOUDFORMATION else ".yaml"
|
|
248
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
|
|
249
|
+
f.write(original)
|
|
250
|
+
tmp_path = Path(f.name)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
detected_fmt, result = validate_file_sync(tmp_path, fmt)
|
|
254
|
+
finally:
|
|
255
|
+
tmp_path.unlink(missing_ok=True)
|
|
256
|
+
|
|
257
|
+
if result.valid:
|
|
258
|
+
# Pass through unchanged
|
|
259
|
+
sys.stdout.write(original)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
stderr_console.print(f"[yellow]Fixing stdin[/yellow] ({detected_fmt.value}) — {len(result.errors)} error(s)")
|
|
263
|
+
|
|
264
|
+
repairer = _get_repairer(backend, model, adapter)
|
|
265
|
+
repaired = repairer.repair(original, result.errors)
|
|
266
|
+
|
|
267
|
+
if output:
|
|
268
|
+
write_repair(Path(output), repaired)
|
|
269
|
+
stderr_console.print(f"[green]Written to {output}[/green]")
|
|
270
|
+
else:
|
|
271
|
+
sys.stdout.write(repaired)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _verify_repair(repaired: str, fmt: IaCFormat, filename: str) -> bool:
|
|
275
|
+
"""Validate repaired content to confirm the fix works."""
|
|
276
|
+
suffix = ".tf" if fmt != IaCFormat.CLOUDFORMATION else ".yaml"
|
|
277
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
|
|
278
|
+
f.write(repaired)
|
|
279
|
+
tmp_path = Path(f.name)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
_, result = validate_file_sync(tmp_path, fmt)
|
|
283
|
+
if result.valid:
|
|
284
|
+
console.print(f" [green]Verified: fix passes validation[/green]")
|
|
285
|
+
else:
|
|
286
|
+
console.print(f" [red]Verification failed: {len(result.errors)} error(s) remain[/red]")
|
|
287
|
+
for err in result.errors:
|
|
288
|
+
console.print(f" [dim]{err}[/dim]")
|
|
289
|
+
return result.valid
|
|
290
|
+
finally:
|
|
291
|
+
tmp_path.unlink(missing_ok=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _show_diff(original: str, repaired: str, filename: str, color: bool):
|
|
295
|
+
"""Display diff between original and repaired."""
|
|
296
|
+
if color:
|
|
297
|
+
diff_text = colorized_diff(original, repaired, filename)
|
|
298
|
+
if diff_text:
|
|
299
|
+
console.print(Panel(diff_text, title="Proposed Fix", border_style="blue"))
|
|
300
|
+
else:
|
|
301
|
+
diff_text = unified_diff(original, repaired, filename)
|
|
302
|
+
if diff_text:
|
|
303
|
+
click.echo(diff_text)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@cli.command(name="pre-commit")
|
|
307
|
+
@click.argument("files", nargs=-1, type=click.Path(exists=True))
|
|
308
|
+
@click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
|
|
309
|
+
@click.option("--model", default=None)
|
|
310
|
+
@click.option("--adapter", default=None)
|
|
311
|
+
def pre_commit(files: tuple[str, ...], backend: str, model: str | None, adapter: str | None):
|
|
312
|
+
"""Pre-commit hook: validate and auto-fix staged IaC files.
|
|
313
|
+
|
|
314
|
+
Exits 0 if all files are valid (or were successfully fixed).
|
|
315
|
+
Exits 1 if any file has unfixable errors.
|
|
316
|
+
|
|
317
|
+
Usage in .pre-commit-config.yaml:
|
|
318
|
+
- repo: local
|
|
319
|
+
hooks:
|
|
320
|
+
- id: stackfix
|
|
321
|
+
name: stackfix
|
|
322
|
+
entry: stackfix pre-commit
|
|
323
|
+
language: python
|
|
324
|
+
types_or: [terraform, yaml]
|
|
325
|
+
"""
|
|
326
|
+
if not files:
|
|
327
|
+
sys.exit(0)
|
|
328
|
+
|
|
329
|
+
iac_files = [
|
|
330
|
+
Path(f) for f in files
|
|
331
|
+
if f.endswith((".tf", ".yaml", ".yml", ".json"))
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
if not iac_files:
|
|
335
|
+
sys.exit(0)
|
|
336
|
+
|
|
337
|
+
any_failed = False
|
|
338
|
+
any_fixed = False
|
|
339
|
+
|
|
340
|
+
for path in iac_files:
|
|
341
|
+
detected_fmt, result = validate_file_sync(path)
|
|
342
|
+
|
|
343
|
+
if result.valid:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
console.print(f"[yellow]stackfix:[/yellow] {path} has {len(result.errors)} error(s), attempting fix...")
|
|
347
|
+
|
|
348
|
+
repairer = _get_repairer(backend, model, adapter)
|
|
349
|
+
original = path.read_text()
|
|
350
|
+
repaired = repairer.repair(original, result.errors)
|
|
351
|
+
|
|
352
|
+
if not repaired or repaired.strip() == original.strip():
|
|
353
|
+
console.print(f" [red]Could not fix {path}[/red]")
|
|
354
|
+
any_failed = True
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Verify the fix
|
|
358
|
+
verified = _verify_repair(repaired, detected_fmt, path.name)
|
|
359
|
+
if verified:
|
|
360
|
+
write_repair(path, repaired)
|
|
361
|
+
console.print(f" [green]Fixed {path}[/green]")
|
|
362
|
+
any_fixed = True
|
|
363
|
+
else:
|
|
364
|
+
console.print(f" [red]Fix for {path} did not pass validation[/red]")
|
|
365
|
+
any_failed = True
|
|
366
|
+
|
|
367
|
+
if any_fixed:
|
|
368
|
+
console.print("[yellow]stackfix: Files were modified. Please re-stage and commit.[/yellow]")
|
|
369
|
+
sys.exit(1) # Signal to pre-commit that files changed
|
|
370
|
+
|
|
371
|
+
sys.exit(1 if any_failed else 0)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@cli.command()
|
|
375
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
|
376
|
+
@click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
|
|
377
|
+
default=None, help="Override auto-detected format")
|
|
378
|
+
@click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
|
|
379
|
+
@click.option("--model", default=None)
|
|
380
|
+
@click.option("--adapter", default=None)
|
|
381
|
+
def discuss(files: tuple[str, ...], fmt: str | None, backend: str, model: str | None, adapter: str | None):
|
|
382
|
+
"""Explain IaC errors in plain language with fix guidance.
|
|
383
|
+
|
|
384
|
+
Uses the fine-tuned model to analyze errors, explain what's wrong,
|
|
385
|
+
why it matters, and how to fix it. Great for learning and code review.
|
|
386
|
+
|
|
387
|
+
Examples:
|
|
388
|
+
stackfix discuss main.tf
|
|
389
|
+
stackfix discuss --backend ollama template.yaml
|
|
390
|
+
"""
|
|
391
|
+
iac_fmt = IaCFormat(fmt) if fmt else None
|
|
392
|
+
|
|
393
|
+
for file_str in files:
|
|
394
|
+
path = Path(file_str)
|
|
395
|
+
config = path.read_text()
|
|
396
|
+
detected_fmt, result = validate_file_sync(path, iac_fmt)
|
|
397
|
+
|
|
398
|
+
if result.valid and not result.warnings:
|
|
399
|
+
console.print(f"[green]PASS[/green] {path} — no issues to discuss")
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
errors = result.errors + result.warnings
|
|
403
|
+
console.print(f"\n[bold]{path}[/bold] ({detected_fmt.value})")
|
|
404
|
+
console.print(f"[dim]{len(result.errors)} error(s), {len(result.warnings)} warning(s)[/dim]\n")
|
|
405
|
+
|
|
406
|
+
repairer = _get_repairer(backend, model, adapter)
|
|
407
|
+
with console.status("[bold]Analyzing...", spinner="dots"):
|
|
408
|
+
explanation = repairer.discuss(config, errors)
|
|
409
|
+
|
|
410
|
+
console.print(Panel(explanation, title="Analysis", border_style="blue"))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@cli.command(name="git-diff")
|
|
414
|
+
@click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
|
|
415
|
+
@click.option("--model", default=None)
|
|
416
|
+
@click.option("--adapter", default=None)
|
|
417
|
+
@click.option("--apply", is_flag=True, help="Auto-fix and stage repaired files")
|
|
418
|
+
def git_diff(backend: str, model: str | None, adapter: str | None, apply: bool):
|
|
419
|
+
"""Check and repair IaC files changed in the current git diff.
|
|
420
|
+
|
|
421
|
+
Scans staged and unstaged IaC file changes, validates them,
|
|
422
|
+
and offers AI-powered fixes. Ideal for CI or local git workflows.
|
|
423
|
+
|
|
424
|
+
Examples:
|
|
425
|
+
stackfix git-diff # check changed IaC files
|
|
426
|
+
stackfix git-diff --apply # fix and re-stage
|
|
427
|
+
"""
|
|
428
|
+
import subprocess
|
|
429
|
+
|
|
430
|
+
# Get list of changed IaC files (staged + unstaged)
|
|
431
|
+
try:
|
|
432
|
+
result = subprocess.run(
|
|
433
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD"],
|
|
434
|
+
capture_output=True, text=True, check=True,
|
|
435
|
+
)
|
|
436
|
+
changed = result.stdout.strip().splitlines()
|
|
437
|
+
except subprocess.CalledProcessError:
|
|
438
|
+
# No HEAD yet (initial commit) — check staged files
|
|
439
|
+
try:
|
|
440
|
+
result = subprocess.run(
|
|
441
|
+
["git", "diff", "--name-only", "--cached"],
|
|
442
|
+
capture_output=True, text=True, check=True,
|
|
443
|
+
)
|
|
444
|
+
changed = result.stdout.strip().splitlines()
|
|
445
|
+
except subprocess.CalledProcessError:
|
|
446
|
+
console.print("[red]Not a git repository or git not available[/red]")
|
|
447
|
+
sys.exit(1)
|
|
448
|
+
|
|
449
|
+
# Also include unstaged changes
|
|
450
|
+
try:
|
|
451
|
+
result = subprocess.run(
|
|
452
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR"],
|
|
453
|
+
capture_output=True, text=True, check=True,
|
|
454
|
+
)
|
|
455
|
+
changed += result.stdout.strip().splitlines()
|
|
456
|
+
except subprocess.CalledProcessError:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
# Deduplicate and filter to IaC files
|
|
460
|
+
iac_files = sorted(set(
|
|
461
|
+
f for f in changed
|
|
462
|
+
if f.endswith((".tf", ".yaml", ".yml", ".json"))
|
|
463
|
+
and Path(f).exists()
|
|
464
|
+
))
|
|
465
|
+
|
|
466
|
+
if not iac_files:
|
|
467
|
+
console.print("[green]No changed IaC files to check[/green]")
|
|
468
|
+
sys.exit(0)
|
|
469
|
+
|
|
470
|
+
console.print(f"[bold]Checking {len(iac_files)} changed IaC file(s)...[/bold]")
|
|
471
|
+
|
|
472
|
+
any_errors = False
|
|
473
|
+
any_fixed = False
|
|
474
|
+
|
|
475
|
+
for file_str in iac_files:
|
|
476
|
+
path = Path(file_str)
|
|
477
|
+
detected_fmt, result = validate_file_sync(path)
|
|
478
|
+
|
|
479
|
+
if result.valid:
|
|
480
|
+
console.print(f" [green]PASS[/green] {path}")
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
console.print(f" [red]FAIL[/red] {path} — {len(result.errors)} error(s)")
|
|
484
|
+
for err in result.errors:
|
|
485
|
+
console.print(f" [dim]{err}[/dim]")
|
|
486
|
+
|
|
487
|
+
if apply:
|
|
488
|
+
repairer = _get_repairer(backend, model, adapter)
|
|
489
|
+
original = path.read_text()
|
|
490
|
+
with console.status(f" Fixing {path}...", spinner="dots"):
|
|
491
|
+
repaired = repairer.repair(original, result.errors)
|
|
492
|
+
|
|
493
|
+
if repaired and repaired.strip() != original.strip():
|
|
494
|
+
verified = _verify_repair(repaired, detected_fmt, path.name)
|
|
495
|
+
if verified:
|
|
496
|
+
write_repair(path, repaired)
|
|
497
|
+
subprocess.run(["git", "add", str(path)], check=True)
|
|
498
|
+
console.print(f" [green]Fixed and staged[/green]")
|
|
499
|
+
any_fixed = True
|
|
500
|
+
else:
|
|
501
|
+
any_errors = True
|
|
502
|
+
else:
|
|
503
|
+
any_errors = True
|
|
504
|
+
else:
|
|
505
|
+
any_errors = True
|
|
506
|
+
|
|
507
|
+
if any_fixed:
|
|
508
|
+
console.print(f"\n[green]{sum(1 for _ in iac_files)} file(s) checked, fixes applied and staged[/green]")
|
|
509
|
+
elif any_errors:
|
|
510
|
+
console.print(f"\n[yellow]Use --apply to auto-fix errors[/yellow]")
|
|
511
|
+
|
|
512
|
+
sys.exit(1 if any_errors and not apply else 0)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def main():
|
|
516
|
+
"""Entry point."""
|
|
517
|
+
cli()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
if __name__ == "__main__":
|
|
521
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Detect IaC format and validate configurations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cloudgym.validator.terraform import ValidationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IaCFormat(Enum):
|
|
14
|
+
TERRAFORM = "terraform"
|
|
15
|
+
CLOUDFORMATION = "cloudformation"
|
|
16
|
+
OPENTOFU = "opentofu"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DetectionResult:
|
|
21
|
+
"""Result of detecting the IaC format of a file."""
|
|
22
|
+
|
|
23
|
+
format: IaCFormat
|
|
24
|
+
confidence: float
|
|
25
|
+
path: Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_format(path: Path) -> DetectionResult:
|
|
29
|
+
"""Auto-detect whether a file is Terraform, CloudFormation, or OpenTofu."""
|
|
30
|
+
suffix = path.suffix.lower()
|
|
31
|
+
name = path.name.lower()
|
|
32
|
+
content = path.read_text(errors="replace") if path.is_file() else ""
|
|
33
|
+
|
|
34
|
+
# Terraform: .tf files
|
|
35
|
+
if suffix == ".tf":
|
|
36
|
+
return DetectionResult(IaCFormat.TERRAFORM, 1.0, path)
|
|
37
|
+
|
|
38
|
+
# CloudFormation: .yaml/.yml/.json with AWSTemplateFormatVersion
|
|
39
|
+
if suffix in (".yaml", ".yml", ".json"):
|
|
40
|
+
if "AWSTemplateFormatVersion" in content:
|
|
41
|
+
return DetectionResult(IaCFormat.CLOUDFORMATION, 1.0, path)
|
|
42
|
+
# Check for common CF patterns
|
|
43
|
+
if "Type: AWS::" in content or '"Type": "AWS::' in content:
|
|
44
|
+
return DetectionResult(IaCFormat.CLOUDFORMATION, 0.9, path)
|
|
45
|
+
|
|
46
|
+
# HCL files that aren't .tf (e.g., .hcl)
|
|
47
|
+
if suffix == ".hcl":
|
|
48
|
+
return DetectionResult(IaCFormat.TERRAFORM, 0.8, path)
|
|
49
|
+
|
|
50
|
+
# Check content patterns as fallback
|
|
51
|
+
if "resource " in content and "{" in content and "provider " in content:
|
|
52
|
+
return DetectionResult(IaCFormat.TERRAFORM, 0.6, path)
|
|
53
|
+
if "AWSTemplateFormatVersion" in content:
|
|
54
|
+
return DetectionResult(IaCFormat.CLOUDFORMATION, 0.9, path)
|
|
55
|
+
|
|
56
|
+
# Default to terraform for unknown
|
|
57
|
+
return DetectionResult(IaCFormat.TERRAFORM, 0.3, path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def validate_file(path: Path, fmt: IaCFormat | None = None) -> tuple[IaCFormat, ValidationResult]:
|
|
61
|
+
"""Validate a file, auto-detecting format if not specified."""
|
|
62
|
+
if fmt is None:
|
|
63
|
+
detection = detect_format(path)
|
|
64
|
+
fmt = detection.format
|
|
65
|
+
|
|
66
|
+
if fmt == IaCFormat.CLOUDFORMATION:
|
|
67
|
+
from cloudgym.validator import cloudformation
|
|
68
|
+
result = await cloudformation.validate(path)
|
|
69
|
+
elif fmt == IaCFormat.OPENTOFU:
|
|
70
|
+
from cloudgym.validator import opentofu
|
|
71
|
+
result = await opentofu.validate(path)
|
|
72
|
+
else:
|
|
73
|
+
from cloudgym.validator import terraform
|
|
74
|
+
result = await terraform.validate(path)
|
|
75
|
+
|
|
76
|
+
return fmt, result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_file_sync(path: Path, fmt: IaCFormat | None = None) -> tuple[IaCFormat, ValidationResult]:
|
|
80
|
+
"""Synchronous wrapper for validate_file."""
|
|
81
|
+
return asyncio.run(validate_file(path, fmt))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Diff and output formatting for repair results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def unified_diff(original: str, repaired: str, filename: str = "config") -> str:
|
|
10
|
+
"""Generate a unified diff between original and repaired config."""
|
|
11
|
+
original_lines = original.splitlines(keepends=True)
|
|
12
|
+
repaired_lines = repaired.splitlines(keepends=True)
|
|
13
|
+
|
|
14
|
+
diff = difflib.unified_diff(
|
|
15
|
+
original_lines,
|
|
16
|
+
repaired_lines,
|
|
17
|
+
fromfile=f"a/{filename}",
|
|
18
|
+
tofile=f"b/{filename}",
|
|
19
|
+
lineterm="",
|
|
20
|
+
)
|
|
21
|
+
return "".join(diff)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def colorized_diff(original: str, repaired: str, filename: str = "config") -> str:
|
|
25
|
+
"""Generate a colorized diff using Rich markup."""
|
|
26
|
+
original_lines = original.splitlines(keepends=True)
|
|
27
|
+
repaired_lines = repaired.splitlines(keepends=True)
|
|
28
|
+
|
|
29
|
+
diff = difflib.unified_diff(
|
|
30
|
+
original_lines,
|
|
31
|
+
repaired_lines,
|
|
32
|
+
fromfile=f"a/{filename}",
|
|
33
|
+
tofile=f"b/{filename}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
colored_lines = []
|
|
37
|
+
for line in diff:
|
|
38
|
+
line = line.rstrip("\n")
|
|
39
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
40
|
+
colored_lines.append(f"[bold]{line}[/bold]")
|
|
41
|
+
elif line.startswith("@@"):
|
|
42
|
+
colored_lines.append(f"[cyan]{line}[/cyan]")
|
|
43
|
+
elif line.startswith("+"):
|
|
44
|
+
colored_lines.append(f"[green]{line}[/green]")
|
|
45
|
+
elif line.startswith("-"):
|
|
46
|
+
colored_lines.append(f"[red]{line}[/red]")
|
|
47
|
+
else:
|
|
48
|
+
colored_lines.append(line)
|
|
49
|
+
|
|
50
|
+
return "\n".join(colored_lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_repair(path: Path, content: str) -> None:
|
|
54
|
+
"""Write repaired content to file."""
|
|
55
|
+
path.write_text(content)
|