diff --git a/ai_reviewer.py b/ai_reviewer.py index cfb84e3..99e6a87 100644 --- a/ai_reviewer.py +++ b/ai_reviewer.py @@ -3,6 +3,7 @@ from dotenv import load_dotenv from groq import Groq from groq.types.chat import ChatCompletionUserMessageParam +import glob load_dotenv() @@ -13,28 +14,47 @@ client = Groq(api_key=GROQ_API_KEY) -with open("main.tf", "r") as file: - code_to_review = file.read() +tf_files = glob.glob("**/*.tf", recursive=True) +if not tf_files: + print("No Terraform files found.") + sys.exit(0) -prompt = f"Act as a Cloud security and terraform expert. Check if this code has any errors (it is meant for Azure), security vulnerabilities, invalid syntax, or bad practices. If it has, you MUST start your response with the exact word 'REJECTED'. Only reject the code if there are undeniable, critical security vulnerabilities or actual syntax errors. Do not reject for stylistic choices or implicit Terraform dependencies. If the code is fine and safe, start with the exact word 'APPROVED'. Then provide a brief explanation. \nTerraform Code: \n{code_to_review}" +has_failures = False -messages: list[ChatCompletionUserMessageParam] = [{"role": "user", "content": prompt}] +for file_path in tf_files: + print(f"\nAnalyzing: {file_path}") + with open(file_path, "r", encoding="utf-8") as file: + code_to_review = file.read() -response = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=messages, - temperature=0.1 -) + if not code_to_review.strip(): + print("Skipping empty file.") + continue -response_text = response.choices[0].message.content + prompt = f"Act as a Cloud security and terraform expert. Check if this code has any errors (it is meant for Azure), security vulnerabilities, invalid syntax, or bad practices. If it has, you MUST start your response with the exact word 'REJECTED'. Only reject the code if there are undeniable, critical security vulnerabilities or actual syntax errors. Do not reject for stylistic choices or implicit Terraform dependencies. If the code is fine and safe, start with the exact word 'APPROVED'. Then provide a brief explanation. \nTerraform Code: \n{code_to_review}" -print("AI Review of the Code:") -print(response_text) -print("\n") + messages: list[ChatCompletionUserMessageParam] = [{"role": "user", "content": prompt}] -if response_text.strip().upper().startswith("REJECTED"): - print("\nCRITICAL ERROR: AI has rejected this code! The pipeline will now stop.") + response = client.chat.completions.create( + model="llama-3.3-70b-versatile", + messages=messages, + temperature=0.1 + ) + + response_text = response.choices[0].message.content + + print("AI Review of the Code:") + print(response_text) + print("\n") + + if response_text.strip().upper().startswith("REJECTED"): + print(f"❌ CRITICAL ERROR: AI has rejected this code in {file_path}!") + has_failures = True + else: + print(f"✅ SUCCESS: AI has approved this code in {file_path}.") + +if has_failures: + print("\nCRITICAL ERROR: AI has rejected one or more Terraform files! The pipeline will now stop.") sys.exit(1) # this tells GitHub: stop pipeline else: - print("\nSUCCESS: AI has approved this code.") + print("\nSUCCESS: AI has approved all Terraform code.") sys.exit(0) \ No newline at end of file diff --git a/iac_generator.py b/iac_generator.py new file mode 100644 index 0000000..5bfd0f0 --- /dev/null +++ b/iac_generator.py @@ -0,0 +1,117 @@ +from dotenv import load_dotenv +from groq import Groq +import os +import re +import sys + +load_dotenv() + +GROQ_API_KEY = os.environ.get("GROQ_API_KEY") +if not GROQ_API_KEY: + print("\nERROR: No API key found in environment variables.") + sys.exit(1) + +client = Groq(api_key=GROQ_API_KEY) + +# Color codes for terminal +GREEN = '\033[92m' +YELLOW = '\033[93m' +RED = '\033[91m' +RESET = '\033[0m' +BOLD = '\033[1m' + +def get_user_inputs(): + print(f"\n{BOLD} IAC Generator with AI {RESET}\n") + + dir = input("Enter the directory where you want to generate the IaC files (e.g., './iac_output'): ") or "./iac_output" + cloud = input("Enter the cloud provider (e.g., 'AWS', 'Azure', 'GCP'): ") or "azure" + + desc = input("Enter a brief description of the infrastructure you want to generate: ") + if not desc: + print(f"\n{RED} ERROR: No description provided. {RESET}") + sys.exit(1) + + print("\nAnswer the following questions (ENTER for default values): ") + proj_name = input("Project name [myproject]: ") or "myproject" + envs = input("Environments (comma-separated) [staging, prod]: ") or "staging, prod" + + return dir, cloud, desc, proj_name, envs + + +def generate_iac(dir, cloud, desc, proj_name, envs): + + print(f"\n{YELLOW} Generating iac for: {desc}... {RESET}\n") + + prompt = f"""Act as a Senior Cloud and DevOps Engineer expert in Terraform. + Your task is to write production-ready Terraform code for {cloud.upper()}. + + Project Details: + - Project Name: {proj_name} + - Environments needed: {envs} + - Architecture Requirements: {desc} + + CRITICAL INSTRUCTIONS FOR OUTPUT FORMAT: + You must output multiple files. For EACH file, use EXACTLY this format: + + ### [relative/path/to/file.tf] + ```hcl + # File content here + ``` + + Make sure to include necessary files like: + - providers.tf + - variables.tf + - main.tf (or split by resource like network.tf, compute.tf, databases.tf) + - outputs.tf + - envs/ (folder with .tfvars files for the requested environments) + + Do not add extra explanations outside of the file blocks. Just output the files.""" + + + answer = client.chat.completions.create( + model="llama-3.3-70b-versatile", + messages=[{"role": "user", "content": prompt}], + temperature=0.2 + ) + + return answer.choices[0].message.content + + +def extract_files_and_save(ia_text, base_dir): + # Use REGEX expressions to find all blocks in the requested format + standard = re.compile(r"###\s*\[?(.+?)\]?\n.*?```(?:hcl|terraform|tf|json|yaml)?\n(.*?)```", re.DOTALL) + matches = standard.findall(ia_text) + + if not matches: + print(f"\n{RED} ERROR: No files found in the AI response. {RESET}") + return + + print(f"\n{len(matches)} generated files. Review each one before confirming:\n") + + for path, content in matches: + print(f"{GREEN}[NEW]{RESET} {path.strip()}") + + confirm = input("\nDo you want to save these files? (y/n): ").lower() + + if confirm != 'y': + print(f"\n{RED} Aborting file save. {RESET}") + return + + for path, content in matches: + clean_path = path.strip().strip("/") # clean spaces and initial "/" + full_path = os.path.join(base_dir, clean_path) + + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + with open(full_path, "w", encoding="utf-8") as f: + f.write(content.strip() + "\n") + + print(f"\n{GREEN} All files saved successfully in {base_dir}! {RESET}\n") + +def main(): + dir, cloud, desc, proj_name, envs = get_user_inputs() + ia_text = generate_iac(dir, cloud, desc, proj_name, envs) + extract_files_and_save(ia_text, dir) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/iac_output/compute.tf b/iac_output/compute.tf new file mode 100644 index 0000000..63f19fa --- /dev/null +++ b/iac_output/compute.tf @@ -0,0 +1,20 @@ +# Create a service plan +resource "azurerm_service_plan" "plan" { + name = "myproject-plan" + resource_group_name = var.resource_group_name + location = var.location + os_type = "Linux" + sku_name = "P1v2" +} + +# Create a web app +resource "azurerm_linux_web_app" "app" { + name = var.app_service_name + resource_group_name = var.resource_group_name + location = var.location + service_plan_name = azurerm_service_plan.plan.name + + site_config { + linux_fx_version = "NODE|14-lts" + } +} diff --git a/iac_output/databases.tf b/iac_output/databases.tf new file mode 100644 index 0000000..667dde7 --- /dev/null +++ b/iac_output/databases.tf @@ -0,0 +1,25 @@ +# Create a PostgreSQL server +resource "azurerm_postgresql_server" "database" { + name = "myproject-postgres" + resource_group_name = var.resource_group_name + location = var.location + sku_name = "GP_Gen5_2" + + storage_mb = 5120 + backup_retention_days = 7 + geo_redundant_backup_enabled = false + auto_grow_enabled = true + + administrator_login = var.database_username + administrator_login_password = var.database_password + version = "11" +} + +# Create a PostgreSQL database +resource "azurerm_postgresql_database" "db" { + name = var.database_name + resource_group_name = var.resource_group_name + server_name = azurerm_postgresql_server.database.name + charset = "UTF8" + collation = "English_United States.1252" +} diff --git a/iac_output/envs/prod.tfvars b/iac_output/envs/prod.tfvars new file mode 100644 index 0000000..26d9002 --- /dev/null +++ b/iac_output/envs/prod.tfvars @@ -0,0 +1,8 @@ +environment = "prod" +location = "East US" +resource_group_name = "myproject-prod-rg" +storage_account_name = "myprojectprodstorage" +app_service_name = "myproject-prod-app" +database_name = "myproject_prod_db" +database_username = "myproject_prod_user" +database_password = "myproject_prod_password" diff --git a/iac_output/envs/staging.tfvars b/iac_output/envs/staging.tfvars new file mode 100644 index 0000000..572c723 --- /dev/null +++ b/iac_output/envs/staging.tfvars @@ -0,0 +1,8 @@ +environment = "staging" +location = "West US" +resource_group_name = "myproject-staging-rg" +storage_account_name = "myprojectstagingstorage" +app_service_name = "myproject-staging-app" +database_name = "myproject_staging_db" +database_username = "myproject_staging_user" +database_password = "myproject_staging_password" diff --git a/iac_output/network.tf b/iac_output/network.tf new file mode 100644 index 0000000..999ad5d --- /dev/null +++ b/iac_output/network.tf @@ -0,0 +1,15 @@ +# Create a virtual network +resource "azurerm_virtual_network" "vnet" { + name = "myproject-vnet" + address_space = ["10.0.0.0/16"] + location = var.location + resource_group_name = var.resource_group_name +} + +# Create a subnet +resource "azurerm_subnet" "subnet" { + name = "myproject-subnet" + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = ["10.0.1.0/24"] +} diff --git a/iac_output/outputs.tf b/iac_output/outputs.tf new file mode 100644 index 0000000..2832bb0 --- /dev/null +++ b/iac_output/outputs.tf @@ -0,0 +1,15 @@ +# Output the storage account URL +output "storage_account_url" { + value = azurerm_storage_account.storage.primary_web_endpoint +} + +# Output the web app URL +output "web_app_url" { + value = azurerm_linux_web_app.app.default_hostname +} + +# Output the database connection string +output "database_connection_string" { + value = "host=${azurerm_postgresql_server.database.fqdn} port=5432 dbname=${azurerm_postgresql_database.db.name} user=${var.database_username} password=${var.database_password} sslmode=require" + sensitive = true +} diff --git a/iac_output/providers.tf b/iac_output/providers.tf new file mode 100644 index 0000000..c1bf629 --- /dev/null +++ b/iac_output/providers.tf @@ -0,0 +1,4 @@ +# Configure the Azure Provider +provider "azurerm" { + features {} +} diff --git a/iac_output/storage.tf b/iac_output/storage.tf new file mode 100644 index 0000000..6e7e163 --- /dev/null +++ b/iac_output/storage.tf @@ -0,0 +1,13 @@ +# Create a storage account +resource "azurerm_storage_account" "storage" { + name = var.storage_account_name + resource_group_name = var.resource_group_name + location = var.location + account_tier = "Standard" + account_replication_type = "LRS" + + static_website { + index_document = "index.html" + error_404_document = "404.html" + } +} diff --git a/iac_output/variables.tf b/iac_output/variables.tf new file mode 100644 index 0000000..24c9f7c --- /dev/null +++ b/iac_output/variables.tf @@ -0,0 +1,41 @@ +# Define input variables +variable "environment" { + type = string + description = "Environment name" +} + +variable "location" { + type = string + description = "Location for all resources" +} + +variable "resource_group_name" { + type = string + description = "Resource group name" +} + +variable "storage_account_name" { + type = string + description = "Storage account name" +} + +variable "app_service_name" { + type = string + description = "App service name" +} + +variable "database_name" { + type = string + description = "Database name" +} + +variable "database_username" { + type = string + description = "Database username" +} + +variable "database_password" { + type = string + sensitive = true + description = "Database password" +}