Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions app_python/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ __pycache__/
*.py[cod]
venv/
*.log
.pytest_cache

# IDE
.vscode/
Expand Down
2 changes: 2 additions & 0 deletions app_python/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 5 additions & 5 deletions app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -157,4 +157,4 @@ async def internal_error_handler(request: Request, exc: Exception):
host=HOST,
port=PORT,
reload=DEBUG
)
)
184 changes: 184 additions & 0 deletions app_python/docs/LAB03.md
Original file line number Diff line number Diff line change
@@ -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
```
Binary file added app_python/docs/screenshots/status-badge.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app_python/docs/screenshots/successful-ci.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion app_python/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
uvicorn[standard]==0.32.0
pytest==8.0.0
httpx
flake8
22 changes: 22 additions & 0 deletions app_python/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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