diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..28fa32cebc --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,73 @@ +name: Python CI (Lab03) + +on: + push: + branches: [ "main", "master", "lab3", "lab03" ] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: "app_python/requirements.txt" + + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Lint (ruff) + run: ruff check . + + - name: Tests + run: pytest -q + + docker-build-push: + needs: test-lint + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'lab3' || github.ref_name == 'lab03') + + steps: + - uses: actions/checkout@v4 + + - name: Set version (CalVer) + run: | + echo "CALVER=$(date +%Y.%m)" >> $GITHUB_ENV + echo "BUILD=${{ github.run_number }}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build & Push + uses: docker/build-push-action@v6 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/devops-info-service:${{ env.CALVER }}.${{ env.BUILD }} + ${{ secrets.DOCKER_USERNAME }}/devops-info-service:latest diff --git a/.gitignore b/.gitignore index 30d74d2584..236b92ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,13 @@ -test \ No newline at end of file +__pycache__/ +*.py[cod] +venv/ +__MACOSX/ +*.log + +.vscode/ +.idea/ + +.obsidian + +.DS_Store +.env \ No newline at end of file diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..333846391d --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,13 @@ +# Build outputs +devops-info-service +*.exe +*.out + +# Git / editor / OS +.git/ +.DS_Store +.idea/ +.vscode/ + +# Docs/tests not needed in image build context (optional) +docs/ diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..c90ba9e50f --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# --- Stage 1: Builder --- +FROM golang:1.23.5-alpine3.21 AS builder + +WORKDIR /src + +# Cache deps first +COPY go.mod ./ +RUN go mod download + +# Copy source +COPY main.go ./ + +# Build a static binary (smaller + easier to run in minimal images) +ARG TARGETOS +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-arm64} \ + go build -trimpath -ldflags="-s -w" -o /out/devops-info-service . + +# --- Stage 2: Runtime (minimal, non-root) --- +FROM gcr.io/distroless/static-debian12:nonroot AS runtime + +WORKDIR /app + +COPY --from=builder /out/devops-info-service /app/devops-info-service + +EXPOSE 8080 + +ENV HOST=0.0.0.0 \ + PORT=8080 + +# distroless:nonroot already runs as nonroot +ENTRYPOINT ["/app/devops-info-service"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..9e7e356fb0 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,53 @@ +# DevOps Info Service (Go) — Bonus Task + +## Overview +A Go implementation of the DevOps Info Service. It provides two endpoints: +- `GET /` returns service/system/runtime/request information in JSON +- `GET /health` returns a simple health status JSON + +## Prerequisites +- Go installed (check with `go version`) + +## Run (from source) +```bash +go run . +``` +By default the service listens on `0.0.0.0:8080`. + +### Custom configuration + +```bash +HOST=127.0.0.1 PORT=9090 go run . +``` + +## Build (binary) + +```bash +go build -o devops-info-service +``` + +## Run (binary) + +```bash +./devops-info-service +``` + +### Custom configuration (binary) + +```bash +HOST=127.0.0.1 PORT=9090 ./devops-info-service +``` + +## API Endpoints + +### GET / + +```bash +curl -s http://127.0.0.1:8080/ | python -m json.tool +``` + +### GET /health + +```bash +curl -s http://127.0.0.1:8080/health | python -m json.tool +``` diff --git a/app_go/devops-info-service b/app_go/devops-info-service new file mode 100755 index 0000000000..2b6d5eb4fd Binary files /dev/null and b/app_go/devops-info-service differ diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..dd58a9af72 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,7 @@ +# Why Go (Compiled Language) + +I chose Go for the compiled-language bonus task because: +- Go compiles into a single binary, which is convenient for deployment. +- It has a minimal standard library for building HTTP services (`net/http`). +- It’s fast to build and run and is commonly used in infrastructure/DevOps tooling. +- Small, self-contained binaries work well with Docker multi-stage builds in later labs. \ No newline at end of file diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..25a045574f --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,49 @@ +# LAB01 — Bonus Task (Go) + +## Implemented Endpoints +- `GET /` — returns service, system, runtime, request info + endpoints list (JSON) +- `GET /health` — returns health status + timestamp + uptime_seconds (JSON) + +The JSON structure matches the Python version (same top-level fields and same key layout inside each section). + +## How to Run (from source) +```bash +go run . +``` +#### Test: + +```bash +curl -s http://127.0.0.1:8080/ | python -m json.tool curl -s http://127.0.0.1:8080/health | python -m json.tool +``` + +## How to Build and Run (binary) + +#### Build: + +```bash +go build -o devops-info-service ls -lh devops-info-service +``` + +#### Run: + +```bash +./devops-info-service +``` + +#### Test binary: + +```bash +curl -s http://127.0.0.1:8080/health | python -m json.tool +``` + +## Screenshots + +Screenshots are stored in `docs/screenshots/`: + +Recommended set: +- `01-go-run.png` — running from source (`go run .`) +- `02-main-endpoint.png` — `GET /` output +- `03-health-endpoint.png` — `GET /health` output +- `04-go-build.png` — `go build` + `ls -lh` showing binary size +- `05-binary-run.png` — running compiled binary (`./devops-info-service`) +- `06-binary-health.png` — health check from binary run \ No newline at end of file diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..8c261d923f --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,146 @@ +# LAB02 (Bonus) — Multi-Stage Docker Build (Go) + +This document explains how I containerized the compiled Go application using a **multi-stage Docker build** to minimize the final image size and reduce the runtime attack surface.  + +--- + +## Multi-Stage Strategy + +### Stage 1 — Builder + +- **Image:** `golang:1.23.5-alpine3.21` + +- **Purpose:** provides the Go toolchain needed to compile the application. + +- **Output artifact:** a single Linux binary at `/out/devops-info-service`. + + +Key choices and why they matter: + +- **Dependency caching:** `go mod download` runs right after copying `go.mod`, before copying source code. + This means dependency download is cached and not repeated on every code change. + +- **Static binary:** `CGO_ENABLED=0` builds a static binary, which makes it suitable for minimal runtime images. + +- **Smaller binary:** `-ldflags="-s -w"` strips debug symbols to reduce binary size. + + +### Stage 2 — Runtime + +- **Image:** `gcr.io/distroless/static-debian12:nonroot` + +- **Purpose:** run only the binary (no shell, no package manager, minimal filesystem). + +- **Security benefit:** default **non-root** user and fewer components installed → smaller attack surface. + + +--- + +## Size Comparison (builder vs final) + +Final image size from `docker images`: + +``` +REPOSITORY TAG IMAGE ID CREATED SIZE +lab02-go latest 0f3bc22c104c 2 minutes ago 13.4MB +lab02-go-builder latest a52c4160b20d 2 minutes ago 468MB +``` + + +**Analysis:** +The multi-stage Go image is much smaller because the final runtime stage contains only the compiled binary and a minimal runtime filesystem. The builder stage contains the full Go toolchain and is not shipped. + +--- + +## Dockerfile Walkthrough + +Key parts of the Dockerfile and purpose: + +```dockerfile +FROM golang:1.23.5-alpine3.21 AS builder +WORKDIR /src + +COPY go.mod ./ +RUN go mod download +``` + +- Copies only `go.mod` first and downloads dependencies → maximizes Docker layer caching. + + +```dockerfile +COPY main.go ./ + +ARG TARGETOS +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-arm64} \ + go build -trimpath -ldflags="-s -w" -o /out/devops-info-service . +``` + +- Copies the source and compiles a **static** binary. + +- Uses `TARGETOS/TARGETARCH` to build correctly on different platforms (important on Apple Silicon / arm64). + + +```dockerfile +FROM gcr.io/distroless/static-debian12:nonroot AS runtime +WORKDIR /app +COPY --from=builder /out/devops-info-service /app/devops-info-service +EXPOSE 8080 ENV HOST=0.0.0.0 PORT=8080 +ENTRYPOINT ["/app/devops-info-service"] +``` + +- Distroless runtime is minimal and runs as non-root. + +- Only the binary is copied into the final image. + +- Port and env vars match the app defaults. + + +--- + +## Build & Run Evidence + +### Build + +```shell +docker build -t lab02-go -f app_go/Dockerfile app_go [+] +Building ... FINISHED +=> naming to docker.io/library/lab02-go:latest +``` + +### Run + +```shell +docker run --rm -p 8080:8080 lab02-go +``` + +### Test endpoints + +```shell +curl http://localhost:8080/ +# returned JSON with service/system/runtime/request information +``` + +(Optionally) + +```shell +curl http://localhost:8080/health +``` + +--- + +## Why Multi-Stage Builds Matter (Compiled Languages) + +- **Smaller images:** faster pulls, less storage, faster deploys (final image is ~13.4MB). + +- **Security:** runtime image excludes compilers, shells, package managers → fewer vulnerabilities and lower attack surface. + +- **Clear separation:** build-time vs run-time concerns are isolated. + + +Trade-offs: + +- Dockerfile becomes slightly more complex. + +- Debugging inside distroless containers is harder (no shell), so logs/metrics are preferred. \ No newline at end of file diff --git a/app_go/docs/screenshots/01-go-run.png b/app_go/docs/screenshots/01-go-run.png new file mode 100644 index 0000000000..c4c509db6b Binary files /dev/null and b/app_go/docs/screenshots/01-go-run.png differ diff --git a/app_go/docs/screenshots/02-main-endpoint.png b/app_go/docs/screenshots/02-main-endpoint.png new file mode 100644 index 0000000000..b3feb72f57 Binary files /dev/null and b/app_go/docs/screenshots/02-main-endpoint.png differ diff --git a/app_go/docs/screenshots/03-health-endpoint.png b/app_go/docs/screenshots/03-health-endpoint.png new file mode 100644 index 0000000000..da87ce83f7 Binary files /dev/null and b/app_go/docs/screenshots/03-health-endpoint.png differ diff --git a/app_go/docs/screenshots/04-go-build.png b/app_go/docs/screenshots/04-go-build.png new file mode 100644 index 0000000000..a3b615a218 Binary files /dev/null and b/app_go/docs/screenshots/04-go-build.png differ diff --git a/app_go/docs/screenshots/05-binary-run.png b/app_go/docs/screenshots/05-binary-run.png new file mode 100644 index 0000000000..ce3bea4ff6 Binary files /dev/null and b/app_go/docs/screenshots/05-binary-run.png differ diff --git a/app_go/docs/screenshots/06-binary-health.png b/app_go/docs/screenshots/06-binary-health.png new file mode 100644 index 0000000000..68dc2894a7 Binary files /dev/null and b/app_go/docs/screenshots/06-binary-health.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..3d6605d68d --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service-go + +go 1.23 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..7c57e28961 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "time" +) + +var startTime = time.Now().UTC() + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type ResponseRoot struct { + Service map[string]any `json:"service"` + System map[string]any `json:"system"` + Runtime map[string]any `json:"runtime"` + Request map[string]any `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +func uptimeSeconds() int { + return int(time.Since(startTime).Seconds()) +} + +func uptimeHuman(sec int) string { + h := sec / 3600 + m := (sec % 3600) / 60 + return fmt.Sprintf("%d hour(s), %d minute(s)", h, m) +} + +func hostname() string { + h, err := os.Hostname() + if err != nil { + return "" + } + return h +} + +func getClientIP(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + for i := 0; i < len(xff); i++ { + if xff[i] == ',' { + return xff[:i] + } + } + return xff + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func mainHandler(w http.ResponseWriter, r *http.Request) { + up := uptimeSeconds() + + resp := ResponseRoot{ + Service: map[string]any{ + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http", + }, + System: map[string]any{ + "hostname": hostname(), + "platform": runtime.GOOS, + "platform_version": "", + "architecture": runtime.GOARCH, + "cpu_count": runtime.NumCPU(), + "python_version": runtime.Version(), + }, + Runtime: map[string]any{ + "uptime_seconds": up, + "uptime_human": uptimeHuman(up), + "current_time": time.Now().UTC().Format(time.RFC3339Nano), + "timezone": "UTC", + }, + Request: map[string]any{ + "client_ip": getClientIP(r), + "user_agent": r.UserAgent(), + "method": r.Method, + "path": r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + log.Printf("Request: %s %s", r.Method, r.URL.Path) + writeJSON(w, http.StatusOK, resp) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("Request: %s %s", r.Method, r.URL.Path) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "uptime_seconds": uptimeSeconds(), + }) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) + + addr := host + ":" + port + log.Printf("Starting Go app on %s", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..b3d91b6ca5 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,23 @@ +# Python cache / bytecode +__pycache__/ +*.py[cod] + +# Virtual environments +venv/ +.venv/ + +# Editor/OS files +.DS_Store +.idea/ +.vscode/ + +# Tests and docs are not needed at runtime +tests/ +docs/ + +# Git +.git/ +.gitignore + +# Misc +*.log diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..236b92ec13 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +venv/ +__MACOSX/ +*.log + +.vscode/ +.idea/ + +.obsidian + +.DS_Store +.env \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..42002e9f4a --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.13.1-slim AS runtime + +# Prevent Python from writing .pyc files and ensure logs are unbuffered +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Create a dedicated, non-root user +RUN groupadd --system app && useradd --system --gid app --uid 10001 --create-home app + +# Install dependencies first for better layer caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy only the application code (no venv/tests/docs) +COPY --chown=app:app app.py . + +EXPOSE 5000 + +USER app + +# Flask app binds to 0.0.0.0 by default in this project (HOST env) +ENV HOST=0.0.0.0 \ + PORT=5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..74462d6f89 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,63 @@ +![CI](https://github.com/ostxxp/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) + +# DevOps Info Service (Labs 01–03) + +## Overview +Simple web service that returns service, system, runtime and request information. + +## Prerequisites +- Python 3.11+ +- pip +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` +## Running the Application + +```bash +python app.py +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +``` + +## API Endpoints + +- `GET /` - Service and system information +- `GET /health` - Health check +## Configuration + +|Variable|Default|Description| +|---|---|---| +|HOST|0.0.0.0|Bind host| +|PORT|5000|Bind port| +|DEBUG|False|Flask debug mode + +## Docker + +This app can be containerized and run with Docker. + +**Build (pattern):** + +- `docker build -t : -f app_python/Dockerfile app_python` + +**Run (pattern):** + +- `docker run --rm -p :5000 :` +- Optional envs: `-e PORT=5000 -e HOST=0.0.0.0` + +**Test endpoints (pattern):** + +- `curl http://localhost:/` +- `curl http://localhost:/health` + +**Pull from Docker Hub (pattern):** + +- `docker pull /:` +- then run it with the same `docker run -p ...` pattern + +Example: +`docker run --rm -p 5000:5000 ostxxp/devops-lab02-python:latest` + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..8514d3da91 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,115 @@ +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("devops-info-service") + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +app = Flask(__name__) +START_TIME = datetime.now(timezone.utc) + + +def _uptime_seconds() -> int: + return int((datetime.now(timezone.utc) - START_TIME).total_seconds()) + + +def _uptime_human(seconds: int) -> str: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return f"{hours} hour(s), {minutes} minute(s)" + + +def get_system_info() -> dict: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } + + +def get_request_info() -> dict: + forwarded_for = request.headers.get("X-Forwarded-For", "") + client_ip = forwarded_for.split(",")[0].strip() if forwarded_for else request.remote_addr + + return { + "client_ip": client_ip or "", + "user_agent": request.headers.get("User-Agent", ""), + "method": request.method, + "path": request.path, + } + + +def list_endpoints() -> list: + return [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ] + + +@app.get("/") +def index(): + logger.info("Request: %s %s", request.method, request.path) + + uptime_sec = _uptime_seconds() + payload = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime_sec, + "uptime_human": _uptime_human(uptime_sec), + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": get_request_info(), + "endpoints": list_endpoints(), + } + return jsonify(payload), 200 + + +@app.get("/health") +def health(): + logger.info("Request: %s %s", request.method, request.path) + + return ( + jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": _uptime_seconds(), + } + ), + 200, + ) + +@app.errorhandler(404) +def not_found(_err): + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 + + +@app.errorhandler(500) +def internal_error(_err): + return jsonify({"error": "Internal Server Error", "message": "An unexpected error occurred"}), 500 + + +if __name__ == "__main__": + logger.info("Starting app on %s:%s (debug=%s)", HOST, PORT, DEBUG) + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..1d18796cfa --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,65 @@ +# Lab 1 + +## 1) Framework Selection +**Chosen framework:** Flask + +**Why Flask:** +- Minimal setup and easy to understand for a beginner +- Perfect for a small service with only a couple endpoints +- Clear request handling and simple JSON responses + +| Framework | Pros | Cons | +|---|---|---| +| Flask | Simple, lightweight, easy learning curve | Less “built-in” features than Django | +| FastAPI | Great docs, async-ready, OpenAPI | Slightly more concepts (typing, ASGI) | +| Django | Full-featured framework | Overkill for this small service | + +## 2) Best Practices Applied +### Clean Code Organization +- Separate helper functions: system info, request info, uptime +- Clear naming and small functions + +### Configuration via Environment Variables +- `HOST`, `PORT`, `DEBUG` are read from environment variables + +### Error Handling +- Custom JSON responses for 404 and 500 errors + +### Logging +- Basic logging configured (INFO level) +- Logs requests to `/` and `/health` + +## 3) API Documentation +### GET / +Returns service metadata, system information, runtime info and request details. + +Example test: +```bash +curl -s http://127.0.0.1:5000/ | python -m json.tool +``` +### GET /health + +Returns health status, timestamp and uptime. + +Example test: + +`curl -s http://127.0.0.1:5000/health | python -m json.tool` + +## 4) Testing Evidence + +Screenshots are stored in: +`docs/screenshots/` +- `main-endpoint.png` — main endpoint JSON output +- `health-check.png` — health endpoint JSON output + +## 5) Challenges & Solutions + +- **Challenge:** Understanding required JSON structure + **Solution:** Implemented endpoints step-by-step and validated output using curl + json.tool. +- **Challenge:** Making the service configurable + **Solution:** Added environment variables `HOST` and `PORT` and verified by running on port 8080. + +## 6) GitHub Community + +Starring repositories helps bookmark useful projects and signals appreciation to maintainers, improving open-source discovery. +Following developers (professor/TAs/classmates) helps networking and makes it easier to learn from others’ activity and collaborate in team projects. \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..5ccd48c535 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,195 @@ +# LAB02 — Docker Containerization (Python) + +This document explains how the Lab 1 Python application was containerized using Docker best practices and published to Docker Hub.  + +--- + +## Docker Best Practices Applied + +### 1) Pinning a specific base image version + +**What I did:** +Used a pinned slim Python image: `python:3.13.1-slim`. + +**Why it matters:** +Pinning a specific image version guarantees reproducible builds and protects the application from unexpected breaking changes introduced by upstream image updates. + +**Snippet:** + +```dockerfile +FROM python:3.13.1-slim +``` + +--- + +### 2) Running as a non-root user + +**What I did:** +Created a dedicated system user and switched to it using the `USER` directive. + +**Why it matters:** +Running containers as non-root significantly reduces the impact of a potential container compromise by following the principle of least privilege. + +**Snippet:** + +```dockerfile +RUN groupadd --system app && useradd --system --gid app --uid 1000 app USER app +``` + +--- + +### 3) Proper layer ordering (cache optimization) + +**What I did:** +Copied `requirements.txt` first, installed dependencies, and only then copied the application source code. + +**Why it matters:** +Docker caches layers. Since dependencies change less frequently than application code, this approach speeds up rebuilds by reusing cached dependency layers. + +**Snippet:** + +```dockerfile +COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY --chown=app:app app.py . +``` + +--- + +### 4) Copying only necessary files + `.dockerignore` + +**What I did:** +Copied only required files (`requirements.txt`, `app.py`) and excluded unnecessary files using `.dockerignore`. + +**Why it matters:** +A smaller build context leads to faster builds, smaller images, and prevents accidental inclusion of sensitive or irrelevant files. + +**`.dockerignore` excerpt:** + +``` +venv/ +tests/ +docs/ +.git/ +__pycache__/ +``` + +--- + +### 5) Minimal runtime image + +**What I did:** +Used the `slim` Python image and disabled pip cache during dependency installation. + +**Why it matters:** +Smaller images reduce download time, storage usage, and overall attack surface. + +--- + +## Image Information & Decisions + +- **Base image:** `python:3.13.1-slim` + **Justification:** Official Python image with minimal footprint while fully supporting Flask. + +- **Exposed port:** `5000` (matches application default) + +- **Final image size:** `214 MB` + +- **Layer structure:** + + 1. Base image + + 2. Dependency installation + + 3. Application source code + + +--- + +## Build & Run Process + +### Build output + +```shell +docker build -t lab02-python -f app_python/Dockerfile app_python +``` + +``` +Successfully built 4e71b36e52d3 +Successfully tagged lab02-python:latest +``` + +--- + +### Run output + +```shell +docker run --rm -p 5000:5000 lab02-python Running on http://0.0.0.0:5000 +``` + +--- + +### Endpoint tests + +```shell +curl http://localhost:5000/ HTTP/1.1 200 OK +``` + +```shell +curl http://localhost:5000/health {"status":"healthy"} +``` + +--- + +## Docker Hub + +- **Repository URL:** + https://hub.docker.com/r/ostxxp/devops-lab02-python + + +### Tagging strategy + +The image was tagged using the pattern: + +`/:` + +For this lab, the image was published as: + +`ostxxp/devops-lab02-python:latest` + +This strategy ensures global uniqueness in Docker Hub and allows future versioned releases alongside the `latest` tag. + +--- + +## Technical Analysis + +### Why does this Dockerfile work the way it does? + +The Dockerfile installs dependencies first to leverage caching, runs the application as a non-root user for security, and exposes the correct port to allow access via Docker port mapping. + +### What would happen if the layer order changed? + +If application code were copied before installing dependencies, Docker would invalidate the cache on every code change, forcing a full reinstall of dependencies and slowing down rebuilds. + +### Security considerations implemented + +- Non-root container user + +- Minimal base image + +- Limited copied files + +- No pip cache retained + + +### How does `.dockerignore` improve the build? + +It reduces the build context size, speeds up the build process, and prevents unnecessary or sensitive files from being included in the image. + +--- + +## Challenges & Solutions + +- **Issue:** Understanding Docker image caching behavior. + +- **Solution:** Reordered layers and tested rebuild performance. + +- **What I learned:** Proper Dockerfile structure directly impacts performance, security, and maintainability. \ 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..d4f09805bc --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,92 @@ +# LAB03 — CI/CD with GitHub Actions + +## 1. Overview + +- Testing framework: **pytest** + - Chosen for simple syntax and good Flask integration. +- Linting tool: **ruff** + - Fast Python linter, easy to integrate in CI. +- CI: GitHub Actions + - Runs on push and pull request for `app_python/**` +- Versioning strategy: **Calendar Versioning (CalVer)** + - Format: `YYYY.MM.` + - Also tags image as `latest` +- Docker image: + - `/devops-info-service` + +--- + +## 2. Local Testing + +Run locally: + +```bash +cd app_python +pip install -r requirements.txt +ruff check . +pytest -q +``` + +Example output: + +`3 passed in 0.24s` + +--- + +## 3. Workflow Evidence + +- ✅ Tests and lint pass in GitHub Actions + +- ✅ Docker image built and pushed automatically + +- ✅ Tags created: + + - `YYYY.MM.` + + - `latest` + +- ✅ CI status badge added to README + + +--- + +## 4. CI Best Practices Implemented + +- **Dependency caching** (`cache: pip`) + Speeds up repeated workflow runs. + +- **Job dependency (`needs`)** + Docker build runs only if tests and lint pass. + +- **Path filters** + Workflow runs only when `app_python/**` changes. + +- **Concurrency cancel** + Cancels outdated runs on same branch. + + +--- + +## 5. Key Decisions + +### Why CalVer? + +This project is a service (not a library), so breaking changes are not critical for version communication. +CalVer makes versioning simple and automatically generated in CI. + +### Docker Tags + +- `YYYY.MM.` + +- `latest` + + +### What is tested? + +- `GET /` + +- `GET /health` + +- 404 error handling + +- JSON response structure \ No newline at end of file diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..dcc5cb65de Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..0df0550336 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..fc962b6bc1 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..4bad402958 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.0 +pytest +pytest-cov +ruff \ No newline at end of file diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py new file mode 100644 index 0000000000..db710a6dd6 --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,5 @@ +import os +import sys + +# add app_python/ to import path so "import app" works +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/app_python/tests/test_api.py b/app_python/tests/test_api.py new file mode 100644 index 0000000000..a00cc43368 --- /dev/null +++ b/app_python/tests/test_api.py @@ -0,0 +1,53 @@ +import pytest +from app import app + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +def test_root_ok_and_structure(client): + r = client.get("/") + assert r.status_code == 200 + + data = r.get_json() + assert isinstance(data, dict) + + # service + assert "service" in data + assert data["service"]["framework"] == "Flask" + + # system + assert "system" in data + assert "hostname" in data["system"] + assert "python_version" in data["system"] + + # runtime + assert "runtime" in data + assert "uptime_seconds" in data["runtime"] + assert "current_time" in data["runtime"] + + # endpoints + assert "endpoints" in data + assert isinstance(data["endpoints"], list) + + +def test_health_ok_and_structure(client): + r = client.get("/health") + assert r.status_code == 200 + + data = r.get_json() + assert isinstance(data, dict) + assert data["status"] == "healthy" + assert "timestamp" in data + assert "uptime_seconds" in data + + +def test_404_json(client): + r = client.get("/nope") + assert r.status_code == 404 + data = r.get_json() + assert data["error"] == "Not Found" \ No newline at end of file