From 851823140016513878b18733e615f78340fc0e35 Mon Sep 17 00:00:00 2001 From: Agustin Celentano <12614595+agustincelentano@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:24:58 -0300 Subject: [PATCH 1/3] feat(lambda): optional public ALB in requirements module Add an opt-in public ALB (install_alb, default false) to the Lambda requirements module: ALB + HTTPS(443)/HTTP(80->redirect) listeners + SG, so stacks can expose Lambda over HTTP without hand-copying the ALB per cluster (previously done inline in galicia). The Lambda scope workflow attaches per-scope target groups + listener rules to the HTTPS listener at runtime. - cert: created as a DNS-validated wildcard from domain_name + public_zone_id, or reused via certificate_arn (avoids duplicating the static-files wildcard) - subnets: discovered by nullplatform/subnet-type=public tag, or public_subnet_ids - outputs: lambda_alb_arn / lambda_alb_listener_arn / lambda_alb_dns_name - default OFF keeps IAM-only consumers unaffected --- lambda/specs/requirements/alb.tf | 176 +++++++++++++++++++++++++ lambda/specs/requirements/outputs.tf | 15 +++ lambda/specs/requirements/variables.tf | 44 +++++++ 3 files changed, 235 insertions(+) create mode 100644 lambda/specs/requirements/alb.tf diff --git a/lambda/specs/requirements/alb.tf b/lambda/specs/requirements/alb.tf new file mode 100644 index 0000000..c317be3 --- /dev/null +++ b/lambda/specs/requirements/alb.tf @@ -0,0 +1,176 @@ +############################################################################### +# Optional public ALB for AWS Lambda HTTP exposure. +# +# Off by default (var.install_alb = false) so stacks that only need the Lambda +# IAM permissions role are unaffected. When enabled, this creates a dedicated +# public ALB + HTTPS(443)/HTTP(80->redirect) listeners + security group. The +# Lambda scope workflow registers per-scope target groups and listener rules on +# this listener at runtime (host-header routing), reading the listener ARN from +# the aws-networking-configuration provider. +# +# Certificate: created here (wildcard, DNS-validated via var.public_zone_id) +# unless var.certificate_arn is supplied — pass an existing wildcard ARN to +# reuse the cert already used by static-files instead of minting a second one. +# +# Subnets: discovered by the `nullplatform/subnet-type=public` tag on var.vpc_id, +# or taken from var.public_subnet_ids when provided (clusters without the tag). +############################################################################### + +locals { + alb_create = var.install_alb + create_cert = var.install_alb && var.certificate_arn == "" + + alb_name = var.alb_name != "" ? var.alb_name : "np-${var.cluster_name}-lambda" + + alb_subnet_ids = length(var.public_subnet_ids) > 0 ? var.public_subnet_ids : ( + local.alb_create ? data.aws_subnets.public[0].ids : [] + ) + + alb_certificate_arn = var.certificate_arn != "" ? var.certificate_arn : ( + local.create_cert ? aws_acm_certificate_validation.lambda_wildcard[0].certificate_arn : "" + ) + + alb_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "nullplatform-custom-scope-role" + Module = local.iam_module_name + Purpose = "lambda-public-alb" + }) +} + +data "aws_subnets" "public" { + count = local.alb_create && length(var.public_subnet_ids) == 0 ? 1 : 0 + + filter { + name = "vpc-id" + values = [var.vpc_id] + } + filter { + name = "tag:nullplatform/subnet-type" + values = ["public"] + } +} + +# --- Wildcard certificate (only when no certificate_arn is provided) --------- +resource "aws_acm_certificate" "lambda_wildcard" { + count = local.create_cert ? 1 : 0 + + domain_name = "*.${var.domain_name}" + subject_alternative_names = [var.domain_name] + validation_method = "DNS" + + tags = local.alb_tags + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "lambda_wildcard_validation" { + count = local.create_cert ? 1 : 0 + + zone_id = var.public_zone_id + name = tolist(aws_acm_certificate.lambda_wildcard[0].domain_validation_options)[0].resource_record_name + type = tolist(aws_acm_certificate.lambda_wildcard[0].domain_validation_options)[0].resource_record_type + records = [tolist(aws_acm_certificate.lambda_wildcard[0].domain_validation_options)[0].resource_record_value] + ttl = 60 + allow_overwrite = true +} + +resource "aws_acm_certificate_validation" "lambda_wildcard" { + count = local.create_cert ? 1 : 0 + + certificate_arn = aws_acm_certificate.lambda_wildcard[0].arn + validation_record_fqdns = [aws_route53_record.lambda_wildcard_validation[0].fqdn] +} + +# --- Security group ---------------------------------------------------------- +resource "aws_security_group" "lambda_alb_public" { + count = local.alb_create ? 1 : 0 + + name = "${local.alb_name}-alb-public" + description = "Allow HTTPS + HTTP (redirected) from the internet to the public Lambda ALB." + vpc_id = var.vpc_id + + ingress { + description = "HTTPS from internet" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP from internet (redirected to HTTPS)" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All egress (ALB to Lambda invoke)" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = local.alb_tags +} + +# --- ALB + listeners --------------------------------------------------------- +resource "aws_lb" "lambda_public" { + count = local.alb_create ? 1 : 0 + + name = local.alb_name + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.lambda_alb_public[0].id] + subnets = local.alb_subnet_ids + enable_deletion_protection = false + + tags = local.alb_tags +} + +# HTTPS:443 — TLS terminated with the wildcard cert. Default action is a fixed +# 404; per-scope host-header rules are added at runtime by the Lambda workflow. +resource "aws_lb_listener" "lambda_public_https" { + count = local.alb_create ? 1 : 0 + + load_balancer_arn = aws_lb.lambda_public[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = local.alb_certificate_arn + + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "404 - no scope matches this host" + status_code = "404" + } + } + + tags = local.alb_tags +} + +# HTTP:80 -> redirect to HTTPS:443. +resource "aws_lb_listener" "lambda_public_http" { + count = local.alb_create ? 1 : 0 + + load_balancer_arn = aws_lb.lambda_public[0].arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + tags = local.alb_tags +} diff --git a/lambda/specs/requirements/outputs.tf b/lambda/specs/requirements/outputs.tf index 34b64cf..a54eeb8 100644 --- a/lambda/specs/requirements/outputs.tf +++ b/lambda/specs/requirements/outputs.tf @@ -17,3 +17,18 @@ output "placeholder_image_repository_url" { description = "URL of the private ECR repository holding the Lambda placeholder image. Consumed by the agent via PLACEHOLDER_IMAGE_URI_DEFAULT." value = local.iam_create ? aws_ecr_repository.lambda_placeholder[0].repository_url : "" } + +output "lambda_alb_arn" { + description = "ARN of the public Lambda ALB (empty when install_alb = false)." + value = local.alb_create ? aws_lb.lambda_public[0].arn : "" +} + +output "lambda_alb_listener_arn" { + description = "ARN of the HTTPS:443 listener on the Lambda ALB. Publish to the aws-networking-configuration provider (load_balancer.public.listener_arn) so the Lambda scope workflow can attach per-scope rules. Empty when install_alb = false." + value = local.alb_create ? aws_lb_listener.lambda_public_https[0].arn : "" +} + +output "lambda_alb_dns_name" { + description = "DNS name of the public Lambda ALB (empty when install_alb = false)." + value = local.alb_create ? aws_lb.lambda_public[0].dns_name : "" +} diff --git a/lambda/specs/requirements/variables.tf b/lambda/specs/requirements/variables.tf index 88f4971..7868b79 100644 --- a/lambda/specs/requirements/variables.tf +++ b/lambda/specs/requirements/variables.tf @@ -60,3 +60,47 @@ variable "assets_bucket_name" { type = string default = "lambda-files-aws-services" } + +# --- Optional public ALB for Lambda HTTP exposure --------------------------- + +variable "install_alb" { + description = "When true, create a dedicated public ALB (+ HTTPS/HTTP listeners + SG) for exposing Lambda functions over HTTP. Off by default so IAM-only consumers are unaffected." + type = bool + default = false +} + +variable "vpc_id" { + description = "VPC where the Lambda ALB is created. Required when install_alb = true." + type = string + default = "" +} + +variable "public_subnet_ids" { + description = "Public subnet IDs for the ALB. When empty and install_alb = true, subnets are discovered by the nullplatform/subnet-type=public tag on vpc_id." + type = list(string) + default = [] +} + +variable "public_zone_id" { + description = "Route53 public hosted zone ID used to DNS-validate the wildcard certificate. Required when install_alb = true and certificate_arn is empty." + type = string + default = "" +} + +variable "domain_name" { + description = "Base domain for the wildcard certificate (*.). Required when install_alb = true and certificate_arn is empty." + type = string + default = "" +} + +variable "certificate_arn" { + description = "Existing ACM wildcard certificate ARN to reuse for the ALB HTTPS listener. When empty and install_alb = true, a wildcard cert is created from domain_name + public_zone_id." + type = string + default = "" +} + +variable "alb_name" { + description = "Override for the ALB name (max 32 chars). Defaults to np-{cluster_name}-lambda." + type = string + default = "" +} From 1f823fcdcd18029da53fbd81e335c9429edda0c0 Mon Sep 17 00:00:00 2001 From: Agustin Celentano <12614595+agustincelentano@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:10:22 -0300 Subject: [PATCH 2/3] fix(lambda): grant elasticloadbalancing:Describe* on the permissions role The ALB networking override does data.aws_lb_listener / data.aws_lb lookups (and the provider reads tags on refresh), needing read actions missing from the enumerated list (DescribeTags, DescribeLoadBalancers, DescribeLoadBalancerAttributes). Replace the enumerated Describe* reads with elasticloadbalancing:Describe* to cover them all; mutating actions stay enumerated. --- lambda/specs/requirements/main.tf | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lambda/specs/requirements/main.tf b/lambda/specs/requirements/main.tf index 286f13a..1ea3e2e 100644 --- a/lambda/specs/requirements/main.tf +++ b/lambda/specs/requirements/main.tf @@ -150,20 +150,20 @@ resource "aws_iam_policy" "nullplatform_lambda_networking_policy" { { Effect = "Allow" Action = [ + # All read actions (Describe*) so the provider's data lookups + # (aws_lb_listener / aws_lb + tags on refresh) succeed without a + # missing-permission whack-a-mole (e.g. DescribeTags, + # DescribeLoadBalancers). Mutating actions stay enumerated. + "elasticloadbalancing:Describe*", "elasticloadbalancing:CreateTargetGroup", "elasticloadbalancing:DeleteTargetGroup", "elasticloadbalancing:ModifyTargetGroup", "elasticloadbalancing:ModifyTargetGroupAttributes", - "elasticloadbalancing:DescribeTargetGroups", - "elasticloadbalancing:DescribeTargetGroupAttributes", "elasticloadbalancing:RegisterTargets", "elasticloadbalancing:DeregisterTargets", - "elasticloadbalancing:DescribeTargetHealth", "elasticloadbalancing:CreateRule", "elasticloadbalancing:DeleteRule", "elasticloadbalancing:ModifyRule", - "elasticloadbalancing:DescribeRules", - "elasticloadbalancing:DescribeListeners", "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags", ] From 07ffd48c0cde066b9de6bebc779b12442187fb82 Mon Sep 17 00:00:00 2001 From: Agustin Celentano <12614595+agustincelentano@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:20:15 -0300 Subject: [PATCH 3/3] fix(lambda): add lambda:GetPolicy, route53:GetChange, tag:GetResources Reach parity with the galicia agent lambda policy set: the ALB networking override reads the lambda permission (lambda:GetPolicy), waits on the Route53 change (route53:GetChange), and the agent scripts discover resources by tag (tag:GetResources). These were the only actions galicia granted that the requirements role was still missing. --- lambda/specs/requirements/main.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lambda/specs/requirements/main.tf b/lambda/specs/requirements/main.tf index 1ea3e2e..e9971bf 100644 --- a/lambda/specs/requirements/main.tf +++ b/lambda/specs/requirements/main.tf @@ -71,9 +71,11 @@ resource "aws_iam_policy" "nullplatform_lambda_policy" { "lambda:GetAccountSettings", "lambda:AddPermission", "lambda:RemovePermission", + "lambda:GetPolicy", "lambda:TagResource", "lambda:UntagResource", "lambda:ListTags", + "tag:GetResources", ] Resource = "*" } @@ -176,6 +178,7 @@ resource "aws_iam_policy" "nullplatform_lambda_networking_policy" { "route53:GetHostedZone", "route53:ListResourceRecordSets", "route53:ListHostedZones", + "route53:GetChange", ] Resource = "*" }