tf-starter 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/bin/tf-starter.js +88 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +105 -0
- package/setup.py +32 -0
- package/tf_starter/__init__.py +3 -0
- package/tf_starter/__main__.py +6 -0
- package/tf_starter/cli.py +379 -0
- package/tf_starter/generator.py +171 -0
- package/tf_starter/template_engine.py +80 -0
- package/tf_starter/templates/aws/environments/backend.tf.j2 +16 -0
- package/tf_starter/templates/aws/environments/main.tf.j2 +85 -0
- package/tf_starter/templates/aws/environments/terraform.tfvars.j2 +52 -0
- package/tf_starter/templates/aws/environments/variables.tf.j2 +127 -0
- package/tf_starter/templates/aws/github/terraform.yml.j2 +133 -0
- package/tf_starter/templates/aws/misc/Makefile.j2 +60 -0
- package/tf_starter/templates/aws/misc/README.md.j2 +445 -0
- package/tf_starter/templates/aws/misc/init.sh.j2 +110 -0
- package/tf_starter/templates/aws/misc/pre-commit-config.yaml.j2 +34 -0
- package/tf_starter/templates/aws/modules/apigateway/main.tf.j2 +224 -0
- package/tf_starter/templates/aws/modules/apigateway/outputs.tf.j2 +28 -0
- package/tf_starter/templates/aws/modules/apigateway/variables.tf.j2 +69 -0
- package/tf_starter/templates/aws/modules/compute/main.tf.j2 +245 -0
- package/tf_starter/templates/aws/modules/compute/outputs.tf.j2 +38 -0
- package/tf_starter/templates/aws/modules/compute/variables.tf.j2 +68 -0
- package/tf_starter/templates/aws/modules/database/main.tf.j2 +122 -0
- package/tf_starter/templates/aws/modules/database/outputs.tf.j2 +33 -0
- package/tf_starter/templates/aws/modules/database/variables.tf.j2 +63 -0
- package/tf_starter/templates/aws/modules/kubernetes/main.tf.j2 +167 -0
- package/tf_starter/templates/aws/modules/kubernetes/outputs.tf.j2 +33 -0
- package/tf_starter/templates/aws/modules/kubernetes/variables.tf.j2 +64 -0
- package/tf_starter/templates/aws/modules/lambda/main.tf.j2 +215 -0
- package/tf_starter/templates/aws/modules/lambda/outputs.tf.j2 +38 -0
- package/tf_starter/templates/aws/modules/lambda/variables.tf.j2 +88 -0
- package/tf_starter/templates/aws/modules/messaging/main.tf.j2 +85 -0
- package/tf_starter/templates/aws/modules/messaging/outputs.tf.j2 +28 -0
- package/tf_starter/templates/aws/modules/messaging/variables.tf.j2 +41 -0
- package/tf_starter/templates/aws/modules/monitoring/main.tf.j2 +155 -0
- package/tf_starter/templates/aws/modules/monitoring/outputs.tf.j2 +23 -0
- package/tf_starter/templates/aws/modules/monitoring/variables.tf.j2 +39 -0
- package/tf_starter/templates/aws/modules/network/main.tf.j2 +147 -0
- package/tf_starter/templates/aws/modules/network/outputs.tf.j2 +33 -0
- package/tf_starter/templates/aws/modules/network/variables.tf.j2 +52 -0
- package/tf_starter/templates/aws/modules/storage/main.tf.j2 +88 -0
- package/tf_starter/templates/aws/modules/storage/outputs.tf.j2 +23 -0
- package/tf_starter/templates/aws/modules/storage/variables.tf.j2 +25 -0
- package/tf_starter/templates/aws/root/backend.tf.j2 +19 -0
- package/tf_starter/templates/aws/root/main.tf.j2 +219 -0
- package/tf_starter/templates/aws/root/outputs.tf.j2 +134 -0
- package/tf_starter/templates/aws/root/providers.tf.j2 +24 -0
- package/tf_starter/templates/aws/root/variables.tf.j2 +300 -0
- package/tf_starter/templates/aws/root/versions.tf.j2 +26 -0
- package/tf_starter/templates/azure/environments/backend.tf.j2 +11 -0
- package/tf_starter/templates/azure/environments/main.tf.j2 +57 -0
- package/tf_starter/templates/azure/environments/terraform.tfvars.j2 +14 -0
- package/tf_starter/templates/azure/environments/variables.tf.j2 +30 -0
- package/tf_starter/templates/azure/github/terraform.yml.j2 +133 -0
- package/tf_starter/templates/azure/misc/Makefile.j2 +60 -0
- package/tf_starter/templates/azure/misc/README.md.j2 +426 -0
- package/tf_starter/templates/azure/misc/init.sh.j2 +110 -0
- package/tf_starter/templates/azure/misc/pre-commit-config.yaml.j2 +34 -0
- package/tf_starter/templates/azure/modules/apigateway/main.tf.j2 +125 -0
- package/tf_starter/templates/azure/modules/apigateway/outputs.tf.j2 +18 -0
- package/tf_starter/templates/azure/modules/apigateway/variables.tf.j2 +54 -0
- package/tf_starter/templates/azure/modules/compute/main.tf.j2 +114 -0
- package/tf_starter/templates/azure/modules/compute/outputs.tf.j2 +9 -0
- package/tf_starter/templates/azure/modules/compute/variables.tf.j2 +23 -0
- package/tf_starter/templates/azure/modules/database/main.tf.j2 +56 -0
- package/tf_starter/templates/azure/modules/database/outputs.tf.j2 +13 -0
- package/tf_starter/templates/azure/modules/database/variables.tf.j2 +38 -0
- package/tf_starter/templates/azure/modules/kubernetes/main.tf.j2 +50 -0
- package/tf_starter/templates/azure/modules/kubernetes/outputs.tf.j2 +19 -0
- package/tf_starter/templates/azure/modules/kubernetes/variables.tf.j2 +37 -0
- package/tf_starter/templates/azure/modules/lambda/main.tf.j2 +98 -0
- package/tf_starter/templates/azure/modules/lambda/outputs.tf.j2 +23 -0
- package/tf_starter/templates/azure/modules/lambda/variables.tf.j2 +53 -0
- package/tf_starter/templates/azure/modules/messaging/main.tf.j2 +29 -0
- package/tf_starter/templates/azure/modules/messaging/outputs.tf.j2 +14 -0
- package/tf_starter/templates/azure/modules/messaging/variables.tf.j2 +11 -0
- package/tf_starter/templates/azure/modules/monitoring/main.tf.j2 +31 -0
- package/tf_starter/templates/azure/modules/monitoring/outputs.tf.j2 +9 -0
- package/tf_starter/templates/azure/modules/monitoring/variables.tf.j2 +16 -0
- package/tf_starter/templates/azure/modules/network/main.tf.j2 +89 -0
- package/tf_starter/templates/azure/modules/network/outputs.tf.j2 +25 -0
- package/tf_starter/templates/azure/modules/network/variables.tf.j2 +25 -0
- package/tf_starter/templates/azure/modules/storage/main.tf.j2 +41 -0
- package/tf_starter/templates/azure/modules/storage/outputs.tf.j2 +17 -0
- package/tf_starter/templates/azure/modules/storage/variables.tf.j2 +16 -0
- package/tf_starter/templates/azure/root/backend.tf.j2 +11 -0
- package/tf_starter/templates/azure/root/main.tf.j2 +181 -0
- package/tf_starter/templates/azure/root/outputs.tf.j2 +45 -0
- package/tf_starter/templates/azure/root/providers.tf.j2 +18 -0
- package/tf_starter/templates/azure/root/variables.tf.j2 +114 -0
- package/tf_starter/templates/azure/root/versions.tf.j2 +16 -0
- package/tf_starter/templates/gcp/environments/backend.tf.j2 +9 -0
- package/tf_starter/templates/gcp/environments/main.tf.j2 +58 -0
- package/tf_starter/templates/gcp/environments/terraform.tfvars.j2 +12 -0
- package/tf_starter/templates/gcp/environments/variables.tf.j2 +21 -0
- package/tf_starter/templates/gcp/github/terraform.yml.j2 +133 -0
- package/tf_starter/templates/gcp/misc/Makefile.j2 +60 -0
- package/tf_starter/templates/gcp/misc/README.md.j2 +426 -0
- package/tf_starter/templates/gcp/misc/init.sh.j2 +110 -0
- package/tf_starter/templates/gcp/misc/pre-commit-config.yaml.j2 +34 -0
- package/tf_starter/templates/gcp/modules/apigateway/main.tf.j2 +67 -0
- package/tf_starter/templates/gcp/modules/apigateway/outputs.tf.j2 +18 -0
- package/tf_starter/templates/gcp/modules/apigateway/variables.tf.j2 +34 -0
- package/tf_starter/templates/gcp/modules/compute/main.tf.j2 +138 -0
- package/tf_starter/templates/gcp/modules/compute/outputs.tf.j2 +13 -0
- package/tf_starter/templates/gcp/modules/compute/variables.tf.j2 +33 -0
- package/tf_starter/templates/gcp/modules/database/main.tf.j2 +62 -0
- package/tf_starter/templates/gcp/modules/database/outputs.tf.j2 +13 -0
- package/tf_starter/templates/gcp/modules/database/variables.tf.j2 +29 -0
- package/tf_starter/templates/gcp/modules/kubernetes/main.tf.j2 +75 -0
- package/tf_starter/templates/gcp/modules/kubernetes/outputs.tf.j2 +14 -0
- package/tf_starter/templates/gcp/modules/kubernetes/variables.tf.j2 +38 -0
- package/tf_starter/templates/gcp/modules/lambda/main.tf.j2 +122 -0
- package/tf_starter/templates/gcp/modules/lambda/outputs.tf.j2 +18 -0
- package/tf_starter/templates/gcp/modules/lambda/variables.tf.j2 +77 -0
- package/tf_starter/templates/gcp/modules/messaging/main.tf.j2 +44 -0
- package/tf_starter/templates/gcp/modules/messaging/outputs.tf.j2 +13 -0
- package/tf_starter/templates/gcp/modules/messaging/variables.tf.j2 +20 -0
- package/tf_starter/templates/gcp/modules/monitoring/main.tf.j2 +44 -0
- package/tf_starter/templates/gcp/modules/monitoring/outputs.tf.j2 +9 -0
- package/tf_starter/templates/gcp/modules/monitoring/variables.tf.j2 +13 -0
- package/tf_starter/templates/gcp/modules/network/main.tf.j2 +103 -0
- package/tf_starter/templates/gcp/modules/network/outputs.tf.j2 +21 -0
- package/tf_starter/templates/gcp/modules/network/variables.tf.j2 +22 -0
- package/tf_starter/templates/gcp/modules/storage/main.tf.j2 +47 -0
- package/tf_starter/templates/gcp/modules/storage/outputs.tf.j2 +13 -0
- package/tf_starter/templates/gcp/modules/storage/variables.tf.j2 +16 -0
- package/tf_starter/templates/gcp/root/backend.tf.j2 +12 -0
- package/tf_starter/templates/gcp/root/main.tf.j2 +210 -0
- package/tf_starter/templates/gcp/root/outputs.tf.j2 +61 -0
- package/tf_starter/templates/gcp/root/providers.tf.j2 +18 -0
- package/tf_starter/templates/gcp/root/variables.tf.j2 +140 -0
- package/tf_starter/templates/gcp/root/versions.tf.j2 +23 -0
package/setup.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Setup configuration for tf-starter CLI tool."""
|
|
2
|
+
|
|
3
|
+
from setuptools import setup, find_packages
|
|
4
|
+
|
|
5
|
+
setup(
|
|
6
|
+
name="tf-starter",
|
|
7
|
+
version="1.0.0",
|
|
8
|
+
description="Enterprise-grade Terraform Infrastructure-as-Code project generator",
|
|
9
|
+
author="DevOps Team",
|
|
10
|
+
python_requires=">=3.9",
|
|
11
|
+
packages=find_packages(),
|
|
12
|
+
include_package_data=True,
|
|
13
|
+
package_data={
|
|
14
|
+
"tf_starter": ["templates/**/*"],
|
|
15
|
+
},
|
|
16
|
+
install_requires=[
|
|
17
|
+
"questionary>=2.0.0",
|
|
18
|
+
"jinja2>=3.1.0",
|
|
19
|
+
],
|
|
20
|
+
entry_points={
|
|
21
|
+
"console_scripts": [
|
|
22
|
+
"tf-starter=tf_starter.cli:main",
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
classifiers=[
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Topic :: Software Development :: Code Generators",
|
|
30
|
+
"Topic :: System :: Systems Administration",
|
|
31
|
+
],
|
|
32
|
+
)
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""CLI entry point for tf-starter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
from questionary import Style
|
|
10
|
+
|
|
11
|
+
from tf_starter.generator import ProjectGenerator
|
|
12
|
+
|
|
13
|
+
SUPPORTED_PROVIDERS = ["aws", "gcp", "azure"]
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# AWS service categories — each maps to sub-modules inside a category folder.
|
|
17
|
+
# "networking" and "security" are always included.
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
AWS_SERVICE_CATEGORIES = {
|
|
21
|
+
"compute": {
|
|
22
|
+
"label": "Compute (EC2, ALB, Auto Scaling)",
|
|
23
|
+
},
|
|
24
|
+
"lambda": {
|
|
25
|
+
"label": "Lambda (Serverless Functions)",
|
|
26
|
+
},
|
|
27
|
+
"apigateway": {
|
|
28
|
+
"label": "API Gateway (REST API + Lambda Integration)",
|
|
29
|
+
},
|
|
30
|
+
"database": {
|
|
31
|
+
"label": "Database (RDS PostgreSQL)",
|
|
32
|
+
},
|
|
33
|
+
"kubernetes": {
|
|
34
|
+
"label": "Kubernetes (EKS)",
|
|
35
|
+
},
|
|
36
|
+
"monitoring": {
|
|
37
|
+
"label": "Monitoring (CloudWatch, SNS)",
|
|
38
|
+
},
|
|
39
|
+
"messaging": {
|
|
40
|
+
"label": "Messaging (SQS)",
|
|
41
|
+
},
|
|
42
|
+
"storage": {
|
|
43
|
+
"label": "Storage (S3)",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_REGIONS = {
|
|
48
|
+
"aws": [
|
|
49
|
+
"us-east-1",
|
|
50
|
+
"us-east-2",
|
|
51
|
+
"us-west-1",
|
|
52
|
+
"us-west-2",
|
|
53
|
+
"eu-west-1",
|
|
54
|
+
"eu-west-2",
|
|
55
|
+
"eu-central-1",
|
|
56
|
+
"ap-southeast-1",
|
|
57
|
+
"ap-northeast-1",
|
|
58
|
+
],
|
|
59
|
+
"gcp": [
|
|
60
|
+
"us-central1",
|
|
61
|
+
"us-east1",
|
|
62
|
+
"us-west1",
|
|
63
|
+
"europe-west1",
|
|
64
|
+
"europe-west3",
|
|
65
|
+
"asia-east1",
|
|
66
|
+
"asia-southeast1",
|
|
67
|
+
],
|
|
68
|
+
"azure": [
|
|
69
|
+
"eastus",
|
|
70
|
+
"eastus2",
|
|
71
|
+
"westus",
|
|
72
|
+
"westus2",
|
|
73
|
+
"westeurope",
|
|
74
|
+
"northeurope",
|
|
75
|
+
"southeastasia",
|
|
76
|
+
"japaneast",
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
CUSTOM_STYLE = Style(
|
|
81
|
+
[
|
|
82
|
+
("qmark", "fg:#00d7ff bold"),
|
|
83
|
+
("question", "fg:#ffffff bold"),
|
|
84
|
+
("answer", "fg:#00ff87 bold"),
|
|
85
|
+
("pointer", "fg:#00d7ff bold"),
|
|
86
|
+
("highlighted", "fg:#00d7ff bold"),
|
|
87
|
+
("selected", "fg:#00ff87"),
|
|
88
|
+
("separator", "fg:#6c6c6c"),
|
|
89
|
+
("instruction", "fg:#6c6c6c"),
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_args() -> argparse.Namespace:
|
|
95
|
+
"""Parse command-line arguments."""
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
prog="tf-starter",
|
|
98
|
+
description="Enterprise-grade Terraform Infrastructure-as-Code project generator",
|
|
99
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
100
|
+
epilog="""
|
|
101
|
+
Examples:
|
|
102
|
+
tf-starter --provider aws --project-name myapp
|
|
103
|
+
tf-starter --provider gcp --project-name my-gcp-infra
|
|
104
|
+
tf-starter --provider azure --project-name azure-platform
|
|
105
|
+
""",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--provider",
|
|
110
|
+
type=str,
|
|
111
|
+
required=True,
|
|
112
|
+
choices=SUPPORTED_PROVIDERS,
|
|
113
|
+
help="Cloud provider (aws, gcp, azure)",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--project-name",
|
|
118
|
+
type=str,
|
|
119
|
+
required=True,
|
|
120
|
+
help="Name of the project to generate",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--output-dir",
|
|
125
|
+
type=str,
|
|
126
|
+
default=".",
|
|
127
|
+
help="Output directory (default: current directory)",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return parser.parse_args()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_project_name(name: str) -> bool:
|
|
134
|
+
"""Validate project name contains only safe characters."""
|
|
135
|
+
import re
|
|
136
|
+
|
|
137
|
+
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ask_environments() -> list[str]:
|
|
141
|
+
"""Interactively ask which environments to create."""
|
|
142
|
+
env_choices = [
|
|
143
|
+
questionary.Choice("dev", checked=True),
|
|
144
|
+
questionary.Choice("staging"),
|
|
145
|
+
questionary.Choice("prod"),
|
|
146
|
+
questionary.Choice("custom"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
selected = questionary.checkbox(
|
|
150
|
+
"Select environments to create:",
|
|
151
|
+
choices=env_choices,
|
|
152
|
+
style=CUSTOM_STYLE,
|
|
153
|
+
validate=lambda x: len(x) > 0 or "You must select at least one environment",
|
|
154
|
+
).ask()
|
|
155
|
+
|
|
156
|
+
if selected is None:
|
|
157
|
+
print("\nAborted.")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
environments = []
|
|
161
|
+
for env in selected:
|
|
162
|
+
if env == "custom":
|
|
163
|
+
custom_name = questionary.text(
|
|
164
|
+
"Enter custom environment name:",
|
|
165
|
+
style=CUSTOM_STYLE,
|
|
166
|
+
validate=lambda x: (
|
|
167
|
+
len(x) > 0 and x.isalnum() or "Must be alphanumeric"
|
|
168
|
+
),
|
|
169
|
+
).ask()
|
|
170
|
+
if custom_name is None:
|
|
171
|
+
print("\nAborted.")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
environments.append(custom_name.lower())
|
|
174
|
+
else:
|
|
175
|
+
environments.append(env)
|
|
176
|
+
|
|
177
|
+
return environments
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ask_services(provider: str) -> list[str]:
|
|
181
|
+
"""Interactively ask which service categories to enable.
|
|
182
|
+
|
|
183
|
+
For AWS the user picks from real AWS service groups.
|
|
184
|
+
networking + security are always included automatically.
|
|
185
|
+
"""
|
|
186
|
+
if provider == "aws":
|
|
187
|
+
print("\n ℹ Network (VPC, Subnets, IGW, NAT) — always included\n")
|
|
188
|
+
|
|
189
|
+
service_choices = [
|
|
190
|
+
questionary.Choice(
|
|
191
|
+
title=meta["label"],
|
|
192
|
+
value=key,
|
|
193
|
+
checked=(key == "compute"),
|
|
194
|
+
)
|
|
195
|
+
for key, meta in AWS_SERVICE_CATEGORIES.items()
|
|
196
|
+
]
|
|
197
|
+
else:
|
|
198
|
+
# GCP / Azure keep original generic names
|
|
199
|
+
service_choices = [
|
|
200
|
+
questionary.Choice("compute", checked=True),
|
|
201
|
+
questionary.Choice("lambda"),
|
|
202
|
+
questionary.Choice("apigateway"),
|
|
203
|
+
questionary.Choice("database"),
|
|
204
|
+
questionary.Choice("kubernetes"),
|
|
205
|
+
questionary.Choice("monitoring"),
|
|
206
|
+
questionary.Choice("messaging"),
|
|
207
|
+
questionary.Choice("storage"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
selected = questionary.checkbox(
|
|
211
|
+
"Select service categories to enable:",
|
|
212
|
+
choices=service_choices,
|
|
213
|
+
style=CUSTOM_STYLE,
|
|
214
|
+
validate=lambda x: len(x) > 0 or "You must select at least one service",
|
|
215
|
+
).ask()
|
|
216
|
+
|
|
217
|
+
if selected is None:
|
|
218
|
+
print("\nAborted.")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
|
|
221
|
+
# Enforce dependency: kubernetes (EKS) requires compute
|
|
222
|
+
if "kubernetes" in selected and "compute" not in selected:
|
|
223
|
+
print(" ℹ Kubernetes requires compute — auto-enabling compute (EC2, ALB, ASG).")
|
|
224
|
+
selected.append("compute")
|
|
225
|
+
|
|
226
|
+
# Enforce dependency: apigateway requires lambda
|
|
227
|
+
if "apigateway" in selected and "lambda" not in selected:
|
|
228
|
+
print(" ℹ API Gateway requires Lambda — auto-enabling Lambda.")
|
|
229
|
+
selected.append("lambda")
|
|
230
|
+
|
|
231
|
+
# Always include network module
|
|
232
|
+
if "network" not in selected:
|
|
233
|
+
selected.insert(0, "network")
|
|
234
|
+
|
|
235
|
+
return selected
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ask_region(provider: str) -> str:
|
|
239
|
+
"""Interactively ask for the target region."""
|
|
240
|
+
regions = DEFAULT_REGIONS.get(provider, [])
|
|
241
|
+
|
|
242
|
+
region = questionary.select(
|
|
243
|
+
f"Select {provider.upper()} region:",
|
|
244
|
+
choices=regions + ["Other (type manually)"],
|
|
245
|
+
style=CUSTOM_STYLE,
|
|
246
|
+
).ask()
|
|
247
|
+
|
|
248
|
+
if region is None:
|
|
249
|
+
print("\nAborted.")
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
if region == "Other (type manually)":
|
|
253
|
+
region = questionary.text(
|
|
254
|
+
"Enter region:",
|
|
255
|
+
style=CUSTOM_STYLE,
|
|
256
|
+
validate=lambda x: len(x) > 0 or "Region cannot be empty",
|
|
257
|
+
).ask()
|
|
258
|
+
if region is None:
|
|
259
|
+
print("\nAborted.")
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
|
|
262
|
+
return region
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def ask_remote_backend() -> bool:
|
|
266
|
+
"""Ask whether to enable remote state backend."""
|
|
267
|
+
result = questionary.confirm(
|
|
268
|
+
"Enable remote backend (recommended for teams)?",
|
|
269
|
+
default=True,
|
|
270
|
+
style=CUSTOM_STYLE,
|
|
271
|
+
).ask()
|
|
272
|
+
|
|
273
|
+
if result is None:
|
|
274
|
+
print("\nAborted.")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def print_banner() -> None:
|
|
281
|
+
"""Print the tf-starter banner."""
|
|
282
|
+
banner = r"""
|
|
283
|
+
_ __ _ _
|
|
284
|
+
| | / _| ___| |_ __ _ _ __| |_ ___ _ __
|
|
285
|
+
| __| |_ _____ / __| __/ _` | '__| __/ _ \ '__|
|
|
286
|
+
| |_| _|_____|\__ \ || (_| | | | || __/ |
|
|
287
|
+
\__|_| |___/\__\__,_|_| \__\___|_|
|
|
288
|
+
|
|
289
|
+
Enterprise Terraform Project Generator v1.0.0
|
|
290
|
+
"""
|
|
291
|
+
print(banner)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def print_summary(
|
|
295
|
+
provider: str,
|
|
296
|
+
project_name: str,
|
|
297
|
+
environments: list[str],
|
|
298
|
+
services: list[str],
|
|
299
|
+
region: str,
|
|
300
|
+
enable_backend: bool,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Print generation summary."""
|
|
303
|
+
print("\n" + "=" * 55)
|
|
304
|
+
print(" ✔ Project created successfully!")
|
|
305
|
+
print("=" * 55)
|
|
306
|
+
print(f" ✔ Project: {project_name}")
|
|
307
|
+
print(f" ✔ Provider: {provider.upper()}")
|
|
308
|
+
print(f" ✔ Region: {region}")
|
|
309
|
+
print(f" ✔ Environments: {', '.join(environments)}")
|
|
310
|
+
print(f" ✔ Services: {', '.join(services)}")
|
|
311
|
+
print(f" ✔ Backend: {'Remote (S3 + DynamoDB)' if enable_backend else 'Local'}")
|
|
312
|
+
print("=" * 55)
|
|
313
|
+
print()
|
|
314
|
+
|
|
315
|
+
print(" Generated modules:")
|
|
316
|
+
for svc in services:
|
|
317
|
+
print(f" ✔ {svc}/")
|
|
318
|
+
print()
|
|
319
|
+
|
|
320
|
+
print(f" Next steps:")
|
|
321
|
+
print(f" cd {project_name}")
|
|
322
|
+
print(f" make init")
|
|
323
|
+
print(f" make plan")
|
|
324
|
+
print(f" make apply")
|
|
325
|
+
print()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def main() -> None:
|
|
329
|
+
"""Main entry point for tf-starter CLI."""
|
|
330
|
+
print_banner()
|
|
331
|
+
|
|
332
|
+
args = parse_args()
|
|
333
|
+
|
|
334
|
+
if not validate_project_name(args.project_name):
|
|
335
|
+
print(
|
|
336
|
+
"Error: Project name must start with a letter and contain only "
|
|
337
|
+
"letters, digits, hyphens, or underscores."
|
|
338
|
+
)
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
|
|
341
|
+
provider = args.provider.lower()
|
|
342
|
+
project_name = args.project_name
|
|
343
|
+
|
|
344
|
+
print(f"\n Provider: {provider.upper()}")
|
|
345
|
+
print(f" Project name: {project_name}\n")
|
|
346
|
+
|
|
347
|
+
# Interactive questions
|
|
348
|
+
environments = ask_environments()
|
|
349
|
+
services = ask_services(provider)
|
|
350
|
+
region = ask_region(provider)
|
|
351
|
+
enable_backend = ask_remote_backend()
|
|
352
|
+
|
|
353
|
+
# Build context for templates
|
|
354
|
+
context = {
|
|
355
|
+
"project_name": project_name,
|
|
356
|
+
"provider": provider,
|
|
357
|
+
"region": region,
|
|
358
|
+
"environments": environments,
|
|
359
|
+
"services": services,
|
|
360
|
+
"enable_backend": enable_backend,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Generate project
|
|
364
|
+
try:
|
|
365
|
+
generator = ProjectGenerator(
|
|
366
|
+
project_name=project_name,
|
|
367
|
+
output_dir=args.output_dir,
|
|
368
|
+
context=context,
|
|
369
|
+
)
|
|
370
|
+
generator.generate()
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"\n ✘ Error generating project: {e}")
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
|
|
375
|
+
print_summary(provider, project_name, environments, services, region, enable_backend)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Project generator — orchestrates template rendering and file creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tf_starter.template_engine import TemplateEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProjectGenerator:
|
|
14
|
+
"""Generates a complete Terraform project from templates."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
project_name: str,
|
|
19
|
+
output_dir: str,
|
|
20
|
+
context: dict[str, Any],
|
|
21
|
+
) -> None:
|
|
22
|
+
self.project_name = project_name
|
|
23
|
+
self.output_dir = Path(output_dir).resolve()
|
|
24
|
+
self.project_dir = self.output_dir / project_name
|
|
25
|
+
self.context = context
|
|
26
|
+
self.provider = context["provider"]
|
|
27
|
+
self.engine = TemplateEngine(provider=self.provider, context=context)
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
# Public
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def generate(self) -> Path:
|
|
34
|
+
"""Generate the full project. Returns the project directory path."""
|
|
35
|
+
if self.project_dir.exists():
|
|
36
|
+
raise FileExistsError(
|
|
37
|
+
f"Directory '{self.project_dir}' already exists. "
|
|
38
|
+
"Remove it first or choose a different project name."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self._create_directory_tree()
|
|
42
|
+
self._generate_root_files()
|
|
43
|
+
self._generate_modules()
|
|
44
|
+
self._generate_environments()
|
|
45
|
+
self._generate_github_workflow()
|
|
46
|
+
self._generate_misc_files()
|
|
47
|
+
|
|
48
|
+
return self.project_dir
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Directory tree
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _create_directory_tree(self) -> None:
|
|
55
|
+
"""Create the base directory structure."""
|
|
56
|
+
self.project_dir.mkdir(parents=True)
|
|
57
|
+
|
|
58
|
+
# modules/<service>/
|
|
59
|
+
for service in self.context["services"]:
|
|
60
|
+
(self.project_dir / "modules" / service).mkdir(parents=True)
|
|
61
|
+
|
|
62
|
+
# environments/<env>/
|
|
63
|
+
for env in self.context["environments"]:
|
|
64
|
+
(self.project_dir / "environments" / env).mkdir(parents=True)
|
|
65
|
+
|
|
66
|
+
# .github/workflows/
|
|
67
|
+
(self.project_dir / ".github" / "workflows").mkdir(parents=True)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Root-level Terraform files
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _generate_root_files(self) -> None:
|
|
74
|
+
root_templates = [
|
|
75
|
+
("root/main.tf.j2", "main.tf"),
|
|
76
|
+
("root/providers.tf.j2", "providers.tf"),
|
|
77
|
+
("root/variables.tf.j2", "variables.tf"),
|
|
78
|
+
("root/outputs.tf.j2", "outputs.tf"),
|
|
79
|
+
("root/versions.tf.j2", "versions.tf"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
for template_name, output_name in root_templates:
|
|
83
|
+
content = self.engine.render(template_name)
|
|
84
|
+
self._write(output_name, content)
|
|
85
|
+
|
|
86
|
+
# backend.tf only when remote backend enabled
|
|
87
|
+
if self.context["enable_backend"]:
|
|
88
|
+
content = self.engine.render("root/backend.tf.j2")
|
|
89
|
+
self._write("backend.tf", content)
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Module files
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _generate_modules(self) -> None:
|
|
96
|
+
"""Render each selected module's .tf files."""
|
|
97
|
+
for service in self.context["services"]:
|
|
98
|
+
module_template_dir = f"modules/{service}"
|
|
99
|
+
|
|
100
|
+
# Each module has main.tf, variables.tf, outputs.tf
|
|
101
|
+
for tf_file in ["main.tf", "variables.tf", "outputs.tf"]:
|
|
102
|
+
template_path = f"{module_template_dir}/{tf_file}.j2"
|
|
103
|
+
if self.engine.template_exists(template_path):
|
|
104
|
+
content = self.engine.render(template_path)
|
|
105
|
+
self._write(f"modules/{service}/{tf_file}", content)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Environment files
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _generate_environments(self) -> None:
|
|
112
|
+
"""Render per-environment files."""
|
|
113
|
+
for env in self.context["environments"]:
|
|
114
|
+
extra = {
|
|
115
|
+
"environment": env,
|
|
116
|
+
"is_prod": env == "prod",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
env_templates = [
|
|
120
|
+
("environments/main.tf.j2", f"environments/{env}/main.tf"),
|
|
121
|
+
("environments/variables.tf.j2", f"environments/{env}/variables.tf"),
|
|
122
|
+
("environments/terraform.tfvars.j2", f"environments/{env}/terraform.tfvars"),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
if self.context["enable_backend"]:
|
|
126
|
+
env_templates.append(
|
|
127
|
+
("environments/backend.tf.j2", f"environments/{env}/backend.tf")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for template_name, output_name in env_templates:
|
|
131
|
+
content = self.engine.render(template_name, extra_context=extra)
|
|
132
|
+
self._write(output_name, content)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# GitHub Actions
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _generate_github_workflow(self) -> None:
|
|
139
|
+
content = self.engine.render("github/terraform.yml.j2")
|
|
140
|
+
self._write(".github/workflows/terraform.yml", content)
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Makefile, init.sh, README, pre-commit
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _generate_misc_files(self) -> None:
|
|
147
|
+
misc_templates = [
|
|
148
|
+
("misc/Makefile.j2", "Makefile"),
|
|
149
|
+
("misc/init.sh.j2", "init.sh"),
|
|
150
|
+
("misc/README.md.j2", "README.md"),
|
|
151
|
+
("misc/pre-commit-config.yaml.j2", ".pre-commit-config.yaml"),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
for template_name, output_name in misc_templates:
|
|
155
|
+
content = self.engine.render(template_name)
|
|
156
|
+
self._write(output_name, content)
|
|
157
|
+
|
|
158
|
+
# Make init.sh executable
|
|
159
|
+
init_path = self.project_dir / "init.sh"
|
|
160
|
+
if init_path.exists():
|
|
161
|
+
init_path.chmod(init_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Helpers
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _write(self, relative_path: str, content: str) -> None:
|
|
168
|
+
"""Write rendered content to a file inside the project directory."""
|
|
169
|
+
dest = self.project_dir / relative_path
|
|
170
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
dest.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Jinja2 template engine for rendering Terraform files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TemplateEngine:
|
|
15
|
+
"""Renders Jinja2 templates with the given context."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, provider: str, context: dict[str, Any]) -> None:
|
|
18
|
+
self.provider = provider
|
|
19
|
+
self.context = context
|
|
20
|
+
self.template_dir = TEMPLATES_DIR / provider
|
|
21
|
+
|
|
22
|
+
if not self.template_dir.exists():
|
|
23
|
+
raise FileNotFoundError(
|
|
24
|
+
f"Template directory not found for provider '{provider}': "
|
|
25
|
+
f"{self.template_dir}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
self.env = Environment(
|
|
29
|
+
loader=FileSystemLoader(str(self.template_dir)),
|
|
30
|
+
undefined=StrictUndefined,
|
|
31
|
+
keep_trailing_newline=True,
|
|
32
|
+
trim_blocks=True,
|
|
33
|
+
lstrip_blocks=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Register custom filters
|
|
37
|
+
self.env.filters["tf_bool"] = self._tf_bool
|
|
38
|
+
self.env.filters["tf_list"] = self._tf_list
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _tf_bool(value: bool) -> str:
|
|
42
|
+
"""Convert Python bool to Terraform bool."""
|
|
43
|
+
return "true" if value else "false"
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _tf_list(values: list[str]) -> str:
|
|
47
|
+
"""Convert Python list to Terraform list literal."""
|
|
48
|
+
items = ", ".join(f'"{v}"' for v in values)
|
|
49
|
+
return f"[{items}]"
|
|
50
|
+
|
|
51
|
+
def render(self, template_path: str, extra_context: dict[str, Any] | None = None) -> str:
|
|
52
|
+
"""Render a template with context.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
template_path: Relative path within the provider template dir.
|
|
56
|
+
extra_context: Additional context merged on top of base context.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Rendered template string.
|
|
60
|
+
"""
|
|
61
|
+
ctx = {**self.context}
|
|
62
|
+
if extra_context:
|
|
63
|
+
ctx.update(extra_context)
|
|
64
|
+
|
|
65
|
+
template = self.env.get_template(template_path)
|
|
66
|
+
return template.render(**ctx)
|
|
67
|
+
|
|
68
|
+
def render_string(self, template_str: str, extra_context: dict[str, Any] | None = None) -> str:
|
|
69
|
+
"""Render a raw template string with context."""
|
|
70
|
+
ctx = {**self.context}
|
|
71
|
+
if extra_context:
|
|
72
|
+
ctx.update(extra_context)
|
|
73
|
+
|
|
74
|
+
template = self.env.from_string(template_str)
|
|
75
|
+
return template.render(**ctx)
|
|
76
|
+
|
|
77
|
+
def template_exists(self, template_path: str) -> bool:
|
|
78
|
+
"""Check if a template file exists."""
|
|
79
|
+
full_path = self.template_dir / template_path
|
|
80
|
+
return full_path.exists()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
2
|
+
# REMOTE BACKEND — {{ environment | upper }}
|
|
3
|
+
# Project: {{ project_name }}
|
|
4
|
+
# Generated by tf-starter
|
|
5
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
terraform {
|
|
8
|
+
backend "s3" {
|
|
9
|
+
### MUST EDIT THIS ###
|
|
10
|
+
bucket = "{{ project_name }}-terraform-state"
|
|
11
|
+
key = "{{ project_name }}/{{ environment }}/terraform.tfstate"
|
|
12
|
+
region = "{{ region }}"
|
|
13
|
+
encrypt = true
|
|
14
|
+
dynamodb_table = "{{ project_name }}-terraform-lock"
|
|
15
|
+
}
|
|
16
|
+
}
|