carlos / opentofu Public

Clone
git clone https://amadan.net/carlos/opentofu

Plain git — no account needed to clone.

Download

Download this file

1terraform {
2 required_version = ">= 1.11, < 2.0"
3
4 required_providers {
5 aws = {
6 source = "hashicorp/aws"
7 version = "~> 6.0"
8 }
9 }
10}
11
12data "aws_region" "current" {}
13
14data "aws_availability_zones" "available" {
15 state = "available"
16 filter {
17 name = "opt-in-status"
18 values = ["opt-in-not-required"]
19 }
20}
21
22data "aws_ami" "debian_arm64" {
23 most_recent = true
24 owners = ["136693071363"]
25
26 filter {
27 name = "name"
28 values = ["debian-13-arm64-*"]
29 }
30 filter {
31 name = "virtualization-type"
32 values = ["hvm"]
33 }
34 filter {
35 name = "root-device-type"
36 values = ["ebs"]
37 }
38 filter {
39 name = "architecture"
40 values = ["arm64"]
41 }
42}
43
44data "aws_ec2_instance_type_offerings" "hosts" {
45 location_type = "availability-zone"
46 filter {
47 name = "instance-type"
48 values = [var.instance_type]
49 }
50}
51
52locals {
53 available_zones = sort(tolist(setintersection(toset(data.aws_availability_zones.available.names), toset(data.aws_ec2_instance_type_offerings.hosts.locations))))
54 host_keys = [for i in range(var.host_count) : "host-${i}"]
55}
56
57resource "aws_vpc" "this" {
58 lifecycle {
59 precondition {
60 condition = length(local.available_zones) >= min(var.host_count, 2)
61 error_message = "The chosen instance type needs one available zone for one host, or at least two zones for multiple hosts."
62 }
63 }
64 cidr_block = var.vpc_cidr
65 enable_dns_support = true
66 enable_dns_hostnames = true
67
68 tags = {
69 Name = var.name
70 }
71}
72
73resource "aws_internet_gateway" "this" {
74 vpc_id = aws_vpc.this.id
75
76 tags = {
77 Name = var.name
78 }
79}
80
81resource "aws_route_table" "public" {
82 vpc_id = aws_vpc.this.id
83
84 route {
85 cidr_block = "0.0.0.0/0"
86 gateway_id = aws_internet_gateway.this.id
87 }
88
89 tags = {
90 Name = "${var.name}-public"
91 }
92}
93
94resource "aws_subnet" "public" {
95 lifecycle { ignore_changes = [availability_zone] }
96 count = var.host_count
97
98 vpc_id = aws_vpc.this.id
99 cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
100 availability_zone = try(local.available_zones[count.index % max(1, length(local.available_zones))], null)
101 map_public_ip_on_launch = true
102
103 tags = {
104 Name = "${var.name}-public-${count.index}"
105 }
106}
107
108resource "aws_route_table_association" "public" {
109 count = var.host_count
110
111 subnet_id = aws_subnet.public[count.index].id
112 route_table_id = aws_route_table.public.id
113}
114
115resource "aws_security_group" "host" {
116 name = "${var.name}-host"
117 description = "HTTP and HTTPS ingress, all egress"
118 vpc_id = aws_vpc.this.id
119
120 ingress {
121 description = "HTTP"
122 from_port = 80
123 to_port = 80
124 protocol = "tcp"
125 cidr_blocks = ["0.0.0.0/0"]
126 }
127
128 ingress {
129 description = "HTTPS"
130 from_port = 443
131 to_port = 443
132 protocol = "tcp"
133 cidr_blocks = ["0.0.0.0/0"]
134 }
135
136 egress {
137 from_port = 0
138 to_port = 0
139 protocol = "-1"
140 cidr_blocks = ["0.0.0.0/0"]
141 }
142
143 tags = {
144 Name = "${var.name}-host"
145 }
146}
147
148resource "aws_iam_role" "host" {
149 name = "${var.name}-host"
150
151 assume_role_policy = jsonencode({
152 Version = "2012-10-17"
153 Statement = [{
154 Effect = "Allow"
155 Principal = { Service = "ec2.amazonaws.com" }
156 Action = "sts:AssumeRole"
157 }]
158 })
159}
160
161resource "aws_iam_role_policy" "buckets" {
162 name = "buckets"
163 role = aws_iam_role.host.id
164
165 policy = jsonencode({
166 Version = "2012-10-17"
167 Statement = [
168 {
169 Sid = "ObjectAccess"
170 Effect = "Allow"
171 Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
172 Resource = [
173 "${var.deployment_bucket_arn}/*",
174 "${var.data_bucket_arn}/*",
175 ]
176 },
177 {
178 Sid = "BucketAccess"
179 Effect = "Allow"
180 Action = ["s3:ListBucket", "s3:GetBucketLocation"]
181 Resource = [var.deployment_bucket_arn, var.data_bucket_arn]
182 },
183 ]
184 })
185}
186
187resource "aws_iam_role_policy" "metrics" {
188 name = "metrics"
189 role = aws_iam_role.host.id
190
191 policy = jsonencode({
192 Version = "2012-10-17"
193 Statement = [{
194 Sid = "PutHostMetrics"
195 Effect = "Allow"
196 Action = "cloudwatch:PutMetricData"
197 Resource = "*"
198 Condition = {
199 StringEquals = { "cloudwatch:namespace" = "Carlos/Host" }
200 }
201 }]
202 })
203}
204
205resource "aws_iam_role_policy" "deny_parameters" {
206 name = "deny-ssm-parameters"
207 role = aws_iam_role.host.id
208
209 policy = jsonencode({
210 Version = "2012-10-17"
211 Statement = [{
212 Sid = "DenyParameterReads"
213 Effect = "Deny"
214 Action = [
215 "ssm:GetParameter",
216 "ssm:GetParameters",
217 "ssm:GetParameterHistory",
218 "ssm:GetParametersByPath",
219 ]
220 Resource = "*"
221 }]
222 })
223}
224
225resource "aws_iam_role_policy_attachment" "ssm_core" {
226 role = aws_iam_role.host.name
227 policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
228}
229
230resource "aws_iam_instance_profile" "host" {
231 name = "${var.name}-host"
232 role = aws_iam_role.host.name
233}
234
235resource "aws_instance" "host" {
236 for_each = toset(local.host_keys)
237
238 ami = data.aws_ami.debian_arm64.id
239 instance_type = var.instance_type
240 subnet_id = aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].id
241 vpc_security_group_ids = [aws_security_group.host.id]
242 iam_instance_profile = aws_iam_instance_profile.host.name
243 associate_public_ip_address = true
244 disable_api_termination = true
245
246 metadata_options {
247 http_endpoint = "enabled"
248 http_tokens = "required"
249 http_put_response_hop_limit = 1
250 }
251
252 root_block_device {
253 volume_type = "gp3"
254 volume_size = var.root_volume_gib
255 encrypted = true
256 delete_on_termination = false
257 }
258
259 user_data = templatefile("${path.module}/cloud-init.sh.tftpl", {
260 local_region = data.aws_region.current.region
261 deployment_bucket_name = var.deployment_bucket_name
262 deployment_region = var.deployment_region
263 data_bucket_name = var.data_bucket_name
264 placement_region = var.placement_region
265 release_public_key = var.release_public_key
266 apps_domain = var.apps_domain
267 })
268
269 tags = {
270 Name = "${var.name}-${each.key}"
271 }
272
273 depends_on = [
274 aws_route_table_association.public,
275 aws_iam_role_policy.buckets,
276 aws_iam_role_policy.metrics,
277 aws_iam_role_policy.deny_parameters,
278 aws_iam_role_policy_attachment.ssm_core,
279 ]
280
281 lifecycle {
282 prevent_destroy = true
283 ignore_changes = [ami, user_data]
284 precondition {
285 condition = contains(data.aws_ec2_instance_type_offerings.hosts.locations, aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].availability_zone)
286 error_message = "The chosen instance type is unavailable in this host's existing availability zone."
287 }
288 }
289}
290
291resource "aws_eip" "host" {
292 for_each = aws_instance.host
293
294 domain = "vpc"
295 instance = each.value.id
296
297 tags = {
298 Name = "${var.name}-${each.key}"
299 }
300}
301
302resource "aws_cloudwatch_metric_alarm" "recover" {
303 for_each = aws_instance.host
304 alarm_name = "${var.name}-${each.key}-recover"
305 namespace = "AWS/EC2"
306 metric_name = "StatusCheckFailed_System"
307 statistic = "Maximum"
308 period = 60
309 evaluation_periods = 2
310 threshold = 1
311 comparison_operator = "GreaterThanOrEqualToThreshold"
312 alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"]
313 dimensions = { InstanceId = each.value.id }
314}
315