diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..7e89c429e1 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,145 @@ +name: Python CI and Docker Release + +on: + push: + branches: + - main + - lab3 + tags: + - "v*.*.*" + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: python-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint and Test + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: app_python/requirements.txt + + - name: Install dependencies + id: deps + run: | + START=$(date +%s) + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + pip install ruff + END=$(date +%s) + echo "install_seconds=$((END-START))" >> "$GITHUB_OUTPUT" + + - name: Lint (ruff) + run: ruff check app_python + + - name: Run unit tests + run: python -m unittest discover -s app_python/tests -v + + - name: Dependency cache report + if: always() + run: | + echo "### Dependency install metrics (Python ${{ matrix.python-version }})" >> "$GITHUB_STEP_SUMMARY" + echo "- cache-hit: \`${{ steps.setup-python.outputs.cache-hit }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- install-seconds: \`${{ steps.deps.outputs.install_seconds }}\`" >> "$GITHUB_STEP_SUMMARY" + + security: + name: Snyk Dependency Scan + runs-on: ubuntu-latest + needs: test + timeout-minutes: 15 + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + steps: + - name: Checkout + 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 dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + + - name: Set up Snyk CLI + if: ${{ env.SNYK_TOKEN != '' }} + uses: snyk/actions/setup@master + + - name: Run Snyk scan (high and critical) + if: ${{ env.SNYK_TOKEN != '' }} + continue-on-error: true + env: + SNYK_TOKEN: ${{ env.SNYK_TOKEN }} + run: snyk test \ + --org=sofiakulagina \ + --file=app_python/requirements.txt \ + --severity-threshold=high + + + - name: Snyk token reminder + if: ${{ env.SNYK_TOKEN == '' }} + run: echo "SNYK_TOKEN secret is not configured; Snyk scan skipped." + + docker: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: [test, security] + if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 20 + env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_python + file: app_python/Dockerfile + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..ef181e178a --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,5 @@ +bin/ +*.log +__pycache__/ +.git +vendor/ diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..3d2508ceeb --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,10 @@ +# Go build output +bin/ +*.out + +# IDE / Editor +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..47f2677eb1 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,30 @@ +## Multi-stage Dockerfile for Go app + +# Builder stage: use official Go image to compile a static Linux binary +FROM golang:1.22-alpine AS builder + +WORKDIR /src + +# Download dependencies separately to leverage caching +COPY go.mod ./ +RUN apk add --no-cache git \ + && go mod tidy + +# Copy source +COPY . . + +# Build a statically linked binary for Linux +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags "-s -w" -o /devops-info ./ + +# Final stage: tiny runtime image +FROM scratch + +# Copy binary from builder +COPY --from=builder /devops-info /devops-info + +# Expose port and use non-root numeric UID +EXPOSE 5002 +USER 1000 + +ENTRYPOINT ["/devops-info"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..f6125d649b --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,54 @@ +# DevOps Info Service (Go, Bonus) + +## Overview + +This directory contains a Go implementation of the **DevOps info service** with the same endpoints and JSON structure as the Python version: + +- `GET /` — service, system, runtime, request and endpoints information +- `GET /health` — simple health check for monitoring and Kubernetes probes + +## Prerequisites + +- Go 1.22+ installed + +## Build and Run + +```bash +cd app_go + +# Run directly +go run ./... + +# Or build a binary +mkdir -p bin +go build -o bin/devops-info-service-go ./... + +# Run the binary (default PORT=5002) +./bin/devops-info-service-go + +# Custom port +PORT=8080 ./bin/devops-info-service-go +``` + +## API Endpoints + +- `GET /` + - Returns JSON with: + - Service metadata (name, version, description, framework) + - System info (hostname, platform, architecture, CPU count, Go version) + - Runtime info (uptime in seconds and human readable, current time, timezone) + - Request info (client IP, user agent, method, path) + - List of available endpoints + +- `GET /health` + - Returns JSON with status, timestamp and uptime in seconds. + +## Configuration + +Configuration is done through environment variables: + +| Variable | Default | Description | +|----------|---------|------------------------------------| +| `PORT` | `5002` | TCP port for HTTP server | + +The server listens on `0.0.0.0:PORT` by default. diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..6102625301 --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,103 @@ +# LAB02 — Multi-stage Docker build for Go (English) + +Date: 2026-02-05 + +Goal: demonstrate a multi-stage Docker build for the Go application and explain decisions, trade-offs and measurements. + +## Multi-stage build strategy + +- **Builder stage (golang:1.22-alpine)** — compiles a statically linked binary with `CGO_ENABLED=0` and `-ldflags "-s -w"` to strip debugging symbols. +- **Runtime stage (scratch)** — copies only the resulting binary from the builder (`COPY --from=builder`) into a minimal image with no package manager or shell. + +Why this matters: +- The builder image contains compilers, source and toolchain (large). The final image contains only the binary, dramatically reducing size and attack surface. +- `COPY --from=builder` pulls artifacts from the named build stage into the final image. + +Dockerfile excerpts: + +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /src +COPY go.mod ./ +RUN apk add --no-cache git && go mod tidy +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o /devops-info ./ + +FROM scratch +COPY --from=builder /devops-info /devops-info +EXPOSE 5002 +USER 1000 +ENTRYPOINT ["/devops-info"] +``` + +Notes: +- `CGO_ENABLED=0` ensures a static binary that can run in `scratch` (no libc present). If the app uses C bindings, static build may fail and a different runtime base (e.g., distroless) may be required. +- We set `GOARCH=amd64` to produce an amd64 binary; adjust for your target architecture if needed (e.g., `arm64`). + +## Build & size measurements + +Commands executed locally (captured output below): + +```bash +docker build --platform=linux/amd64 -t devops-go:lab2 app_go +docker build --platform=linux/amd64 --target builder -t devops-go:builder app_go +docker images devops-go:lab2 --format "{{.Repository}}:{{.Tag}} {{.Size}}" +docker images devops-go:builder --format "{{.Repository}}:{{.Tag}} {{.Size}}" +``` + +Key terminal output (build & sizes): + +``` +... (build output omitted) ... +devops-go:lab2 2.16MB +devops-go:builder 103MB +``` + +Analysis: +- **Builder image (103MB)**: includes the Go toolchain and apk packages required to fetch dependencies and build the binary. +- **Final image (2.16MB)**: contains only the stripped static binary — well under the 20MB challenge target. + +Why you can't use the builder image as the final image: +- The builder contains compilers and package managers which increase image size and add additional attack surface. Keeping build tools out of production images reduces risk and distribution size. + +Security implications: +- Smaller images have fewer packages and fewer potential vulnerabilities. +- `scratch` has no shell; if an attacker gains code execution they have very limited tooling. +- Running as a non-root numeric user (`USER 1000`) reduces privilege even further. + +Trade-offs and notes: +- Using `scratch` gives the smallest possible image but also removes diagnostic tools; debugging requires reproducing the builder environment or adding temporary debug builds. +- If the binary depends on cgo or system libs, `scratch` may be unsuitable; use a minimal distro or distroless image. + +## Technical explanation of each stage + +- Builder stage: + - Installs `git` to allow `go mod tidy` to fetch modules. + - Copies `go.mod`, runs `go mod tidy` to populate `go.sum` and download dependencies (cached when `go.mod` hasn't changed). + - Copies source and builds a static binary with optimizations and stripped symbols. + +- Final stage: + - Uses `scratch` for minimal footprint. + - Copies only the binary. + - Exposes the application port and runs the binary directly. + +## Terminal outputs (selected) + +Build final image (abridged): + +``` +#10 [builder 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o /devops-info ./ +#12 exporting to image +#12 naming to docker.io/library/devops-go:lab2 done +``` + +Image sizes: + +``` +devops-go:lab2 2.16MB +devops-go:builder 103MB +``` + +## Conclusion + +The multi-stage build achieved a very small runtime image (2.16MB) by compiling a static Go binary and copying only the artifact into a `scratch` image. This reduces distribution size and attack surface, and demonstrates the typical pattern to containerize compiled-language applications efficiently. diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..8e1c02997d --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service-go + +go 1.22 diff --git a/app_go/internal/config/config.go b/app_go/internal/config/config.go new file mode 100644 index 0000000000..6f4afc502a --- /dev/null +++ b/app_go/internal/config/config.go @@ -0,0 +1,20 @@ +package config + +import "os" + +// Config holds application configuration. +type Config struct { + Port string +} + +// FromEnv reads configuration from environment variables with sensible defaults. +func FromEnv() Config { + port := os.Getenv("PORT") + if port == "" { + port = "5002" + } + + return Config{ + Port: port, + } +} diff --git a/app_go/internal/info/handlers.go b/app_go/internal/info/handlers.go new file mode 100644 index 0000000000..724580fd72 --- /dev/null +++ b/app_go/internal/info/handlers.go @@ -0,0 +1,99 @@ +package info + +import ( + "encoding/json" + "log" + "net" + "net/http" + "os" + "runtime" + "time" + + "devops-info-service-go/internal/uptime" +) + +// MainHandler returns an http.HandlerFunc for the "/" endpoint. +func MainHandler(logger *log.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Printf("Handling main endpoint: method=%s path=%s", r.Method, r.URL.Path) + + uptimeSeconds, uptimeHuman := uptime.Get() + + hostname, err := os.Hostname() + if err != nil { + logger.Printf("error getting hostname: %v", err) + hostname = "unknown" + } + + now := time.Now().UTC() + + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + clientIP = r.RemoteAddr + } + + info := ServiceInfo{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service (Go)", + Framework: "net/http", + }, + System: System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: runtime.Version(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: now.Format(time.RFC3339Nano), + Timezone: "UTC", + }, + Request: Request{ + ClientIP: clientIP, + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: http.MethodGet, Description: "Service information"}, + {Path: "/health", Method: http.MethodGet, Description: "Health check"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(info); err != nil { + logger.Printf("error encoding main response: %v", err) + } else { + logger.Printf("Successfully handled main endpoint for client_ip=%s", info.Request.ClientIP) + } + } +} + +// HealthHandler returns an http.HandlerFunc for the "/health" endpoint. +func HealthHandler(logger *log.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Printf("Handling health endpoint: method=%s path=%s", r.Method, r.URL.Path) + + uptimeSeconds, _ := uptime.Get() + + now := time.Now().UTC() + + health := Health{ + Status: "healthy", + Timestamp: now.Format(time.RFC3339Nano), + UptimeSeconds: uptimeSeconds, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(health); err != nil { + logger.Printf("error encoding health response: %v", err) + } else { + logger.Printf("Successfully handled health endpoint, uptime_seconds=%d", health.UptimeSeconds) + } + } +} diff --git a/app_go/internal/info/models.go b/app_go/internal/info/models.go new file mode 100644 index 0000000000..d253127643 --- /dev/null +++ b/app_go/internal/info/models.go @@ -0,0 +1,52 @@ +package info + +// ServiceInfo represents the full JSON payload for the main endpoint. +type ServiceInfo struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request Request `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + GoVersion string `json:"go_version"` +} + +type Runtime struct { + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type Request struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type Health struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int64 `json:"uptime_seconds"` +} diff --git a/app_go/internal/server/server.go b/app_go/internal/server/server.go new file mode 100644 index 0000000000..42438daf95 --- /dev/null +++ b/app_go/internal/server/server.go @@ -0,0 +1,75 @@ +package server + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "devops-info-service-go/internal/info" +) + +// New creates an HTTP handler with registered routes and middleware. +func New(logger *log.Logger) http.Handler { + mux := http.NewServeMux() + + mux.Handle("/", info.MainHandler(logger)) + logger.Println("Registered route: GET /") + + mux.Handle("/health", info.HealthHandler(logger)) + logger.Println("Registered route: GET /health") + + // Wrap with middleware: recovery then logging. + var handler http.Handler = mux + handler = recoveryMiddleware(logger, handler) + handler = loggingMiddleware(logger, handler) + + return handler +} + +// loggingMiddleware logs basic request information and latency. +func loggingMiddleware(logger *log.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + logger.Printf("Incoming request: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + + next.ServeHTTP(ww, r) + + duration := time.Since(start) + logger.Printf("%s %s %d %s", r.Method, r.URL.Path, ww.statusCode, duration) + }) +} + +// recoveryMiddleware recovers from panics and returns a JSON 500 error. +func recoveryMiddleware(logger *log.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + logger.Printf("panic recovered: %v", rec) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "Internal Server Error", + "message": "Unexpected server error", + }) + } + }() + + next.ServeHTTP(w, r) + }) +} + +// responseWriter wraps http.ResponseWriter to capture status codes. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *responseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} diff --git a/app_go/internal/uptime/uptime.go b/app_go/internal/uptime/uptime.go new file mode 100644 index 0000000000..194e485330 --- /dev/null +++ b/app_go/internal/uptime/uptime.go @@ -0,0 +1,18 @@ +package uptime + +import ( + "fmt" + "time" +) + +var startTime = time.Now() + +// Get returns uptime in seconds and human-readable format. +func Get() (seconds int64, human string) { + delta := time.Since(startTime) + seconds = int64(delta.Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + human = fmt.Sprintf("%d hours, %d minutes", hours, minutes) + return +} diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..8d47d64a57 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "net/http" + "os" + + "devops-info-service-go/internal/config" + "devops-info-service-go/internal/server" +) + +func main() { + logger := log.New(os.Stdout, "devops-go ", log.LstdFlags|log.LUTC|log.Lshortfile) + + cfg := config.FromEnv() + logger.Printf("Loaded config: port=%s", cfg.Port) + + handler := server.New(logger) + + addr := ":" + cfg.Port + logger.Printf("Starting Go devops-info-service on %s", addr) + + if err := http.ListenAndServe(addr, handler); err != nil { + logger.Fatalf("server error: %v", err) + } +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..563cc2fd15 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.pytest_cache/ +tests/ +docs/ +.git +.venv +venv/ +env/ +*.env +*.egg-info/ diff --git a/app_python/.env_example b/app_python/.env_example new file mode 100644 index 0000000000..01ef2c131d --- /dev/null +++ b/app_python/.env_example @@ -0,0 +1,8 @@ +# Example environment configuration for DevOps Info Service (Python) + +# Host and port configuration +HOST=0.0.0.0 +PORT=5002 + +# Enable debug mode only in development +DEBUG=false diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..3c9c67f86a --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Virtual environments +venv/ +.env/ +.env + +# Logs +*.log + +# IDE / Editor +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..fc1f8c476f --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Create a non-root user and group +RUN groupadd --gid 1000 appgroup \ + && useradd --uid 1000 --gid appgroup --create-home --home-dir /home/appuser --shell /bin/bash appuser \ + && mkdir -p /app \ + && chown -R appuser:appgroup /app + +WORKDIR /app + +# Install Python dependencies first to leverage layer caching +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy only the application code +COPY app.py ./ + +# Ensure app directory ownership, switch to non-root user +RUN chown -R appuser:appgroup /app +USER appuser + +# Document the port the app uses +EXPOSE 5002 + +# Start the application +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..7ac6e79cfd --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,258 @@ +# DevOps Info Service (Lab 1) + +[![Python CI and Docker Release](https://github.com/sofiakulagina/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=main)](https://github.com/sofiakulagina/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## Overview + +This project implements a simple **DevOps info service** written in Python using **Flask**. The service exposes HTTP endpoints that return detailed information about the application, the underlying system, and its runtime health. It is the base for later labs (Docker, CI/CD, monitoring, persistence, etc.). + +## Prerequisites + +- Python 3.11+ (recommended) +- pip (Python package manager) +- Optional: `virtualenv` or `venv` for isolated environments + +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env_example .env +``` + +## Running the Application + +```bash +# Default configuration (HOST=0.0.0.0, PORT=5002, DEBUG=False) +python app.py + +# Custom configuration via environment variables +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 DEBUG=true python app.py +``` + +## API Endpoints + +- `GET /` – Service and system information + - Service metadata (name, version, description, framework) + - System info (hostname, platform, architecture, CPU count, Python version) + - Runtime info (uptime, current time, timezone) + - Request info (client IP, user agent, HTTP method, path) + - List of available endpoints + +- `GET /health` – Health check + - Returns basic health status, timestamp, and uptime in seconds + +## Configuration + +Configuration is done via environment variables: + +| Variable | Default | Description | +|---------|-------------|---------------------------------------| +| `HOST` | `0.0.0.0` | Address the Flask app listens on | +| `PORT` | `5002` | TCP port for HTTP server | +| `DEBUG` | `False` | Enable Flask debug mode if `true` | + +All configuration is read in `app.py` at startup, so restart the application after changing environment variables. + +## Unit Testing + +### Framework Choice + +For this lab, the project uses Python `unittest`. + +Short comparison: +- `pytest`: concise syntax and rich plugin ecosystem, but adds an external dependency. +- `unittest`: part of the Python standard library, no additional package required. + +Why `unittest` was chosen: +- Works out of the box in minimal lab environments. +- Keeps dependencies small and predictable. +- Supports fixtures (`setUpClass`) and mocking (`unittest.mock`) needed for endpoint testing. + +### Test Structure + +Tests are located in `tests/test_app.py` and cover: +- `GET /` success response: + - expected top-level JSON fields, + - required nested fields and data types, + - request metadata (client IP and user-agent handling). +- `GET /health` success response: + - status, timestamp, uptime checks. +- Error responses: + - `404` JSON error for unknown route, + - simulated internal failures for `/` and `/health` returning JSON `500`. + +### Run Tests Locally + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python -m unittest discover -s tests -v +``` + +Optional coverage (standard library): + +```bash +python -m trace --count --summary -m unittest discover -s tests -v +``` + +### Example Passing Output + +```text +Ran 6 tests in 0.018s + +OK +``` + +## Docker + +How to use the containerized application (patterns): + +- **Build image (local):** `docker build -t : ` +- **Tag for Docker Hub:** `docker tag : /:` +- **Run container (local):** `docker run -p : --name ` +- **Pull from Docker Hub:** `docker pull /:` + +Notes: +- The container exposes port `5002` by default (see `app.py`). +- The image runs as a non-root user for improved security. + +## CI Workflow (GitHub Actions) + +### Workflow Overview + +Workflow file: `.github/workflows/python-ci.yml` + +It runs on: +- `push` to `main` and `lab3`, and `pull_request` into `main` for lint + tests. +- `push` of SemVer git tags (`vX.Y.Z`) for Docker build and push. +- manual run via `workflow_dispatch`. + +### Versioning Strategy + +Chosen strategy: **Semantic Versioning (SemVer)**. + +Why SemVer: +- Clear signal for breaking vs backward-compatible changes. +- Common convention for releases and container tags. + +Docker tags produced on `vX.Y.Z`: +- `X.Y.Z` (full version) +- `X.Y` (rolling minor) +- `latest` + +Example: +- `username/devops-info-service:1.2.3` +- `username/devops-info-service:1.2` +- `username/devops-info-service:latest` + +### Secrets Required + +Add these GitHub repository secrets: +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` (Docker Hub access token) + +### Release Flow + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +The Docker job runs only on SemVer tags and pushes images with the tags above. + +## CI Best Practices and Security (Task 3) + +### Status Badge + +The README includes a GitHub Actions badge for `.github/workflows/python-ci.yml` showing pass/fail status for `main`. + +Badge and workflow link: +- Badge: `https://github.com/sofiakulagina/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=main` +- Workflow runs: `https://github.com/sofiakulagina/DevOps-Core-Course/actions/workflows/python-ci.yml` + +### Dependency Caching + +Implemented in workflow via `actions/setup-python@v5`: +- `cache: pip` +- `cache-dependency-path: app_python/requirements.txt` + +The workflow also writes install metrics into the Job Summary for each Python version: +- `cache-hit` (`true` or `false`) +- `install-seconds` (dependency installation time) + +Measured baseline from workflow summary: +- Python 3.11: `cache-hit=false`, `install-seconds=5` +- Python 3.12: `cache-hit=false`, `install-seconds=3` + +How speed improvement is measured: +1. Run workflow once after dependency change (cache miss baseline). +2. Run workflow again without changing `app_python/requirements.txt` (expected cache hit). +3. Compare `install-seconds` from Job Summary: + `improvement_percent = ((miss_seconds - hit_seconds) / miss_seconds) * 100` + +Current status: +- Baseline (miss) is recorded. +- Next run is needed to capture hit values and final percentage. + +Metrics screenshot: +- Link: `docs/screenshots/metrics_lab3.png` + +![Dependency metrics screenshot](docs/screenshots/metrics_lab3.png) + +### Snyk Security Scanning + +Integrated with `snyk/actions/setup@master` and `snyk test` CLI command in a dedicated `security` job. + +Configuration: +- Secret required: `SNYK_TOKEN` +- Scan target: `app_python/requirements.txt` +- Threshold: `high` (`--severity-threshold=high`) +- Mode: non-blocking (`continue-on-error: true`) to keep visibility without blocking delivery during lab work. + +If `SNYK_TOKEN` is missing, workflow prints a clear skip message. + +Security results documentation: +- Latest scan status: `Succeeded` +- Scan output: `Tested 7 dependencies for known issues, no vulnerable paths found.` +- Vulnerability count: `0` (for threshold `high`) +- Vulnerability handling policy: upgrade direct dependencies first; if no fix exists, track risk in lab notes and keep non-blocking scan mode. + +Snyk screenshot: +- Link: `docs/screenshots/snyk_lab3.png` + +![Snyk scan screenshot](docs/screenshots/snyk_lab3.png) + +How to get `SNYK_TOKEN`: +1. Open `https://app.snyk.io` +2. Go to `Account Settings` -> `API Token` +3. Copy token and add GitHub secret: + `Repository Settings` -> `Secrets and variables` -> `Actions` -> `New repository secret` +4. Secret name must be `SNYK_TOKEN` + +### Additional CI Best Practices Applied + +Implemented practices: +- **Concurrency control:** cancels outdated runs for same ref (`cancel-in-progress: true`). +- **Least-privilege permissions:** workflow-level `permissions: contents: read`. +- **Matrix testing:** tests run on Python `3.11` and `3.12`. +- **Fail-fast matrix:** stops quickly when one matrix leg fails. +- **Job dependencies:** Docker job requires successful `test` and `security` jobs. +- **Docker layer cache:** `cache-from/cache-to type=gha` for faster image builds. +- **Manual trigger:** `workflow_dispatch` for controlled reruns. +- **Timeouts:** explicit `timeout-minutes` per job to avoid stuck pipelines. + +### Docker Build Evidence + +From `Build and Push Docker Image` summary: +- Build status: `completed` +- Build duration: `17s` +- Docker build cache usage in that run: `0%` + +Final CI/CD execution screenshot: +- Link: `docs/screenshots/artifacts_lab3.png` + +![Final CI/CD screenshot](docs/screenshots/artifacts_lab3.png) diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..8fe89858ca --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,160 @@ +""" +DevOps Info Service +Main application module for Lab 1. +""" + +import logging +import os +import platform +import socket +from datetime import datetime, timezone +from typing import Any, Dict + +from flask import Flask, jsonify, request + + +app = Flask(__name__) + + +# Configuration +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5002)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + + +# Application start time (for uptime calculation) +START_TIME = datetime.now(timezone.utc) + + +# Logging configuration +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) +logger.info("Application starting...") + + +def get_uptime() -> Dict[str, Any]: + """Return uptime in seconds and human-readable form.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + "seconds": seconds, + "human": f"{hours} hours, {minutes} minutes", + } + + +def get_system_info() -> Dict[str, Any]: + """Collect system information.""" + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.platform(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 1, + "python_version": platform.python_version(), + } + + +def get_request_info() -> Dict[str, Any]: + """Collect request information from the current Flask request.""" + client_ip = request.headers.get("X-Forwarded-For", request.remote_addr) + user_agent = request.headers.get("User-Agent", "") + + return { + "client_ip": client_ip, + "user_agent": user_agent, + "method": request.method, + "path": request.path, + } + + +@app.route("/", methods=["GET"]) +def index(): + """Main endpoint - service and system information.""" + uptime = get_uptime() + system_info = get_system_info() + request_info = get_request_info() + + response = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + }, + "system": system_info, + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": request_info, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information", + }, + { + "path": "/health", + "method": "GET", + "description": "Health check", + }, + ], + } + + logger.info("Handled main / request") + return jsonify(response) + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check endpoint.""" + uptime = get_uptime() + payload = { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime["seconds"], + } + logger.info("Health check OK") + return jsonify(payload), 200 + + +@app.errorhandler(404) +def not_found(error): + """Return JSON for 404 errors.""" + logger.warning("404 Not Found: %s %s", request.method, request.path) + return ( + jsonify( + { + "error": "Not Found", + "message": "Endpoint does not exist", + } + ), + 404, + ) + + +@app.errorhandler(500) +def internal_error(error): + """Return JSON for 500 errors.""" + logger.exception("500 Internal Server Error") + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("Starting Flask development server on %s:%s", HOST, PORT) + 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..9feb7536df --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,110 @@ +# LAB 1 — DevOps Info Service (Go Bonus) + +## 1. Language Choice (Go) + +For the compiled implementation I chose **Go**. It is well suited for small HTTP services because: + +- It produces small, static binaries that are easy to ship in Docker images. +- The standard library includes a solid `net/http` package, so no heavy frameworks are required. +- Compiles quickly and has good performance for concurrent workloads. + +## 2. Implementation Overview + +The Go service exposes the same endpoints and similar JSON structure as the Python/Flask version: + +- `GET /` — returns `service`, `system`, `runtime`, `request`, `endpoints`. +- `GET /health` — returns `status`, `timestamp`, `uptime_seconds`. + +The uptime is calculated from a `startTime` global set at application start. System information uses values from the Go runtime (OS, architecture, CPU count, Go version) and the OS hostname. + +## 3. Build and Run + +```bash +cd app_go + +# Run directly (development) +go run ./... + +# Build binary +mkdir -p bin +go build -o bin/devops-info-service-go ./... + +# Run with default configuration (PORT=5002) +./bin/devops-info-service-go + +# Custom port +PORT=8080 ./bin/devops-info-service-go +``` + +## 4. API Documentation + +### `GET /` + +Example (shortened): + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "framework": "net/http" + }, + "system": { + "platform": "linux", + "architecture": "amd64", + "cpu_count": 8, + "go_version": "go1.22.0" + }, + "runtime": { + "uptime_seconds": 120, + "uptime_human": "0 hours, 2 minutes" + }, + "request": { + "client_ip": "127.0.0.1:53412", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET"}, + {"path": "/health", "method": "GET"} + ] +} +``` + +### `GET /health` + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T12:00:00.000000Z", + "uptime_seconds": 360 +} +``` + +### Testing Commands + +```bash +# Main endpoint +curl -s http://localhost:5002/ | jq + +# Health check +curl -s http://localhost:5002/health | jq +``` + +## 5. Binary Size Comparison + +After building both implementations in release mode (Python in a slim image and Go as a static binary) you can compare image or binary sizes: + +- Go binary `bin/devops-info-service-go` is typically much smaller than a full Python runtime + dependencies. +- This makes multi‑stage Docker builds efficient: first stage builds the Go binary, second stage copies only the small binary. + +## 6. Screenshots + +Screenshots for the Go version can be stored in `app_go/docs/screenshots/`: + +- `01-main-endpoint-go.png` — `/` response +- `02-health-check-go.png` — `/health` response + +## 7. GitHub Community + +Starring repositories is important in open source because it shows maintainers that their work is useful and increases the visibility of good projects for the wider community. Following developers (professors, TAs, and classmates) helps you discover new projects, learn from real-world commits, and build a professional network that makes teamwork and long‑term career growth easier. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..7f2ffb49d2 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,205 @@ +# LAB02 — Документация по Docker + +Дата: 2026-02-05 + +Цель: документировать решение по контейнеризации `app_python` и объяснить принятые решения. + +## 1. Docker Best Practices Applied + +- **Non-root user**: в `Dockerfile` создан пользователь `appuser` и используется инструкция `USER appuser`. + - Почему важно: запуск приложения не от root снижает риск эскалации привилегий в случае уязвимости. + - Фрагмент Dockerfile: + +# LAB02 — Docker documentation + +Date: 2026-02-05 + +Goal: document the containerization of `app_python` and explain implementation decisions. + +## 1. Docker Best Practices Applied + +- **Non-root user**: the `Dockerfile` creates a dedicated user `appuser` and switches to it with `USER appuser`. + - Why it matters: running the application as non-root reduces the risk of privilege escalation if the application is compromised. + - Dockerfile snippet: + +```dockerfile +RUN groupadd --gid 1000 appgroup \ + && useradd --uid 1000 --gid appgroup --create-home --home-dir /home/appuser --shell /bin/bash appuser +USER appuser +``` + +- **Layer caching (layer ordering)**: dependencies are copied and installed first (`COPY requirements.txt` + `RUN pip install ...`), then application code is copied. + - Why it matters: when code changes, dependency layers remain cached so builds are faster. + - Dockerfile snippet: + +```dockerfile +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py ./ +``` + +- **.dockerignore**: unnecessary files are excluded (`__pycache__`, `tests`, `docs`, `.git`, virtual environments, etc.). + - Why it matters: reduces build context size, speeds up context upload, and lowers the chance of accidentally including secrets. + - Example entries: `__pycache__/`, `*.pyc`, `tests/`, `docs/`, `.git`, `venv/`. + +- **Minimal files copied into the image**: only `requirements.txt` and `app.py` are copied. + - Why it matters: reduces image size and attack surface. + +- **Documented port**: `EXPOSE 5002` reflects the `PORT` used in `app.py`. + - Why it matters: clarifies how container port maps to host port. + +## 2. Image Information & Decisions + +- **Base image:** `python:3.13-slim` + - Rationale: a recent Python 3.13 base that balances completeness with size; the `-slim` variant reduces unnecessary packages while still being well-supported. + +- **Final image size:** locally the image is `208MB` for `sofiakulagina/devops-info:lab2`. + - Assessment: acceptable for a simple Flask app without compiled dependencies. Options to reduce size further include `python:3.13-alpine` or multi-stage builds, but alpine can require additional work for binary dependencies. + +- **Layer structure:** + 1. `FROM python:3.13-slim` + 2. create user and directories (`RUN groupadd && useradd ...`) + 3. `WORKDIR /app` + 4. `COPY requirements.txt` (dependencies layer) + 5. `RUN pip install ...` (installed packages) + 6. `COPY app.py` (application code) + 7. set ownership and switch to non-root user + 8. `EXPOSE` and `CMD` + + - Optimization rationale: place rarely-changed layers (dependencies) before frequently-changed layers (code). + +## 3. Build & Run Process + +Below is the full build and push output copied from the terminal: + +``` +[+] Building 13.7s (13/13) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 762B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 2.2s + => [auth] library/python:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 127B 0.0s + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94f 7.5s + => => resolve docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94f 0.0s + => => sha256:4c4a8dac933699cea1f21584a1e5db68e248aadadfff93ddd7 251B / 251B 0.4s + => => sha256:14c37da83ac4440d59e5d2c0f06fb6ccd1c771929bd408 1.27MB / 1.27MB 1.0s + => => sha256:af94c6242df37e8cf3963ed59ccc0252e79a0554a8f1 11.73MB / 11.73MB 3.7s + => => sha256:3ea009573b472d108af9af31ec35a06fe3649084f661 30.14MB / 30.14MB 6.8s + => => extracting sha256:3ea009573b472d108af9af31ec35a06fe3649084f6611cf11f7 0.4s + => => extracting sha256:14c37da83ac4440d59e5d2c0f06fb6ccd1c771929bd4083c0a3 0.0s + => => extracting sha256:af94c6242df37e8cf3963ed59ccc0252e79a0554a8f18f4555d 0.2s + => => extracting sha256:4c4a8dac933699cea1f21584a1e5db68e248aadadfff93ddd73 0.0s + => [internal] load build context 0.0s + => => transferring context: 4.20kB 0.0s + => [2/7] RUN groupadd --gid 1000 appgroup && useradd --uid 1000 --gid a 0.5s + => [3/7] WORKDIR /app 0.0s + => [4/7] COPY requirements.txt ./ 0.0s + => [5/7] RUN pip install --no-cache-dir -r requirements.txt 3.0s + => [6/7] COPY app.py ./ 0.0s + => [7/7] RUN chown -R appuser:appgroup /app 0.1s + => exporting to image 0.3s + => => exporting layers 0.1s + => => exporting manifest sha256:c642c1c7aaee4610b39023ffcadbed270e6451b5c9f 0.0s + => => exporting config sha256:a9190d3d8c85ae31f27fa2ce1878878d83fce01a98442 0.0s + => => exporting attestation manifest sha256:5e06288fb0b759a0d0c0d51a3509877 0.1s + => => exporting manifest list sha256:2e664e7122e99b89753b34ffa33fad32cf91c2 0.0s + => => naming to docker.io/library/devops-info:lab2 0.0s + => => unpacking to docker.io/library/devops-info:lab2 0.0s +``` + +Authentication and push output: + +``` +Authenticating with existing credentials... [Username: sofiakulagina] + +i Info → To login with a different account, run 'docker logout' followed by 'docker login' + +Login Succeeded +The push refers to repository [docker.io/sofiakulagina/devops-info] +fb9638eac157: Pushed +4c4a8dac9336: Pushed +3ea009573b47: Pushed +334e78adf83a: Pushed +de87663f4206: Pushed +af94c6242df3: Pushed +37c0e012c728: Pushed +004840e29eb7: Pushed +4f4fb700ef54: Mounted from vulhub/langflow +de09dc1fb1f3: Pushed +14c37da83ac4: Pushed +lab2: digest: sha256:2e664e7122e99b89753b34ffa33fad32cf91c2843be398292fd1d2a97b167558 size: 856 +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 2189, + "digest": "sha256:c642c1c7aaee4610b39023ffcadbed270e6451b5c9f1019b3c3cc483d6c260df", + "platform": { + "architecture": "arm64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 566, + "digest": "sha256:5e06288fb0b759a0d0c0d51a35098779dc0b0e8c68ff67b936c7e3f844a25006", + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] +} +``` + +Below is the output from running the container and testing endpoints locally: + +``` +de299d26c9701ccf05e8fc8221db02946e058a9a3c8b766ec3d7c9ad38784f05 +de299d26c970 sofiakulagina/devops-info:lab2 Up 1 second +2026-02-05 10:40:00,921 - __main__ - INFO - Application starting... +2026-02-05 10:40:00,921 - __main__ - INFO - Starting Flask development server on 0. +0.0.0.0:5002 * Serving Flask app 'app' + * Debug mode: off +2026-02-05 10:40:00,926 - werkzeug - INFO - WARNING: This is a development server. +Do not use it in a production deployment. Use a production WSGI server instead. * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5002 + * Running on http://172.17.0.2:5002 +2026-02-05 10:40:00,926 - werkzeug - INFO - Press CTRL+C to quit +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"192.168.65.1","method":"GET","path":"/","user_agent":"curl/8.7.1"},"runtime":{"current_time":"2026-02-05T10:40:01.742865+00:00","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":0},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"aarch64","cpu_count":10,"hostname":"de299d26c970","platform":"Linux","platform_version":"Linux-6.10.14-linuxkit-aarch64-with-glibc2.41","python_version":"3.13.12"}} +{"status":"healthy","timestamp":"2026-02-05T10:40:01.753235+00:00","uptime_seconds":0} +``` + +**Docker Hub repository URL:** https://hub.docker.com/r/sofiakulagina/devops-info + +## 4. Technical Analysis + +- **Why the Dockerfile works:** instruction ordering minimizes unnecessary layer rebuilds—dependencies are installed before copying frequently-changing application code. The user and permissions are created and fixed before switching to the non-root user. + +- **What happens if layer order changes:** copying the entire source first would invalidate the cached dependency layers on every code change, causing slower rebuilds. + +- **Security considerations:** running as non-root, limiting copied files, and excluding sensitive files via `.dockerignore`. + +- **How `.dockerignore` improves build:** reduces context size, lowers the risk of accidentally including sensitive or unnecessary files, and speeds up `docker build`. + +## 5. Challenges & Solutions + +- **Issue:** an earlier attempt to run `docker build` failed because the build context `app_python` was not found. + - **How I debugged:** confirmed the current working directory was the repository root and that the `app_python` folder exists; rerunning the command from the correct directory fixed the issue. + +- **Issue:** Flask prints a warning about using the development server. + - **Resolution/notes:** for production, use a WSGI server like `gunicorn` or `uvicorn`; for lab/testing, the development server is acceptable. + +## 6. Improvements to consider + +- Switch to a production-ready WSGI server (`gunicorn`) and update `CMD` accordingly. +- Consider multi-stage builds or `python:3.13-alpine` to reduce image size (account for compiling dependencies). +- Add CI to build and push images automatically on releases. + +--- + +Files: `Dockerfile` and `.dockerignore` are in `app_python`. diff --git a/app_python/docs/screenshots/artifacts_lab3.png b/app_python/docs/screenshots/artifacts_lab3.png new file mode 100644 index 0000000000..47e8eea188 Binary files /dev/null and b/app_python/docs/screenshots/artifacts_lab3.png differ diff --git a/app_python/docs/screenshots/endpoint_check.png b/app_python/docs/screenshots/endpoint_check.png new file mode 100644 index 0000000000..52c73f342a Binary files /dev/null and b/app_python/docs/screenshots/endpoint_check.png differ diff --git a/app_python/docs/screenshots/metrics_lab3.png b/app_python/docs/screenshots/metrics_lab3.png new file mode 100644 index 0000000000..f778be014b Binary files /dev/null and b/app_python/docs/screenshots/metrics_lab3.png differ diff --git a/app_python/docs/screenshots/snyk_lab3.png b/app_python/docs/screenshots/snyk_lab3.png new file mode 100644 index 0000000000..f71cf425ea Binary files /dev/null and b/app_python/docs/screenshots/snyk_lab3.png differ diff --git a/app_python/docs/screenshots/test.png b/app_python/docs/screenshots/test.png new file mode 100644 index 0000000000..67cfef477a Binary files /dev/null and b/app_python/docs/screenshots/test.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..22ac75b399 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..3eced19bbc --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,133 @@ +import os +import sys + +import unittest +from unittest.mock import patch + + +# Allow importing app_python/app.py as a module named "app" +TESTS_DIR = os.path.dirname(__file__) +APP_DIR = os.path.dirname(TESTS_DIR) +sys.path.insert(0, APP_DIR) + +import app as app_module # noqa: E402 + + +class DevOpsInfoServiceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + app_module.app.config.update( + TESTING=False, + PROPAGATE_EXCEPTIONS=False, + ) + cls.client = app_module.app.test_client() + + def test_root_endpoint_returns_expected_structure_and_types(self): + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.is_json) + + data = response.get_json() + self.assertIsInstance(data, dict) + + for key in ("service", "system", "runtime", "request", "endpoints"): + self.assertIn(key, data) + + self.assertEqual(data["service"]["name"], "devops-info-service") + self.assertEqual(data["service"]["version"], "1.0.0") + self.assertEqual(data["service"]["framework"], "Flask") + self.assertIsInstance(data["service"]["description"], str) + + self.assertIsInstance(data["system"]["hostname"], str) + self.assertIsInstance(data["system"]["platform"], str) + self.assertIsInstance(data["system"]["platform_version"], str) + self.assertIsInstance(data["system"]["architecture"], str) + self.assertIsInstance(data["system"]["cpu_count"], int) + self.assertIsInstance(data["system"]["python_version"], str) + + self.assertGreaterEqual(data["runtime"]["uptime_seconds"], 0) + self.assertIsInstance(data["runtime"]["uptime_human"], str) + self.assertIsInstance(data["runtime"]["current_time"], str) + self.assertEqual(data["runtime"]["timezone"], "UTC") + + self.assertEqual(data["request"]["method"], "GET") + self.assertEqual(data["request"]["path"], "/") + self.assertIn("client_ip", data["request"]) + self.assertIsInstance(data["request"]["user_agent"], str) + + self.assertIsInstance(data["endpoints"], list) + self.assertGreater(len(data["endpoints"]), 0) + for endpoint in data["endpoints"]: + self.assertIn("path", endpoint) + self.assertIn("method", endpoint) + self.assertIn("description", endpoint) + + def test_root_endpoint_extracts_forwarded_ip_and_user_agent(self): + response = self.client.get( + "/", + headers={ + "X-Forwarded-For": "203.0.113.10", + "User-Agent": "unit-test-agent/1.0", + }, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ) + self.assertEqual(response.status_code, 200) + data = response.get_json() + + self.assertEqual(data["request"]["client_ip"], "203.0.113.10") + self.assertEqual(data["request"]["user_agent"], "unit-test-agent/1.0") + + def test_health_endpoint_returns_expected_payload(self): + response = self.client.get("/health") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.is_json) + + data = response.get_json() + self.assertEqual(data["status"], "healthy") + self.assertIsInstance(data["timestamp"], str) + self.assertGreaterEqual(data["uptime_seconds"], 0) + + def test_not_found_returns_json_404(self): + response = self.client.get("/does-not-exist") + self.assertEqual(response.status_code, 404) + self.assertTrue(response.is_json) + + data = response.get_json() + self.assertEqual(data["error"], "Not Found") + self.assertEqual(data["message"], "Endpoint does not exist") + + def test_root_returns_json_500_on_internal_error(self): + with patch.object( + app_module, "get_system_info", side_effect=RuntimeError("simulated failure") + ): + response = self.client.get("/") + + self.assertEqual(response.status_code, 500) + self.assertTrue(response.is_json) + self.assertEqual( + response.get_json(), + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + def test_health_returns_json_500_on_internal_error(self): + with patch.object( + app_module, "get_uptime", side_effect=RuntimeError("simulated failure") + ): + response = self.client.get("/health") + + self.assertEqual(response.status_code, 500) + self.assertTrue(response.is_json) + self.assertEqual( + response.get_json(), + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)