Skip to content
Open
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
90 changes: 90 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Python CI

on: [push]

jobs:
build:

runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app_python

steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
cache: 'pip'
python-version: '3.12'
architecture: 'x64'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint flake8
run: |
flake8 app.py tests --count --max-complexity=15 --max-line-length=127 --statistics
- name: Testing with pytest
run: |
pytest tests/ --doctest-modules --junitxml=junit/test-results.xml

push_to_docker:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
defaults:
run:
working-directory: ./app_python

steps:
- name: Check out the repo
uses: actions/checkout@v5

- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}


- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: bulatgazizov/python_app
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}

- name: Build and push Docker image
id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with:
context: ./app_python
file: ./app_python/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Snyk CLI
run: |
curl https://downloads.snyk.io/cli/stable/snyk-linux -o snyk-linux
curl https://downloads.snyk.io/cli/stable/snyk-linux.sha256 -o snyk.sha256
sha256sum -c snyk.sha256
chmod +x snyk-linux
sudo mv snyk-linux /usr/local/bin/snyk
- name: Run Snyk to test project dependencies
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
cd app_python
pip install -r requirements.txt --break-system-packages
snyk test
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
test
test
.secrets
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# DevOps Engineering: Core Practices

![Workflow](https://github.com/BulatGazizov-dev/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)

[![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs)
[![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative)
[![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap)
Expand Down
2 changes: 0 additions & 2 deletions app_python/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from typing import Union

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import os
Expand Down
76 changes: 76 additions & 0 deletions app_python/docs/LAB03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Lab 03

## 1. Overview

### Testing Framework

* **Framework Chosen:** pytest
* **Rationale:** pytest was chosen for its simple syntax, powerful fixtures, and native integration with many CI tools.
* **Coverage:** My tests cover the following:
* `GET /`: Validates JSON structure, status code 200, and required fields.
* `GET /health`: Verifies the health check response and uptime increments
* `GET /unknown`: Verifies 404 response


### CI Workflow Configuration

* **Trigger Strategy:** The workflow is triggered on:
* `push` to the `main` branch.


* **Versioning Strategy:** SemVer
* **Rationale:** SemVer was chosen because it clearly communicates breaking changes to users of the Docker image.

---

## 2. Workflow Evidence

| Requirement | Evidence/Link |
| --- | --- |
| **Successful Workflow Run** | [Link to GitHub Actions Run](https://github.com/BulatGazizov-dev/DevOps-Core-Course/actions/runs/21883844379) |
| **Docker Hub Image** | [Link to Docker Hub Repository](https://hub.docker.com/r/bulatgazizov/python_app) |

### Local Terminal Output

```text
================================================================== test session starts ===================================================================
platform linux -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/bulatgazizov/Projects/DevOps-Core-Course/app_python
plugins: anyio-4.9.0
collected 4 items

tests/test_main.py .... [100%]

=================================================================== 4 passed in 1.62s ====================================================================
```

---

## 3. Best Practices Implemented

* **Linting:** Integrated `flake8` to ensure code style consistency.
* **Caching:** Implemented `actions/setup-python` caching.
* **Snyk Security:** Integrated Snyk vulnerability scanning. >> Tested 55 dependencies for known issues, no vulnerable paths found.

* **Docker Metadata:** Used `docker/metadata-action` for automated, multi-tag management.

---

## 4. Key Decisions

**Versioning Strategy:**
I chose SemVer because it clearly says whether an update contains bug fixes (patch), new features (minor), or breaking changes (major).

**Docker Tags:**
My CI generates the following tags:

* `latest`: Always points to the most recent build
* `vX.Y.Z`: Specific semantic versions based on Git tags.

**Workflow Triggers:**
The workflow triggers on pushes to main or any other branch. This ensures all code is linted, tested, and scanned for vulnerabilities before integration.

**Test coverage:**
* `GET /`: Validates JSON structure, status code 200, and required fields.
* `GET /health`: Verifies the health check response and uptime increments
* `GET /unknown`: Verifies 404 response
8 changes: 8 additions & 0 deletions app_python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ uvicorn==0.40.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0
iniconfig==2.3.0
pluggy==1.6.0
pytest==9.0.2
jsonschema==4.23.0
flake8==7.3.0
mccabe==0.7.0
pycodestyle==2.14.0
pyflakes==3.4.0
99 changes: 99 additions & 0 deletions app_python/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import time
from fastapi.testclient import TestClient
from jsonschema import validate
from app import app

client = TestClient(app)


def test_read_health():
response = client.get("/health")
schema = {
"type": "object",
"properties": {
"status": {"type": "string"},
"timestamp": {"type": "string"},
"uptime_seconds": {"type": "integer"},
},
"required": ["status", "timestamp", "uptime_seconds"]
}
assert response.status_code == 200

validate(instance=response.json(), schema=schema)


def test_read_root():
response = client.get("/")

schema = {
"type": "object",
"properties": {
"service": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version": {"type": "string"},
"description": {"type": "string"},
"framework": {"type": "string"}
},
"required": ["name", "version", "description", "framework"]
},
"system": {
"type": "object",
"properties": {
"hostname": {"type": "string"},
"platform": {"type": "string"},
"platform_version": {"type": "string"},
"architecture": {"type": "string"},
"cpu_count": {"type": "integer"},
"python_version": {"type": "string"}
},
"required": [
"hostname", "platform", "platform_version",
"architecture", "cpu_count", "python_version"
]
},
"runtime": {
"type": "object",
"properties": {
"uptime_seconds": {"type": "integer"},
"uptime_human": {"type": "string"},
"current_time": {"type": "string"},
"timezone": {"type": "string"}
},
"required": [
"uptime_seconds", "uptime_human",
"current_time", "timezone"
]
},
"request": {
"type": "object",
"properties": {
"client_ip": {"type": "string"},
"user_agent": {"type": "string"},
"method": {"type": "string"},
"path": {"type": "string"}
},
"required": ["client_ip", "user_agent", "method", "path"]
},
"endpoints": {"type": "array"}
},
"required": ["service", "system", "runtime", "request", "endpoints"]
}

assert response.status_code == 200

validate(instance=response.json(), schema=schema)


def test_404_handler():
response = client.get("/this-route-does-not-exist")
assert response.status_code == 404
assert response.json()["error"] == "Not Found"


def test_uptime_increments():
res1 = client.get("/health").json()["uptime_seconds"]
time.sleep(1)
res2 = client.get("/health").json()["uptime_seconds"]
assert res2 > res1