diff --git a/.github/workflows/.gitignore b/.github/workflows/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.github/workflows/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9950f5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI - Build and Push Docker Images to ECR (Test & Prod) + +on: + push: + branches: + - main + workflow_dispatch: # allows manual trigger for prod deploy + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Log in to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + # ----------------------------- + # TEST ENVIRONMENT + # ----------------------------- + + - name: Build Docker image for TEST (SHA) + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_TEST=${{ secrets.ECR_REPOSITORY_TEST }} + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY_TEST:$IMAGE_TAG . + + - name: Tag Docker image for TEST as latest + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_TEST=${{ secrets.ECR_REPOSITORY_TEST }} + docker tag $ECR_REGISTRY/$ECR_REPOSITORY_TEST:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY_TEST:latest + docker images | grep $ECR_REPOSITORY_TEST + + - name: Push Docker image to TEST ECR (SHA and latest) + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_TEST=${{ secrets.ECR_REPOSITORY_TEST }} + docker push $ECR_REGISTRY/$ECR_REPOSITORY_TEST:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY_TEST:latest + + # ----------------------------- + # PROD ENVIRONMENT (manual trigger) + # ----------------------------- + + - name: Build Docker image for PROD (SHA) + if: github.event_name == 'workflow_dispatch' + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_PROD=${{ secrets.ECR_REPOSITORY_PROD }} + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY_PROD:$IMAGE_TAG . + + - name: Tag Docker image for PROD as latest + if: github.event_name == 'workflow_dispatch' + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_PROD=${{ secrets.ECR_REPOSITORY_PROD }} + docker tag $ECR_REGISTRY/$ECR_REPOSITORY_PROD:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY_PROD:latest + docker images | grep $ECR_REPOSITORY_PROD + + - name: Push Docker image to PROD ECR (SHA and latest) + if: github.event_name == 'workflow_dispatch' + run: | + IMAGE_TAG=${GITHUB_SHA} + ECR_REGISTRY=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + ECR_REPOSITORY_PROD=${{ secrets.ECR_REPOSITORY_PROD }} + docker push $ECR_REGISTRY/$ECR_REPOSITORY_PROD:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY_PROD:latest diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51e7874 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Stage 1: Base image with dependencies +FROM python:3.11-slim AS base + +WORKDIR /app + +# Install system deps (if you ever need psycopg2, mysqlclient, etc.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +# Stage 2: Final runtime image +FROM python:3.11-slim AS final + +WORKDIR /app + +# Copy installed dependencies from base +COPY --from=base /usr/local /usr/local + +# Copy application code +COPY . . + +# Expose container port +EXPOSE 8080 + +# Run with Gunicorn (production-ready WSGI server) +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] diff --git a/app.py b/app.py index 997d548..3ba92ac 100644 --- a/app.py +++ b/app.py @@ -1,137 +1,68 @@ from flask import Flask, jsonify, request from flask_restful import Api, Resource from flasgger import Swagger - import book_review +import logging +import signal, sys app = Flask(__name__) api = Api(app) swagger = Swagger(app) +# Logging setup +logging.basicConfig(level=logging.INFO) + +# Graceful shutdown for ECS +def handle_sigterm(*args): + app.logger.info("Received SIGTERM, shutting down gracefully...") + sys.exit(0) + +signal.signal(signal.SIGTERM, handle_sigterm) + + class UppercaseText(Resource): def get(self): - """ - This method responds to the GET request for this endpoint and returns the data in uppercase. - --- - tags: - - Text Processing - parameters: - - name: text - in: query - type: string - required: true - description: The text to be converted to uppercase - responses: - 200: - description: A successful GET request - content: - application/json: - schema: - type: object - properties: - text: - type: string - description: The text in uppercase - """ text = request.args.get('text') - + if not text: + return {"message": "Missing 'text' parameter"}, 400 return jsonify({"text": text.upper()}) - + + class Records(Resource): def get(self): - """ - This method responds to the GET request for returning a number of books. - --- - tags: - - Records - parameters: - - name: count - in: query - type: integer - required: false - description: The number of books to return - - name: sort - in: query - type: string - enum: ['ASC', 'DESC'] - required: false - description: Sort order for the books - responses: - 200: - description: A successful GET request - schema: - type: object - properties: - books: - type: array - items: - type: object - properties: - title: - type: string - description: The title of the book - author: - type: string - description: The author of the book - """ - - count = request.args.get('count') # Default to returning 10 books if count is not provided + count = request.args.get('count') sort = request.args.get('sort') + try: + books = book_review.get_all_records(count=count, sort=sort) + except Exception as e: + app.logger.error(f"Error fetching records: {e}") + return {"error": "Internal Server Error"}, 500 + return {"books": books}, 200 - # Get all the books - books = book_review.get_all_records(count=count, sort=sort) - return {"books": books}, 200 - class AddRecord(Resource): def post(self): - """ - This method responds to the POST request for adding a new record to the DB table. - --- - tags: - - Records - parameters: - - in: body - name: body - required: true - schema: - id: BookReview - required: - - Book - - Rating - properties: - Book: - type: string - description: the name of the book - Rating: - type: integer - description: the rating of the book (1-10) - responses: - 200: - description: A successful POST request - 400: - description: Bad request, missing 'Book' or 'Rating' in the request body - """ - - data = request.json - print(data) - - # Check if 'Book' and 'Rating' are present in the request body + data = request.json or {} if 'Book' not in data or 'Rating' not in data: - return {"message": "Bad request, missing 'Book' or 'Rating' in the request body"}, 400 - # Call the add_record function to add the record to the DB table - success = book_review.add_record(data) - - if success: - return {"message": "Record added successfully"}, 200 - else: + return {"message": "Bad request, missing 'Book' or 'Rating'"}, 400 + try: + success = book_review.add_record(data) + except Exception as e: + app.logger.error(f"Error adding record: {e}") return {"message": "Failed to add record"}, 500 - + return {"message": "Record added successfully"} if success else {"message": "Failed to add record"}, 200 api.add_resource(AddRecord, "/add-record") api.add_resource(Records, "/records") api.add_resource(UppercaseText, "/uppercase") + +@app.route("/health") +def health(): + return jsonify(status="healthy"), 200 + + +# Only for local dev (Gunicorn runs in ECS) if __name__ == "__main__": - app.run(debug=True) \ No newline at end of file + app.run(host="0.0.0.0", port=8080, debug=False)