infragen 0.1.0__tar.gz

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.
Files changed (36) hide show
  1. infragen-0.1.0/.claude/settings.local.json +23 -0
  2. infragen-0.1.0/.env.example +7 -0
  3. infragen-0.1.0/.gitignore +25 -0
  4. infragen-0.1.0/MANIFEST.in +2 -0
  5. infragen-0.1.0/PKG-INFO +98 -0
  6. infragen-0.1.0/README.md +69 -0
  7. infragen-0.1.0/infragen/__init__.py +3 -0
  8. infragen-0.1.0/infragen/agents/__init__.py +0 -0
  9. infragen-0.1.0/infragen/agents/deployment_planner.py +153 -0
  10. infragen-0.1.0/infragen/agents/docs_generator.py +39 -0
  11. infragen-0.1.0/infragen/agents/explainer.py +56 -0
  12. infragen-0.1.0/infragen/agents/free_tier_guard.py +52 -0
  13. infragen-0.1.0/infragen/agents/orchestrator.py +45 -0
  14. infragen-0.1.0/infragen/agents/project_scanner.py +65 -0
  15. infragen-0.1.0/infragen/agents/review.py +57 -0
  16. infragen-0.1.0/infragen/agents/tf_writer.py +103 -0
  17. infragen-0.1.0/infragen/agents/translator.py +63 -0
  18. infragen-0.1.0/infragen/agents/validator.py +40 -0
  19. infragen-0.1.0/infragen/config.py +141 -0
  20. infragen-0.1.0/infragen/graph.py +61 -0
  21. infragen-0.1.0/infragen/main.py +412 -0
  22. infragen-0.1.0/infragen/repl.py +84 -0
  23. infragen-0.1.0/infragen/tools/__init__.py +0 -0
  24. infragen-0.1.0/infragen/tools/aws_info.py +50 -0
  25. infragen-0.1.0/infragen/tools/deployer.py +45 -0
  26. infragen-0.1.0/infragen/tools/doctor.py +108 -0
  27. infragen-0.1.0/infragen/tools/free_tier.py +141 -0
  28. infragen-0.1.0/infragen/tools/llm.py +70 -0
  29. infragen-0.1.0/infragen/tools/scanner.py +161 -0
  30. infragen-0.1.0/infragen/tools/terraform.py +172 -0
  31. infragen-0.1.0/pyproject.toml +47 -0
  32. infragen-0.1.0/tests/__init__.py +0 -0
  33. infragen-0.1.0/tests/test_deployment_planner.py +106 -0
  34. infragen-0.1.0/tests/test_free_tier.py +90 -0
  35. infragen-0.1.0/tests/test_graph.py +44 -0
  36. infragen-0.1.0/tests/test_scanner.py +85 -0
