diff --git a/.github/workflows/build-and-push-greeter.yaml b/.github/workflows/build-and-push-greeter.yaml new file mode 100644 index 0000000..b1fc663 --- /dev/null +++ b/.github/workflows/build-and-push-greeter.yaml @@ -0,0 +1,45 @@ +name: Build and Push Docker Image + +on: + push: + paths: + - 'greeter/**' # Trigger this workflow when changes occur in greeter directory. + pull_request: + paths: + - 'greeter/**' # Trigger this workflow for pull requests involving greeter directory. + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Step 1: Check out the code from the repository + - name: Checkout repository + uses: actions/checkout@v3 + + # Step 2: Set up Docker Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + # Step 3: Log in to Docker Hub + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Step 4: Build and push Docker image using commit SHA as tag + - name: Build and push Docker image + run: | + IMAGE_NAME="juanroldan1989/greeter" + IMAGE_TAG=$(git rev-parse --short HEAD) # Use short commit SHA as tag + + echo "Building Docker image with tag $IMAGE_TAG" + docker build -t $IMAGE_NAME:$IMAGE_TAG ./greeter + + echo "Pushing Docker image $IMAGE_NAME:$IMAGE_TAG" + docker push $IMAGE_NAME:$IMAGE_TAG + + # Step 5: Clean up Docker images + - name: Clean up Docker images + run: docker rmi $IMAGE_NAME:$IMAGE_TAG diff --git a/greeter/Dockerfile b/greeter/Dockerfile new file mode 100644 index 0000000..1abc5dc --- /dev/null +++ b/greeter/Dockerfile @@ -0,0 +1,51 @@ +# Stage 1: Build stage +FROM python:3.9-slim AS builder + +# Set the working directory +WORKDIR /app + +# Install build dependencies for psycopg2 and other compiled libraries +RUN apt-get update && apt-get install -y \ + curl \ + gcc \ + libpq-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy the requirements file (`greeter` folder) and install dependencies +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r /app/requirements.txt + +# Stage 2: Final stage with only the necessary components +FROM python:3.9-slim + +# Set the working directory +WORKDIR /app + +# Install runtime dependencies (including `curl` for health-check of Flask app) +RUN apt-get update && apt-get install -y libpq5 curl \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user and group, and set permissions +RUN useradd -m -d /home/nonrootuser nonrootuser +USER nonrootuser + +# Copy only the installed dependencies from the builder stage, adjust ownership +COPY --from=builder --chown=nonrootuser:nonrootuser /install /usr/local + +# Copy the application code and set correct ownership and permissions +COPY --chown=nonrootuser:nonrootuser . /app + +# Set environment variables +ENV FLASK_APP=app.py +ENV REDIS_HOST=redis +ENV REDIS_PORT=6379 + +# Expose the port the app runs on +EXPOSE 5000 + +# Use non-root user to run the application +USER nonrootuser + +# Run the Flask application +CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/greeter/README.md b/greeter/README.md new file mode 100644 index 0000000..eb1f785 --- /dev/null +++ b/greeter/README.md @@ -0,0 +1,164 @@ +# Greeter App + +This is a Flask-based microservice that communicates with two other services, + +**Name API** and **Greeting API**, + +to generate a personalized greeting message. + +## Features + +- Fetches a random name from the **Name Service**. +- Fetches a random greeting from the **Greeting Service**. +- Combines the name and greeting to return a friendly message. +- Health check endpoint for monitoring the API status. +- Includes logging for better error tracking and debugging. +- Lightweight, containerized, and easy to deploy. + +## Endpoints + +### `GET /greet` + +Fetches a greeting and name from the external services and returns a combined message. + +- Response: + +```bash +{ + "message": "Hello, Alice!" +} +``` + +### `GET /health` + +Returns the health status of the service. + +- Example Response: + +```bash +{ + "status": "healthy" +} +``` + +## How to Run + +1. Clone the repository: + +```bash +git clone +cd +``` + +2. Create a virtual environment and install dependencies: + +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install flask +``` + +3. Run the application: + +```bash +python app.py +``` + +4. Access the API: + +Open your browser or a tool like curl or Postman and navigate to: + +```bash +http://localhost:5000 +``` + +## Environment Variables + +- `NAME_SERVICE_URL`: URL for the name generator service. Default is http://name:5001/name. +- `GREETING_SERVICE_URL`: URL for the greeting service. Default is http://greeting:5002/greeting. + +Example: + +```bash +export NAME_SERVICE_URL="http://localhost:5001/name" +export GREETING_SERVICE_URL="http://localhost:5002/greeting" +``` + +## Deployment + +### Docker + +- Build and run the Docker container: + +```bash +docker build -t name-api . +docker run -d -p 5001:5001 name-api + +docker build -t greeting-api . +docker run -d -p 5002:5002 greeting-api + +docker build -t greeter-api . +docker run -d -p 5000:5000 greeter-api +``` + +- Access the API at http://localhost:5000 + +### Using ECS and EKS + +This Flask app is designed to be easily deployable in AWS ECS and EKS clusters. + +Here's an overview of how to deploy it: + +- ECS Deployment: + +1. Create a Docker image of the app and push it to a container registry like Docker Hub or Amazon Elastic Container Registry (ECR). +2. Use Terraform or AWS CloudFormation to define the ECS task and service. +3. Deploy the app as an ECS service behind an Application Load Balancer (ALB) to ensure high availability. + +- EKS Deployment: + +1. Build and push the Docker image to a container registry. +2. Define a Kubernetes deployment and service YAML file to deploy the app in the EKS cluster. +3. Use kubectl to apply the deployment and service definitions. +4. Expose the service using a LoadBalancer service type or an Ingress resource. + +## Unit Tests + +Unit tests are provided to ensure the correctness of the API. The tests cover the following: + +- Status code verification. +- JSON content type verification. +- Proper handling of external service failures. +- Ensuring the response contains a valid farewell phrase. + +### How to Run Unit Tests + +Ensure you have Python and the required dependencies installed: + +```bash +pip install -r requirements.txt +``` + +Run the unit tests: + +```bash +python -m unittest test_app.py +``` + +### Test Coverage + +The following tests are included: + +- `test_greet_status_code`: Verifies that the `/greet` endpoint returns a 200 status code. +- `test_greet_content_type`: Ensures the response has a content type of `application/json`. +- `test_greet_response_format`: Verifies that the response contains a `message` key. +- `test_health_status_code`: Verifies that the `/health` endpoint returns a `200` status code. +- `test_health_response_format`: Ensures that the `/health` endpoint returns the correct status in JSON format. + +## Contributing + +Contributions are welcome! Feel free to open an issue or submit a pull request if you have any improvements or suggestions. + +## License + +This project is licensed under the MIT License. See the LICENSE file for more details. diff --git a/greeter/app.py b/greeter/app.py new file mode 100644 index 0000000..e594570 --- /dev/null +++ b/greeter/app.py @@ -0,0 +1,50 @@ +from flask import Flask, jsonify +import requests +import logging +import os + +app = Flask(__name__) + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# External service URLs (configurable via environment variables) +NAME_SERVICE_URL = os.getenv("NAME_SERVICE_URL", "http://name:5001/name") +GREETING_SERVICE_URL = os.getenv("GREETING_SERVICE_URL", "http://greeting:5002/greeting") + +app = Flask(__name__) + +@app.route('/greet', methods=['GET']) +def greet(): + try: + # Fetch name and greeting with timeouts + name_response = requests.get(NAME_SERVICE_URL, timeout=5) + greeting_response = requests.get(GREETING_SERVICE_URL, timeout=5) + + # Raise exceptions for HTTP errors + name_response.raise_for_status() + greeting_response.raise_for_status() + + # Parse JSON responses + name = name_response.json().get("name") + greeting = greeting_response.json().get("greeting") + + return jsonify(message=f"{greeting}, {name}!") + + except requests.RequestException as e: + logger.error(f"Failed to fetch data from external services: {str(e)}") + return jsonify(error=f"Failed to fetch data from external services: {str(e)}"), 503 + except Exception as e: + logger.error(f"An unexpected error occurred: {str(e)}") + return jsonify(error=f"An unexpected error occurred: {str(e)}"), 500 + +@app.route('/health', methods=['GET']) +def health(): + return jsonify(status='healthy') + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) diff --git a/greeter/requirements.txt b/greeter/requirements.txt new file mode 100644 index 0000000..205662e --- /dev/null +++ b/greeter/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +python-dotenv==1.0.1 +requests==2.26.0 \ No newline at end of file diff --git a/greeter/test_app.py b/greeter/test_app.py new file mode 100644 index 0000000..c687a1a --- /dev/null +++ b/greeter/test_app.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import patch +import json +from app import app + +class GreeterServiceTestCase(unittest.TestCase): + def setUp(self): + """Set up the test client before each test.""" + self.app = app.test_client() + self.app.testing = True + + @patch('requests.get') + def test_greet_with_mocked_services(self, mock_get): + """Test the /greet endpoint with mocked external services.""" + mock_get.side_effect = [ + unittest.mock.Mock(status_code=200, json=lambda: {'name': 'Alice'}), # Mock for name service + unittest.mock.Mock(status_code=200, json=lambda: {'greeting': 'Hello'}) # Mock for greeting service + ] + + response = self.app.get('/greet') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(data['message'], 'Hello, Alice!') + + def test_greet_status_code(self): + """Test that the /greet endpoint returns a 200 status code.""" + response = self.app.get('/greet') + self.assertEqual(response.status_code, 200) + + def test_greet_content_type(self): + """Test that the /greet endpoint returns JSON content.""" + response = self.app.get('/greet') + self.assertEqual(response.content_type, 'application/json') + + def test_greet_response_format(self): + """Test that the /greet endpoint contains a 'message' key.""" + response = self.app.get('/greet') + data = json.loads(response.data) + self.assertIn('message', data) + + def test_health_status_code(self): + """Test that the /health endpoint returns a 200 status code.""" + response = self.app.get('/health') + self.assertEqual(response.status_code, 200) + + def test_health_response_format(self): + """Test that the /health endpoint contains a 'status' key.""" + response = self.app.get('/health') + data = json.loads(response.data) + self.assertIn('status', data) + +if __name__ == '__main__': + unittest.main()