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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/bin/tf-starter.js +88 -0
  4. package/package.json +43 -0
  5. package/scripts/postinstall.js +105 -0
  6. package/setup.py +32 -0
  7. package/tf_starter/__init__.py +3 -0
  8. package/tf_starter/__main__.py +6 -0
  9. package/tf_starter/cli.py +379 -0
  10. package/tf_starter/generator.py +171 -0
  11. package/tf_starter/template_engine.py +80 -0
  12. package/tf_starter/templates/aws/environments/backend.tf.j2 +16 -0
  13. package/tf_starter/templates/aws/environments/main.tf.j2 +85 -0
  14. package/tf_starter/templates/aws/environments/terraform.tfvars.j2 +52 -0
  15. package/tf_starter/templates/aws/environments/variables.tf.j2 +127 -0
  16. package/tf_starter/templates/aws/github/terraform.yml.j2 +133 -0
  17. package/tf_starter/templates/aws/misc/Makefile.j2 +60 -0
  18. package/tf_starter/templates/aws/misc/README.md.j2 +445 -0
  19. package/tf_starter/templates/aws/misc/init.sh.j2 +110 -0
  20. package/tf_starter/templates/aws/misc/pre-commit-config.yaml.j2 +34 -0
  21. package/tf_starter/templates/aws/modules/apigateway/main.tf.j2 +224 -0
  22. package/tf_starter/templates/aws/modules/apigateway/outputs.tf.j2 +28 -0
  23. package/tf_starter/templates/aws/modules/apigateway/variables.tf.j2 +69 -0
  24. package/tf_starter/templates/aws/modules/compute/main.tf.j2 +245 -0
  25. package/tf_starter/templates/aws/modules/compute/outputs.tf.j2 +38 -0
  26. package/tf_starter/templates/aws/modules/compute/variables.tf.j2 +68 -0
  27. package/tf_starter/templates/aws/modules/database/main.tf.j2 +122 -0
  28. package/tf_starter/templates/aws/modules/database/outputs.tf.j2 +33 -0
  29. package/tf_starter/templates/aws/modules/database/variables.tf.j2 +63 -0
  30. package/tf_starter/templates/aws/modules/kubernetes/main.tf.j2 +167 -0
  31. package/tf_starter/templates/aws/modules/kubernetes/outputs.tf.j2 +33 -0
  32. package/tf_starter/templates/aws/modules/kubernetes/variables.tf.j2 +64 -0
  33. package/tf_starter/templates/aws/modules/lambda/main.tf.j2 +215 -0
  34. package/tf_starter/templates/aws/modules/lambda/outputs.tf.j2 +38 -0
  35. package/tf_starter/templates/aws/modules/lambda/variables.tf.j2 +88 -0
  36. package/tf_starter/templates/aws/modules/messaging/main.tf.j2 +85 -0
  37. package/tf_starter/templates/aws/modules/messaging/outputs.tf.j2 +28 -0
  38. package/tf_starter/templates/aws/modules/messaging/variables.tf.j2 +41 -0
  39. package/tf_starter/templates/aws/modules/monitoring/main.tf.j2 +155 -0
  40. package/tf_starter/templates/aws/modules/monitoring/outputs.tf.j2 +23 -0
  41. package/tf_starter/templates/aws/modules/monitoring/variables.tf.j2 +39 -0
  42. package/tf_starter/templates/aws/modules/network/main.tf.j2 +147 -0
  43. package/tf_starter/templates/aws/modules/network/outputs.tf.j2 +33 -0
  44. package/tf_starter/templates/aws/modules/network/variables.tf.j2 +52 -0
  45. package/tf_starter/templates/aws/modules/storage/main.tf.j2 +88 -0
  46. package/tf_starter/templates/aws/modules/storage/outputs.tf.j2 +23 -0
  47. package/tf_starter/templates/aws/modules/storage/variables.tf.j2 +25 -0
  48. package/tf_starter/templates/aws/root/backend.tf.j2 +19 -0
  49. package/tf_starter/templates/aws/root/main.tf.j2 +219 -0
  50. package/tf_starter/templates/aws/root/outputs.tf.j2 +134 -0
  51. package/tf_starter/templates/aws/root/providers.tf.j2 +24 -0
  52. package/tf_starter/templates/aws/root/variables.tf.j2 +300 -0
  53. package/tf_starter/templates/aws/root/versions.tf.j2 +26 -0
  54. package/tf_starter/templates/azure/environments/backend.tf.j2 +11 -0
  55. package/tf_starter/templates/azure/environments/main.tf.j2 +57 -0
  56. package/tf_starter/templates/azure/environments/terraform.tfvars.j2 +14 -0
  57. package/tf_starter/templates/azure/environments/variables.tf.j2 +30 -0
  58. package/tf_starter/templates/azure/github/terraform.yml.j2 +133 -0
  59. package/tf_starter/templates/azure/misc/Makefile.j2 +60 -0
  60. package/tf_starter/templates/azure/misc/README.md.j2 +426 -0
  61. package/tf_starter/templates/azure/misc/init.sh.j2 +110 -0
  62. package/tf_starter/templates/azure/misc/pre-commit-config.yaml.j2 +34 -0
  63. package/tf_starter/templates/azure/modules/apigateway/main.tf.j2 +125 -0
  64. package/tf_starter/templates/azure/modules/apigateway/outputs.tf.j2 +18 -0
  65. package/tf_starter/templates/azure/modules/apigateway/variables.tf.j2 +54 -0
  66. package/tf_starter/templates/azure/modules/compute/main.tf.j2 +114 -0
  67. package/tf_starter/templates/azure/modules/compute/outputs.tf.j2 +9 -0
  68. package/tf_starter/templates/azure/modules/compute/variables.tf.j2 +23 -0
  69. package/tf_starter/templates/azure/modules/database/main.tf.j2 +56 -0
  70. package/tf_starter/templates/azure/modules/database/outputs.tf.j2 +13 -0
  71. package/tf_starter/templates/azure/modules/database/variables.tf.j2 +38 -0
  72. package/tf_starter/templates/azure/modules/kubernetes/main.tf.j2 +50 -0
  73. package/tf_starter/templates/azure/modules/kubernetes/outputs.tf.j2 +19 -0
  74. package/tf_starter/templates/azure/modules/kubernetes/variables.tf.j2 +37 -0
  75. package/tf_starter/templates/azure/modules/lambda/main.tf.j2 +98 -0
  76. package/tf_starter/templates/azure/modules/lambda/outputs.tf.j2 +23 -0
  77. package/tf_starter/templates/azure/modules/lambda/variables.tf.j2 +53 -0
  78. package/tf_starter/templates/azure/modules/messaging/main.tf.j2 +29 -0
  79. package/tf_starter/templates/azure/modules/messaging/outputs.tf.j2 +14 -0
  80. package/tf_starter/templates/azure/modules/messaging/variables.tf.j2 +11 -0
  81. package/tf_starter/templates/azure/modules/monitoring/main.tf.j2 +31 -0
  82. package/tf_starter/templates/azure/modules/monitoring/outputs.tf.j2 +9 -0
  83. package/tf_starter/templates/azure/modules/monitoring/variables.tf.j2 +16 -0
  84. package/tf_starter/templates/azure/modules/network/main.tf.j2 +89 -0
  85. package/tf_starter/templates/azure/modules/network/outputs.tf.j2 +25 -0
  86. package/tf_starter/templates/azure/modules/network/variables.tf.j2 +25 -0
  87. package/tf_starter/templates/azure/modules/storage/main.tf.j2 +41 -0
  88. package/tf_starter/templates/azure/modules/storage/outputs.tf.j2 +17 -0
  89. package/tf_starter/templates/azure/modules/storage/variables.tf.j2 +16 -0
  90. package/tf_starter/templates/azure/root/backend.tf.j2 +11 -0
  91. package/tf_starter/templates/azure/root/main.tf.j2 +181 -0
  92. package/tf_starter/templates/azure/root/outputs.tf.j2 +45 -0
  93. package/tf_starter/templates/azure/root/providers.tf.j2 +18 -0
  94. package/tf_starter/templates/azure/root/variables.tf.j2 +114 -0
  95. package/tf_starter/templates/azure/root/versions.tf.j2 +16 -0
  96. package/tf_starter/templates/gcp/environments/backend.tf.j2 +9 -0
  97. package/tf_starter/templates/gcp/environments/main.tf.j2 +58 -0
  98. package/tf_starter/templates/gcp/environments/terraform.tfvars.j2 +12 -0
  99. package/tf_starter/templates/gcp/environments/variables.tf.j2 +21 -0
  100. package/tf_starter/templates/gcp/github/terraform.yml.j2 +133 -0
  101. package/tf_starter/templates/gcp/misc/Makefile.j2 +60 -0
  102. package/tf_starter/templates/gcp/misc/README.md.j2 +426 -0
  103. package/tf_starter/templates/gcp/misc/init.sh.j2 +110 -0
  104. package/tf_starter/templates/gcp/misc/pre-commit-config.yaml.j2 +34 -0
  105. package/tf_starter/templates/gcp/modules/apigateway/main.tf.j2 +67 -0
  106. package/tf_starter/templates/gcp/modules/apigateway/outputs.tf.j2 +18 -0
  107. package/tf_starter/templates/gcp/modules/apigateway/variables.tf.j2 +34 -0
  108. package/tf_starter/templates/gcp/modules/compute/main.tf.j2 +138 -0
  109. package/tf_starter/templates/gcp/modules/compute/outputs.tf.j2 +13 -0
  110. package/tf_starter/templates/gcp/modules/compute/variables.tf.j2 +33 -0
  111. package/tf_starter/templates/gcp/modules/database/main.tf.j2 +62 -0
  112. package/tf_starter/templates/gcp/modules/database/outputs.tf.j2 +13 -0
  113. package/tf_starter/templates/gcp/modules/database/variables.tf.j2 +29 -0
  114. package/tf_starter/templates/gcp/modules/kubernetes/main.tf.j2 +75 -0
  115. package/tf_starter/templates/gcp/modules/kubernetes/outputs.tf.j2 +14 -0
  116. package/tf_starter/templates/gcp/modules/kubernetes/variables.tf.j2 +38 -0
  117. package/tf_starter/templates/gcp/modules/lambda/main.tf.j2 +122 -0
  118. package/tf_starter/templates/gcp/modules/lambda/outputs.tf.j2 +18 -0
  119. package/tf_starter/templates/gcp/modules/lambda/variables.tf.j2 +77 -0
  120. package/tf_starter/templates/gcp/modules/messaging/main.tf.j2 +44 -0
  121. package/tf_starter/templates/gcp/modules/messaging/outputs.tf.j2 +13 -0
  122. package/tf_starter/templates/gcp/modules/messaging/variables.tf.j2 +20 -0
  123. package/tf_starter/templates/gcp/modules/monitoring/main.tf.j2 +44 -0
  124. package/tf_starter/templates/gcp/modules/monitoring/outputs.tf.j2 +9 -0
  125. package/tf_starter/templates/gcp/modules/monitoring/variables.tf.j2 +13 -0
  126. package/tf_starter/templates/gcp/modules/network/main.tf.j2 +103 -0
  127. package/tf_starter/templates/gcp/modules/network/outputs.tf.j2 +21 -0
  128. package/tf_starter/templates/gcp/modules/network/variables.tf.j2 +22 -0
  129. package/tf_starter/templates/gcp/modules/storage/main.tf.j2 +47 -0
  130. package/tf_starter/templates/gcp/modules/storage/outputs.tf.j2 +13 -0
  131. package/tf_starter/templates/gcp/modules/storage/variables.tf.j2 +16 -0
  132. package/tf_starter/templates/gcp/root/backend.tf.j2 +12 -0
  133. package/tf_starter/templates/gcp/root/main.tf.j2 +210 -0
  134. package/tf_starter/templates/gcp/root/outputs.tf.j2 +61 -0
  135. package/tf_starter/templates/gcp/root/providers.tf.j2 +18 -0
  136. package/tf_starter/templates/gcp/root/variables.tf.j2 +140 -0
  137. 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,3 @@
1
+ """tf-starter: Enterprise-grade Terraform IaC project generator."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow running tf-starter as `python -m tf_starter.cli`."""
2
+
3
+ from tf_starter.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ }