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

on:
push:
branches: ["lab03", "master"]
paths:
- "app_python/**"
- ".github/workflows/python-ci.yml"
pull_request:
branches: ["master"]
paths:
- "app_python/**"
- ".github/workflows/python-ci.yml"

concurrency:
group: python-ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
IMAGE_NAME: devops-info-service
APP_DIR: app_python

jobs:
test-and-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
cache-dependency-path: |
app_python/requirements.txt
app_python/requirements-dev.txt

- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r app_python/requirements.txt
pip install -r app_python/requirements-dev.txt

- name: Lint (ruff)
run: |
cd app_python
ruff check .

- name: Tests (pytest) + coverage
run: |
cd app_python
pytest -q tests --cov=. --cov-report=term-missing --cov-report=xml

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: app_python/coverage.xml

- name: Install Snyk CLI
run: npm install -g snyk

- name: Snyk scan (dependencies)
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: |
cd app_python
snyk test --severity-threshold=high --file=requirements.txt

docker-build-and-push:
runs-on: ubuntu-latest
needs: test-and-lint
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03')
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Prepare CalVer tags
run: |
echo "CALVER_MONTH=$(date -u +'%Y.%m')" >> $GITHUB_ENV
echo "CALVER_BUILD=$(date -u +'%Y.%m').${{ github.run_number }}" >> $GITHUB_ENV

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/Dockerfile
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.CALVER_BUILD }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.CALVER_MONTH }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
61 changes: 40 additions & 21 deletions app_python/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# DevOps Info Service

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

## Overview
DevOps Info Service is a production-ready starter web service for the DevOps course.
It reports service metadata, runtime details, and basic system information.
Expand All @@ -16,22 +18,22 @@ The service exposes two endpoints:

## Installation

```
```bash
python -m venv venv
source venv/bin/activate
# Windows: .\venv\Scripts\activate
# Linux/macOS: source venv/bin/activate

pip install -r requirements.txt
```



## Running the Application

### Default run (port 5000)
> If `PORT` is not set, the application runs on **0.0.0.0:5000**.
```bash
python app.py
```
I will also test IP *192.168.31.32:5000*

### Custom configuration

**Linux/Mac:**
Expand Down Expand Up @@ -81,31 +83,48 @@ curl -i http://127.0.0.1:5000/health
curl -s http://127.0.0.1:5000/ | python -m json.tool
```

## Configuration
## Testing & Linting (LAB03)

| Variable | Default | Description |
|----------|---------|-------------|
| HOST | 0.0.0.0 | Bind address |
| PORT | 5000 | HTTP port |
| DEBUG | False | Flask debug mode |
> Dev dependencies live in `requirements-dev.txt` (pytest, coverage, ruff).

----
Install dev deps:
```bash
pip install -r requirements-dev.txt
```

## Run locally
Run linter:
```bash
ruff check .
```

Run tests + coverage:
```bash
pip install -r requirements.txt
python app.py
pytest -q tests --cov=. --cov-report=term-missing
```

By default the service listens on `0.0.0.0:5000`.
## CI/CD Secrets (GitHub Actions)

- `GET /` — service + system + runtime + request info
- `GET /health` — health check
In your GitHub repository:
**Settings → Secrets and variables → Actions → New repository secret**

Add:
- `DOCKERHUB_USERNAME` — your Docker Hub username
- `DOCKERHUB_TOKEN` — Docker Hub Access Token (Account Settings → Security)
- `SNYK_TOKEN` — Snyk API token (Account settings → API token)

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| HOST | 0.0.0.0 | Bind address |
| PORT | 5000 | HTTP port |
| DEBUG | False | Flask debug mode |

---

## Docker

> Patterns below use placeholders like `<image>` and `<tag>`.
> Examples below use placeholders like `<image>` and `<tag>`.

### Build (local)

