Skip to content
Open

Lab03 #2445

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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
**/__pycache__/
*.py[cod]
*$py.class
**/.env
**/.venv
**/env/
**/venv/
.git/
.gitignore
tests/
docs/
101 changes: 101 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Python CI

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

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Install dependencies
working-directory: app_python
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Lint with flake8
working-directory: app_python
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --exit-zero --max-complexity=10 --statistics

- name: Test with pytest
working-directory: app_python
run: pytest tests/pytest.py -v


security:
name: Security Scan
runs-on: ubuntu-latest
defaults:
run:
working-directory: app_python

steps:
- name: Checkout code
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'

- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt

- name: Set up Snyk
uses: snyk/actions/setup@master

- name: Run Snyk to check for vulnerabilities
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk test --package-manager=pip --file=requirements.txt --severity-threshold=high

docker:
needs: [test, security]
# if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Extract version
id: version
run: |
VERSION=$(grep '^APP_VERSION' app_python/app.py | cut -d'"' -f2)
echo "version=$VERSION" >> $GITHUB_OUTPUT

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

- name: Log in 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: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ steps.version.outputs.version }},${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app

COPY app_python/requirements.txt .

RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim

WORKDIR /app

RUN groupadd -r appuser && useradd -r -g appuser appuser

COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin

COPY app_python/app.py .

RUN chown -R appuser:appuser /app

USER appuser

EXPOSE 5000

ENV HOST=0.0.0.0 \
PORT=5000

CMD ["python", "app.py"]

5 changes: 5 additions & 0 deletions app_python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Python
__pycache__/
*.py[cod]
venv/
*.log
52 changes: 52 additions & 0 deletions app_python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# DevOps Info Service
[![Python CI](https://github.com/saddogsec/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](...)
## Overview
This service reports:
- Service metadata (name, version, framework)
- System data (hostname, operating system, cpu, python version)
- Runtime data (uptime and current UTC time)
- Request metadata (IP, user agent, method, path)

## Prerequisites
- Python 3.11+

## Installation
```bash
cd app_python
python -m venv venv
source venv/bin/activate # or source `venv/bin/activate.fish` if you using fish instead of bash/sh.
pip install -r requirements.txt
```

## Running the Application
```bash
python app.py

# Or with custom config
PORT=8080 python app.py
```

## API Endpoints
- `GET /` - Service and system information
- `GET /health` - Health check

## Configuration
`HOST` - Interface to bind (0.0.0.0 by default)

`PORT` - Port to listen on (5000 by default)

`DEBUG` - Enable Flask debug logging (False by default)

## Docker
- **Build image:**
```
docker build -t <image-name> .
```
- **Get image from dockerhub:**
```
docker pull saddogsec/devops-info-service:1.0.0
```
- **Run the image**
```
docker run -p 5000:5000 saddogsec/devops-info-service:1.0.0
```
152 changes: 152 additions & 0 deletions app_python/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import logging
import os
import platform
import socket
from datetime import datetime, timezone
from flask import Flask, jsonify, request

APP_NAME = "devops-info-service"
APP_VERSION = "1.0.0"
APP_DESCRIPTION = "DevOps course info service"
FRAMEWORK = "Flask"

HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "5000"))
DEBUG = os.getenv("DEBUG", "False").lower() == "true"

START_TIME = datetime.now(timezone.utc)


def iso_utc_z(dt: datetime) -> str:
utc_dt = dt.astimezone(timezone.utc)
return utc_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")


def get_uptime() -> dict:
delta = datetime.now(timezone.utc) - START_TIME
seconds = int(delta.total_seconds())
hours = seconds // 3600
minutes = (seconds % 3600) // 60
hour_label = "hour" if hours == 1 else "hours"
minute_label = "minute" if minutes == 1 else "minutes"
return {
"seconds": seconds,
"human": f"{hours} {hour_label}, {minutes} {minute_label}",
}


def get_client_ip() -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or "unknown"


def get_system_info() -> dict:
return {
"hostname": socket.gethostname(),
"platform": platform.system(),
"platform_version": platform.platform(),
"architecture": platform.machine(),
"cpu_count": os.cpu_count() or 0,
"python_version": platform.python_version(),
}


def get_runtime_info() -> dict:
uptime = get_uptime()
now_utc = datetime.now(timezone.utc)
return {
"uptime_seconds": uptime["seconds"],
"uptime_human": uptime["human"],
"current_time": iso_utc_z(now_utc),
"timezone": "UTC",
}


def get_request_info() -> dict:
return {
"client_ip": get_client_ip(),
"user_agent": request.headers.get("User-Agent", ""),
"method": request.method,
"path": request.path,
}


def get_endpoints() -> list[dict]:
return [
{"path": "/", "method": "GET", "description": "Service information"},
{"path": "/health", "method": "GET", "description": "Health check"},
]


def create_app() -> Flask:
app = Flask(__name__)

logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

@app.before_request
def log_request() -> None:
logger.debug("Request: %s %s", request.method, request.path)

@app.get("/")
def index():
payload = {
"service": {
"name": APP_NAME,
"version": APP_VERSION,
"description": APP_DESCRIPTION,
"framework": FRAMEWORK,
},
"system": get_system_info(),
"runtime": get_runtime_info(),
"request": get_request_info(),
"endpoints": get_endpoints(),
}
return jsonify(payload)

@app.get("/health")
def health():
uptime = get_uptime()
return jsonify(
{
"status": "healthy",
"timestamp": iso_utc_z(datetime.now(timezone.utc)),
"uptime_seconds": uptime["seconds"],
}
)

@app.errorhandler(404)
def not_found(_error):
return (
jsonify(
{
"error": "Not Found",
"message": "Endpoint does not exist",
}
),
404,
)

@app.errorhandler(500)
def internal_error(_error):
return (
jsonify(
{
"error": "Internal Server Error",
"message": "An unexpected error occurred",
}
),
500,
)

return app


if __name__ == "__main__":
app = create_app()
app.run(host=HOST, port=PORT, debug=DEBUG)
Loading