From 4e06080fb79f03316cd0cc89d9b934105301873e Mon Sep 17 00:00:00 2001 From: FinnIckler Date: Fri, 1 Nov 2024 11:22:50 +0100 Subject: [PATCH] Add V2 Polling Lambda (#10123) * add api gateway * add lambda function * update terraform so we can use ruby3.3 in lambdas * add hooks to mark a registration as processing * fix typo * fix typo v2 * move registration_processing_cache_key to helper method * destructure array in before_enqueue --- app/jobs/add_registration_job.rb | 5 + app/models/registration.rb | 6 + infra/wca_on_rails/lambda/.gitignore | 4 + infra/wca_on_rails/lambda/Gemfile | 9 ++ infra/wca_on_rails/lambda/package_lambda.sh | 7 + .../wca_on_rails/lambda/processing_status.rb | 72 ++++++++++ infra/wca_on_rails/main.tf | 2 +- infra/wca_on_rails/production/lambda.tf | 127 ++++++++++++++++++ infra/wca_on_rails/production/variables.tf | 4 + infra/wca_on_rails/shared/api_gateway.tf | 11 ++ infra/wca_on_rails/staging/lambda.tf | 127 ++++++++++++++++++ infra/wca_on_rails/staging/variables.tf | 4 + lib/cache_access.rb | 4 + 13 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 infra/wca_on_rails/lambda/.gitignore create mode 100644 infra/wca_on_rails/lambda/Gemfile create mode 100644 infra/wca_on_rails/lambda/package_lambda.sh create mode 100644 infra/wca_on_rails/lambda/processing_status.rb create mode 100644 infra/wca_on_rails/production/lambda.tf create mode 100644 infra/wca_on_rails/shared/api_gateway.tf create mode 100644 infra/wca_on_rails/staging/lambda.tf diff --git a/app/jobs/add_registration_job.rb b/app/jobs/add_registration_job.rb index 16606dc017..f587d2e767 100644 --- a/app/jobs/add_registration_job.rb +++ b/app/jobs/add_registration_job.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class AddRegistrationJob < ApplicationJob + before_enqueue do |job| + _, competition_id, user_id = job.arguments + Rails.cache.write(CacheAccess.registration_processing_cache_key(competition_id, user_id), true) + end + def self.prepare_task(user_id, competition_id) message_deduplication_id = "competing-registration-#{competition_id}-#{user_id}" message_group_id = competition_id diff --git a/app/models/registration.rb b/app/models/registration.rb index 6c27d0bc90..cd7f8d971f 100644 --- a/app/models/registration.rb +++ b/app/models/registration.rb @@ -39,6 +39,12 @@ class Registration < ApplicationRecord end end + after_create :mark_registration_processing_as_done + + private def mark_registration_processing_as_done + Rails.cache.delete(CacheAccess.registration_processing_cache_key(competition_id, user_id)) + end + def guest_limit competition.guests_per_registration_limit end diff --git a/infra/wca_on_rails/lambda/.gitignore b/infra/wca_on_rails/lambda/.gitignore new file mode 100644 index 0000000000..8d984ad914 --- /dev/null +++ b/infra/wca_on_rails/lambda/.gitignore @@ -0,0 +1,4 @@ +Gemfile.lock +vendor +.bundle +processing_status.zip diff --git a/infra/wca_on_rails/lambda/Gemfile b/infra/wca_on_rails/lambda/Gemfile new file mode 100644 index 0000000000..cc7a4e2d84 --- /dev/null +++ b/infra/wca_on_rails/lambda/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +ruby '3.3.5' + +gem 'aws-sdk-sqs' +gem 'superconfig' +gem 'redis' diff --git a/infra/wca_on_rails/lambda/package_lambda.sh b/infra/wca_on_rails/lambda/package_lambda.sh new file mode 100644 index 0000000000..8db5bd1eff --- /dev/null +++ b/infra/wca_on_rails/lambda/package_lambda.sh @@ -0,0 +1,7 @@ +bundle install --path vendor/bundle +# remove old zip if it exists +rm -f processing_status.zip +zip -r processing_status.zip processing_status.rb vendor +# remove any bundler or vendor files +rm -rf .bundle +rm -rf vendor diff --git a/infra/wca_on_rails/lambda/processing_status.rb b/infra/wca_on_rails/lambda/processing_status.rb new file mode 100644 index 0000000000..f535644b73 --- /dev/null +++ b/infra/wca_on_rails/lambda/processing_status.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'json' +require 'redis' +require 'aws-sdk-sqs' +require 'superconfig' + +EnvConfig = SuperConfig.new do + mandatory :REDIS_URL, :string + mandatory :QUEUE_URL, :string + mandatory :AWS_REGION, :string +end + +RedisConn = Redis.new(url: EnvConfig.REDIS_URL) + +def lambda_handler(event:, context:) + # Parse the input event + query = event['queryStringParameters'] + if query.nil? || query['competition_id'].nil? || query['user_id'].nil? + response = { + statusCode: 400, + body: JSON.generate({ status: 'Missing fields in request' }), + headers: { + "Access-Control-Allow-Headers" => "*", + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => "OPTIONS,POST,GET", + }, + } + else + sqs_client = Aws::SQS::Client.new(region: EnvConfig.AWS_REGION) + + queue_attributes = sqs_client.get_queue_attributes({ + queue_url: EnvConfig.QUEUE_URL, + attribute_names: ['ApproximateNumberOfMessages'], + }) + message_count = queue_attributes.attributes['ApproximateNumberOfMessages'].to_i + + processing = RedisConn.get("#{query['competition_id']}-#{query['user_id']}-processing") + + response = { + statusCode: 200, + body: JSON.generate({ processing: !processing.nil?, queue_count: message_count }), + headers: { + "Access-Control-Allow-Headers" => "*", + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => "OPTIONS,POST,GET", + }, + } + end + + # Return the response + { + statusCode: response[:statusCode], + body: response[:body], + headers: { + "Access-Control-Allow-Headers" => "*", + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => "OPTIONS,POST,GET", + }, + } +rescue StandardError => e + # Handle any errors + { + statusCode: 500, + body: JSON.generate({ error: e.message }), + headers: { + "Access-Control-Allow-Headers" => "*", + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Methods" => "OPTIONS,POST,GET", + }, + } +end diff --git a/infra/wca_on_rails/main.tf b/infra/wca_on_rails/main.tf index 96d27fe488..5837afabf6 100644 --- a/infra/wca_on_rails/main.tf +++ b/infra/wca_on_rails/main.tf @@ -6,7 +6,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.0" + version = ">= 5.72.1" } } diff --git a/infra/wca_on_rails/production/lambda.tf b/infra/wca_on_rails/production/lambda.tf new file mode 100644 index 0000000000..0791cc5248 --- /dev/null +++ b/infra/wca_on_rails/production/lambda.tf @@ -0,0 +1,127 @@ +resource "aws_lambda_function" "registration_status_lambda" { + filename = "./lambda/processing_status.zip" + function_name = "${var.name_prefix}-poller-lambda" + role = aws_iam_role.lambda_role.arn + handler = "processing_status.lambda_handler" + runtime = "ruby3.3" + source_code_hash = filebase64sha256("./lambda/processing_status.zip") + vpc_config { + security_group_ids = [var.shared.cluster_security.id] + subnet_ids = var.shared.private_subnets[*].id + } + timeout = 10 + environment { + variables = { + REDIS_URL = "redis://wca-main-cache.iebvzt.ng.0001.usw2.cache.amazonaws.com:6379" + QUEUE_URL = aws_sqs_queue.this.url + } + } +} + +resource "aws_iam_role" "lambda_role" { + name = "${var.name_prefix}-lambda-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_exec_policy_attach" { + role = aws_iam_role.lambda_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_lambda_permission" "this" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.registration_status_lambda.function_name + principal = "apigateway.amazonaws.com" +} + +data "aws_iam_policy_document" "lambda_policy" { + statement { + effect = "Allow" + actions = [ + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ] + resources = [aws_sqs_queue.this.arn] + } +} + +resource "aws_iam_role_policy" "lambda_policy_attachment" { + role = aws_iam_role.lambda_role.name + policy = data.aws_iam_policy_document.lambda_policy.json +} + +resource "aws_api_gateway_resource" "prod" { + rest_api_id = var.shared.api_gateway.id + parent_id = var.shared.api_gateway.root_resource_id + path_part = "prod" +} + +resource "aws_api_gateway_method" "poll_registration_status_method" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.prod.id + http_method = "GET" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "poll_registration_integration" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.prod.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.registration_status_lambda.invoke_arn +} + +resource "aws_api_gateway_method_response" "registration_status_method" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.prod.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + status_code = "200" + + response_parameters = { + "method.response.header.Content-Type" = true + "method.response.header.Access-Control-Allow-Origin" = false + } +} + +resource "aws_api_gateway_integration_response" "registration_status_integration_response" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.prod.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + status_code = aws_api_gateway_method_response.registration_status_method.status_code + + response_templates = { + "application/json" = jsonencode({ + processing = "Processing Status" + queue_count = "Queue Count" + }) + } + + response_parameters = { + "method.response.header.Access-Control-Allow-Origin" = "'*'" + } + + depends_on = [aws_api_gateway_resource.prod, aws_api_gateway_method.poll_registration_status_method, aws_api_gateway_method_response.registration_status_method, aws_api_gateway_integration.poll_registration_integration] +} +resource "aws_api_gateway_deployment" "this" { + rest_api_id = var.shared.api_gateway.id + + lifecycle { + create_before_destroy = true + } + depends_on = [aws_api_gateway_method.poll_registration_status_method, aws_api_gateway_integration.poll_registration_integration] +} diff --git a/infra/wca_on_rails/production/variables.tf b/infra/wca_on_rails/production/variables.tf index 7d95ddf978..f95ab8206b 100644 --- a/infra/wca_on_rails/production/variables.tf +++ b/infra/wca_on_rails/production/variables.tf @@ -101,6 +101,10 @@ variable "shared" { pma_production: object({ arn: string }) + api_gateway: object({ + id: string, + root_resource_id: string + }) account_id: string # These are booth arrays private_subnets: any diff --git a/infra/wca_on_rails/shared/api_gateway.tf b/infra/wca_on_rails/shared/api_gateway.tf new file mode 100644 index 0000000000..f52599fb47 --- /dev/null +++ b/infra/wca_on_rails/shared/api_gateway.tf @@ -0,0 +1,11 @@ +resource "aws_api_gateway_rest_api" "this" { + name = "wca-monolith-pollingco-api" + description = "The API to Poll for updates" + endpoint_configuration { + types = ["REGIONAL"] + } +} + +output "api_gateway" { + value = aws_api_gateway_rest_api.this +} diff --git a/infra/wca_on_rails/staging/lambda.tf b/infra/wca_on_rails/staging/lambda.tf new file mode 100644 index 0000000000..77c051973f --- /dev/null +++ b/infra/wca_on_rails/staging/lambda.tf @@ -0,0 +1,127 @@ +resource "aws_lambda_function" "registration_status_lambda" { + filename = "./lambda/processing_status.zip" + function_name = "${var.name_prefix}-poller-lambda" + role = aws_iam_role.lambda_role.arn + handler = "processing_status.lambda_handler" + runtime = "ruby3.3" + source_code_hash = filebase64sha256("./lambda/processing_status.zip") + vpc_config { + security_group_ids = [var.shared.cluster_security.id] + subnet_ids = var.shared.private_subnets[*].id + } + timeout = 10 + environment { + variables = { + REDIS_URL = "redis://redis-main-staging-001.iebvzt.0001.usw2.cache.amazonaws.com:6379" + QUEUE_URL = aws_sqs_queue.this.url + } + } +} + +resource "aws_iam_role" "lambda_role" { + name = "${var.name_prefix}-lambda-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_exec_policy_attach" { + role = aws_iam_role.lambda_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_lambda_permission" "this" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.registration_status_lambda.function_name + principal = "apigateway.amazonaws.com" +} + +data "aws_iam_policy_document" "lambda_policy" { + statement { + effect = "Allow" + actions = [ + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ] + resources = [aws_sqs_queue.this.arn] + } +} + +resource "aws_iam_role_policy" "lambda_policy_attachment" { + role = aws_iam_role.lambda_role.name + policy = data.aws_iam_policy_document.lambda_policy.json +} + +resource "aws_api_gateway_resource" "staging" { + rest_api_id = var.shared.api_gateway.id + parent_id = var.shared.api_gateway.root_resource_id + path_part = "staging" +} + +resource "aws_api_gateway_method" "poll_registration_status_method" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.staging.id + http_method = "GET" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "poll_registration_integration" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.staging.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.registration_status_lambda.invoke_arn +} + +resource "aws_api_gateway_method_response" "registration_status_method" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.staging.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + status_code = "200" + + response_parameters = { + "method.response.header.Content-Type" = true + "method.response.header.Access-Control-Allow-Origin" = false + } +} + +resource "aws_api_gateway_integration_response" "registration_status_integration_response" { + rest_api_id = var.shared.api_gateway.id + resource_id = aws_api_gateway_resource.staging.id + http_method = aws_api_gateway_method.poll_registration_status_method.http_method + status_code = aws_api_gateway_method_response.registration_status_method.status_code + + response_templates = { + "application/json" = jsonencode({ + processing = "Processing Status" + queue_count = "Queue Count" + }) + } + + response_parameters = { + "method.response.header.Access-Control-Allow-Origin" = "'*'" + } + + depends_on = [aws_api_gateway_resource.staging, aws_api_gateway_method.poll_registration_status_method, aws_api_gateway_method_response.registration_status_method, aws_api_gateway_integration.poll_registration_integration] +} +resource "aws_api_gateway_deployment" "this" { + rest_api_id = var.shared.api_gateway.id + + lifecycle { + create_before_destroy = true + } + depends_on = [aws_api_gateway_method.poll_registration_status_method, aws_api_gateway_integration.poll_registration_integration] +} diff --git a/infra/wca_on_rails/staging/variables.tf b/infra/wca_on_rails/staging/variables.tf index d72989845c..cbd7d3a951 100644 --- a/infra/wca_on_rails/staging/variables.tf +++ b/infra/wca_on_rails/staging/variables.tf @@ -94,6 +94,10 @@ variable "shared" { mailcatcher: object({ arn: string }) + api_gateway: object({ + id: string, + root_resource_id: string + }) account_id: string private_subnets: any }) diff --git a/lib/cache_access.rb b/lib/cache_access.rb index 6093d91433..d0bd3bb6cb 100644 --- a/lib/cache_access.rb +++ b/lib/cache_access.rb @@ -34,6 +34,10 @@ def self.hydrate_entities(key_prefix, ids, expires_in: EXPIRATION_DEFAULT, &hydr end end + def self.registration_processing_cache_key(competition_id, user_id) + "#{competition_id}-#{user_id}-processing" + end + private_class_method def self.hydration_key(prefix, id) "#{prefix}-#{id}" end