Expand All @@ -119,7 +138,7 @@ docker build -t <image>:<tag> .
docker run --rm -p 5000:5000 <image>:<tag>
```

(Optionally override env vars)
(Optional: override env vars)

```bash
docker run --rm -p 5000:5000 -e PORT=5000 -e DEBUG=false <image>:<tag>
Expand All @@ -137,4 +156,4 @@ docker run --rm -p 5000:5000 <dockerhub-username>/<repo>:<tag>
```bash
curl http://localhost:5000/health
curl http://localhost:5000/
```
```
2 changes: 1 addition & 1 deletion app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import platform
import socket
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Dict

from flask import Flask, jsonify, request

Expand Down
74 changes: 74 additions & 0 deletions app_python/docs/LAB03.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# LAB03 — CI/CD (GitHub Actions)

## 1) Overview

### Testing framework
This project uses **pytest** because it provides:
- clean assertions and fixtures
- Flask test client without running a live server
- easy coverage reporting via `pytest-cov`

### Test coverage (what is tested)
- `GET /` — validates the JSON structure and required fields
- `GET /health` — validates the health-check response
- `GET /does-not-exist` — validates the JSON 404 error handler
- 500-case — forces an internal error and validates the JSON 500 error handler

### CI triggers
The workflow runs on `push` and `pull_request` for the selected branches, **only when** these paths change:
- `app_python/**`
- `.github/workflows/python-ci.yml`

### Versioning strategy
We use **CalVer** for Docker image tags:
- monthly tag: `YYYY.MM` (e.g., `2026.02`)
- build tag: `YYYY.MM.<run_number>` (e.g., `2026.02.31`)
- plus `latest`

This makes it easy to see *when* an image was built and to find the most recent build.

---

## 2) How to run locally

From the `app_python` directory:

```bash
python -m venv .venv
# Windows: .\.venv\Scripts\activate
# Linux/macOS: source .venv/bin/activate

pip install -r requirements.txt
pip install -r requirements-dev.txt

ruff check .
pytest -q tests --cov=. --cov-report=term-missing
```

---

## 3) Workflow evidence (paste links here)

Add links to prove the pipeline works:

- ✅ Successful workflow run: [https://github.com/dorley174/DevOps-Core-Course/actions/runs/21917089826/job/63287177794](https://github.com/dorley174/DevOps-Core-Course/actions/runs/21917089826/job/63287177794)
- ✅ Docker Hub image/repo: [https://hub.docker.com/repository/docker/dorley174/devops-info-service/general](https://hub.docker.com/repository/docker/dorley174/devops-info-service/general)
- ✅ Status badge in README: see `README.md`
or go
[![python-ci](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml)

---

## 4) Best practices implemented

- **Path filters**: CI does not run if changes are outside `app_python/**`
- **Job dependency**: Docker push runs only if lint + tests succeeded
- **Concurrency**: cancels outdated runs on the same branch
- **Least privileges**: `permissions: contents: read`
- **Caching**:
- pip cache via `actions/setup-python`
- Docker layer cache via `cache-to/cache-from type=gha`
- **Snyk scan**:
- scans dependencies from `requirements.txt`
- severity threshold = high
- `continue-on-error: true` (learning mode; does not block pipeline)
3 changes: 3 additions & 0 deletions app_python/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest==8.3.4
pytest-cov==6.0.0
ruff==0.9.7
9 changes: 9 additions & 0 deletions app_python/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import pytest
from app import app as flask_app


@pytest.fixture()
def client():
flask_app.config.update(TESTING=True)
with flask_app.test_client() as client:
yield client
40 changes: 40 additions & 0 deletions app_python/tests/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import app as app_module
from app import app as flask_app


def test_root_ok_structure(client):
resp = client.get("/", headers={"User-Agent": "pytest"})
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
for key in ("service", "system", "runtime", "request", "endpoints"):
assert key in data


def test_health_ok(client):
resp = client.get("/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "healthy"


def test_404_error_shape(client):
resp = client.get("/does-not-exist")
assert resp.status_code == 404
data = resp.get_json()
assert data["error"] == "Not Found"


def test_500_error_shape(monkeypatch):
flask_app.config.update(TESTING=False, PROPAGATE_EXCEPTIONS=False)

def boom():
raise RuntimeError("boom")

monkeypatch.setattr(app_module, "get_system_info", boom)

with flask_app.test_client() as client:
resp = client.get("/")
assert resp.status_code == 500
data = resp.get_json()
assert data["error"] == "Internal Server Error"