diff --git a/lab1c/app_go/README.md b/lab1c/app_go/README.md new file mode 100644 index 0000000000..36e81eb856 --- /dev/null +++ b/lab1c/app_go/README.md @@ -0,0 +1,41 @@ +# DevOps Info Service (Go) + +## Overview +Compiled-language version of the DevOps info service. It exposes the same two endpoints as the Python app and keeps the JSON response structure consistent. + +## Prerequisites +- Go 1.22+ installed + +## Build and Run +Run directly: +```bash +go run main.go +``` + +Build a binary: +```bash +go build -o devops-info +./devops-info +``` + +Windows build/run: +```bash +go build -o devops-info.exe +.\devops-info.exe +``` + +Custom config examples: +```bash +PORT=8080 go run main.go +HOST=127.0.0.1 PORT=3000 go run main.go +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | diff --git a/lab1c/app_go/devops-info.exe b/lab1c/app_go/devops-info.exe new file mode 100644 index 0000000000..85936092e9 Binary files /dev/null and b/lab1c/app_go/devops-info.exe differ diff --git a/lab1c/app_go/docs/GO.md b/lab1c/app_go/docs/GO.md new file mode 100644 index 0000000000..3469e6878d --- /dev/null +++ b/lab1c/app_go/docs/GO.md @@ -0,0 +1,3 @@ +# Why Go + +I picked Go for the compiled version because it builds fast, produces a single binary, and the standard library already has everything I need for a small HTTP service. It also starts quickly and is easy to run in a container later in the course. diff --git a/lab1c/app_go/docs/LAB01.md b/lab1c/app_go/docs/LAB01.md new file mode 100644 index 0000000000..98442315ca --- /dev/null +++ b/lab1c/app_go/docs/LAB01.md @@ -0,0 +1,79 @@ +# LAB01 - DevOps Info Service (Go) + +## Implementation Notes +This version mirrors the Python API and keeps the same JSON shape. The `python_version` field is kept for compatibility, and I fill it with the Go runtime version so the field is still informative. + +## Build and Run +```bash +go run main.go +``` + +Build a binary: +```bash +go build -o devops-info +./devops-info +``` + +## Binary Size Comparison +- Go binary: `` +- Python app (source only): `` + +Quick size commands: +```bash +ls -lh devops-info +dir devops-info.exe +``` + +## API Endpoints +### `GET /` +Returns service, system, runtime, and request details. + +Example: +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http" + }, + "system": { + "hostname": "my-machine", + "platform": "windows", + "platform_version": "Windows_NT", + "architecture": "amd64", + "cpu_count": 12, + "python_version": "go1.22.1" + }, + "runtime": { + "uptime_seconds": 42, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-27T10:15:00Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.5.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### `GET /health` +Example: +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T10:15:05Z", + "uptime_seconds": 47 +} +``` + +## Challenges & Solutions +- The Go mux treats `/` as a catch-all, so I added explicit path checks to return a JSON 404 for unknown routes. +- `RemoteAddr` includes the port, so I split host/port to get a clean client IP. diff --git a/lab1c/app_go/docs/screenshots/build.jpg b/lab1c/app_go/docs/screenshots/build.jpg new file mode 100644 index 0000000000..52f42db7d7 Binary files /dev/null and b/lab1c/app_go/docs/screenshots/build.jpg differ diff --git a/lab1c/app_go/docs/screenshots/health-check.jpg b/lab1c/app_go/docs/screenshots/health-check.jpg new file mode 100644 index 0000000000..2d47e2e956 Binary files /dev/null and b/lab1c/app_go/docs/screenshots/health-check.jpg differ diff --git a/lab1c/app_go/docs/screenshots/root.jpg b/lab1c/app_go/docs/screenshots/root.jpg new file mode 100644 index 0000000000..4e33b3b010 Binary files /dev/null and b/lab1c/app_go/docs/screenshots/root.jpg differ diff --git a/lab1c/app_go/go.mod b/lab1c/app_go/go.mod new file mode 100644 index 0000000000..7a7fcedd1c --- /dev/null +++ b/lab1c/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.22 diff --git a/lab1c/app_go/main.go b/lab1c/app_go/main.go new file mode 100644 index 0000000000..2abcd3938a --- /dev/null +++ b/lab1c/app_go/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "strings" + "time" +) + +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"` + PythonVersion string `json:"python_version"` +} + +type Runtime struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type RequestInfo 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 Response struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +var startTime = time.Now().UTC() + +func main() { + host := getenv("HOST", "0.0.0.0") + port := getenv("PORT", "5000") + addr := net.JoinHostPort(host, port) + + mux := http.NewServeMux() + mux.HandleFunc("/", rootHandler) + mux.HandleFunc("/health", healthHandler) + + handler := recoverMiddleware(loggingMiddleware(mux)) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("Starting DevOps Info Service on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, uptimeHuman := getUptime() + now := time.Now().UTC() + + hostname, _ := os.Hostname() + response := Response{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: getPlatformVersion(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + PythonVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: now.Format(time.RFC3339), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: getClientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + writeJSON(w, http.StatusOK, response) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, _ := getUptime() + payload := map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "uptime_seconds": uptimeSeconds, + } + + writeJSON(w, http.StatusOK, payload) +} + +func getUptime() (int, string) { + seconds := int(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + hourLabel := "hours" + if hours == 1 { + hourLabel = "hour" + } + minuteLabel := "minutes" + if minutes == 1 { + minuteLabel = "minute" + } + return seconds, fmt.Sprintf("%d %s, %d %s", hours, hourLabel, minutes, minuteLabel) +} + +func getClientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func getPlatformVersion() string { + if value := os.Getenv("OS"); value != "" { + return value + } + if data, err := os.ReadFile("/etc/os-release"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PRETTY_NAME=") { + return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + } + } + return "unknown" +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("json encode error: %v", err) + } +} + +func writeNotFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + }) +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{ + "error": "Method Not Allowed", + "message": "Only GET is supported for this endpoint", + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (recorder *statusRecorder) WriteHeader(code int) { + recorder.status = code + recorder.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + log.Printf("Request: %s %s", r.Method, r.URL.Path) + next.ServeHTTP(recorder, r) + log.Printf("Response: %s %s -> %d (%s)", r.Method, r.URL.Path, recorder.status, time.Since(start)) + }) +} + +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("panic recovered: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/lab1c/app_python/.gitignore b/lab1c/app_python/.gitignore new file mode 100644 index 0000000000..3b66351052 --- /dev/null +++ b/lab1c/app_python/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.log +venv/ +.env + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/lab1c/app_python/README.md b/lab1c/app_python/README.md new file mode 100644 index 0000000000..777680e4c8 --- /dev/null +++ b/lab1c/app_python/README.md @@ -0,0 +1,42 @@ +# DevOps Info Service (FastAPI) + +## Overview +Web service returning system info about the machine it runs on, plus a simple health check. + +## Prerequisites +- Python 3.11+ +- pip +- (Optional) venv tool + +## Installation +### Windows +```bash +python -m venv venv +.\venv\Scripts\Activate.ps1 +pip install -r requirements.txt +``` + +## Running the Application +```bash +python app.py +``` + +Custom cfg examples: +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +``` + +FastAPI docs: +- `http://localhost:port/docs` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable auto-reload | diff --git a/lab1c/app_python/app.py b/lab1c/app_python/app.py new file mode 100644 index 0000000000..8935b94091 --- /dev/null +++ b/lab1c/app_python/app.py @@ -0,0 +1,158 @@ +""" +DevOps Info Service +FastAPI application module. +""" + +from __future__ import annotations + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +# Config +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +SERVICE_NAME = "devops-info-service" +SERVICE_VERSION = "1.0.0" +SERVICE_DESCRIPTION = "DevOps course info service" +SERVICE_FRAMEWORK = "FastAPI" + +START_TIME = datetime.now(timezone.utc) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("devops-info-service") + +app = FastAPI( + title="DevOps Info Service", + version=SERVICE_VERSION, + description=SERVICE_DESCRIPTION, +) + + +def _format_uptime(seconds: int) -> str: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + hour_label = "hour" if hours == 1 else "hours" + minute_label = "minute" if minutes == 1 else "minutes" + return f"{hours} {hour_label}, {minutes} {minute_label}" + + +def get_uptime() -> dict[str, int | str]: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + return { + "seconds": seconds, + "human": _format_uptime(seconds), + } + + +def get_system_info() -> dict[str, str | int]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } + + +def isoformat_utc(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info("Request: %s %s", request.method, request.url.path) + response = await call_next(request) + logger.info("Response: %s %s -> %s", request.method, request.url.path, response.status_code) + return response + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code == 404: + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": "Endpoint does not exist", + }, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled error: %s", exc) + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + +@app.get("/") +async def root(request: Request): + uptime = get_uptime() + now = datetime.now(timezone.utc) + + response = { + "service": { + "name": SERVICE_NAME, + "version": SERVICE_VERSION, + "description": SERVICE_DESCRIPTION, + "framework": SERVICE_FRAMEWORK, + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": isoformat_utc(now), + "timezone": "UTC", + }, + "request": { + "client_ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent", "unknown"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + return response + + +@app.get("/health") +async def health(): + uptime = get_uptime() + return { + "status": "healthy", + "timestamp": isoformat_utc(datetime.now(timezone.utc)), + "uptime_seconds": uptime["seconds"], + } + + +if __name__ == "__main__": + logger.info("Starting DevOps Info Service on %s:%s", HOST, PORT) + uvicorn.run("app:app", host=HOST, port=PORT, reload=DEBUG, log_level="info") diff --git a/lab1c/app_python/docs/LAB01.md b/lab1c/app_python/docs/LAB01.md new file mode 100644 index 0000000000..b38b4a2a4f --- /dev/null +++ b/lab1c/app_python/docs/LAB01.md @@ -0,0 +1,126 @@ +# LAB01 - DevOps Info Service (Python) + +## Framework Selection +I chose **FastAPI** because I use this stack at my job, so it feels familiar and I can move faster. The auto-generated docs are also handy for quick checks. + +| Framework | Pros | Cons | Decision | +| --- | --- | --- | --- | +| FastAPI | Fast, async-ready, automatic docs, type hints | Slightly more setup than Flask | Chosen (daily stack at work) | +| Flask | Very lightweight, easy to start | Manual docs, fewer built-ins | Not chosen | +| Django | Full-featured, includes ORM | Heavy for a small service | Not chosen | + +## Best Practices Applied +**Clean structure and helpers** +I kept the main endpoint small and pushed the system/runtime details into helper functions so it stays readable. + +```python +def get_system_info() -> dict[str, str | int]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } +``` + +**Configuration through environment variables** +The app can be configured without code changes: + +```python +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" +``` + +**Logging** +Requests and responses are logged through middleware so it is easy to trace incoming calls. + +```python +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info("Request: %s %s", request.method, request.url.path) + response = await call_next(request) + logger.info("Response: %s %s -> %s", request.method, request.url.path, response.status_code) + return response +``` + +**Error handling** +There are explicit handlers for 404 and 500 to return errors. + +```python +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code == 404: + return JSONResponse( + status_code=404, + content={"error": "Not Found", "message": "Endpoint does not exist"}, + ) +``` + +## API Documentation +### `GET /` +Returns service, system, runtime, and request details. + +Example: +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "my-machine", + "platform": "Windows", + "platform_version": "10", + "architecture": "AMD64", + "cpu_count": 12, + "python_version": "3.11.7" + }, + "runtime": { + "uptime_seconds": 42, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-27T10:15:00Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.5.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### `GET /health` +Returns a lightweight health response used for probes. + +Example: +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T10:15:05Z", + "uptime_seconds": 47 +} +``` + +### Testing Commands +```bash +curl http://localhost:5000/ +curl http://localhost:5000/health +``` +Command outputs can be captured with same commands + +## Challenges & Solutions +- **Uptime formatting:** I wanted something readable instead of raw seconds, so I added a small formatter that outputs hours and minutes. +- **Request metadata:** FastAPI's `Request` object has what I need, so I pulled `client_ip`, `user_agent`, and the path from there. + +## GitHub Community +Stars are a quick way to show that a project is useful and to keep a personal list of tools I might revisit. Following developers keeps me in the loop on what people are building, which helps with collaboration and professional growth. diff --git a/lab1c/app_python/docs/screenshots/formatted-output.jpg b/lab1c/app_python/docs/screenshots/formatted-output.jpg new file mode 100644 index 0000000000..4fd5871f1d Binary files /dev/null and b/lab1c/app_python/docs/screenshots/formatted-output.jpg differ diff --git a/lab1c/app_python/docs/screenshots/health-check.jpg b/lab1c/app_python/docs/screenshots/health-check.jpg new file mode 100644 index 0000000000..9faf4aa9a2 Binary files /dev/null and b/lab1c/app_python/docs/screenshots/health-check.jpg differ diff --git a/lab1c/app_python/docs/screenshots/main-endpoint.jpg b/lab1c/app_python/docs/screenshots/main-endpoint.jpg new file mode 100644 index 0000000000..b8d3123e78 Binary files /dev/null and b/lab1c/app_python/docs/screenshots/main-endpoint.jpg differ diff --git a/lab1c/app_python/requirements.txt b/lab1c/app_python/requirements.txt new file mode 100644 index 0000000000..792449289f --- /dev/null +++ b/lab1c/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/lab1c/app_python/tests/__init__.py b/lab1c/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2