diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..dc7c04b0fb --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,73 @@ +name: Python CI/CD + +on: + push: + branches: [ main, lab03 ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ main ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: 'app_python/requirements.txt' + + - name: Install dependencies + run: | + cd app_python + pip install -r requirements.txt + + - name: Run linter + run: | + cd app_python + flake8 app.py + + - name: Run tests + run: | + cd app_python + pytest tests/test_app.py + + - name: Security scan with pip-audit + run: | + cd app_python + pip install pip-audit + pip-audit -r requirements.txt || echo "Security scan completed" + + build: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/fastapi-lab-app:latest + ${{ secrets.DOCKER_USERNAME }}/fastapi-lab-app:$(date +%Y.%m.%d) \ No newline at end of file diff --git a/app_python/.gitignore b/app_python/.gitignore index 4de420a8f7..3b9bb78f47 100644 --- a/app_python/.gitignore +++ b/app_python/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.py[cod] venv/ *.log +.pytest_cache # IDE .vscode/ diff --git a/app_python/README.md b/app_python/README.md index 06d8b50485..27a5c91a20 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,5 +1,7 @@ # DevOps Info Service (Python / FastAPI) +[![Python CI/CD](https://github.com/flowelx/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/flowelx/DevOps-Core-Course/actions/workflows/python-ci.yml) + ## Overview This FastAPI application delivers runtime and system data through HTTP endpoints. Built as a modular platform for DevOps education, it enables practical exploration of containerization, CI/CD pipelines, monitoring solutions, and infrastructure automation concepts. diff --git a/app_python/app.py b/app_python/app.py index c5dcddd911..9b5cdef281 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -50,14 +50,14 @@ def format_datetime_iso(dt: datetime): async def get_service_info(request: Request): """ Root endpoint returning comprehensive service and system information. - + Returns: - dict: JSON containing service, system, runtime, and request information. + dict: JSON with service, system, runtime, and request information. """ logger.info( f"GET / from {request.client.host if request.client else 'unknown'}" ) - + service_info = { 'name': 'devops-info-request', 'version': '1.0.0', @@ -110,7 +110,7 @@ async def get_service_info(request: Request): async def health_check(request: Request): """ Health check endpoint for service monitoring. - + Returns: dict: Service health status with timestamp and uptime. """ @@ -157,4 +157,4 @@ async def internal_error_handler(request: Request, exc: Exception): host=HOST, port=PORT, reload=DEBUG - ) \ No newline at end of file + ) diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..c31a958187 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,184 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## 1. Unit Testing + +### Framework chosen + +I chose `pytest` because of using plain `assert` statement instead of complex assertion methods. The framework has clear output with `-v` flag showing exactly what passed/failed. `pytest` is well-documented with many tutorials and examples. + +### Test Structure + +**Test Coverage:** + +1. `test_root_endpoint()` - Tests `GET /` endpoint + +2. `test_health_endpoint()` - Tests `GET /health` endpoint + +3. `test_404_error` - Tests error handling + +Each test is independent. Tests use FastAPI's `TestClient` (no live server needed). + +### How to Run Tests Locally + +```bash +cd app_python +pip install -r requirements.txt +pytest tests/test_app.py -v +``` + +### Terminal Output Showing All Tests Passing + +```bash +=================================================================== test session starts ==================================================================== +platform linux -- Python 3.14.2, pytest-8.0.0, pluggy-1.6.0 +rootdir: /home/flowelx/DevOps-Core-Course/app_python +plugins: anyio-4.12.1 +collected 3 items + +tests/test_app.py ... [100%] + +===================================================================== warnings summary ===================================================================== +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +venv/lib/python3.14/site-packages/starlette/_utils.py:40 +tests/test_app.py::test_404_error + /home/flowelx/DevOps-Core-Course/app_python/venv/lib/python3.14/site-packages/starlette/_utils.py:40: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead + return asyncio.iscoroutinefunction(obj) or (callable(obj) and asyncio.iscoroutinefunction(obj.__call__)) + +venv/lib/python3.14/site-packages/fastapi/routing.py:233 +venv/lib/python3.14/site-packages/fastapi/routing.py:233 + /home/flowelx/DevOps-Core-Course/app_python/venv/lib/python3.14/site-packages/fastapi/routing.py:233: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead + is_coroutine = asyncio.iscoroutinefunction(dependant.call) + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +============================================================== 3 passed, 11 warnings in 0.27s ============================================================== +``` + +## 2. GitHub Actions CI Workflow + +### Workflow Trigger Strategy + +**Configuration:** + +```yaml +on: + push: + branches: [ main, lab03 ] + pull_request: + branches: [ main ] +``` + +CI runs on feature branch and main. It saves GitHub Actions minutes, focused on importnant branches. +Docker build only runs on push to `main`. This prevents unnecessary Docker builds for every commit. + +### Marketplace Actions Chosen + +1. `actions/checkout@v4` - Official GitHub action, reliable, well-maintained + +2. `actions/setup-python@v5` - Handles multiple Python versions, caching built-in + +3. `docker/login-action@v3` - Secure token-based login, handles credentials properly + +4. `docker/build-push-action@v5` - Single action for both operations, supports caching + +### Docker Tagging Strategy + +**Strategy:** Calendar Versioning + +**Format:** `YYYY.NN.DD` + +It is convinient for frequent updates. There is no need to track breaking changes. + +### Successful Workflow Run + +**Link to Workflow Run:** https://github.com/flowelx/DevOps-Core-Course/actions/runs/21786077651/job/62857660802 + +**Screenshot of Green Checkmark:** + +![successfull ci](screenshots/successful-ci.jpg) + +## CI Best Practices & Security + +### Status Badge in README + +![status badge](screenshots/status-badge.jpg) + +### Caching Implementation + +**Python Package Caching:** + +```yaml +- uses: actions/setup-python@v5 + with: + cache: 'pip' + cache-dependency-path: 'app_python/requirements.txt' +``` + +### CI Best Practices Applied + +1. Path-based Triggers + +```yaml +paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' +``` + +2. **Job Dependencies** + +```yaml +build: + needs: test +``` + +3. **Conditional Execution** + +```yaml +if: github.event_name == 'push' && github.ref == 'refs/heads/main' +``` + +4. **Security Scanning** + +```yaml +- name: Security scan with pip-audit + run: | + cd app_python + pip install pip-audit + pip-audit -r requirements.txt || echo "Security scan completed" +``` + +5. **Linting** + +```yaml +- name: Run linter + run: | + cd app_python + flake8 app.py +``` + +6. **Test Reporting** + +```yaml +pytest tests/test_app.py +``` + +### Security Scanning Results + +**Tool Used:** `pip-audit` + +I couldn't use Snyk because the site did not open with or without vpn. So I applied `pip-audit`. + +**Scan Results:** + +``` +Found 2 known vulnerabilities in 1 package +Name Version ID Fix Versions +--------- ------- -------------- ------------ +starlette 0.38.6 CVE-2024-47874 0.40.0 +starlette 0.38.6 CVE-2025-54121 0.47.2 +``` diff --git a/app_python/docs/screenshots/status-badge.jpg b/app_python/docs/screenshots/status-badge.jpg new file mode 100644 index 0000000000..526a6e96ef Binary files /dev/null and b/app_python/docs/screenshots/status-badge.jpg differ diff --git a/app_python/docs/screenshots/successful-ci.jpg b/app_python/docs/screenshots/successful-ci.jpg new file mode 100644 index 0000000000..5320ec759e Binary files /dev/null and b/app_python/docs/screenshots/successful-ci.jpg differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt index 9abb353041..00365486c9 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -1,2 +1,5 @@ fastapi==0.115.0 -uvicorn[standard]==0.32.0 \ No newline at end of file +uvicorn[standard]==0.32.0 +pytest==8.0.0 +httpx +flake8 \ No newline at end of file diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..0583f71165 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,22 @@ +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + +def test_root_endpoint(): + response = client.get("/") + assert response.status_code == 200 + + data = response.json() + assert "service" in data + assert data["service"]["name"] == "devops-info-request" + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + +def test_404_error(): + response = client.get("/not-exists") + assert response.status_code == 404 \ No newline at end of file