From 39650fa1ce3224b5750e6fdd930972aaeeb7f8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristopher=20Pinz=C3=B3n?= <18080804+pinzon@users.noreply.github.com> Date: Fri, 29 Nov 2024 11:13:11 -0500 Subject: [PATCH] Implement CDK deployment (#18) * implement backent into cdk * Initial app deploys * Set up top level package.json for cdk/cdklocal * Move cdk into subdir * Subscribe scoring function to submission queue * Add APIGW * Format file * Set up sqs queue and dlq * Set up verified email and sns subscription * Implement pipe for sqs -> sns * Include step function * Start to build website deploy custom resource * Implement permissions from manual JSON files * Add deploy cdk script * Add CORS handling to lambdas * Add cors handling to rest api * Fix bugs after deployment * Replace previous policy json files They are used for the integration tests * Perform first build if doesn't exist * Use CDK_CMD in deploy script * Update README with CDK instructions * Add AWS deployment instructions --------- Co-authored-by: Simon Walker Co-authored-by: Simon Walker --- .gitignore | 1 + README.md | 17 ++ bin/deploy_cdk.sh | 36 ++++ cdk/.gitignore | 2 + cdk/app.py | 31 ++++ cdk/cdk.json | 66 +++++++ cdk/package-lock.json | 67 +++++++ cdk/package.json | 17 ++ cdk/quiz_app/__init__.py | 0 cdk/quiz_app/frontend_stack.py | 58 ++++++ cdk/quiz_app/quiz_app_stack.py | 281 +++++++++++++++++++++++++++++ cdk/requirements-dev.txt | 1 + cdk/requirements.txt | 2 + cdk/source.bat | 13 ++ cdk/website_deploy/index.js | 3 + lambdas/create_quiz/handler.py | 16 ++ lambdas/get_leaderboard/handler.py | 12 ++ lambdas/get_quiz/handler.py | 12 ++ lambdas/get_submission/handler.py | 18 +- lambdas/list_quizzes/handler.py | 8 + lambdas/submit_quiz/handler.py | 20 ++ tests/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_quiz_app_stack.py | 15 ++ 24 files changed, 693 insertions(+), 3 deletions(-) create mode 100755 bin/deploy_cdk.sh create mode 100644 cdk/.gitignore create mode 100644 cdk/app.py create mode 100644 cdk/cdk.json create mode 100644 cdk/package-lock.json create mode 100644 cdk/package.json create mode 100644 cdk/quiz_app/__init__.py create mode 100644 cdk/quiz_app/frontend_stack.py create mode 100644 cdk/quiz_app/quiz_app_stack.py create mode 100644 cdk/requirements-dev.txt create mode 100644 cdk/requirements.txt create mode 100644 cdk/source.bat create mode 100644 cdk/website_deploy/index.js create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_quiz_app_stack.py diff --git a/.gitignore b/.gitignore index cd920f5..528dfd9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__ # LocalStack volume/ +.idea diff --git a/README.md b/README.md index 72bc32e..c7d653c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ EXTRA_CORS_ALLOWED_ORIGINS='*' DISABLE_CUSTOM_CORS_APIGATEWAY=1 DISABLE_CUSTOM_C If you run into specific CORS issues, disable it using a [browser extension](https://webextension.org/listing/access-control.html). ## Local Deployment +### awslocal To deploy the app locally, run the following command: @@ -61,6 +62,22 @@ API Gateway Endpoint: http://localhost:4566/_aws/execute-api/4xu5emxibf/test Navigate to the CloudFront URL to check out the app. The script would also seed some quiz data and user data to make local testing easier. +### cdk + +To deploy the application to AWS, ensure your account is bootstraped via `cdk bootstrap` and then run + +```bash +AWS_CMD=aws CDK_CMD=cdk bash ./bin/deploy_cdk.sh +``` + +### cdklocal + +Alternatively the application can be deployed to LocalStack via `cdklocal`, our wrapper around the AWS CDK. Perform the following steps: +1. Bootstrap LocalStack: `cd cdk && cdklocal bootstrap` +2. Deploy the application: `AWS_CMD=awslocal CDK_CMD=cdklocal bash ./bin/deploy_cdk.sh` + +_Note: while the core quiz application works with CDK, additional features have not been implemented yet._ + ## Local Testing To run an automated test suite against the local deployment, run the following command: diff --git a/bin/deploy_cdk.sh b/bin/deploy_cdk.sh new file mode 100755 index 0000000..ef51ebe --- /dev/null +++ b/bin/deploy_cdk.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +AWS_CMD=${AWS_CMD:-aws} +CDK_CMD=${CDK_CMD:-cdk} + +# stub build the frontend code since the CDK stack needs this code to +# synthesise the FrontendStack, but we don't yet know the backend URL to inject +# into the static HTML +if [ ! -d frontend/build ]; then + (cd frontend + echo "REACT_APP_API_ENDPOINT=https://example.com" > .env.local + npx react-scripts build + ) +fi + +# deploy bulk of the application +(cd cdk +npm run ${CDK_CMD} -- deploy --require-approval never QuizAppStack +) + +# get the backend API url +API_URL=$($AWS_CMD cloudformation describe-stacks --stack-name QuizAppStack --query Stacks[0].Outputs[0].OutputValue --output text) +echo "Backend API URL: $API_URL" + +# build the frontend code +(cd frontend +echo "REACT_APP_API_ENDPOINT=$API_URL" > .env.local +npx react-scripts build +) + +# deploy the frontend stack +(cd cdk +npm run ${CDK_CMD} -- deploy --require-approval never FrontendStack +) diff --git a/cdk/.gitignore b/cdk/.gitignore new file mode 100644 index 0000000..f55fdc6 --- /dev/null +++ b/cdk/.gitignore @@ -0,0 +1,2 @@ +node_modules +cdk.out diff --git a/cdk/app.py b/cdk/app.py new file mode 100644 index 0000000..3324028 --- /dev/null +++ b/cdk/app.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import os + +import aws_cdk as cdk + +from quiz_app.frontend_stack import FrontendStack +from quiz_app.quiz_app_stack import QuizAppStack + + +app = cdk.App() +QuizAppStack(app, "QuizAppStack", + # If you don't specify 'env', this stack will be environment-agnostic. + # Account/Region-dependent features and context lookups will not work, + # but a single synthesized template can be deployed anywhere. + + # Uncomment the next line to specialize this stack for the AWS Account + # and Region that are implied by the current CLI configuration. + + #env=cdk.Environment(account=os.getenv('CDK_DEFAULT_ACCOUNT'), region=os.getenv('CDK_DEFAULT_REGION')), + + # Uncomment the next line if you know exactly what Account and Region you + # want to deploy the stack to. */ + + #env=cdk.Environment(account='123456789012', region='us-east-1'), + + # For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html + ) + +FrontendStack(app, "FrontendStack") + +app.synth() diff --git a/cdk/cdk.json b/cdk/cdk.json new file mode 100644 index 0000000..9aac610 --- /dev/null +++ b/cdk/cdk.json @@ -0,0 +1,66 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true + } +} diff --git a/cdk/package-lock.json b/cdk/package-lock.json new file mode 100644 index 0000000..c95e121 --- /dev/null +++ b/cdk/package-lock.json @@ -0,0 +1,67 @@ +{ + "name": "serverless-quiz-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "serverless-quiz-app", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "aws-cdk": "^2.171.0", + "aws-cdk-local": "^2.19.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.171.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.171.0.tgz", + "integrity": "sha512-tVo4hYS0iAbiCFxUh2/7KoDL6EHEIUAurCJaBs2BOUAB9DfBuUAPp8DGUK4iVF8XzrQQ4f3a5ivN7DteQrGBEQ==", + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 14.15.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/aws-cdk-local": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/aws-cdk-local/-/aws-cdk-local-2.19.0.tgz", + "integrity": "sha512-R94hygViohNw6+yPc1dfPhBaU8AGuDMXedelEWETjXuekfUcrlXlF1c2UtEcS+pO6ulHV3wID0ZFwdJj22rxcA==", + "license": "Apache-2.0", + "dependencies": { + "diff": "^5.0.0" + }, + "bin": { + "cdklocal": "bin/cdklocal" + } + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/cdk/package.json b/cdk/package.json new file mode 100644 index 0000000..362a232 --- /dev/null +++ b/cdk/package.json @@ -0,0 +1,17 @@ +{ + "name": "serverless-quiz-app", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "cdk": "cdk", + "cdklocal": "cdklocal" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "aws-cdk": "^2.171.0", + "aws-cdk-local": "^2.19.0" + } +} diff --git a/cdk/quiz_app/__init__.py b/cdk/quiz_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cdk/quiz_app/frontend_stack.py b/cdk/quiz_app/frontend_stack.py new file mode 100644 index 0000000..139a78a --- /dev/null +++ b/cdk/quiz_app/frontend_stack.py @@ -0,0 +1,58 @@ +import os + +import aws_cdk +from aws_cdk import ( + Stack, + aws_s3 as s3, + aws_cloudfront as cf, + aws_cloudfront_origins as origins, + aws_s3_deployment as s3deploy, + CfnOutput, +) +from constructs import Construct + + +class FrontendStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + webapp_bucket = s3.Bucket( + self, + "WebAppBucket", + auto_delete_objects=True, + removal_policy=aws_cdk.RemovalPolicy.DESTROY, + ) + origin_access_identity = cf.OriginAccessIdentity(self, "OriginAccessIdentity") + webapp_bucket.grant_read(origin_access_identity) + + # deploy process + distribution = cf.Distribution( + self, + "FrontendDistribution", + default_root_object="index.html", + default_behavior=cf.BehaviorOptions( + origin=origins.S3Origin( + webapp_bucket, + origin_access_identity=origin_access_identity, + ), + viewer_protocol_policy=cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + ), + ) + + s3deploy.BucketDeployment( + self, + "DeployApp", + sources=[ + s3deploy.Source.asset( + os.path.join( + os.path.dirname(__file__), "..", "..", "frontend", "build" + ) + ), + ], + destination_bucket=webapp_bucket, + distribution=distribution, + distribution_paths=["/*"], + ) + + CfnOutput(self, "DistributionDomainName", value=distribution.domain_name) + diff --git a/cdk/quiz_app/quiz_app_stack.py b/cdk/quiz_app/quiz_app_stack.py new file mode 100644 index 0000000..8a88112 --- /dev/null +++ b/cdk/quiz_app/quiz_app_stack.py @@ -0,0 +1,281 @@ +import aws_cdk +from aws_cdk import ( + Stack, + aws_apigateway as apigateway, + aws_dynamodb as dynamodb, + aws_iam as iam, + aws_lambda as _lambda, + aws_sns as sns, + aws_stepfunctions as sfn, + aws_pipes as pipes, + aws_sqs as sqs, + custom_resources as cr, +) +from constructs import Construct + + +class QuizAppStack(Stack): + backend_api_url: str + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # TABLES + quizzes_table = dynamodb.Table( + self, + "QuizzesTable", + table_name="Quizzes", + partition_key=dynamodb.Attribute( + name="QuizID", + type=dynamodb.AttributeType.STRING, + ), + billing_mode=dynamodb.BillingMode.PROVISIONED, + read_capacity=5, + write_capacity=5, + ) + + user_submissions_table = dynamodb.Table( + self, + "UserSubmissionsTable", + table_name="UserSubmissions", + partition_key=dynamodb.Attribute( + name="SubmissionID", + type=dynamodb.AttributeType.STRING, + ), + billing_mode=dynamodb.BillingMode.PROVISIONED, + read_capacity=5, + write_capacity=5, + ) + user_submissions_table.add_global_secondary_index( + index_name="QuizID-Score-index", + partition_key=dynamodb.Attribute( + name="QuizID", + type=dynamodb.AttributeType.STRING, + ), + sort_key=dynamodb.Attribute( + name="Score", + type=dynamodb.AttributeType.NUMBER, + ), + projection_type=dynamodb.ProjectionType.ALL, + read_capacity=5, + write_capacity=5, + ) + + dlq_submission_queue = sqs.Queue(self, "QuizSubmissionDLQ") + submission_queue = sqs.Queue( + self, + "QuizSubmissionQueue", + queue_name="QuizSubmissionQueue", + dead_letter_queue=sqs.DeadLetterQueue( + max_receive_count=1, queue=dlq_submission_queue + ), + visibility_timeout=aws_cdk.Duration.seconds(10), + ) + functions_and_roles = [ + ( + "CreateQuizFunction", + "lambdas/create_quiz", + ), + ( + "GetQuizFunction", + "lambdas/get_quiz", + ), + ( + "SubmitQuizFunction", + "lambdas/submit_quiz", + ), + ( + "ScoringFunction", + "lambdas/scoring", + ), + ( + "GetSubmissionFunction", + "lambdas/get_submission", + ), + ( + "GetLeaderboardFunction", + "lambdas/get_leaderboard", + ), + ( + "ListPublicQuizzesFunction", + "lambdas/list_quizzes", + ), + ( + "RetryQuizzesWritesFunction", + "lambdas/retry_quizzes_writes", + ), + ] + functions = {} + + for function_info in functions_and_roles: + function_name, handler_path = function_info + current_function = _lambda.Function( + self, + f"{function_name}LambdaFunction", + function_name=function_name, + runtime=_lambda.Runtime.PYTHON_3_11, + handler="handler.lambda_handler", + code=_lambda.Code.from_asset(f"../{handler_path}"), + timeout=aws_cdk.Duration.seconds(30), + ) + functions[function_name] = current_function + + _lambda.EventSourceMapping( + self, + "ScoringFunctionSubscription", + target=functions["ScoringFunction"], + event_source_arn=submission_queue.queue_arn, + ) + + # create rest api + # TODO: this is a circular dependency as we need to know the cloudfront + # domain name from the FrontendStack to add a specific origin, but the + # FrontendStack depends on the APIGW URL from this stack + rest_api = apigateway.RestApi( + self, + "QuizAPI", + default_cors_preflight_options=apigateway.CorsOptions( + allow_origins=apigateway.Cors.ALL_ORIGINS, + allow_methods=apigateway.Cors.ALL_METHODS, + ), + ) + + endpoints = [ + ("getquiz", "GET", "GetQuizFunction"), + ("createquiz", "POST", "CreateQuizFunction"), + ("submitquiz", "POST", "SubmitQuizFunction"), + ("getsubmission", "GET", "GetSubmissionFunction"), + ("getleaderboard", "GET", "GetLeaderboardFunction"), + ("listquizzes", "GET", "ListPublicQuizzesFunction"), + ] + for path_part, http_method, function_name in endpoints: + resource = rest_api.root.add_resource(path_part) + integration = apigateway.LambdaIntegration( + functions[function_name], proxy=True + ) + resource.add_method(http_method, integration=integration) + + self.backend_api_url = rest_api.url + + # verify email identity for SES + for email in ["your.email@example.com", "admin@localstack.cloud"]: + sanitised_email = email.replace(".", "-").replace("@", "-") + cr.AwsCustomResource( + self, + f"EmailVerifier{sanitised_email}", + on_update=cr.AwsSdkCall( + service="SES", + action="VerifyEmailIdentity", + parameters={ + "EmailAddress": email, + }, + physical_resource_id=cr.PhysicalResourceId.of( + f"verify-{sanitised_email}" + ), + ), + policy=cr.AwsCustomResourcePolicy.from_sdk_calls( + resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE, + ), + ) + + dlq_alarm_topic = sns.Topic(self, "DLQAlarmTopic") + dlq_alarm_topic.add_subscription( + aws_cdk.aws_sns_subscriptions.EmailSubscription( + email_address="your.email@example.com", + ) + ) + + # eventbridge pipe + policy_document = iam.PolicyDocument.from_json( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ], + "Resource": dlq_submission_queue.queue_arn, + }, + { + "Effect": "Allow", + "Action": "sns:Publish", + "Resource": dlq_alarm_topic.topic_arn, + }, + ], + } + ) + policy = iam.ManagedPolicy( + self, + "PipesPolicy", + document=policy_document, + ) + pipes_role = iam.Role( + self, + f"PipeRole", + assumed_by=iam.ServicePrincipal("pipes.amazonaws.com"), + managed_policies=[policy], + ) + pipe = pipes.CfnPipe( + self, + "DLQToSNSPipe", + source=dlq_submission_queue.queue_arn, + target=dlq_alarm_topic.topic_arn, + role_arn=pipes_role.role_arn, + ) + + # state machine + + policy_document = iam.PolicyDocument.from_json( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ses:SendEmail", + "ses:SendRawEmail", + "sesv2:SendEmail", + ], + "Resource": "*", + } + ], + } + ) + policy = iam.ManagedPolicy( + self, "SendEmailStateMachinePolicy", document=policy_document + ) + state_machine_role = iam.Role( + self, + "SendEmailStateMachineRole", + assumed_by=iam.ServicePrincipal("states.amazonaws.com"), + managed_policies=[policy], + ) + + self.state_machine = sfn.StateMachine( + self, + "SendEmailStateMachine", + definition_body=sfn.DefinitionBody.from_file( + "../configurations/statemachine.json" + ), + role=state_machine_role, + ) + + # set up lambda permissions + quizzes_table.grant_write_data(functions["CreateQuizFunction"]) + # TODO: createquizfunction should be able to write to QuizzesWriteFailures + quizzes_table.grant_read_data(functions["GetQuizFunction"]) + quizzes_table.grant_read_data(functions["SubmitQuizFunction"]) + submission_queue.grant_send_messages(functions["SubmitQuizFunction"]) + quizzes_table.grant_read_write_data(functions["ScoringFunction"]) + self.state_machine.grant_start_execution(functions["ScoringFunction"]) + submission_queue.grant_consume_messages(functions["ScoringFunction"]) + user_submissions_table.grant_read_write_data(functions["ScoringFunction"]) + user_submissions_table.grant_read_data(functions["GetSubmissionFunction"]) + user_submissions_table.grant_read_data(functions["GetLeaderboardFunction"]) + quizzes_table.grant_read_data(functions["ListPublicQuizzesFunction"]) + quizzes_table.grant_read_write_data(functions["RetryQuizzesWritesFunction"]) + # TODO: retryquizzeswritesfunction should have access to read and write to quizzeswritefailuresqueue diff --git a/cdk/requirements-dev.txt b/cdk/requirements-dev.txt new file mode 100644 index 0000000..9270945 --- /dev/null +++ b/cdk/requirements-dev.txt @@ -0,0 +1 @@ +pytest==6.2.5 diff --git a/cdk/requirements.txt b/cdk/requirements.txt new file mode 100644 index 0000000..44f8c02 --- /dev/null +++ b/cdk/requirements.txt @@ -0,0 +1,2 @@ +aws-cdk-lib==2.136.0 +constructs>=10.0.0,<11.0.0 diff --git a/cdk/source.bat b/cdk/source.bat new file mode 100644 index 0000000..9e1a834 --- /dev/null +++ b/cdk/source.bat @@ -0,0 +1,13 @@ +@echo off + +rem The sole purpose of this script is to make the command +rem +rem source .venv/bin/activate +rem +rem (which activates a Python virtualenv on Linux or Mac OS X) work on Windows. +rem On Windows, this command just runs this batch file (the argument is ignored). +rem +rem Now we don't need to document a Windows command for activating a virtualenv. + +echo Executing .venv\Scripts\activate.bat for you +.venv\Scripts\activate.bat diff --git a/cdk/website_deploy/index.js b/cdk/website_deploy/index.js new file mode 100644 index 0000000..1e8c7d4 --- /dev/null +++ b/cdk/website_deploy/index.js @@ -0,0 +1,3 @@ +exports.handler = async function(event, context) { + return { status: "ok" }; +} diff --git a/lambdas/create_quiz/handler.py b/lambdas/create_quiz/handler.py index a0577f7..22067c0 100644 --- a/lambdas/create_quiz/handler.py +++ b/lambdas/create_quiz/handler.py @@ -91,6 +91,10 @@ def lambda_handler(event, context): except (KeyError, json.JSONDecodeError, ValueError, TypeError) as e: return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({ 'message': 'Invalid input data', 'error': str(e) @@ -101,6 +105,10 @@ def lambda_handler(event, context): if not all(k in question for k in ('QuestionText', 'Options', 'CorrectAnswer', 'Trivia')): return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({ 'message': 'Each question must contain QuestionText, Options, CorrectAnswer, and Trivia' }) @@ -138,6 +146,10 @@ def lambda_handler(event, context): print(f"Failed to publish to SNS: {sns_e}") return { 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({ 'message': 'Error storing quiz data. It has been queued for retry.', 'error': str(e) @@ -146,5 +158,9 @@ def lambda_handler(event, context): return { 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'QuizID': quiz_id}) } diff --git a/lambdas/get_leaderboard/handler.py b/lambdas/get_leaderboard/handler.py index 9f4ae6f..9d516c8 100644 --- a/lambdas/get_leaderboard/handler.py +++ b/lambdas/get_leaderboard/handler.py @@ -9,6 +9,10 @@ def lambda_handler(event, context): except (KeyError, TypeError, ValueError) as e: return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'quiz_id is required and top should be an integer', 'error': str(e)}) } @@ -32,10 +36,18 @@ def lambda_handler(event, context): ] return { 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps(leaderboard) } except Exception as e: return { 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Error retrieving leaderboard', 'error': str(e)}) } diff --git a/lambdas/get_quiz/handler.py b/lambdas/get_quiz/handler.py index c73a9f8..4c35566 100644 --- a/lambdas/get_quiz/handler.py +++ b/lambdas/get_quiz/handler.py @@ -21,6 +21,10 @@ def lambda_handler(event, context): except (KeyError, TypeError) as e: return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'quiz_id is required', 'error': str(e)}) } @@ -35,10 +39,18 @@ def lambda_handler(event, context): quiz = convert_decimal(quiz) return { 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps(quiz) } else: return { 'statusCode': 404, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Quiz not found'}) } diff --git a/lambdas/get_submission/handler.py b/lambdas/get_submission/handler.py index 039d7fa..182b7db 100644 --- a/lambdas/get_submission/handler.py +++ b/lambdas/get_submission/handler.py @@ -22,7 +22,11 @@ def lambda_handler(event, context): except (KeyError, TypeError) as e: return { 'statusCode': 400, - 'headers': {'Content-Type': 'application/json'}, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'submission_id is required', 'error': str(e)}) } @@ -36,12 +40,20 @@ def lambda_handler(event, context): submission = convert_decimal(submission) return { 'statusCode': 200, - 'headers': {'Content-Type': 'application/json'}, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps(submission) } else: return { 'statusCode': 404, - 'headers': {'Content-Type': 'application/json'}, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Submission not found'}) } diff --git a/lambdas/list_quizzes/handler.py b/lambdas/list_quizzes/handler.py index 83e311d..8852723 100644 --- a/lambdas/list_quizzes/handler.py +++ b/lambdas/list_quizzes/handler.py @@ -16,6 +16,10 @@ def lambda_handler(event, context): return { 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'Quizzes': quizzes}) } @@ -23,5 +27,9 @@ def lambda_handler(event, context): print(f"Error retrieving public quizzes: {e}") return { 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Error retrieving public quizzes', 'error': str(e)}) } diff --git a/lambdas/submit_quiz/handler.py b/lambdas/submit_quiz/handler.py index 4e8fd17..37b9fff 100644 --- a/lambdas/submit_quiz/handler.py +++ b/lambdas/submit_quiz/handler.py @@ -22,6 +22,10 @@ def lambda_handler(event, context): except (KeyError, json.JSONDecodeError, ValueError, TypeError) as e: return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Invalid input data', 'error': str(e)}) } @@ -33,11 +37,19 @@ def lambda_handler(event, context): if 'Item' not in response: return { 'statusCode': 400, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': f'QuizID "{quiz_id}" does not exist.'}) } except Exception as e: return { 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Error accessing the Quizzes table.', 'error': str(e)}) } @@ -62,10 +74,18 @@ def lambda_handler(event, context): except Exception as e: return { 'statusCode': 500, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Error sending message to SQS.', 'error': str(e)}) } return { 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + }, 'body': json.dumps({'message': 'Submission received', 'SubmissionID': message_body['SubmissionID']}) } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_quiz_app_stack.py b/tests/unit/test_quiz_app_stack.py new file mode 100644 index 0000000..216e048 --- /dev/null +++ b/tests/unit/test_quiz_app_stack.py @@ -0,0 +1,15 @@ +import aws_cdk as core +import aws_cdk.assertions as assertions + +from quiz_app.quiz_app_stack import QuizAppStack + +# example tests. To run these tests, uncomment this file along with the example +# resource in quiz_app/quiz_app_stack.py +def test_sqs_queue_created(): + app = core.App() + stack = QuizAppStack(app, "quiz-app") + template = assertions.Template.from_stack(stack) + +# template.has_resource_properties("AWS::SQS::Queue", { +# "VisibilityTimeout": 300 +# })