diff --git a/.github/workflows/ephemeral.yml b/.github/workflows/ephemeral.yml deleted file mode 100644 index 3333ac8..0000000 --- a/.github/workflows/ephemeral.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Deploy Redirect to GitHub Pages - -on: - schedule: - - cron: '0 * * * *' - workflow_dispatch: - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set Up Git Configuration - run: | - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - - - name: Set up Python 3.11 - id: setup-python - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install AWS CLI - run: | - pip install awscli awscli-local - - - name: Run Ephemeral Script and Capture Output - run: | - OUTPUT=$(bash bin/ephemeral.sh 2>&1) - echo "$OUTPUT" - NEW_URL=$(echo "$OUTPUT" | grep -Eo 'https://ls-[^ ]+') - - if [ -z "$NEW_URL" ]; then - echo "Error: Failed to extract URL from script output." - exit 1 - fi - - echo "Extracted URL: $NEW_URL" - - echo "NEW_URL=$NEW_URL" >> $GITHUB_ENV - env: - LOCALSTACK_API_KEY: ${{ secrets.LOCALSTACK_API_KEY }} - - - name: Generate index.html with New Redirect URL - run: | - NEW_URL="${{ env.NEW_URL }}" - - # Validate the URL - if [[ ! "$NEW_URL" =~ ^https?:// ]]; then - echo "Error: Invalid URL provided." - exit 1 - fi - - echo "Redirecting to: $NEW_URL" - - cat > index.html < - - - - - Redirecting... - - -

If you are not redirected automatically, follow this link.

