diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a1c9b..c50db34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,9 @@ jobs: - single-vm/azure - ha-hot-hot/azure - unlimited-scale/azure + # Shared infrastructure — foundational modules consumed by every tier + - network/aws + - network/azure steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/modules/asm-aws-single/main.tf b/modules/asm-aws-single/main.tf index ebc1dbd..f71bbec 100644 --- a/modules/asm-aws-single/main.tf +++ b/modules/asm-aws-single/main.tf @@ -18,6 +18,7 @@ module "this" { allow_internet_ingress = var.allow_internet_ingress enable_snapshots = var.enable_snapshots marketplace_product_code = var.marketplace_product_code + enable_flow_logs = var.enable_flow_logs # Patching and migration safety create_backup_bucket = var.create_backup_bucket diff --git a/modules/asm-aws-single/outputs.tf b/modules/asm-aws-single/outputs.tf index c97f267..f8ce3f1 100644 --- a/modules/asm-aws-single/outputs.tf +++ b/modules/asm-aws-single/outputs.tf @@ -73,3 +73,9 @@ output "pre_patch_ssm_document_name" { value = module.this.pre_patch_ssm_document_name sensitive = false } + +output "flow_log_group_name" { + description = "CloudWatch log group name receiving VPC Flow Logs. Empty string when enable_flow_logs is false." + value = module.this.flow_log_group_name + sensitive = false +} diff --git a/modules/asm-aws-single/variables.tf b/modules/asm-aws-single/variables.tf index 94018e4..14d4fb9 100644 --- a/modules/asm-aws-single/variables.tf +++ b/modules/asm-aws-single/variables.tf @@ -115,6 +115,12 @@ variable "backup_noncurrent_version_expiration_days" { default = 365 } +variable "enable_flow_logs" { + description = "Enable VPC Flow Logs for the provided VPC, sending ALL traffic to a CloudWatch log group named /aws/vpc-flow-logs/. Matches the default stated in SECURITY-DEFAULTS.md." + type = bool + default = true +} + variable "tags" { description = "Additional tags applied to every resource." type = map(string) diff --git a/modules/network/aws/tests/basic.tftest.hcl b/modules/network/aws/tests/basic.tftest.hcl new file mode 100644 index 0000000..e10b8e2 --- /dev/null +++ b/modules/network/aws/tests/basic.tftest.hcl @@ -0,0 +1,87 @@ +# Minimal-input apply against a mocked AWS provider. No real credentials and no +# API calls — the mock provider stubs the provider entirely. This proves the +# module instantiates with only its required variables and that every +# operator-facing output is populated. +# +# The aws_availability_zones data source is overridden via mock_data so that +# slice(names, 0, az_count) evaluates to a known, non-empty list. Without this +# override the mock provider returns an empty list and the slice call fails. +# +# The apply command is used (rather than plan) because the private route tables +# reference aws_nat_gateway.main[*].id inside a for_each. With mock apply the +# provider generates deterministic mock IDs for all resources, so that reference +# is fully known; with plan only, it would be unknown and Terraform would refuse +# to evaluate the for_each. + +mock_provider "aws" { + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + ids = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } + + # Flow-log IAM role ARN must look like a real ARN so downstream policy + # resources that reference it pass provider-side validation. + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/mock-role" + } + } + + # aws_flow_log.main validates that log_destination is a well-formed ARN, so the + # mocked CloudWatch log group must carry one (the random mock token isn't). + mock_resource "aws_cloudwatch_log_group" { + defaults = { + arn = "arn:aws:logs:us-east-1:123456789012:log-group:/aws/vpc-flow-logs/mock:*" + } + } +} + +variables { + name_prefix = "hailbytes-test" +} + +run "minimal_inputs_apply" { + command = apply + + assert { + condition = output.vpc_id != "" + error_message = "vpc_id output must be non-empty" + } + + assert { + condition = output.vpc_cidr == "10.0.0.0/16" + error_message = "vpc_cidr must match the default value" + } + + assert { + condition = length(output.public_subnet_ids) == 2 + error_message = "public_subnet_ids must contain one entry per AZ (default az_count = 2)" + } + + assert { + condition = length(output.private_subnet_ids) == 2 + error_message = "private_subnet_ids must contain one entry per AZ (default az_count = 2)" + } + + assert { + condition = length(output.db_subnet_ids) == 2 + error_message = "db_subnet_ids must contain one entry per AZ (default az_count = 2)" + } + + assert { + condition = output.internet_gateway_id != "" + error_message = "internet_gateway_id output must be non-empty" + } + + assert { + condition = length(output.nat_gateway_ids) == 2 + error_message = "nat_gateway_ids must contain 2 entries when enable_nat_gateway = true (the default)" + } + + assert { + condition = length(output.nat_gateway_public_ips) == 2 + error_message = "nat_gateway_public_ips must contain 2 entries when enable_nat_gateway = true (the default)" + } +} diff --git a/modules/network/aws/tests/feature_flags.tftest.hcl b/modules/network/aws/tests/feature_flags.tftest.hcl new file mode 100644 index 0000000..6bfdbd2 --- /dev/null +++ b/modules/network/aws/tests/feature_flags.tftest.hcl @@ -0,0 +1,95 @@ +# Conditional resources must respect their feature flags. All runs use +# command = plan; no apply or credentials are needed. +# +# Every run explicitly sets enable_nat_gateway = false. When nat is enabled the +# private route table's for_each references aws_nat_gateway.main[*].id, which +# is unknown at plan time. Setting it false makes the for_each evaluate to [] +# (always known) so each run can focus on the flag under test in isolation. + +mock_provider "aws" { + mock_data "aws_availability_zones" { + defaults = { + names = ["us-east-1a", "us-east-1b", "us-east-1c"] + ids = ["us-east-1a", "us-east-1b", "us-east-1c"] + } + } +} + +variables { + name_prefix = "hailbytes-test" + enable_nat_gateway = false +} + +run "nat_disabled_creates_no_gateways" { + command = plan + + assert { + condition = length(aws_nat_gateway.main) == 0 + error_message = "enable_nat_gateway = false must create zero NAT gateways." + } + + assert { + condition = length(aws_eip.nat) == 0 + error_message = "enable_nat_gateway = false must create zero Elastic IPs." + } +} + +run "flow_logs_disabled_creates_no_resources" { + command = plan + + variables { + enable_flow_logs = false + } + + assert { + condition = length(aws_flow_log.main) == 0 + error_message = "enable_flow_logs = false must create zero VPC Flow Log resources." + } + + assert { + condition = length(aws_cloudwatch_log_group.flow_logs) == 0 + error_message = "enable_flow_logs = false must create zero CloudWatch log groups." + } + + assert { + condition = length(aws_iam_role.flow_logs) == 0 + error_message = "enable_flow_logs = false must create zero IAM roles for flow logs." + } +} + +run "flow_logs_enabled_creates_one_set" { + command = plan + + assert { + condition = length(aws_flow_log.main) == 1 + error_message = "enable_flow_logs = true (the default) must create exactly one flow log." + } + + assert { + condition = length(aws_cloudwatch_log_group.flow_logs) == 1 + error_message = "enable_flow_logs = true must create exactly one CloudWatch log group." + } +} + +run "three_az_span_creates_three_subnets_each" { + command = plan + + variables { + az_count = 3 + } + + assert { + condition = length(aws_subnet.public) == 3 + error_message = "az_count = 3 must create three public subnets." + } + + assert { + condition = length(aws_subnet.private) == 3 + error_message = "az_count = 3 must create three private subnets." + } + + assert { + condition = length(aws_subnet.db) == 3 + error_message = "az_count = 3 must create three database subnets." + } +} diff --git a/modules/network/azure/tests/basic.tftest.hcl b/modules/network/azure/tests/basic.tftest.hcl new file mode 100644 index 0000000..6d5cc85 --- /dev/null +++ b/modules/network/azure/tests/basic.tftest.hcl @@ -0,0 +1,83 @@ +# Minimal-input apply against a mocked azurerm provider. No real credentials and +# no API calls. Proves the module instantiates with only its required variables +# and that every operator-facing output is populated. +# +# The Azure network module creates a vnet, three subnets (lb, workload, and a +# Postgres-delegated db subnet), a baseline NSG per subnet, a private DNS zone +# for Postgres Flexible Server, and a DNS zone virtual-network link. All +# resources are unconditional, so a single apply run covers the full surface. +# +# azurerm_network_security_group is mocked with a well-formed ID because the +# azurerm_subnet_network_security_group_association resource validates the NSG +# ID format; the mock provider's random token would otherwise fail that check. + +mock_provider "azurerm" { + mock_resource "azurerm_virtual_network" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hailbytes-test/providers/Microsoft.Network/virtualNetworks/mock-vnet" + } + } + + mock_resource "azurerm_subnet" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hailbytes-test/providers/Microsoft.Network/virtualNetworks/mock-vnet/subnets/mock-snet" + } + } + + mock_resource "azurerm_network_security_group" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hailbytes-test/providers/Microsoft.Network/networkSecurityGroups/mock-nsg" + } + } + + mock_resource "azurerm_private_dns_zone" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hailbytes-test/providers/Microsoft.Network/privateDnsZones/privatelink.postgres.database.azure.com" + } + } +} + +variables { + name_prefix = "hailbytes-test" + resource_group_name = "rg-hailbytes-test" + location = "eastus" +} + +run "minimal_inputs_apply" { + command = apply + + assert { + condition = output.vnet_id != "" + error_message = "vnet_id output must be non-empty" + } + + assert { + condition = output.vnet_name != "" + error_message = "vnet_name output must be non-empty" + } + + assert { + condition = output.workload_subnet_id != "" + error_message = "workload_subnet_id output must be non-empty (pass to var.subnet_id / var.vm_subnet_id on workload modules)" + } + + assert { + condition = output.lb_subnet_id != "" + error_message = "lb_subnet_id output must be non-empty" + } + + assert { + condition = output.db_delegated_subnet_id != "" + error_message = "db_delegated_subnet_id output must be non-empty (pass to var.db_delegated_subnet_id on ha-hot-hot / unlimited-scale)" + } + + assert { + condition = output.private_dns_zone_id != "" + error_message = "private_dns_zone_id output must be non-empty (pass to var.private_dns_zone_id on ha-hot-hot / unlimited-scale)" + } + + assert { + condition = output.private_dns_zone_name == "privatelink.postgres.database.azure.com" + error_message = "private_dns_zone_name must be the well-known Postgres Flexible Server FQDN" + } +} diff --git a/modules/sat-aws-single/main.tf b/modules/sat-aws-single/main.tf index 1ce4f91..13b6fc2 100644 --- a/modules/sat-aws-single/main.tf +++ b/modules/sat-aws-single/main.tf @@ -18,6 +18,7 @@ module "this" { allow_internet_ingress = var.allow_internet_ingress enable_snapshots = var.enable_snapshots marketplace_product_code = var.marketplace_product_code + enable_flow_logs = var.enable_flow_logs # Patching and migration safety create_backup_bucket = var.create_backup_bucket diff --git a/modules/sat-aws-single/outputs.tf b/modules/sat-aws-single/outputs.tf index c97f267..f8ce3f1 100644 --- a/modules/sat-aws-single/outputs.tf +++ b/modules/sat-aws-single/outputs.tf @@ -73,3 +73,9 @@ output "pre_patch_ssm_document_name" { value = module.this.pre_patch_ssm_document_name sensitive = false } + +output "flow_log_group_name" { + description = "CloudWatch log group name receiving VPC Flow Logs. Empty string when enable_flow_logs is false." + value = module.this.flow_log_group_name + sensitive = false +} diff --git a/modules/sat-aws-single/variables.tf b/modules/sat-aws-single/variables.tf index 04b6bc3..fa4e720 100644 --- a/modules/sat-aws-single/variables.tf +++ b/modules/sat-aws-single/variables.tf @@ -115,6 +115,12 @@ variable "backup_noncurrent_version_expiration_days" { default = 365 } +variable "enable_flow_logs" { + description = "Enable VPC Flow Logs for the provided VPC, sending ALL traffic to a CloudWatch log group named /aws/vpc-flow-logs/. Matches the default stated in SECURITY-DEFAULTS.md." + type = bool + default = true +} + variable "tags" { description = "Additional tags applied to every resource." type = map(string) diff --git a/modules/single-vm/aws/main.tf b/modules/single-vm/aws/main.tf index aa77b4e..46d1a5e 100644 --- a/modules/single-vm/aws/main.tf +++ b/modules/single-vm/aws/main.tf @@ -424,3 +424,70 @@ resource "aws_ssm_document" "pre_patch_backup" { ] }) } + +# ----- VPC Flow Logs ----- + +resource "aws_cloudwatch_log_group" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "/aws/vpc-flow-logs/${local.name_prefix}" + retention_in_days = 30 + kms_key_id = var.enable_customer_managed_key ? aws_kms_key.ebs[0].arn : null + tags = local.common_tags +} + +resource "aws_iam_role" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "${local.name_prefix}-flow-logs" + tags = local.common_tags + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "vpc-flow-logs.amazonaws.com" } + }] + }) +} + +# logs:DescribeLogGroups requires Resource = "*" — the AWS IAM docs explicitly +# state this action does not support resource-level restrictions. +#tfsec:ignore:aws-iam-no-policy-wildcards +resource "aws_iam_role_policy" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "${local.name_prefix}-flow-logs" + role = aws_iam_role.flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + ] + Resource = [ + aws_cloudwatch_log_group.flow_logs[0].arn, + "${aws_cloudwatch_log_group.flow_logs[0].arn}:*", + ] + }, + { + Effect = "Allow" + Action = ["logs:DescribeLogGroups"] + Resource = "*" + }, + ] + }) +} + +resource "aws_flow_log" "vpc" { + count = var.enable_flow_logs ? 1 : 0 + iam_role_arn = aws_iam_role.flow_logs[0].arn + log_destination = aws_cloudwatch_log_group.flow_logs[0].arn + log_destination_type = "cloud-watch-logs" + traffic_type = "ALL" + vpc_id = var.vpc_id + tags = local.common_tags +} diff --git a/modules/single-vm/aws/outputs.tf b/modules/single-vm/aws/outputs.tf index c19be80..8a0cee1 100644 --- a/modules/single-vm/aws/outputs.tf +++ b/modules/single-vm/aws/outputs.tf @@ -59,3 +59,8 @@ output "pre_patch_ssm_document_name" { description = "Name of the AWS Systems Manager Run Command document that triggers a pre-patch backup + EBS data-volume snapshot. Run from the Console under Systems Manager -> Run Command, targeting instances tagged hailbytes-=true." value = aws_ssm_document.pre_patch_backup.name } + +output "flow_log_group_name" { + description = "CloudWatch log group name receiving VPC Flow Logs. Empty string when enable_flow_logs is false." + value = var.enable_flow_logs ? aws_cloudwatch_log_group.flow_logs[0].name : "" +} diff --git a/modules/single-vm/aws/tests/basic.tftest.hcl b/modules/single-vm/aws/tests/basic.tftest.hcl index bda8af5..ab689d8 100644 --- a/modules/single-vm/aws/tests/basic.tftest.hcl +++ b/modules/single-vm/aws/tests/basic.tftest.hcl @@ -21,6 +21,14 @@ mock_provider "aws" { arn = "arn:aws:iam::123456789012:role/mock-role" } } + + # aws_flow_log.vpc validates that log_destination is a well-formed ARN, so the + # mocked CloudWatch log group must carry one (the random mock token isn't). + mock_resource "aws_cloudwatch_log_group" { + defaults = { + arn = "arn:aws:logs:us-east-1:123456789012:log-group:/aws/vpc-flow-logs/mock:*" + } + } } variables { diff --git a/modules/single-vm/aws/variables.tf b/modules/single-vm/aws/variables.tf index 716e3a4..ee1a8d5 100644 --- a/modules/single-vm/aws/variables.tf +++ b/modules/single-vm/aws/variables.tf @@ -128,6 +128,12 @@ variable "backup_noncurrent_version_expiration_days" { default = 365 } +variable "enable_flow_logs" { + description = "Enable VPC Flow Logs for the provided VPC, sending ALL traffic to a CloudWatch log group named /aws/vpc-flow-logs/. Matches the default stated in SECURITY-DEFAULTS.md." + type = bool + default = true +} + variable "tags" { description = "Additional tags applied to every resource." type = map(string)