diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..bab2935820 --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,8 @@ +# Go build outputs +/devops-info-service +*.exe +*.out + +# Go tooling +/bin/ +/dist/ diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..7a6066cc28 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,214 @@ +# DevOps Info Service (Lab 01) — Go version + +Small Go web app for DevOps labs (extra points). + +Provides: +- `GET /` — service/system/runtime/request info + list of endpoints +- `GET /health` — simple health check endpoint (for monitoring / K8s probes) + +Configuration is done via environment variables: `HOST`, `PORT`, `DEBUG`. + +--- + +## Overview + +This service returns diagnostic information about: +- service metadata (name/version/description/framework) +- host system (hostname/platform/arch/CPU/Go version) +- runtime (uptime + current UTC time) +- current request (client IP, user-agent, method, path) +- available API endpoints (kept as a registry in the app and returned sorted) + +--- + +## Prerequisites + +- Go **1.20+** (any modern Go should work) +- No third-party dependencies (standard library only) + +--- + +## Quick Start (go run) +If `go.mod` is missing, you can create it: + +```bash +go mod init devops-info-service +``` +From the directory containing `main.go`: + +Default (0.0.0.0:5000): +```bash +go run . +# or: go run main.go +``` + +Custom port: +```bash +PORT=8080 go run . +``` + +Custom host + port: +```bash +HOST=127.0.0.1 PORT=3000 go run . +``` + +Enable debug-style logging: +```bash +DEBUG=true go run . +``` + +--- + +## Build (Binary) + +1. Initialize modules (if needed): +```bash +go mod init devops-info-service +go mod tidy +``` +2. Build a local binary: +```bash +go build -o devops-info-service . +``` +3. Run the binary: +```bash +./devops-info-service +# or with config: +HOST=127.0.0.1 PORT=3000 DEBUG=true ./devops-info-service +``` +--- + +## API Endpoints + +### `GET /` + +Returns full service + runtime info. + +Example: +```bash +curl -s http://127.0.0.1:5000/ | jq '{service, system, runtime, request, endpoints}' +``` + +Response structure: +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http" + }, + "system": { + "hostname": "SerggAidd", + "platform": "linux", + "platform_version": "6.18.5-arch1-1", + "architecture": "amd64", + "cpu_count": 24, + "go_version": "go1.25.5 X:nodwarf5" + }, + "runtime": { + "uptime_seconds": 3, + "uptime_human": "0 hour, 0 minutes", + "current_time": "2026-01-23T19:08:17Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.18.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "method": "GET", + "path": "/", + "description": "Root endpoint: returns service metadata and diagnostic information." + }, + { + "method": "GET", + "path": "/health", + "description": "Health check endpoint for monitoring and Kubernetes probes." + } + ] +} +``` + +> Note: JSON object key ordering is not guaranteed by the HTTP/JSON standard. +> Use `python -m json.tool` or `jq` (like in example) only for pretty printing. + +--- + +### `GET /health` + +Health endpoint for monitoring / Kubernetes probes. + +Example: +```bash +curl -s http://127.0.0.1:5000/health | python -m json.tool +``` + +Response: +```json +{ + "status": "healthy", + "timestamp": "2026-01-23T19:08:22Z", + "uptime_seconds": 8 +} +``` + +Always returns HTTP **200** when the service is running. + +--- + +## Error Handling + +- Unknown routes return JSON 404: + +Example: +```bash +curl -s http://127.0.0.1:5000/does-not-exist | python -m json.tool +``` + +Response: +```json +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +- Internal errors return JSON 500 (panic is recovered by middleware): + +Example (can be tested by uncommenting the code block with the corresponding endpoint): +```bash +curl -s http://127.0.0.1:5000/crash | python -m json.tool +``` + +Response: +```json +{ + "error": "Internal Server Error", + "message": "An unexpected error occurred" +} +``` + +--- + +## Logging + +The app logs to **stdout**, which is the recommended approach for Docker/Kubernetes. + +Logged events: +- request method/path/client IP/user-agent +- final HTTP status code and request latency +- recovered panics (500) with a short error message + +--- + +## Configuration + +| Variable | Default | Description | +|---------:|-----------|-------------| +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | Listen port | +| `DEBUG` | `False` | `true` enables more verbose log flags | diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..ff2519d273 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,28 @@ +# Language justification + +## Why Go +Go was selected as the implementation language because it offers a strong trade-off for a small, container-oriented HTTP JSON service: +- **Fast implementation**: minimal boilerplate, straightforward concurrency model, and a simple standard toolchain. +- **Standard library coverage**: `net/http`, `encoding/json`, `os`, `runtime`, and `time` are sufficient to implement routing, JSON serialization, environment-based configuration, and uptime without external dependencies. +- **Deployment simplicity**: produces a single static-like executable (depending on build settings), integrates cleanly with Docker/Kubernetes, and favors stdout logging by default. +- **Low operational overhead**: fast startup time and modest memory footprint, which is well-suited for health checks and probe endpoints. +- **Portability**: cross-compilation is first-class and enables easy builds for common targets (e.g., Linux/amd64) from a single development environment. + +## Contrast with other compiled languages + +### Go vs Rust +Rust provides stronger compile-time guarantees around memory safety, but typically increases development complexity (ownership model, lifetimes) and build friction for small services. For this lab-scale HTTP JSON service, Go achieves the required functionality faster while remaining reliable and maintainable. + +### Go vs Java +Java commonly implies a JVM/JRE runtime plus build tooling (Maven/Gradle), which increases packaging complexity and container footprint relative to a single Go binary. For a small service intended for probes/monitoring, Go keeps runtime requirements and deployment steps minimal. + +### Go vs C/C++ +C/C++ can produce small binaries, but requires manual memory management and often more complex build configuration. Go reduces the likelihood of memory-related defects and simplifies maintenance while still providing compiled performance and simple distribution. + +## Trade-offs +- Go provides fewer compile-time memory safety guarantees than Rust. +- Implementing routing/middleware without a third-party framework can require more manual code (though the standard library remains sufficient for the scope of this service). + +## Summary +Go was chosen to minimize dependencies and operational complexity while delivering a compact, portable HTTP JSON service suitable for containerized environments. Compared to Rust, Java, and C/C++, Go reduces implementation and deployment overhead for this specific lab task, with trade-offs that are acceptable given the service’s limited scope and reliance on the standard library. + diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..c98ca10f06 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,294 @@ +# LAB01 — DevOps Info Service (Go) + +This document describes the Go implementation of **DevOps Info Service** for Lab 01. +The service is a small HTTP JSON API that exposes system/runtime/request metadata and a health check endpoint. + +--- + +## Framework Selection + +### Choice: Go standard library (`net/http`) + +This implementation uses only the Go standard library: +- `net/http` — HTTP server +- `encoding/json` — JSON encoding +- `os`, `runtime`, `time` — configuration + system/runtime metadata +- a minimal custom router + middleware for logging and panic recovery + +**Rationale** +- Minimal dependencies (easy to review and reproduce). +- Predictable behavior and portability (single compiled binary). +- Container-friendly defaults (stdout logging, fast startup). + +### Comparison table with alternatives + +| Option | Pros | Cons | Decision | +|---|---|---|---| +| **Standard library (`net/http`)** | Zero deps, small binary, portable | Routing/middleware is manual | **Selected** (fits Lab 01 scope) | +| Gin | Fast, popular, good DX | External dependency, more abstraction | Not required for 2–3 routes | +| Echo | Middleware-rich, ergonomic | External dependency | Not required for Lab 01 | +| chi | Lightweight router | External dependency | Chose zero-deps approach | +| Gorilla/mux | Mature ecosystem | Heavier router, extra dep | Not needed for exact matches | + +--- + +## Best Practices Applied + +Below is a list of practices applied in the implementation, with short code excerpts and the reason each matters. + +### 1) Configuration via environment variables +**Example** +```go +host := os.Getenv("HOST") +if host == "" { host = "0.0.0.0" } + +port := os.Getenv("PORT") +if port == "" { port = "5000" } + +debug := strings.ToLower(os.Getenv("DEBUG")) == "true" +``` + +**Why it matters** +- Matches common container/Kubernetes configuration patterns. +- Allows the same binary to run in different environments without code changes. + +### 2) Consistent JSON errors (404 / 500) +**Examples** +```go +writeJSON(w, http.StatusNotFound, ErrorResponse{ + Error: "Not Found", Message: "Endpoint does not exist", +}) +``` + +```go +defer func() { + if rec := recover(); rec != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{ + Error: "Internal Server Error", + Message: "An unexpected error occurred", + }) + } +}() +``` + +**Why it matters** +- Ensures clients always receive machine-readable error payloads. +- Prevents unexpected crashes from stopping the service. + +### 3) Request logging to stdout +**Example** +```go +log.Printf("Request %s %s from %s UA=%s -> %d (%s)", + r.Method, r.URL.Path, clientIP(r), r.UserAgent(), sw.status, lat) +``` + +**Why it matters** +- Stdout logging is the standard in Docker/Kubernetes. +- Useful for validating health probes and debugging locally. + +### 4) Proxy-aware client IP extraction +**Example** +```go +xff := r.Header.Get("X-Forwarded-For") +if xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) +} +``` + +**Why it matters** +- Preserves real client IP when the service is behind an ingress/reverse proxy. + +--- + +## API Documentation + +### `GET /` +Returns service metadata, system/runtime details, request metadata, and the list of available endpoints. + +**Request** +```bash +curl -s http://127.0.0.1:5000/ +``` + +**Response** +See *Testing Evidence* below. + +### `GET /health` +Health endpoint for monitoring / Kubernetes probes. Returns HTTP **200** when the service is running. + +**Request** +```bash +curl -s http://127.0.0.1:5000/health +``` + +**Response (example)** +```json +{ + "status": "healthy", + "timestamp": "2026-01-23T19:08:22Z", + "uptime_seconds": 8 +} +``` + +--- + +## Testing Commands + +Run the service: +```bash +HOST=0.0.0.0 PORT=5000 DEBUG=false go run main.go +``` + +Test endpoints: +```bash +curl -s http://127.0.0.1:5000/ +curl -s http://127.0.0.1:5000/health +curl -s http://127.0.0.1:5000/does-not-exist +# You also can use command bellow of uncomment crash endpoint in code +# curl -s http://127.0.0.1:5000/crash +``` + +Pretty-print JSON: +```bash +curl -s http://127.0.0.1:5000/ | python -m json.tool +``` + +--- + +## Testing Evidence + +### Screenshots showing endpoints work + +Required screenshots are stored in `docs/screenshots/`: + +1) **Main endpoint showing complete JSON** +- `docs/screenshots/01_root_complete_json.png` + +![GET / — complete JSON](./screenshots/01_root_complete_json.png) + + +2) **Health check response** +- `docs/screenshots/02_health_check.png` + +![GET /health — health probe](./screenshots/02_health_check.png) + +3) **Formatted/pretty-printed output** +- `docs/screenshots/03_pretty_print_command.png` + +![Pretty-print example](./screenshots/03_pretty_print_command.png) + + +### Terminal output + +```text +$ curl -s http://127.0.0.1:5000/ | python -m json.tool +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http" + }, + "system": { + "hostname": "SerggAidd", + "platform": "linux", + "platform_version": "6.18.5-arch1-1", + "architecture": "amd64", + "cpu_count": 24, + "go_version": "go1.25.5 X:nodwarf5" + }, + "runtime": { + "uptime_seconds": 3, + "uptime_human": "0 hour, 0 minutes", + "current_time": "2026-01-23T19:08:17Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.18.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "method": "GET", + "path": "/", + "description": "Root endpoint: returns service metadata and diagnostic information." + }, + { + "method": "GET", + "path": "/health", + "description": "Health check endpoint for monitoring and Kubernetes probes." + } + ] +} +``` + +```text +$ curl -s http://127.0.0.1:5000/health | python -m json.tool +{ + "status": "healthy", + "timestamp": "2026-01-23T19:08:22Z", + "uptime_seconds": 8 +} +``` + +```text +$ curl -s http://127.0.0.1:5000/does-not-exist | python -m json.tool +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +```text +$ curl -s http://127.0.0.1:5000/crash | python -m json.tool +{ + "error": "Internal Server Error", + "message": "An unexpected error occurred" +} +``` + +--- + +## Challenges & Solutions + +### 1) Endpoint discovery without a framework +**Problem:** `net/http` does not provide a route registry similar to Flask. +**Solution:** Implemented a minimal router that stores routes and exposes a sorted `endpoints` list for the root response. + +### 2) Correct client IP behind proxies +**Problem:** `RemoteAddr` can reflect only the proxy address. +**Solution:** Prefer `X-Forwarded-For` (first value) and fall back to `RemoteAddr` parsing. + +### 3) Handling internal failures without process exit +**Problem:** A panic would terminate the process by default. +**Solution:** Added panic recovery middleware that converts panics into a JSON 500 response. + +### 4) Readable evidence output +**Problem:** JSON key ordering is not guaranteed by the standard. +**Solution:** Evidence uses pretty-printing tools; the endpoints list is sorted by `(path, method)` for deterministic output. + +--- + +## Compare binary size to Python +To compare the sizes of application binaries, the following commands were executed: + +- Go application (8591356 bytes): +```bash +go mod tidy +go build -o devops-info-service . +stat -c '%n %s bytes' devops-info-service +``` +![Binary size of go app](./screenshots/04_go_binary_size.png) + +- Python application (13959512 bytes): +```bash +pip install pyinstaller +pyinstaller --onefile app.py +stat -c '%n %s bytes' dist/app +``` +![Binary size of python app](./screenshots/05_python_binary_size.png) + +### Summary +According to measurements, the Go binary (8.19 MiB) is noticeably smaller than a Python onefile via PyInstaller (13.31 MiB) - a difference of about 5.12 MiB (around 38.5%). This is because Go builds a single native executable with runtime and dependencies, while PyInstaller in `--onefile` mode also packages the Python interpreter and a set of libraries, resulting in a larger final artifact. This gives Go an advantage in terms of size and portability for containers and fast deployments. \ No newline at end of file diff --git a/app_go/docs/screenshots/01_root_complete_json.png b/app_go/docs/screenshots/01_root_complete_json.png new file mode 100644 index 0000000000..5e2fb64f16 Binary files /dev/null and b/app_go/docs/screenshots/01_root_complete_json.png differ diff --git a/app_go/docs/screenshots/02_health_check.png b/app_go/docs/screenshots/02_health_check.png new file mode 100644 index 0000000000..722e052290 Binary files /dev/null and b/app_go/docs/screenshots/02_health_check.png differ diff --git a/app_go/docs/screenshots/03_pretty_print_command.png b/app_go/docs/screenshots/03_pretty_print_command.png new file mode 100644 index 0000000000..7269db29c5 Binary files /dev/null and b/app_go/docs/screenshots/03_pretty_print_command.png differ diff --git a/app_go/docs/screenshots/04_go_binary_size.png b/app_go/docs/screenshots/04_go_binary_size.png new file mode 100644 index 0000000000..3c05be8cd8 Binary files /dev/null and b/app_go/docs/screenshots/04_go_binary_size.png differ diff --git a/app_go/docs/screenshots/05_python_binary_size.png b/app_go/docs/screenshots/05_python_binary_size.png new file mode 100644 index 0000000000..54feec58b4 Binary files /dev/null and b/app_go/docs/screenshots/05_python_binary_size.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..4f3ceac16b --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.25.5 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..6b4a4712ed --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,356 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "sort" + "strings" + "time" +) + +// Service describes metadata about the running service. +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +// System contains basic host and runtime details. +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"` +} + +// RuntimeInfo reports uptime and current timestamp. +type RuntimeInfo struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +// RequestInfo captures request metadata. +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +// Endpoint represents a single API route. +type Endpoint struct { + Method string `json:"method"` + Path string `json:"path"` + Description string `json:"description"` +} + +// RootResponse is the response schema for the root endpoint. +type RootResponse struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime RuntimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +// HealthResponse is the response schema for /health. +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int `json:"uptime_seconds"` +} + +// ErrorResponse is a JSON error payload for non-200 responses. +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// routeKey uniquely identifies a route by method and path. +type routeKey struct { + Method string + Path string +} + +// route binds a handler with route metadata. +type route struct { + Method string + Path string + Description string + Handler http.HandlerFunc +} + +// router is a tiny HTTP router for exact method+path matches. +type router struct { + routes map[routeKey]route + endpoints []Endpoint +} + +// newRouter initializes an empty router instance. +func newRouter() *router { + return &router{ + routes: make(map[routeKey]route), + endpoints: make([]Endpoint, 0), + } +} + +// Handle registers a handler for an exact HTTP method and path. +func (rt *router) Handle(method, path, description string, h http.HandlerFunc) { + key := routeKey{Method: method, Path: path} + rt.routes[key] = route{ + Method: method, + Path: path, + Description: description, + Handler: h, + } + + rt.endpoints = append(rt.endpoints, Endpoint{ + Method: method, + Path: path, + Description: description, + }) +} + +// Endpoints returns a sorted copy of the registered endpoints list. +func (rt *router) Endpoints() []Endpoint { + out := make([]Endpoint, len(rt.endpoints)) + copy(out, rt.endpoints) + sort.Slice(out, func(i, j int) bool { + if out[i].Path == out[j].Path { + return out[i].Method < out[j].Method + } + return out[i].Path < out[j].Path + }) + return out +} + +// ServeHTTP dispatches the request to a registered route or returns a 404 JSON error. +// Note: method mismatch is treated as "not found" in this simplified router. +func (rt *router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + key := routeKey{Method: r.Method, Path: r.URL.Path} + if rr, ok := rt.routes[key]; ok { + rr.Handler(w, r) + return + } + + writeJSON(w, http.StatusNotFound, ErrorResponse{ + Error: "Not Found", + Message: "Endpoint does not exist", + }) +} + +var ( + // startTime is captured once at startup and used to compute uptime. + startTime = time.Now().UTC() + + // service contains static service metadata returned by the root endpoint. + service = Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + } +) + +// systemInfo collects basic system/runtime information. +func systemInfo() System { + hostname, _ := os.Hostname() + + return System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: linuxKernelRelease(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + } +} + +// runtimeInfo computes uptime and generates a UTC timestamp in ISO 8601 format. +func runtimeInfo() RuntimeInfo { + now := time.Now().UTC() + uptime := now.Sub(startTime) + + seconds := int(uptime.Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + + return RuntimeInfo{ + UptimeSeconds: seconds, + UptimeHuman: fmt.Sprintf("%d hour, %d minutes", hours, minutes), + CurrentTime: now.Format("2006-01-02T15:04:05Z"), + Timezone: "UTC", + } +} + +// requestInfo extracts request metadata for the JSON response payload. +func requestInfo(r *http.Request) RequestInfo { + return RequestInfo{ + ClientIP: clientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + } +} + +// clientIP returns the best-effort client IP address. +// If behind a proxy, the first X-Forwarded-For value is preferred. +func clientIP(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + parts := strings.Split(xff, ",") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return host + } + return r.RemoteAddr +} + +// linuxKernelRelease reads Linux kernel release from /proc as a best-effort value. +// On non-Linux platforms (or if the file is missing), it returns an empty string. +func linuxKernelRelease() string { + if runtime.GOOS != "linux" { + return "" + } + b, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// writeJSON writes a JSON response with the given HTTP status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +// WriteHeader captures the status code for logging. +func (sw *statusWriter) WriteHeader(code int) { + sw.status = code + sw.ResponseWriter.WriteHeader(code) +} + +// loggingMiddleware logs request metadata and the final status code. +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + + lat := time.Since(start) + log.Printf("Request %s %s from %s UA=%s -> %d (%s)", + r.Method, + r.URL.Path, + clientIP(r), + r.UserAgent(), + sw.status, + lat, + ) + }) +} + +// recoverMiddleware converts panics into a JSON 500 response. +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("ERROR 500 Internal Server Error: %v", rec) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{ + Error: "Internal Server Error", + Message: "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +// rootHandler returns the service diagnostic payload. +func rootHandler(rt *router) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + payload := RootResponse{ + Service: service, + System: systemInfo(), + Runtime: runtimeInfo(), + Request: requestInfo(r), + Endpoints: rt.Endpoints(), + } + writeJSON(w, http.StatusOK, payload) + } +} + +// healthHandler returns a minimal health probe response (HTTP 200 on success). +func healthHandler(w http.ResponseWriter, r *http.Request) { + rt := runtimeInfo() + writeJSON(w, http.StatusOK, HealthResponse{ + Status: "healthy", + Timestamp: rt.CurrentTime, + UptimeSeconds: rt.UptimeSeconds, + }) +} + +// crashHandler intentionally panics to verify 500 error handling. +// func crashHandler(w http.ResponseWriter, r *http.Request) { +// panic("crash test") +// } + +func main() { + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + port := os.Getenv("PORT") + if port == "" { + port = "5000" + } + debug := strings.ToLower(os.Getenv("DEBUG")) == "true" + + if debug { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.Println("DEBUG enabled") + } else { + log.SetFlags(log.LstdFlags) + } + + rt := newRouter() + rt.Handle(http.MethodGet, "/", "Root endpoint: returns service metadata and diagnostic information.", rootHandler(rt)) + rt.Handle(http.MethodGet, "/health", "Health check endpoint for monitoring and Kubernetes probes.", healthHandler) + // rt.Handle(http.MethodGet, "/crash", "Intentional error to test 500 handler.", crashHandler) + + + handler := recoverMiddleware(loggingMiddleware(rt)) + + addr := net.JoinHostPort(host, port) + log.Printf("Listening on http://%s", addr) + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Fatal(srv.ListenAndServe()) +} diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..e6d498e35d --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,40 @@ +# Python bytecode / cache +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# Packaging / build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +*.spec + +# Logs +*.log + +# Test / tooling cache +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# IDEs / editors +.vscode/ +.idea/ +*.swp + +# OS junk +.DS_Store +Thumbs.db + +# Environment files +.env +.env.* \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..43384d0f95 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,192 @@ +# DevOps Info Service (Lab 01) + +Small Flask web app for DevOps labs. + +Provides: +- `GET /` — service/system/runtime/request info + list of endpoints +- `GET /health` — simple health check endpoint (for monitoring / K8s probes) + +Configuration is done via environment variables: `HOST`, `PORT`, `DEBUG`. + +--- + +## Overview + +This service returns diagnostic information about: +- service metadata (name/version/description/framework) +- host system (hostname/platform/arch/CPU/python) +- runtime (uptime + current UTC time) +- current request (client IP, user-agent, method, path) +- available API endpoints (generated from Flask URL map) + +--- + +## Prerequisites + +- Python **3.11+** (Flask 3.x) +- pip / venv + +--- + +## Installation + +```bash +cd app_python + +python -m venv venv +source venv/bin/activate + +pip install -r requirements.txt +``` + +--- + +## Running the Application + +Default (0.0.0.0:5000): +```bash +python app.py +``` + +Custom port: +```bash +PORT=8080 python app.py +``` + +Custom host + port: +```bash +HOST=127.0.0.1 PORT=3000 python app.py +``` + +Enable debug-level logging (and Flask debug mode): +```bash +DEBUG=true python app.py +``` + +--- + +## API Endpoints + +### `GET /` + +Returns full service + runtime info. + +Example: +```bash +curl -s http://127.0.0.1:5000/ | jq '{service, system, runtime, request, endpoints}' +``` + +Response structure: +```json +{ + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 24, + "hostname": "SerggAidd", + "platform": "Linux", + "platform_version": "6.18.5-arch1-1", + "python_version": "3.14.2" + }, + "runtime": { + "current_time": "2026-01-23T18:16:16Z", + "timezone": "UTC", + "uptime_human": "0 hour, 0 minutes", + "uptime_seconds": 4 + }, + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "endpoints": [ + { + "description": "Root endpoint: returns service metadata and diagnostic information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check endpoint for monitoring and Kubernetes probes.", + "method": "GET", + "path": "/health" + } + ] +} +``` + +> Note: JSON object key ordering is not guaranteed by the HTTP/JSON standard. +> Use `python -m json.tool` or `jq` (like in example) only for pretty printing. + +### `GET /health` + +Health endpoint for monitoring / Kubernetes probes. + +Example: +```bash +curl -s http://127.0.0.1:5000/health | python -m json.tool +``` + +Response: +```json +{ + "status": "healthy", + "timestamp": "2026-01-23T21:25:39Z", + "uptime_seconds": 43 +} +``` + +Always returns HTTP **200** when service is running. + +--- + +## Error Handling + +- Unknown routes return JSON 404: + +Example: +```bash +curl -i http://127.0.0.1:5000/does-not-exist +``` + +Response: +```json +{"error":"Not Found","message":"Endpoint does not exist"} +``` + +- Internal errors return JSON 500 (test endpoint is intentionally NOT included by default). + +Example: +```bash +curl -i http://127.0.0.1:5000/crash +``` +Response: +```json +{"error":"Internal Server Error","message":"An unexpected error occurred"} +``` + +--- + +## Logging + +The app logs to **stdout**, which is the recommended approach for Docker/Kubernetes. + +Logged events: +- request metadata before handling (`@app.before_request`) +- response status code after handling (`@app.after_request`) +- custom 404/500 handlers + +--- + +## Configuration + +| Variable | Default | Description | +|---------:|-----------|-------------| +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | Listen port | +| `DEBUG` | `False` | `true` enables Flask debug mode and DEBUG logging | diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..1a152e338a --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,205 @@ +""" +DevOps Info Service + +Small Flask web app for DevOps labs. +Provides basic system/runtime/request information and a health check endpoint. +Configured via environment variables (HOST, PORT, DEBUG). +""" + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +# Flask application instance +app = Flask(__name__) + +# Runtime configuration (can be overridden via environment variables) +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +# Timestamp captured at startup (used to calculate uptime) +START_TIME = datetime.now(timezone.utc) + +SERVICE = { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + } + +# Logging configuration (stdout; suitable for Docker/Kubernetes) +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", +) + + +# General functions of application +def system_info(): + """Return basic host and Python runtime information.""" + + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +def runtime_info(): + """Return uptime and current UTC timestamp for the running application.""" + + current_time = datetime.now(timezone.utc) + delta = current_time - START_TIME + timestamp = current_time.strftime("%Y-%m-%dT%H:%M:%SZ") + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + "uptime_seconds": seconds, + "uptime_human": f"{hours} hour, {minutes} minutes", + "current_time": timestamp, + "timezone": "UTC", + } + + +def request_info(): + """ + Extract request metadata. + + If the app is behind a reverse proxy, the client IP may be passed via + X-Forwarded-For header (first IP in the list). Fallback to remote_addr. + """ + + xff = request.headers.get("X-Forwarded-For", "") + client_ip = xff.split(",")[0].strip() if xff else request.remote_addr + + return { + "client_ip": client_ip, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + } + + +def endpoints_info(): + """ + Build an API endpoints list dynamically from Flask URL map. + Description is taken from the first line of each handler's docstring. + """ + + endpoints = [] + for rule in app.url_map.iter_rules(): + if rule.endpoint == "static": + continue + + view_func = app.view_functions.get(rule.endpoint) + doc = getattr(view_func, "__doc__", None) if view_func else None + desc = (doc or "").strip().splitlines()[0] if doc else "No description" + + methods = sorted((rule.methods or set()) - {"HEAD", "OPTIONS"}) + for m in methods: + endpoints.append({ + "method": m, + "path": rule.rule, + "description": desc, + }) + + endpoints.sort(key=lambda e: (e["path"], e["method"])) + return endpoints + + +# General endpoints +@app.get("/") +def index(): + """Root endpoint: returns service metadata and diagnostic information.""" + + payload = { + "service": SERVICE, + "system": system_info(), + "runtime": runtime_info(), + "request": request_info(), + "endpoints": endpoints_info(), + } + return jsonify(payload) + + +@app.get("/health") +def health(): + """Health check endpoint for monitoring and Kubernetes probes.""" + rt = runtime_info() + payload = { + "status": "healthy", + "timestamp": rt["current_time"], + "uptime_seconds": rt["uptime_seconds"], + } + return jsonify(payload) + + +# Test-only endpoint to trigger HTTP 500 (uncomment to verify error handler) +# @app.get("/crash") +# def crash(): +# """Intentional error to test 500 handler.""" +# 1 / 0 + + +# Error Handlers +@app.errorhandler(404) +def not_found(error): + """Return JSON error for unknown endpoints.""" + + app.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 error for unhandled server exceptions.""" + + app.logger.exception("500 Internal Server Error") + return jsonify({ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }), 500 + + +# Logging endpoints +@app.before_request +def log_requests(): + """Log basic request metadata before handling.""" + + app.logger.info( + "Request %s %s from %s UA=%s", + request.method, + request.path, + request.headers.get("X-Forwarded-For", request.remote_addr), + request.headers.get("User-Agent"), + ) + + +@app.after_request +def log_response(response): + """Log response status code after handling.""" + + app.logger.info( + "Response %s %s -> %s", + request.method, + request.path, + response.status_code, + ) + + return response + + +if __name__ == "__main__": + 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..d246c40b68 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,358 @@ +# LAB01 — DevOps Info Service (Python) + +This document describes the **Python/Flask** implementation of **DevOps Info Service** for Lab 01. +The service is a small HTTP JSON API that exposes system/runtime/request metadata and a health check endpoint. + +--- + +## Framework Selection + +### Choice: Flask + +This implementation uses **Flask** as a lightweight WSGI web framework to build a small JSON API with minimal overhead. + +**Rationale** +- **Small scope fit:** Flask is well-suited for 2–3 endpoints without extra abstractions. +- **Simple request/response model:** easy access to request metadata (`request.method`, `request.path`, headers). +- **Built-in routing + hooks:** `@app.get`, `@app.before_request`, `@app.after_request`, and error handlers reduce boilerplate. +- **Container-friendly logging:** logs can be emitted to stdout and captured by Docker/Kubernetes. + +### Comparison table with alternatives + +| Option | Pros | Cons | Decision | +|---|---|---|---| +| **Flask** | Minimal API, easy routing, simple hooks | Not async-first | **Selected** (best fit for small lab service) | +| FastAPI | Automatic OpenAPI, type hints, async support | More dependencies, more setup | Not needed for Lab 01 | +| Django | Full-featured framework | Heavy for a tiny JSON service | Overkill | + +--- + +## Best Practices Applied + +Below is a list of practices applied in the implementation, with short code excerpts and why each matters. + +### 1) Configuration via environment variables +**Example** +```python +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" +``` + +**Why it matters** +- Matches Docker/Kubernetes configuration conventions. +- The same code runs in different environments without changes. + +### 2) Consistent JSON error responses (404 / 500) +**Examples** +```python +@app.errorhandler(404) +def not_found(error): + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 +``` + +```python +@app.errorhandler(500) +def internal_error(error): + return jsonify({"error": "Internal Server Error", "message": "An unexpected error occurred"}), 500 +``` + +**Why it matters** +- Clients always receive machine-readable responses. +- Prevents HTML error pages, which are inconvenient for API consumers. + +### 3) Request/response logging to stdout +**Examples** +```python +@app.before_request +def log_requests(): + app.logger.info("Request %s %s ...", request.method, request.path) +``` + +```python +@app.after_request +def log_response(response): + app.logger.info("Response %s %s -> %s", request.method, request.path, response.status_code) + return response +``` + +**Why it matters** +- Stdout logging is the standard for containers. +- Helps verify probes and debug behavior without extra tooling. + +### 4) Proxy-aware client IP extraction +**Example** +```python +xff = request.headers.get("X-Forwarded-For", "") +client_ip = xff.split(",")[0].strip() if xff else request.remote_addr +``` + +**Why it matters** +- Preserves the real client IP when the service runs behind an ingress/reverse proxy. + +--- + +## API Documentation + +### `GET /` +Returns service metadata, system/runtime details, request metadata, and a list of available endpoints. + +**Request** +```bash +curl -s http://127.0.0.1:5000/ +``` + +**Response (schema)** +```json +{ + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 24, + "hostname": "SerggAidd", + "platform": "Linux", + "platform_version": "6.18.5-arch1-1", + "python_version": "3.14.2" + }, + "runtime": { + "current_time": "2026-01-23T18:16:16Z", + "timezone": "UTC", + "uptime_human": "0 hour, 0 minutes", + "uptime_seconds": 4 + }, + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "endpoints": [ + { + "description": "Root endpoint: returns service metadata and diagnostic information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check endpoint for monitoring and Kubernetes probes.", + "method": "GET", + "path": "/health" + } + ] +} +``` + +### `GET /health` +Health endpoint for monitoring / Kubernetes probes. Returns HTTP **200** when the service is running. + +**Request** +```bash +curl -s http://127.0.0.1:5000/health +``` + +**Response (example)** +```json +{ + "status": "healthy", + "timestamp": "2026-01-23T19:08:22Z", + "uptime_seconds": 8 +} +``` + +--- + +### Error handling + +**404 Not Found** (unknown routes) +```bash +curl -s http://127.0.0.1:5000/does-not-exist +``` + +**Response** +```json +{"error":"Not Found","message":"Endpoint does not exist"} +``` + +**500 Internal Server Error** (unhandled exceptions) +- A test endpoint can be temporarily enabled by uncommenting the `/crash` handler in the code. + +```bash +curl -s http://127.0.0.1:5000/crash +``` + +**Response** +```json +{"error":"Internal Server Error","message":"An unexpected error occurred"} +``` + +> Note: JSON object key ordering is not guaranteed. Use `python -m json.tool` or `jq` only for pretty-printing. + +--- + +## Testing Commands + +### Setup and run + +Create venv and install dependencies: +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +Run the service: +```bash +HOST=0.0.0.0 PORT=5000 DEBUG=false python app.py +``` + +### Endpoint checks + +```bash +curl -s http://127.0.0.1:5000/ +curl -s http://127.0.0.1:5000/health +curl -s http://127.0.0.1:5000/does-not-exist +# Optional (if /crash is enabled): +# curl -s http://127.0.0.1:5000/crash +``` + +Pretty-print JSON: +```bash +curl -s http://127.0.0.1:5000/ | python -m json.tool +``` + +--- + +## Testing Evidence + +### Screenshots showing endpoints work + +Required screenshots should be stored in `docs/screenshots/`: + +1) **Main endpoint showing complete JSON** +- `docs/screenshots/01_root_complete_json.png` +![GET / — complete JSON](./screenshots/01_root_complete_json.png) + +2) **Health check response** +- `docs/screenshots/02_health_check.png` +![GET /health — health probe](./screenshots/02_health_check.png) + +3) **Formatted/pretty-printed output** +- `docs/screenshots/03_pretty_print_command.png` +![Pretty-print example](./screenshots/03_pretty_print_command.png) + + +### Terminal output + +Include terminal output demonstrating: +```text +curl -s http://127.0.0.1:5000/ | jq '{service, system, runtime, request, endpoints}' +{ + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 24, + "hostname": "SerggAidd", + "platform": "Linux", + "platform_version": "6.18.5-arch1-1", + "python_version": "3.14.2" + }, + "runtime": { + "current_time": "2026-01-23T21:25:29Z", + "timezone": "UTC", + "uptime_human": "0 hour, 0 minutes", + "uptime_seconds": 32 + }, + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "endpoints": [ + { + "description": "Root endpoint: returns service metadata and diagnostic information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check endpoint for monitoring and Kubernetes probes.", + "method": "GET", + "path": "/health" + } + ] +} +``` + +```text +curl -s http://127.0.0.1:5000/health | python -m json.tool +{ + "status": "healthy", + "timestamp": "2026-01-23T21:25:39Z", + "uptime_seconds": 43 +} +``` + +```text +curl -s http://127.0.0.1:5000/does-not-exist | python -m json.tool +{ + "error": "Not Found", + "message": "Endpoint does not exist" +} +``` + +```text +curl -s http://127.0.0.1:5000/crash | python -m json.tool +{ + "error": "Internal Server Error", + "message": "An unexpected error occurred" +} +``` + +--- + +## Challenges & Solutions + +### 1) Deterministic endpoint list ordering +**Problem:** Flask’s URL map iteration order is not guaranteed to match a desired display order. +**Solution:** Collected endpoint entries and sorted by `(path, method)` before returning. + +### 2) Correct client IP behind reverse proxies +**Problem:** `request.remote_addr` may show only the proxy address. +**Solution:** Prefer the first value from `X-Forwarded-For` and fall back to `remote_addr`. + +### 3) Consistent 500 responses for exceptions +**Problem:** Unhandled exceptions can result in default HTML error pages. +**Solution:** Added a `500` error handler returning a JSON payload. A test-only `/crash` endpoint can be enabled to demonstrate this behavior during validation. + +--- +## GitHub Community Engagement +Starring repositories is a lightweight way to bookmark useful projects and also signals community interest, which improves discovery and encourages maintainers. Following developers and classmates helps track relevant updates, learn from real code activity, and makes collaboration easier by keeping your team’s work visible in one place. + +### My Stars: +- Star the course repository +![Course repository star](./screenshots/04_star_for_course.png) +- Star simple-container-com/api +![Simple-container-com/api repository star](./screenshots/05_star_for_simple-container-com.png) + +### My Follows: +- Following to Dmitriy Creed (Professor) +![Follow to Professor](./screenshots/06_prof_follow.png) +- Following to Du Tham Lieu (TA) +![Follow to TA](./screenshots/07_ta1_follow.png) +- Following to Marat Biriushev (TA) +![Follow to TA](./screenshots/08_ta2_follow.png) +- Following to Alexander Rozanov (classmate) +![Follow to CM](./screenshots/09_cm1_follow.png) +- Following to Ilvina Akhmetzyanova (classmate) +![Follow to CM](./screenshots/10_cm2_follow.png) +- Following to Klimentii Chistyakov (classmate) +![Follow to CM](./screenshots/11_cm3_follow.png) \ No newline at end of file diff --git a/app_python/docs/screenshots/01_root_complete_json.png b/app_python/docs/screenshots/01_root_complete_json.png new file mode 100644 index 0000000000..0fdb582e1d Binary files /dev/null and b/app_python/docs/screenshots/01_root_complete_json.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..92a1b3c4ef Binary files /dev/null and b/app_python/docs/screenshots/02_health_check.png differ diff --git a/app_python/docs/screenshots/03_pretty_print_command.png b/app_python/docs/screenshots/03_pretty_print_command.png new file mode 100644 index 0000000000..341f696aa1 Binary files /dev/null and b/app_python/docs/screenshots/03_pretty_print_command.png differ diff --git a/app_python/docs/screenshots/04_star_for_course.png b/app_python/docs/screenshots/04_star_for_course.png new file mode 100644 index 0000000000..39126d7366 Binary files /dev/null and b/app_python/docs/screenshots/04_star_for_course.png differ diff --git a/app_python/docs/screenshots/05_star_for_simple-container-com.png b/app_python/docs/screenshots/05_star_for_simple-container-com.png new file mode 100644 index 0000000000..4292b6a506 Binary files /dev/null and b/app_python/docs/screenshots/05_star_for_simple-container-com.png differ diff --git a/app_python/docs/screenshots/06_prof_follow.png b/app_python/docs/screenshots/06_prof_follow.png new file mode 100644 index 0000000000..d6aedbde4c Binary files /dev/null and b/app_python/docs/screenshots/06_prof_follow.png differ diff --git a/app_python/docs/screenshots/07_ta1_follow.png b/app_python/docs/screenshots/07_ta1_follow.png new file mode 100644 index 0000000000..58d8181edc Binary files /dev/null and b/app_python/docs/screenshots/07_ta1_follow.png differ diff --git a/app_python/docs/screenshots/08_ta2_follow.png b/app_python/docs/screenshots/08_ta2_follow.png new file mode 100644 index 0000000000..dde8ddba47 Binary files /dev/null and b/app_python/docs/screenshots/08_ta2_follow.png differ diff --git a/app_python/docs/screenshots/09_cm1_follow.png b/app_python/docs/screenshots/09_cm1_follow.png new file mode 100644 index 0000000000..a9048265d4 Binary files /dev/null and b/app_python/docs/screenshots/09_cm1_follow.png differ diff --git a/app_python/docs/screenshots/10_cm2_follow.png b/app_python/docs/screenshots/10_cm2_follow.png new file mode 100644 index 0000000000..36ab874f38 Binary files /dev/null and b/app_python/docs/screenshots/10_cm2_follow.png differ diff --git a/app_python/docs/screenshots/11_cm3_follow.png b/app_python/docs/screenshots/11_cm3_follow.png new file mode 100644 index 0000000000..a1e0951b2f Binary files /dev/null and b/app_python/docs/screenshots/11_cm3_follow.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..5fc39647c3 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,15 @@ +blinker==1.9.0 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +DateTime==6.0 +Flask==3.1.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +pytz==2025.2 +requests==2.32.5 +urllib3==2.6.3 +Werkzeug==3.1.5 +zope.interface==8.2 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2