@@ -0,0 +1,23 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(curl *)",
5
+ "PowerShell(infragen *)",
6
+ "PowerShell(python -c \"import infragen.config, infragen.tools.llm, infragen.main; print\\('imports OK'\\)\")",
7
+ "PowerShell(git ls-tree *)",
8
+ "PowerShell(git *)",
9
+ "PowerShell(python -m pytest tests/ -q)",
10
+ "PowerShell(python -c \"import hcl2, io, json; print\\(json.dumps\\(hcl2.load\\(io.StringIO\\('resource \\\\\"aws_instance\\\\\" \\\\\"web\\\\\" {`n instance_type = \\\\\"m5.4xlarge\\\\\"`n}'\\)\\), indent=1\\)\\)\")",
11
+ "PowerShell(python \"C:\\\\Users\\\\adity\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\C--Users-adity-OneDrive-Desktop-infragen\\\\364394a5-fda1-45bd-afa0-f21a33004478\\\\scratchpad\\\\inspect_hcl.py\")",
12
+ "PowerShell(python -m pytest tests/ -q; python -c \"from infragen.graph import build_generate_graph; from infragen import repl, main; print\\('imports OK'\\)\")",
13
+ "PowerShell(python -m pytest tests/ -q 2>$null)",
14
+ "PowerShell(New-Item *)",
15
+ "PowerShell(terraform *)",
16
+ "PowerShell(python -m pytest tests/ -q 2>$null; if \\($LASTEXITCODE -eq 0\\) { \"all 28 tests pass\" })",
17
+ "PowerShell(python -m pytest tests/ -q 2>$null; if \\($LASTEXITCODE -eq 0\\) { \"30 tests pass\" })",
18
+ "PowerShell(python -m pytest tests/ -q --tb=no 2>$null)",
19
+ "PowerShell(curl *)",
20
+ "PowerShell(try { Invoke-WebRequest -Uri https://pypi.org/pypi/infragen/json -UseBasicParsing -ErrorAction Stop | Out-Null; \"TAKEN\" } catch { if \\($_.Exception.Response.StatusCode.value__ -eq 404\\) { \"available\" } else { $_.Exception.Message } })"
21
+ ]
22
+ }
23
+ }
@@ -0,0 +1,7 @@
1
+ # Copy this file to .env and fill in your key.
2
+ # Get a free API key at https://console.groq.com/keys (no credit card required).
3
+ GROQ_API_KEY=gsk_your_key_here
4
+
5
+ # Optional overrides (defaults are set in infragen/config.py)
6
+ # INFRAGEN_MODEL_MAIN=openai/gpt-oss-120b
7
+ # INFRAGEN_MODEL_FAST=openai/gpt-oss-20b
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+
10
+ # Env / secrets
11
+ .env
12
+
13
+ # Tooling
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+
17
+ # InfraGen workspace state (generated per user project, not this repo)
18
+ .infragen/
19
+
20
+ # Terraform (in case of local experiments)
21
+ .terraform/
22
+ *.tfstate
23
+ *.tfstate.backup
24
+
25
+ CLAUDE.md
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include .env.example
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: infragen
3
+ Version: 0.1.0
4
+ Summary: Multi-agent CLI that generates Terraform and deploys full-stack apps to AWS/GCP free tier
5
+ Project-URL: Repository, https://github.com/adityapanyala/infragen
6
+ Author: Aditya Panyala
7
+ License: MIT
8
+ Keywords: agents,aws,cli,free-tier,gcp,iac,llm,terraform
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: httpx>=0.27.0
17
+ Requires-Dist: langchain-core>=0.3.0
18
+ Requires-Dist: langchain-groq>=0.2.0
19
+ Requires-Dist: langgraph>=0.2.0
20
+ Requires-Dist: python-dotenv>=1.0.0
21
+ Requires-Dist: python-hcl2>=4.3.0
22
+ Requires-Dist: rich>=13.0.0
23
+ Requires-Dist: typer>=0.12.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
27
+ Requires-Dist: twine; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # InfraGen
31
+
32
+ Multi-agent CLI that takes a plain-English description of your app and deploys it to the **AWS or GCP free tier** — generates Terraform, provisions infrastructure, and pushes your code, with a review-and-confirm gate before anything runs.
33
+
34
+ ```
35
+ $ infragen deploy
36
+ detected: react frontend at ./frontend; fastapi backend at ./backend
37
+ tf_write: attempt 1 generated 219 lines of HCL
38
+ guardrail: PASS, 0 warning(s)
39
+ validate: PASS
40
+ +--------------------------- review before deploy ---------------------------+
41
+ | terraform plan: 10 to add, 0 to change, 0 to destroy |
42
+ | aws_instance.backend (created) |
43
+ | aws_s3_bucket.frontend (created) ... |
44
+ | free tier: all resources within free tier limits |
45
+ | deploy steps: |
46
+ | 1. build frontend (react) |
47
+ | 2. upload frontend to s3://... |
48
+ | 3. copy backend code to ubuntu@... |
49
+ | 4. install dependencies and start fastapi on port 8000 |
50
+ | estimated monthly cost: $0.00 (free tier) |
51
+ +----------------------------------------------------------------------------+
52
+ Deploy this? [y/N]:
53
+ ```
54
+
55
+ ## Why
56
+
57
+ Free-tier users get surprise bills from two things: resources that were never free (NAT gateways, oversized instances) and forgotten resources left running. InfraGen guards both ends: a **deterministic free-tier auditor** checks every generated resource against hardcoded limit tables (an LLM can miss a paid instance type; a dict lookup can't), and `infragen destroy` tears everything down from tracked state.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install -e ".[dev]" # from a clone (PyPI release planned)
63
+ cp .env.example .env # add YOUR OWN free Groq API key (console.groq.com/keys)
64
+ infragen doctor # verify terraform + aws/gcloud are ready
65
+ ```
66
+
67
+ **Bring your own accounts** — infragen ships no credentials. You need:
68
+
69
+ - Python 3.11+ and [Terraform](https://developer.hashicorp.com/terraform/install)
70
+ - your own **Groq API key** (free, no credit card) in a `.env` file in your project, or globally in `~/.infragen.env`
71
+ - your own **AWS** account with the CLI authenticated (`aws configure`), or **GCP** with `gcloud auth login` — deploys go to *your* cloud account, on its free tier
72
+
73
+ The CLI checks all of this up front and prints setup instructions for anything missing.
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ infragen # REPL: describe infra, get audited Terraform
79
+ infragen deploy # scan project -> generate -> review -> deploy
80
+ infragen deploy --dry-run # everything except apply (CI-friendly)
81
+ infragen destroy # tear down everything (with confirmation)
82
+ infragen explain ./main.tf # plain-English explanation of any Terraform
83
+ infragen translate ./ --to gcp # convert AWS Terraform to GCP (or vice versa)
84
+ infragen validate ./ # terraform validate + free-tier audit + tflint
85
+ infragen doctor # prerequisite checks
86
+ ```
87
+
88
+ Supported app patterns (any combination): static frontends (React/Vue/Next/plain) on S3 or Cloud Storage, Python backends (FastAPI/Flask/Django) on EC2 or Compute Engine. All artifacts and state live in `.infragen/` inside your project, so redeploys update the same stack.
89
+
90
+ ## How it works
91
+
92
+ A LangGraph state machine (running on Groq's free LLM tier) drives generation: a **TF writer** produces HCL, a **deterministic guardrail** blocks anything outside free-tier limits (feeding precise substitutions back for a rewrite), and a **validator** loops `terraform validate` errors back until clean — max 3 attempts. Deployment steps are built deterministically from the scanned app spec plus real terraform outputs, and the review panel is backed by actual `terraform plan` output, never an LLM's summary of intent.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ python -m pytest tests/ -q # unit tests (no API key needed)
98
+ ```
@@ -0,0 +1,69 @@
1
+ # InfraGen
2
+
3
+ Multi-agent CLI that takes a plain-English description of your app and deploys it to the **AWS or GCP free tier** — generates Terraform, provisions infrastructure, and pushes your code, with a review-and-confirm gate before anything runs.
4
+
5
+ ```
6
+ $ infragen deploy
7
+ detected: react frontend at ./frontend; fastapi backend at ./backend
8
+ tf_write: attempt 1 generated 219 lines of HCL
9
+ guardrail: PASS, 0 warning(s)
10
+ validate: PASS
11
+ +--------------------------- review before deploy ---------------------------+
12
+ | terraform plan: 10 to add, 0 to change, 0 to destroy |
13
+ | aws_instance.backend (created) |
14
+ | aws_s3_bucket.frontend (created) ... |
15
+ | free tier: all resources within free tier limits |
16
+ | deploy steps: |
17
+ | 1. build frontend (react) |
18
+ | 2. upload frontend to s3://... |
19
+ | 3. copy backend code to ubuntu@... |
20
+ | 4. install dependencies and start fastapi on port 8000 |
21
+ | estimated monthly cost: $0.00 (free tier) |
22
+ +----------------------------------------------------------------------------+
23
+ Deploy this? [y/N]:
24
+ ```
25
+
26
+ ## Why
27
+
28
+ Free-tier users get surprise bills from two things: resources that were never free (NAT gateways, oversized instances) and forgotten resources left running. InfraGen guards both ends: a **deterministic free-tier auditor** checks every generated resource against hardcoded limit tables (an LLM can miss a paid instance type; a dict lookup can't), and `infragen destroy` tears everything down from tracked state.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install -e ".[dev]" # from a clone (PyPI release planned)
34
+ cp .env.example .env # add YOUR OWN free Groq API key (console.groq.com/keys)
35
+ infragen doctor # verify terraform + aws/gcloud are ready
36
+ ```
37
+
38
+ **Bring your own accounts** — infragen ships no credentials. You need:
39
+
40
+ - Python 3.11+ and [Terraform](https://developer.hashicorp.com/terraform/install)
41
+ - your own **Groq API key** (free, no credit card) in a `.env` file in your project, or globally in `~/.infragen.env`
42
+ - your own **AWS** account with the CLI authenticated (`aws configure`), or **GCP** with `gcloud auth login` — deploys go to *your* cloud account, on its free tier
43
+
44
+ The CLI checks all of this up front and prints setup instructions for anything missing.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ infragen # REPL: describe infra, get audited Terraform
50
+ infragen deploy # scan project -> generate -> review -> deploy
51
+ infragen deploy --dry-run # everything except apply (CI-friendly)
52
+ infragen destroy # tear down everything (with confirmation)
53
+ infragen explain ./main.tf # plain-English explanation of any Terraform
54
+ infragen translate ./ --to gcp # convert AWS Terraform to GCP (or vice versa)
55
+ infragen validate ./ # terraform validate + free-tier audit + tflint
56
+ infragen doctor # prerequisite checks
57
+ ```
58
+
59
+ Supported app patterns (any combination): static frontends (React/Vue/Next/plain) on S3 or Cloud Storage, Python backends (FastAPI/Flask/Django) on EC2 or Compute Engine. All artifacts and state live in `.infragen/` inside your project, so redeploys update the same stack.
60
+
61
+ ## How it works
62
+
63
+ A LangGraph state machine (running on Groq's free LLM tier) drives generation: a **TF writer** produces HCL, a **deterministic guardrail** blocks anything outside free-tier limits (feeding precise substitutions back for a rewrite), and a **validator** loops `terraform validate` errors back until clean — max 3 attempts. Deployment steps are built deterministically from the scanned app spec plus real terraform outputs, and the review panel is backed by actual `terraform plan` output, never an LLM's summary of intent.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ python -m pytest tests/ -q # unit tests (no API key needed)
69
+ ```
@@ -0,0 +1,3 @@
1
+ """InfraGen — multi-agent CLI that deploys full-stack apps to AWS/GCP free tier."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,153 @@
1
+ """Deployment Planner — builds the ordered, structured step list that the
2
+ deployer executes after terraform apply.
3
+
4
+ Deterministic: steps are derived from the scanned app spec + terraform
5
+ outputs. Steps are plain data (JSON-serializable) so the review panel can
6
+ show exactly what will run, and `.infragen/deploy_steps.json` records it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass, field, asdict
15
+ from pathlib import Path
16
+
17
+ from infragen.tools.scanner import AppSpec
18
+
19
+ SSH_OPTS = ["-i", "{key_path}", "-o", "StrictHostKeyChecking=accept-new"]
20
+
21
+
22
+ @dataclass
23
+ class Step:
24
+ description: str
25
+ argv: list[str] # command + args (no shell), placeholders resolved
26
+ cwd: str | None = None
27
+ shell: bool = False # npm on Windows needs shell resolution
28
+ env: dict[str, str] = field(default_factory=dict)
29
+
30
+ def as_dict(self) -> dict:
31
+ return asdict(self)
32
+
33
+
34
+ class MissingOutputError(RuntimeError):
35
+ """A terraform output the deploy steps depend on was not produced."""
36
+
37
+
38
+ def _require(outputs: dict[str, str], name: str) -> str:
39
+ value = outputs.get(name, "").strip()
40
+ if not value:
41
+ raise MissingOutputError(
42
+ f"terraform output '{name}' is missing or empty - cannot build deploy steps"
43
+ )
44
+ return value
45
+
46
+
47
+ def _key_permission_steps(key_path: str, windows: bool | None = None) -> list[Step]:
48
+ """Restrict the SSH key so OpenSSH accepts it.
49
+
50
+ Terraform's file_permission=0600 does not translate to Windows ACLs, and
51
+ Windows OpenSSH refuses keys readable by other users ("bad permissions").
52
+ """
53
+ if windows is None:
54
+ windows = os.name == "nt"
55
+ if windows:
56
+ user = getpass.getuser()
57
+ return [
58
+ Step(
59
+ description="restrict SSH key permissions (remove inherited ACLs)",
60
+ argv=["icacls", key_path, "/inheritance:r"],
61
+ ),
62
+ Step(
63
+ description=f"restrict SSH key permissions (grant only {user})",
64
+ argv=["icacls", key_path, "/grant:r", f"{user}:R"],
65
+ ),
66
+ ]
67
+ return [Step(description="restrict SSH key permissions", argv=["chmod", "600", key_path])]
68
+
69
+
70
+ def build_steps(spec: AppSpec, provider: str, outputs: dict[str, str], workdir: str | Path) -> list[Step]:
71
+ steps: list[Step] = []
72
+ workdir = Path(workdir)
73
+
74
+ if spec.frontend:
75
+ f = spec.frontend
76
+ if f.build_command:
77
+ steps.append(Step(
78
+ description=f"build frontend ({f.framework})",
79
+ argv=f.build_command.split(),
80
+ cwd=f.path,
81
+ shell=True, # npm/npx are .cmd shims on Windows
82
+ ))
83
+ build_dir = str(Path(f.path) / (f.build_output_dir or "."))
84
+ bucket = _require(outputs, "frontend_bucket_name")
85
+ if provider == "aws":
86
+ steps.append(Step(
87
+ description=f"upload frontend to s3://{bucket}",
88
+ argv=["aws", "s3", "sync", build_dir, f"s3://{bucket}", "--delete"],
89
+ shell=True,
90
+ ))
91
+ else:
92
+ steps.append(Step(
93
+ description=f"upload frontend to gs://{bucket}",
94
+ argv=["gsutil", "-m", "rsync", "-r", "-d", build_dir, f"gs://{bucket}"],
95
+ shell=True,
96
+ ))
97
+
98
+ if spec.backend:
99
+ b = spec.backend
100
+ ip = _require(outputs, "backend_public_ip")
101
+ user = outputs.get("backend_ssh_user", "ubuntu").strip() or "ubuntu"
102
+ key_path = str(workdir / "infragen_key.pem")
103
+ ssh_opts = [opt.format(key_path=key_path) for opt in SSH_OPTS]
104
+ steps.extend(_key_permission_steps(key_path))
105
+ module = (b.entry_point or "main.py").replace("\\", "/").removesuffix(".py").replace("/", ".")
106
+
107
+ steps.append(Step(
108
+ description=f"copy backend code to {user}@{ip}",
109
+ # rm first: scp -r into an existing dir would nest a copy inside it
110
+ argv=["ssh", *ssh_opts, f"{user}@{ip}", f"rm -rf /home/{user}/app"],
111
+ ))
112
+ steps.append(Step(
113
+ description=f"upload backend code",
114
+ argv=["scp", *ssh_opts, "-r", b.path, f"{user}@{ip}:/home/{user}/app"],
115
+ ))
116
+ # A venv sidesteps PEP 668 (--break-system-packages exists only on new
117
+ # pip and errors on old pip) and works on every Ubuntu version.
118
+ setup = (
119
+ f"cd /home/{user}/app && "
120
+ "sudo apt-get update -qq && "
121
+ "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-venv python3-pip psmisc && "
122
+ "python3 -m venv .venv && "
123
+ ".venv/bin/pip install -q --upgrade pip && "
124
+ ".venv/bin/pip install -q -r requirements.txt "
125
+ )
126
+ if b.framework == "django":
127
+ run_cmd = f"nohup .venv/bin/python manage.py runserver 0.0.0.0:{b.port} > app.log 2>&1 &"
128
+ elif b.framework == "flask":
129
+ run_cmd = f"nohup .venv/bin/python -m flask --app {module} run --host 0.0.0.0 --port {b.port} > app.log 2>&1 &"
130
+ else: # fastapi
131
+ setup += "&& .venv/bin/pip install -q uvicorn "
132
+ run_cmd = f"nohup .venv/bin/python -m uvicorn {module}:app --host 0.0.0.0 --port {b.port} > app.log 2>&1 &"
133
+ # Verify the server responds (any HTTP status counts) or fail loudly
134
+ # with the log tail - a silent nohup must not report success.
135
+ verify = (
136
+ f"sleep 4; curl -s -o /dev/null http://127.0.0.1:{b.port}/ "
137
+ f"&& echo BACKEND_UP || (echo BACKEND_FAILED; tail -n 30 app.log; exit 1)"
138
+ )
139
+ steps.append(Step(
140
+ description=f"install dependencies and start {b.framework} on port {b.port}",
141
+ # Stop any previous server by PORT (fuser), never by process name:
142
+ # pkill -f would match this very ssh command line and kill itself.
143
+ argv=["ssh", *ssh_opts, f"{user}@{ip}",
144
+ f"{setup}&& sudo fuser -k {b.port}/tcp 2>/dev/null || true; sleep 1; {run_cmd} {verify}"],
145
+ ))
146
+
147
+ return steps
148
+
149
+
150
+ def save_steps(steps: list[Step], workdir: str | Path) -> Path:
151
+ path = Path(workdir) / "deploy_steps.json"
152
+ path.write_text(json.dumps([s.as_dict() for s in steps], indent=2), encoding="utf-8")
153
+ return path
@@ -0,0 +1,39 @@
1
+ """Docs Generator agent — writes a README for the deployed infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from langchain_core.messages import HumanMessage, SystemMessage
8
+
9
+ from infragen.tools.llm import get_llm
10
+
11
+ _SYSTEM_PROMPT = """Write a README.md for infrastructure that was just deployed with \
12
+ the infragen CLI. Sections, in order:
13
+
14
+ # <short project title>
15
+ ## What is deployed
16
+ ## Live URLs / endpoints (from the terraform outputs given)
17
+ ## Prerequisites (terraform, aws/gcloud CLI authenticated)
18
+ ## Redeploying (`infragen deploy` is idempotent - updates in place)
19
+ ## Free tier impact (which limits apply; state it costs $0/month within them)
20
+ ## Cleanup (`infragen destroy` removes everything; warn that free \
21
+ tier hours are finite - do not leave unused instances running)
22
+
23
+ Be accurate to the given Terraform and outputs - do not invent resources or URLs. \
24
+ Output only the markdown."""
25
+
26
+
27
+ def generate_readme(workdir: str | Path, outputs: dict[str, str]) -> Path:
28
+ """Generate .infragen/README.md from the deployed config + outputs."""
29
+ workdir = Path(workdir)
30
+ hcl = (workdir / "main.tf").read_text(encoding="utf-8")
31
+ outputs_text = "\n".join(f"{k} = {v}" for k, v in outputs.items()) or "(none)"
32
+
33
+ response = get_llm(fast=True).invoke([
34
+ SystemMessage(content=_SYSTEM_PROMPT),
35
+ HumanMessage(content=f"Terraform:\n{hcl}\n\nTerraform outputs:\n{outputs_text}"),
36
+ ])
37
+ readme = workdir / "README.md"
38
+ readme.write_text(response.content, encoding="utf-8")
39
+ return readme
@@ -0,0 +1,56 @@
1
+ """Explainer agent — plain-English summaries of Terraform files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from langchain_core.messages import HumanMessage, SystemMessage
8
+
9
+ from infragen import config
10
+ from infragen.tools.llm import get_llm
11
+
12
+ _SYSTEM_PROMPT = """You explain Terraform configurations to developers who may not \
13
+ know Terraform. Be concrete and brief. Structure the answer as markdown:
14
+
15
+ ## What gets deployed
16
+ One bullet per resource, in dependency order, each explaining WHAT it is and WHY \
17
+ it's there (e.g. "an S3 bucket configured as a public static website - hosts the \
18
+ frontend files").
19
+
20
+ ## How it connects
21
+ 2-4 sentences on how the pieces talk to each other (network path, ports, policies).
22
+
23
+ ## Free tier impact
24
+ For each billable service, state the relevant free tier limit and whether this \
25
+ config stays inside it. Relevant limits:
26
+ {quotas}
27
+
28
+ Do not invent resources that are not in the file. If something looks risky or \
29
+ unusual, say so plainly."""
30
+
31
+
32
+ def read_tf(path: str | Path) -> str:
33
+ """Read a .tf file, or concatenate all .tf files in a directory."""
34
+ path = Path(path)
35
+ if path.is_file():
36
+ return path.read_text(encoding="utf-8")
37
+ files = sorted(path.glob("*.tf"))
38
+ if not files:
39
+ raise FileNotFoundError(f"no .tf files found at {path}")
40
+ return "\n\n".join(
41
+ f"# --- {f.name} ---\n{f.read_text(encoding='utf-8')}" for f in files
42
+ )
43
+
44
+
45
+ def explain(path: str | Path) -> str:
46
+ hcl = read_tf(path)
47
+ quotas = "\n".join(
48
+ f"- {cloud} {service}: {limit}"
49
+ for cloud, services in config.FREE_TIER_QUOTAS.items()
50
+ for service, limit in services.items()
51
+ )
52
+ response = get_llm(fast=True).invoke([
53
+ SystemMessage(content=_SYSTEM_PROMPT.format(quotas=quotas)),
54
+ HumanMessage(content=hcl),
55
+ ])
56
+ return response.content
@@ -0,0 +1,52 @@
1
+ """Free Tier Guardrail graph node.
2
+
3
+ The audit itself is deterministic (tools/free_tier.py). This node runs it
4
+ and, when there are violations, turns them into rewrite feedback for the
5
+ TF Writer. No LLM call is needed for the loop — findings already carry
6
+ the substitute value.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from infragen.graph import InfraGenState
12
+ from infragen.tools import free_tier
13
+
14
+
15
+ def guardrail_node(state: InfraGenState) -> dict:
16
+ try:
17
+ result = free_tier.audit_hcl(state["hcl"])
18
+ except free_tier.HclParseError:
19
+ # The writer produced prose (e.g. a refusal) instead of HCL — loop back.
20
+ return {
21
+ "guardrail_findings": [{
22
+ "severity": "violation",
23
+ "resource": "(output)",
24
+ "message": "previous output was not valid Terraform HCL",
25
+ "attribute": None, "actual_value": None, "suggestion": None,
26
+ }],
27
+ "feedback": [
28
+ "Your output was not valid Terraform HCL. Output ONLY raw HCL code. "
29
+ "If the request asks for non-free-tier resources, generate the closest "
30
+ "free-tier-eligible equivalent instead of refusing or explaining."
31
+ ],
32
+ "log": ["guardrail: output was not parseable HCL"],
33
+ }
34
+ findings = [f.as_dict() for f in result.findings]
35
+
36
+ feedback = []
37
+ for finding in result.violations:
38
+ line = f"{finding.resource_address}: {finding.message}"
39
+ if finding.suggestion and finding.attribute:
40
+ line += f" Set {finding.attribute} = \"{finding.suggestion}\"."
41
+ elif finding.suggestion:
42
+ line += f" Use {finding.suggestion} instead."
43
+ else:
44
+ line += " Remove this resource and restructure without it."
45
+ feedback.append(line)
46
+
47
+ status = "PASS" if result.passed else f"{len(result.violations)} violation(s)"
48
+ return {
49
+ "guardrail_findings": findings,
50
+ "feedback": feedback,
51
+ "log": [f"guardrail: {status}, {len(result.warnings)} warning(s)"],
52
+ }
@@ -0,0 +1,45 @@
1
+ """Orchestrator — the entry point the REPL talks to.
2
+
3
+ Phase 3 scope: detect the target provider, prepare the workspace, and run
4
+ the generate graph. Intent routing to explain/translate/deploy expands in
5
+ Phases 4-5.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from infragen import config
14
+ from infragen.graph import InfraGenState, build_generate_graph
15
+
16
+ _GCP_HINTS = re.compile(r"\b(gcp|google|gcloud|firestore|cloud run|cloud function)\b", re.IGNORECASE)
17
+
18
+
19
+ def detect_provider(request: str) -> str:
20
+ return "gcp" if _GCP_HINTS.search(request) else "aws"
21
+
22
+
23
+ def workspace_dir(project_root: str | Path = ".") -> Path:
24
+ return (Path(project_root) / config.WORKSPACE_DIR).resolve()
25
+
26
+
27
+ def run_generate(
28
+ request: str,
29
+ project_root: str | Path = ".",
30
+ *,
31
+ provider: str | None = None,
32
+ app_spec: dict | None = None,
33
+ ) -> InfraGenState:
34
+ """Run the tf_write -> guardrail -> validate loop; returns final state."""
35
+ graph = build_generate_graph()
36
+ initial: InfraGenState = {
37
+ "request": request,
38
+ "provider": provider or detect_provider(request),
39
+ "workdir": str(workspace_dir(project_root)),
40
+ "app_spec": app_spec,
41
+ "attempts": 0,
42
+ "feedback": [],
43
+ "log": [],
44
+ }
45
+ return graph.invoke(initial)
@@ -0,0 +1,65 @@
1
+ """Project Scanner agent — wraps the deterministic scan and describes the
2
+ detected app for the TF Writer. Ambiguities (multiple entry points) are
3
+ surfaced rather than silently guessed wrong.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from infragen.tools.scanner import AppSpec, scan
11
+
12
+
13
+ def scan_project(root: str | Path = ".") -> tuple[AppSpec, list[str]]:
14
+ """Scan and return (spec, notes) where notes are human-facing caveats."""
15
+ spec = scan(root)
16
+ notes: list[str] = []
17
+ if spec.backend and len(spec.backend.candidates) > 1:
18
+ notes.append(
19
+ f"multiple backend entry points found {spec.backend.candidates}; "
20
+ f"using '{spec.backend.entry_point}'"
21
+ )
22
+ return spec, notes
23
+
24
+
25
+ def describe_for_writer(spec: AppSpec, provider: str, instance_type: str = "t3.micro") -> str:
26
+ """Build the TF Writer request for a full app deploy, including the exact
27
+ terraform outputs the deployment steps rely on."""
28
+ parts: list[str] = ["Deploy this application to the free tier:"]
29
+ outputs_needed: list[str] = []
30
+
31
+ if spec.frontend:
32
+ f = spec.frontend
33
+ parts.append(
34
+ f"- Static frontend: {f.framework} app at '{f.path}'"
35
+ + (f", built with `{f.build_command}` into '{f.build_output_dir}/'" if f.build_command else "")
36
+ + ". Host it on "
37
+ + ("an S3 bucket configured as a static website (public read via bucket policy, "
38
+ "website configuration with index.html)." if provider == "aws"
39
+ else "a public Cloud Storage bucket with website configuration.")
40
+ )
41
+ outputs_needed += (
42
+ ['output "frontend_bucket_name" (the bucket name)',
43
+ 'output "frontend_website_url" (the website endpoint URL)']
44
+ )
45
+
46
+ if spec.backend:
47
+ b = spec.backend
48
+ parts.append(
49
+ f"- Python backend: {b.framework} app at '{b.path}', entry point '{b.entry_point}', "
50
+ f"listening on port {b.port}. Host it on "
51
+ + (f"a {instance_type} EC2 instance (ubuntu AMI via data source; pick the AMI "
52
+ f"architecture matching the instance type) with a security group "
53
+ f"allowing inbound tcp {b.port} and 22. Create a tls_private_key + aws_key_pair "
54
+ f"and write the private key to a local_file named 'infragen_key.pem' with the "
55
+ f"file permission 0600." if provider == "aws"
56
+ else f"an e2-micro compute instance with ubuntu image, firewall allowing tcp {b.port} and 22.")
57
+ )
58
+ outputs_needed += (
59
+ ['output "backend_public_ip" (the instance public IP)',
60
+ 'output "backend_ssh_user" (value "ubuntu")']
61
+ )
62
+
63
+ if outputs_needed:
64
+ parts.append("REQUIRED terraform outputs (exact names): " + "; ".join(outputs_needed))
65
+ return "\n".join(parts)