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.
- infragen-0.1.0/.claude/settings.local.json +23 -0
- infragen-0.1.0/.env.example +7 -0
- infragen-0.1.0/.gitignore +25 -0
- infragen-0.1.0/MANIFEST.in +2 -0
- infragen-0.1.0/PKG-INFO +98 -0
- infragen-0.1.0/README.md +69 -0
- infragen-0.1.0/infragen/__init__.py +3 -0
- infragen-0.1.0/infragen/agents/__init__.py +0 -0
- infragen-0.1.0/infragen/agents/deployment_planner.py +153 -0
- infragen-0.1.0/infragen/agents/docs_generator.py +39 -0
- infragen-0.1.0/infragen/agents/explainer.py +56 -0
- infragen-0.1.0/infragen/agents/free_tier_guard.py +52 -0
- infragen-0.1.0/infragen/agents/orchestrator.py +45 -0
- infragen-0.1.0/infragen/agents/project_scanner.py +65 -0
- infragen-0.1.0/infragen/agents/review.py +57 -0
- infragen-0.1.0/infragen/agents/tf_writer.py +103 -0
- infragen-0.1.0/infragen/agents/translator.py +63 -0
- infragen-0.1.0/infragen/agents/validator.py +40 -0
- infragen-0.1.0/infragen/config.py +141 -0
- infragen-0.1.0/infragen/graph.py +61 -0
- infragen-0.1.0/infragen/main.py +412 -0
- infragen-0.1.0/infragen/repl.py +84 -0
- infragen-0.1.0/infragen/tools/__init__.py +0 -0
- infragen-0.1.0/infragen/tools/aws_info.py +50 -0
- infragen-0.1.0/infragen/tools/deployer.py +45 -0
- infragen-0.1.0/infragen/tools/doctor.py +108 -0
- infragen-0.1.0/infragen/tools/free_tier.py +141 -0
- infragen-0.1.0/infragen/tools/llm.py +70 -0
- infragen-0.1.0/infragen/tools/scanner.py +161 -0
- infragen-0.1.0/infragen/tools/terraform.py +172 -0
- infragen-0.1.0/pyproject.toml +47 -0
- infragen-0.1.0/tests/__init__.py +0 -0
- infragen-0.1.0/tests/test_deployment_planner.py +106 -0
- infragen-0.1.0/tests/test_free_tier.py +90 -0
- infragen-0.1.0/tests/test_graph.py +44 -0
- 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
|
infragen-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
infragen-0.1.0/README.md
ADDED
|
@@ -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
|
+
```
|
|
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)
|