# Terraform owns the function skeletons (role, runtime, memory, environment). # Code is owned by .github/workflows/deploy.yaml, which uploads # functions//.zip and calls update-function-code. The lifecycle # block is the seam: an app deploy is not drift, and a Terraform apply never # rolls the code back to the bootstrap stub. data "aws_iam_policy_document" "lambda_assume" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } } } locals { lambda_identity = { classifier = [ { sid = "ReadRaw" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.exports.arn}/raw/*"] }, { sid = "WriteAnalytics" actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload"] resources = ["${aws_s3_bucket.exports.arn}/analytics/*"] }, { sid = "WriteMeta" actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] resources = ["${aws_s3_bucket.exports.arn}/meta/*"] }, { sid = "ListExports" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.exports.arn] }, { sid = "AnthropicSecret" actions = ["secretsmanager:GetSecretValue"] resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:apm-wo-analysis/anthropic-api-key*"] }, { sid = "InvokeSlackPost" actions = ["lambda:InvokeFunction"] resources = ["arn:aws:lambda:${var.aws_region}:${local.account_id}:function:apm-wo-analysis-slack-post"] }, { sid = "DlqSend" actions = ["sqs:SendMessage"] resources = [aws_sqs_queue.classifier_dlq.arn] }, ] slack_post = [ { sid = "ReadMeta" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.exports.arn}/meta/*"] }, { sid = "ListExports" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.exports.arn] }, { sid = "SlackSecret" actions = ["secretsmanager:GetSecretValue"] resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:apm-wo-analysis/slack-credentials*"] }, { sid = "DashboardParam" actions = ["ssm:GetParameter"] resources = ["arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.grafana_url_param}"] }, ] slack_interactions = [ { sid = "ReadMeta" actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.exports.arn}/meta/*"] }, { sid = "ListExports" actions = ["s3:ListBucket"] resources = [aws_s3_bucket.exports.arn] }, { sid = "SlackSecret" actions = ["secretsmanager:GetSecretValue"] resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:apm-wo-analysis/slack-credentials*"] }, { sid = "DashboardParam" actions = ["ssm:GetParameter"] resources = ["arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.grafana_url_param}"] }, ] } lambda_env = { classifier = { APM_HAIKU_FALLBACK = "on" SLACK_POST_FUNCTION_NAME = local.functions.slack_post.function_name } slack_post = { SLACK_SECRET_NAME = "apm-wo-analysis/slack-credentials" DASHBOARD_URL_PARAM = local.grafana_url_param ANALYTICS_BUCKET = aws_s3_bucket.exports.bucket } slack_interactions = { SLACK_SECRET_NAME = "apm-wo-analysis/slack-credentials" DASHBOARD_URL_PARAM = local.grafana_url_param ANALYTICS_BUCKET = aws_s3_bucket.exports.bucket } } } resource "aws_iam_role" "lambda" { for_each = local.functions name = each.value.role_name path = "/tf-managed/" description = "Lambda execution role for ${each.value.function_name}" assume_role_policy = data.aws_iam_policy_document.lambda_assume.json permissions_boundary = aws_iam_policy.exec_boundary.arn } data "aws_iam_policy_document" "lambda" { for_each = local.functions dynamic "statement" { for_each = local.lambda_identity[each.key] content { sid = statement.value.sid effect = "Allow" actions = statement.value.actions resources = statement.value.resources } } } resource "aws_iam_role_policy" "lambda" { for_each = local.functions name = each.key role = aws_iam_role.lambda[each.key].id policy = data.aws_iam_policy_document.lambda[each.key].json } resource "aws_iam_role_policy_attachment" "lambda_basic" { for_each = local.functions role = aws_iam_role.lambda[each.key].name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } resource "aws_lambda_function" "this" { for_each = local.functions function_name = each.value.function_name role = aws_iam_role.lambda[each.key].arn handler = each.value.handler runtime = "python3.12" architectures = ["arm64"] memory_size = each.value.memory_size timeout = each.value.timeout layers = each.value.layers s3_bucket = aws_s3_bucket.artifacts.id s3_key = aws_s3_object.bootstrap_stub.key source_code_hash = data.archive_file.bootstrap_stub.output_base64sha256 environment { variables = local.lambda_env[each.key] } dynamic "dead_letter_config" { for_each = each.key == "classifier" ? [aws_sqs_queue.classifier_dlq.arn] : [] content { target_arn = dead_letter_config.value } } lifecycle { ignore_changes = [filename, s3_bucket, s3_key, s3_object_version, source_code_hash] } depends_on = [ aws_cloudwatch_log_group.lambda, aws_iam_role_policy.lambda, aws_iam_role_policy_attachment.lambda_basic, ] } resource "aws_lambda_permission" "s3_classifier" { statement_id = "AllowS3InvokeClassifier" action = "lambda:InvokeFunction" function_name = aws_lambda_function.this["classifier"].function_name principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.exports.arn } resource "aws_s3_bucket_notification" "raw_exports" { bucket = aws_s3_bucket.exports.id lambda_function { lambda_function_arn = aws_lambda_function.this["classifier"].arn events = ["s3:ObjectCreated:*"] filter_prefix = "raw/" filter_suffix = ".xlsx" } lambda_function { lambda_function_arn = aws_lambda_function.this["classifier"].arn events = ["s3:ObjectCreated:*"] filter_prefix = "raw/" filter_suffix = ".csv" } depends_on = [aws_lambda_permission.s3_classifier] }