- - - EOL - - - name: Switch to gh-pages Branch and Commit Changes - run: | - git fetch origin gh-pages || echo "gh-pages branch does not exist; creating it." - git checkout gh-pages || git checkout --orphan gh-pages - git rm -rf . # Remove all files in the branch - git add index.html - git commit -m "Update redirect to ${NEW_URL}" - git push origin gh-pages --force diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 2c5c483..7d18e19 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -17,7 +17,10 @@ on: type: choice options: - ubuntu-latest - +env: + LOCALSTACK_API_KEY: ${{ secrets.LOCALSTACK_API_KEY }} + LOCALSTACK_PROJECT_ID: project_1bipvrdld3 + LOCALSTACK_PIPELINE_ID: pipeline_dw7flhan3i jobs: integration-test-job: @@ -27,6 +30,20 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout CI Extension + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_PULL_TOKEN }} + repository: localstack/localstack-ci-extension + path: localstack-ci-extension + + - name: Checkout CI Extension Plugin + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_PULL_TOKEN }} + repository: localstack/localstack-ci-extension-plugins + path: localstack-ci-extension-plugins + - name: Set up Python 3.11 id: setup-python uses: actions/setup-python@v2 @@ -40,59 +57,90 @@ jobs: - name: Set up Dependencies run: | - pip install requests boto3 pytest localstack-sdk-python + make install + pip install localstack-ci-extension-plugins/pytest_plugin + cd localstack-ci-extension && pip install -e . && cd - + + - name: Prepare LocalStack + run: | + LOCALSTACK_API_KEY=$LOCALSTACK_API_KEY \ + localstack extensions dev enable ./localstack-ci-extension - name: Start LocalStack - uses: LocalStack/setup-localstack@v0.2.3 - with: - image-tag: 'latest' - use-pro: 'true' - configuration: LS_LOG=trace - install-awslocal: 'true' - env: - LOCALSTACK_API_KEY: ${{ secrets.LOCALSTACK_API_KEY }} + run: | + DEBUG=1 \ + LOCALSTACK_API_KEY=$LOCALSTACK_API_KEY \ + LOCALSTACK_PROJECT_ID=$LOCALSTACK_PROJECT_ID \ + LOCALSTACK_PIPELINE_ID=$LOCALSTACK_PIPELINE_ID \ + DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM="*cianalyticsbucket*" \ + EXTENSION_DEV_MODE=1 \ + localstack start -d + + - name: Wait for LocalStack to be ready + run: | + echo "Waiting for LocalStack CI extension to activate..." + for i in {1..10}; do + if localstack logs | grep -q "activated CI Extension"; then + echo "CI extension activated. Proceeding..." + exit 0 + fi + sleep 5 + done + echo "CI extension not activated in time. Failing..." + exit 1 - name: Deploy infrastructure run: | + START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") bash bin/deploy.sh + END_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") + curl -X POST http://localhost:4566/_localstack/extensions/ci/steps \ + -H "Content-Type: application/json" \ + -d '{ + "steps": [ + { + "step_id": "deploy", + "name": "Deploy Infrastructure", + "step_type" : "step", + "state": "passed", + "time_start": "'"$START_TIME"'", + "time_end": "'"$END_TIME"'" + } + ] + }' + + START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") bash bin/seed.sh + END_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") + curl -X POST http://localhost:4566/_localstack/extensions/ci/steps \ + -H "Content-Type: application/json" \ + -d '{ + "steps": [ + { + "step_id": "seed", + "name": "Seed Data", + "step_type" : "step", + "state": "passed", + "time_start": "'"$START_TIME"'", + "time_end": "'"$END_TIME"'" + } + ] + }' - - name: Run Integration Tests + - name: Run Tests env: AWS_DEFAULT_REGION: us-east-1 AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: test AWS_SECRET_ACCESS_KEY: test run: | - pytest tests/test_infra.py - - - name: Run Outages Tests - env: - AWS_DEFAULT_REGION: us-east-1 - AWS_REGION: us-east-1 - AWS_ACCESS_KEY_ID: test - AWS_SECRET_ACCESS_KEY: test - run: | - pytest tests/test_outage.py + pytest tests - name: Show localstack logs if: always() run: | localstack logs - - name: Send a Slack notification - if: failure() || github.event_name != 'pull_request' - uses: ravsamhq/notify-slack-action@v2 - with: - status: ${{ job.status }} - token: ${{ secrets.GITHUB_TOKEN }} - notification_title: "{workflow} has {status_message}" - message_format: "{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}>" - footer: "Linked Repo <{repo_url}|{repo}> | <{run_url}|View Workflow run>" - notify_when: "failure" - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - - name: Generate a Diagnostic Report if: failure() run: | diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml deleted file mode 100644 index e3a888d..0000000 --- a/.github/workflows/preview.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: 'Create Preview on Pull Request' -on: - workflow_dispatch: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - localstack: - permissions: write-all - name: Setup LocalStack Preview - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: LocalStack Preview - uses: LocalStack/setup-localstack@v0.2.3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - state-backend: ephemeral - state-action: start - include-preview: 'true' - install-awslocal: 'true' - extension-auto-install: 'localstack-extension-mailhog' - preview-cmd: | - pip install awscli awscli-local - bash bin/deploy.sh - bash bin/seed.sh - distributionId=$(awslocal cloudfront list-distributions | jq -r '.DistributionList.Items[0].Id'); - echo LS_PREVIEW_URL=$AWS_ENDPOINT_URL/cloudfront/$distributionId/ >> $GITHUB_ENV; - env: - LOCALSTACK_API_KEY: ${{ secrets.LOCALSTACK_API_KEY }} diff --git a/.gitignore b/.gitignore index cd920f5..dfcbd3b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,14 @@ # Python +.idea .Python build/ develop-eggs/ dist/ .env .venv +*.egg-info/ env/ venv/ __pycache__ diff --git a/Makefile b/Makefile index 5df3609..ce932af 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +install: + pip install -e .; usage: ## Show usage for this Makefile @cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' diff --git a/lambdas/utils/__init__.py b/lambdas/utils/__init__.py new file mode 100644 index 0000000..8f35413 --- /dev/null +++ b/lambdas/utils/__init__.py @@ -0,0 +1,10 @@ +import json + +def prepare_response(status_code, body): + return { + "statusCode": status_code, + "body": json.dumps(body), + "headers": { + "Content-Type": "application/json", + } + } diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4c7a0c7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,24 @@ +[metadata] +name = serverless-quiz-app +version = 0.0.1 +url = localstack.cloud +author = LocalStack Team +author_email = info@localstack.cloud +description = LocalStack Quiz App +license = Proprietary +classifiers = + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + License :: Other/Proprietary License + Topic :: Software Development :: Testing + +[options] +zip_safe = False +packages = find_namespace: +install_requires = + localstack + requests + boto3 + pytest + localstack-sdk-python + awscli-local diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c823345 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +from setuptools import setup + +setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..56973f5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import logging +import sys + +logging.getLogger("urllib3").setLevel(logging.WARNING) + +logging.basicConfig( + level=logging.DEBUG, + stream=sys.stdout, +) diff --git a/tests/test_infra.py b/tests/test_infra.py index 4a46478..3408efb 100644 --- a/tests/test_infra.py +++ b/tests/test_infra.py @@ -4,6 +4,11 @@ import localstack.sdk.aws import json import time +import logging + +from lambdas.utils import prepare_response + +LOG = logging.getLogger(__name__) @pytest.fixture(scope='module') def api_endpoint(): @@ -21,7 +26,7 @@ def api_endpoint(): API_ID = api['id'] API_ENDPOINT = f"http://localhost:4566/restapis/{API_ID}/test/_user_request_" - print(f"API Endpoint: {API_ENDPOINT}") + LOG.info(f"API Endpoint: {API_ENDPOINT}") time.sleep(2) @@ -78,7 +83,7 @@ def test_quiz_workflow(api_endpoint): assert 'QuizID' in quiz_creation_response quiz_id = quiz_creation_response['QuizID'] - print(f"Quiz created with ID: {quiz_id}") + LOG.info(f"Quiz created with ID: {quiz_id}") response = requests.get(f"{api_endpoint}/listquizzes") assert response.status_code == 200 @@ -149,7 +154,7 @@ def test_quiz_workflow(api_endpoint): "SubmissionID": submission_response["SubmissionID"] }) - print(f"{user['Username']} submitted quiz with SubmissionID: {submission_response['SubmissionID']}") + LOG.info(f"{user['Username']} submitted quiz with SubmissionID: {submission_response['SubmissionID']}") time.sleep(5) @@ -191,7 +196,7 @@ def calculate_user_score(user_answers): expected_score = expected_scores[username] assert actual_score == pytest.approx(expected_score, abs=0.01) - print(f"{username} - Expected Score: {expected_score}, Actual Score: {actual_score}") + LOG.info(f"{username} - Expected Score: {expected_score}, Actual Score: {actual_score}") for submission in submissions: response = requests.get(f"{api_endpoint}/getsubmission?submission_id={submission['SubmissionID']}") @@ -206,7 +211,7 @@ def calculate_user_score(user_answers): actual_score = submission_data['Score'] assert actual_score == pytest.approx(expected_score, abs=0.01) - print(f"Verified submission for {submission['Username']} with Score: {actual_score}") + LOG.info(f"Verified submission for {submission['Username']} with Score: {actual_score}") client = localstack.sdk.aws.AWSClient() sender_email = "sender@example.com" @@ -227,6 +232,23 @@ def calculate_user_score(user_answers): assert hasattr(body, 'html_part') html_content = body.html_part - print(f"Email content: {html_content}") + LOG.info(f"Email content: {html_content}") assert email_found, f"No email found sent from {sender_email}" + +def test_faulty_assertion(api_endpoint): + test_quiz_workflow(api_endpoint=api_endpoint) + + response = requests.get(f"{api_endpoint}/getleaderboard?quiz_id=wrongid&top=3") + assert response.status_code == 500 + +@pytest.mark.resource_snapshot(before=True, after=True) +def test_faulty_invocation(api_endpoint): + test_quiz_workflow(api_endpoint=api_endpoint) + + class NonSerializable: + pass + + response = prepare_response(200, NonSerializable()) + + assert response['statusCode'] == 200 diff --git a/tests/test_outage.py b/tests/test_outage.py index eb7331b..c636ffa 100644 --- a/tests/test_outage.py +++ b/tests/test_outage.py @@ -3,11 +3,14 @@ import boto3 import json import requests +import logging import localstack.sdk.chaos from localstack.sdk.models import FaultRule from localstack.sdk.chaos.managers import fault_configuration +LOG = logging.getLogger(__name__) + LOCALSTACK_ENDPOINT = "http://localhost.localstack.cloud:4566" API_NAME = 'QuizAPI' @@ -30,7 +33,7 @@ def api_endpoint(apigateway_client): API_ID = api['id'] API_ENDPOINT = f"{LOCALSTACK_ENDPOINT}/restapis/{API_ID}/test/_user_request_" - print(f"API Endpoint: {API_ENDPOINT}") + LOG.info(f"API Endpoint: {API_ENDPOINT}") time.sleep(2) @@ -41,7 +44,7 @@ def test_dynamodb_outage(api_endpoint): # Using fault_configuration context manager to apply and automatically clean up the fault rule with fault_configuration(fault_rules=[outage_rule]): - print("DynamoDB outage initiated within context.") + LOG.info("DynamoDB outage initiated within context.") # Attempt to create a quiz during the outage create_quiz_payload = { @@ -68,10 +71,10 @@ def test_dynamodb_outage(api_endpoint): assert response.status_code == 500 response_data = response.json() assert "Error storing quiz data. It has been queued for retry." in response_data.get("message", "") - print("Received expected error message during outage.") + LOG.info("Received expected error message during outage.") # After the context manager exits, the outage should be resolved - print("Waiting for the system to process the queued request...") + LOG.info("Waiting for the system to process the queued request...") time.sleep(15) # Check if the quiz was eventually created successfully @@ -80,4 +83,4 @@ def test_dynamodb_outage(api_endpoint): quizzes_list = response.json().get('Quizzes', []) quiz_titles = [quiz['Title'] for quiz in quizzes_list] assert "Outage Test Quiz" in quiz_titles - print("Quiz successfully created after outage resolved.") + LOG.info("Quiz successfully created after outage resolved.")