diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..3ed6a39264 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,69 @@ +name: Go CI + +on: + push: + branches: [master, lab03] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + pull_request: + branches: [master] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_go + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: app_go/go.mod + + - name: Lint with golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + working-directory: app_go + + - name: Run tests + run: go test -v ./... + + docker: + name: Build & Push Docker + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - 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: Generate CalVer tag + id: version + run: echo "TAG=$(date +%Y.%m).${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_go + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service-go:${{ steps.version.outputs.TAG }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service-go:latest diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..15acfd334c --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,85 @@ +name: Python CI + +on: + push: + branches: [master, lab03] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [master] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: app_python/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Lint with ruff + run: ruff check . + + - name: Run tests with coverage + run: pytest -v --cov=. --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Snyk security scan + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=requirements.txt --severity-threshold=high + + docker: + name: Build & Push Docker + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - 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: Generate CalVer tag + id: version + run: echo "TAG=$(date +%Y.%m).${{ github.run_number }}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ steps.version.outputs.TAG }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/.gitignore b/.gitignore index 30d74d2584..f052e6476d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +pyrightconfig.json diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..130e674c30 --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,10 @@ +devops-info-service +.git/ +.gitignore +.vscode/ +.idea/ +*.md +docs/ +.DS_Store +Dockerfile +.dockerignore diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..ffc25971dc --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,10 @@ +# Binary +devops-info-service +*.exe + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..b34c19e563 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /build + +COPY go.mod . +COPY main.go . + +RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service + +FROM scratch + +COPY --from=builder /build/devops-info-service /devops-info-service + +EXPOSE 8080 + +ENTRYPOINT ["/devops-info-service"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..28eabc5215 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,69 @@ +# DevOps Info Service (Go) + +A web service that reports system information and health status, built with Go's standard library `net/http`. + +## Prerequisites + +- Go 1.21+ + +## Build + +```bash +go build -o devops-info-service +``` + +## Running the Application + +```bash +./devops-info-service +``` + +With custom configuration: + +```bash +PORT=3000 ./devops-info-service +HOST=127.0.0.1 PORT=3000 ./devops-info-service +``` + +Or run directly without building: + +```bash +go run main.go +``` + +The service starts on `http://localhost:8080` by default. + +## API Endpoints + +| Method | Path | Description | +|--------|-----------|--------------------------------------| +| GET | `/` | Service and system information | +| GET | `/health` | Health check (status, uptime) | + +### `GET /` + +```bash +curl http://localhost:8080/ +``` + +### `GET /health` + +```bash +curl http://localhost:8080/health +``` + +## Configuration + +| Variable | Default | Description | +|----------|-----------|----------------------| +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8080` | Server port | + +## Binary Size Comparison + +| Artifact | Size | +|----------------------|----------| +| Python (source) | ~5 KB | +| Go (compiled binary) | ~7 MB | + +The Go binary is self-contained — no runtime, no dependencies, no virtual environment needed. Just copy and run. diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..e09b91ca10 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,28 @@ +# Language Justification: Go + +## Why Go + +| Criteria | Python (FastAPI) | Go (net/http) | Java (Spring Boot) | +|---------------------|------------------|---------------------|---------------------| +| Binary size | N/A (interpreted)| ~7 MB | ~20 MB (fat JAR) | +| Startup time | ~1s | Instant | ~2-3s | +| Dependencies | pip + venv | Zero (stdlib only) | Maven + JDK | +| Compilation speed | N/A | Very fast | Moderate | +| Memory usage | Moderate | Very low | High | +| Concurrency | GIL-limited | Goroutines (native) | Threads | + +## Reasons for Choosing Go + +1. **Zero external dependencies** — the entire service is built using Go's standard library (`net/http`, `encoding/json`, `runtime`). No frameworks, no package managers, no dependency hell. + +2. **Single static binary** — `go build` produces one executable with everything baked in. No runtime needed, no JVM, no interpreter. Just copy the binary and run it anywhere. + +3. **Ideal for Docker** — small binary size and no runtime dependencies make for minimal Docker images. A multi-stage build with `FROM scratch` can produce images under 10 MB. + +4. **Fast compilation** — the entire project compiles in under a second, making the development loop fast. + +## Trade-offs + +- **Verbosity** — Go requires explicit struct definitions with JSON tags. More typing than Python dicts, but safer. +- **No auto-docs** — unlike FastAPI's built-in Swagger, Go's stdlib has no API documentation generation. +- **Error handling** — Go uses explicit `if err != nil` patterns instead of exceptions. More boilerplate but more predictable. diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..599a576455 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,42 @@ +# Lab 01 — DevOps Info Service (Go) + +## Implementation Details + +The Go implementation keeps everything in a single `main.go` file using only the standard library. This is idiomatic for small Go services — no need for a framework. + +**Key components:** +- Struct types with JSON tags for response serialization +- `rootHandler` and `healthHandler` for the two endpoints +- `getUptime()` and `getHostname()` helper functions +- `startTime` package-level variable for uptime tracking + +## Key Differences from Python Version + +| Aspect | Python (FastAPI) | Go (net/http) | +|-----------------|----------------------------|----------------------------| +| Models | Pydantic `BaseModel` | Structs with JSON tags | +| Routing | `@router.get("/")` | `http.HandleFunc("/", fn)` | +| JSON | Automatic from dict/model | `json.NewEncoder().Encode` | +| Server | uvicorn (external) | Built-in `http.ListenAndServe` | +| Dependencies | fastapi, uvicorn | None (stdlib only) | +| Field naming | `python_version` | `go_version` | + +## Build & Run + +```bash +go build -o devops-info-service +./devops-info-service +``` + +## Testing + +```bash +curl http://localhost:8080/ +curl http://localhost:8080/health +``` + +## Screenshots + +Screenshots in `screenshots/`: +- Compilation and binary output +- Running application with endpoint responses diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..61ac9906ee --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,71 @@ +# Lab 02 — Docker Containerization (Go) + +## Multi-Stage Build Strategy + +The Go Dockerfile uses two stages: + +### Stage 1: Builder + +```dockerfile +FROM golang:1.21-alpine AS builder +WORKDIR /build +COPY go.mod . +COPY main.go . +RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service +``` + +**Purpose:** Compile the Go source into a static binary. The `golang:1.21-alpine` image includes the full Go toolchain (~300 MB). `CGO_ENABLED=0` produces a fully static binary with no C library dependencies, which is required to run on `scratch`. + +### Stage 2: Runtime + +```dockerfile +FROM scratch +COPY --from=builder /build/devops-info-service /devops-info-service +EXPOSE 8080 +ENTRYPOINT ["/devops-info-service"] +``` + +**Purpose:** The final image starts from `scratch` — literally an empty filesystem. Only the compiled binary is copied in. No shell, no package manager, no OS — just the executable. + +## Size Comparison + +| Image | Size | +|------------------------|----------| +| golang:1.21-alpine | ~300 MB | +| Final image (scratch) | ~7 MB | +| Python (3.13-slim) | ~170 MB | + +The multi-stage build reduces the image from ~300 MB (builder) to ~7 MB (final) — a **97% reduction**. + +## Why Multi-Stage Builds Matter + +**Without multi-stage:** The final image includes the entire Go SDK, build tools, source code, and intermediate build artifacts. This wastes disk space and increases the attack surface. + +**With multi-stage:** The final image contains only the compiled binary. There is nothing else to exploit — no shell to exec into, no package manager to install tools, no OS libraries with CVEs. + +## Security Benefits + +- **`FROM scratch`** — the image has zero packages, zero CVEs by definition. There is literally nothing to patch. +- **No shell** — an attacker cannot `docker exec` into the container and run commands. +- **Static binary** — no dynamic library dependencies that could be exploited. +- **Minimal attack surface** — the only thing running is the application binary. + +## Build & Run + +```bash +cd app_go +docker build -t devops-info-service-go . +docker run -p 8080:8080 devops-info-service-go +``` + +## Testing + +```bash +curl http://localhost:8080/ +curl http://localhost:8080/health +``` + +## Challenges & Solutions + +1. **`scratch` has no CA certificates** — if the app needed HTTPS outbound calls, `scratch` would fail. For this service it's not needed, but for production apps you'd either copy certs from the builder stage or use `gcr.io/distroless/static` instead. +2. **Static compilation required** — `CGO_ENABLED=0` is mandatory for `scratch`. Without it, the binary dynamically links glibc, which doesn't exist in `scratch`. diff --git a/app_go/docs/screenshots/01-compilation-of-program.png b/app_go/docs/screenshots/01-compilation-of-program.png new file mode 100644 index 0000000000..a68330b21a Binary files /dev/null and b/app_go/docs/screenshots/01-compilation-of-program.png differ diff --git a/app_go/docs/screenshots/02-root-endpoint.png b/app_go/docs/screenshots/02-root-endpoint.png new file mode 100644 index 0000000000..ea1bc8ba62 Binary files /dev/null and b/app_go/docs/screenshots/02-root-endpoint.png differ diff --git a/app_go/docs/screenshots/03-health-endpoint.png b/app_go/docs/screenshots/03-health-endpoint.png new file mode 100644 index 0000000000..0b43c98279 Binary files /dev/null and b/app_go/docs/screenshots/03-health-endpoint.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..307ce0d1c5 --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.21 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..50d361272d --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "runtime" + "time" +) + +var startTime = time.Now() + +type ServiceInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type SystemInfo struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + GoVersion string `json:"go_version"` +} + +type RuntimeInfo struct { + UptimeSeconds int64 `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 EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type RootResponse struct { + Service ServiceInfo `json:"service"` + System SystemInfo `json:"system"` + Runtime RuntimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []EndpointInfo `json:"endpoints"` +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +func getHostname() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + return hostname +} + +func getUptime() (int64, string) { + seconds := int64(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + human := fmt.Sprintf("%d hours, %d minutes", hours, minutes) + return seconds, human +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + uptimeSeconds, uptimeHuman := getUptime() + + response := RootResponse{ + Service: ServiceInfo{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "net/http", + }, + System: SystemInfo{ + Hostname: getHostname(), + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + }, + Runtime: RuntimeInfo{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: time.Now().UTC().Format(time.RFC3339), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: r.RemoteAddr, + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []EndpointInfo{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, _ := getUptime() + + response := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format(time.RFC3339), + UptimeSeconds: uptimeSeconds, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + addr := host + ":" + port + + http.HandleFunc("/", rootHandler) + http.HandleFunc("/health", healthHandler) + + log.Printf("Starting DevOps Info Service on %s", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..804047645d --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRootHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + rootHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp RootResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if resp.Service.Name != "devops-info-service" { + t.Errorf("expected service name 'devops-info-service', got '%s'", resp.Service.Name) + } + if resp.Service.Version != "1.0.0" { + t.Errorf("expected version '1.0.0', got '%s'", resp.Service.Version) + } + if resp.Service.Framework != "net/http" { + t.Errorf("expected framework 'net/http', got '%s'", resp.Service.Framework) + } + if resp.System.CPUCount <= 0 { + t.Errorf("expected cpu_count > 0, got %d", resp.System.CPUCount) + } + if resp.Runtime.Timezone != "UTC" { + t.Errorf("expected timezone 'UTC', got '%s'", resp.Runtime.Timezone) + } + if len(resp.Endpoints) < 2 { + t.Errorf("expected at least 2 endpoints, got %d", len(resp.Endpoints)) + } +} + +func TestHealthHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + healthHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp HealthResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if resp.Status != "healthy" { + t.Errorf("expected status 'healthy', got '%s'", resp.Status) + } + if resp.UptimeSeconds < 0 { + t.Errorf("expected uptime >= 0, got %d", resp.UptimeSeconds) + } + if resp.Timestamp == "" { + t.Error("expected non-empty timestamp") + } +} + +func TestRootHandlerNotFound(t *testing.T) { + req := httptest.NewRequest("GET", "/nonexistent", nil) + w := httptest.NewRecorder() + rootHandler(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestRootHandlerContentType(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + rootHandler(w, req) + + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected content-type 'application/json', got '%s'", ct) + } +} diff --git a/app_python/.coverage b/app_python/.coverage new file mode 100644 index 0000000000..bc2460004b Binary files /dev/null and b/app_python/.coverage differ diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..8b9e2e9397 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ +.git/ +.gitignore +.vscode/ +.idea/ +*.md +docs/ +tests/ +.DS_Store +Dockerfile +.dockerignore diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..efe14f9287 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ +*.egg-info/ +dist/ +build/ +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..1d9cffbdb0 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim + +RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..3745f96f09 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,190 @@ +# DevOps Info Service + +[![Python CI](https://github.com/4hellboy4/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/4hellboy4/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![codecov](https://codecov.io/gh/4hellboy4/DevOps-Core-Course/branch/master/graph/badge.svg)](https://codecov.io/gh/4hellboy4/DevOps-Core-Course) + +A web service that reports system information and health status, built with FastAPI. + +## Overview + +DevOps Info Service provides detailed information about itself and its runtime environment through a REST API. It exposes system metadata, uptime tracking, and a health check endpoint for monitoring. + +## Prerequisites + +- Python 3.11+ +- pip + +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application + +```bash +python app.py +``` + +With custom configuration: + +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +``` + +Or directly via uvicorn (with auto-reload for development): + +```bash +uvicorn app:app --reload --port 8000 +``` + +## API Endpoints + +| Method | Path | Description | +|--------|-----------|--------------------------------------| +| GET | `/` | Service and system information | +| GET | `/health` | Health check (status, uptime) | + +### `GET /` + +Returns comprehensive service, system, runtime, and request information. + +```bash +curl http://localhost:8000/ +``` + +Example response: + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "my-laptop", + "platform": "Darwin", + "platform_version": "macOS-15.2-arm64-arm-64bit", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 120, + "uptime_human": "0 hours, 2 minutes", + "current_time": "2026-02-11T14:30:00.000000+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.7.1", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### `GET /health` + +Returns service health status and uptime. + +```bash +curl http://localhost:8000/health +``` + +Example response: + +```json +{ + "status": "healthy", + "timestamp": "2026-02-11T14:30:00.000000+00:00", + "uptime_seconds": 120 +} +``` + +## Testing + +Install dev dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Run tests: + +```bash +pytest -v +``` + +Run with coverage: + +```bash +pytest --cov=. --cov-report=term +``` + +## Configuration + +| Variable | Default | Description | +|----------|-----------|----------------------| +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8000` | Server port | +| `DEBUG` | `false` | Enable debug mode | + +## Docker + +### Build the image + +```bash +docker build -t devops-info-service . +``` + +### Run a container + +```bash +docker run -p 8000:8000 devops-info-service +``` + +With custom port: + +```bash +docker run -p 3000:8000 devops-info-service +``` + +### Pull from Docker Hub + +```bash +docker pull 4hellboy4/devops-info-service:latest +docker run -p 8000:8000 4hellboy4/devops-info-service:latest +``` + +## Project Structure + +``` +app_python/ +├── app.py # Application entry point +├── config.py # Environment variable configuration +├── requirements.txt # Pinned dependencies +├── .gitignore +├── README.md +├── models/ # Pydantic response schemas +│ ├── root_responses.py +│ └── health_responses.py +├── routes/ # Endpoint handlers +│ ├── root.py +│ └── health.py +├── services/ # Business logic +│ ├── system_info.py +│ └── uptime.py +├── tests/ +└── docs/ + └── LAB01.md +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..c7fe433ecc --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,45 @@ +import logging + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from config import HOST, PORT +from routes.root import router as root_router +from routes.health import router as health_router + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +app = FastAPI(title="DevOps Info Service", version="1.0.0") + +app.include_router(root_router) +app.include_router(health_router) + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=404, + content={"error": "Not Found", "message": "Endpoint does not exist"}, + ) + + +@app.exception_handler(500) +async def internal_error_handler(request: Request, exc: Exception): + logger.error("Internal server error: %s", exc) + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + +if __name__ == "__main__": + logger.info("Starting DevOps Info Service on %s:%d", HOST, PORT) + uvicorn.run(app, host=HOST, port=PORT) diff --git a/app_python/config.py b/app_python/config.py new file mode 100644 index 0000000000..c74ec700fb --- /dev/null +++ b/app_python/config.py @@ -0,0 +1,5 @@ +import os + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8000")) +DEBUG = os.getenv("DEBUG", "false").lower() == "true" diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..af412a7d8a --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,71 @@ +# Lab 01 — DevOps Info Service + +## Framework Selection + +**Choice:** FastAPI + +| Criteria | Flask | FastAPI | Django | +|--------------------|----------------|---------------------|-------------------| +| Performance | Moderate | High (async) | Moderate | +| Auto documentation | No (manual) | Yes (Swagger/ReDoc) | No | +| Type safety | No | Yes (Pydantic) | Partial | +| Learning curve | Low | Low-Medium | High | +| Async support | Limited | Native | Partial | + +**Why FastAPI:** +- Built-in request validation with Pydantic models +- Automatic OpenAPI/Swagger documentation at `/docs` +- Native async support for future scalability +- Type hints enforced at runtime, catching bugs early + +## Best Practices Applied + +1. **Clean code structure** — separated into `models/`, `routes/`, `services/`, and `config.py`. Each module has a single responsibility. +2. **Pydantic response models** — all endpoints use typed response schemas, ensuring consistent JSON output and enabling auto-generated API docs. +3. **Environment variable configuration** — `HOST`, `PORT`, `DEBUG` are configurable via env vars with sensible defaults. +4. **Error handling** — custom 404 and 500 handlers return structured JSON errors. +5. **Logging** — configured with timestamps and log levels for production readability. +6. **Pinned dependencies** — `requirements.txt` uses exact versions for reproducible builds. +7. **Proper `.gitignore`** — excludes `__pycache__/`, `venv/`, IDE files, and OS artifacts. + +## API Documentation + +### `GET /` — Service Information + +```bash +curl http://localhost:8000/ +``` + +Returns service metadata, system info, runtime uptime, request details, and available endpoints. + +### `GET /health` — Health Check + +```bash +curl http://localhost:8000/health +``` + +Returns health status, timestamp, and uptime in seconds. + +### Error Responses + +```bash +curl http://localhost:8000/nonexistent +# {"error": "Not Found", "message": "Endpoint does not exist"} +``` + +## Testing Evidence + +Screenshots in `screenshots/`: +- `01-main-endpoint.png` — GET / response +- `02-health-check.png` — GET /health response +- `03-formatted-output.png` — Pretty-printed JSON output + +## Challenges & Solutions + +1. **Pyright not resolving imports** — The workspace root differs from `app_python/`, so basedpyright couldn't find the venv. Solved by adding `pyrightconfig.json` at the workspace root with `venvPath` pointing to `app_python`. +2. **Shared uptime logic** — Both `/` and `/health` need uptime data. Extracted `services/uptime.py` as a shared module with `START_TIME` initialized at import time. +3. **Type narrowing for TypedDict** — `get_uptime()` originally returned `dict[str, int | str]`, which pyright couldn't narrow. Fixed by using `TypedDict` for precise per-key types. + +## GitHub Community + +Starring repositories helps with discovery and bookmarking — it signals project quality to the community and encourages maintainers. Following developers builds professional connections and keeps you informed about relevant projects and industry trends. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..98d324e10a --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,111 @@ +# Lab 02 — Docker Containerization + +## Docker Best Practices Applied + +### 1. Non-root user + +```dockerfile +RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser +USER appuser +``` + +**Why:** Running as root inside a container means a container escape vulnerability gives the attacker root on the host. A non-root user limits the blast radius — even if the container is compromised, the attacker has restricted permissions. + +### 2. Specific base image version + +```dockerfile +FROM python:3.13-slim +``` + +**Why:** Using `python:latest` or `python:3` means your image can change without warning when a new version is released. Pinning `3.13-slim` ensures reproducible builds — the same Dockerfile produces the same image every time. + +**Why slim:** The full `python:3.13` image is ~1 GB (includes gcc, build tools). The `slim` variant is ~150 MB — it has everything needed to run Python apps but strips build tools we don't need. + +### 3. Layer ordering (dependencies before code) + +```dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +``` + +**Why:** Docker caches each layer. Dependencies change rarely, application code changes often. By copying `requirements.txt` first and installing dependencies in a separate layer, Docker reuses the cached dependency layer on every code change. This makes rebuilds take seconds instead of minutes. + +### 4. .dockerignore + +**Why:** Without `.dockerignore`, Docker sends the entire directory as build context to the daemon — including `venv/` (hundreds of MBs), `.git/`, docs, tests. This slows down builds and can leak secrets into the image. The `.dockerignore` keeps the build context minimal. + +### 5. --no-cache-dir for pip + +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Why:** pip caches downloaded packages by default for faster reinstalls. Inside a Docker image, there are no reinstalls — the cache just wastes space. `--no-cache-dir` keeps the image smaller. + +## Image Information & Decisions + +- **Base image:** `python:3.13-slim` — Debian-based minimal Python image. Chosen over `alpine` because alpine uses musl libc which can cause compatibility issues with some Python packages. Slim provides a good balance of size and compatibility. +- **Final image size:** ~170 MB (run `docker images` to verify) +- **Layer structure:** + 1. Base image (python:3.13-slim) + 2. User creation + 3. Working directory + 4. Copy requirements.txt + 5. Install dependencies (cached unless requirements change) + 6. Copy application code + 7. Set ownership and switch to non-root user + +## Build & Run Process + +### Build + +```bash +cd app_python +docker build -t devops-info-service . +``` + +### Run + +```bash +docker run -p 8000:8000 devops-info-service +``` + +### Test + +```bash +curl http://localhost:8000/ +curl http://localhost:8000/health +``` + +### Docker Hub + +```bash +docker tag devops-info-service 4hellboy4/devops-info-service:latest +docker login +docker push 4hellboy4/devops-info-service:latest +``` + +**Docker Hub URL:** `https://hub.docker.com/r/4hellboy4/devops-info-service` + +## Technical Analysis + +**Why this layer order works:** The most frequently changing layers (application code) are at the bottom. When you change `app.py`, Docker rebuilds from `COPY . .` onwards — the dependency installation layer above it is cached. If dependencies were copied together with code, every code change would reinstall all packages. + +**What if layer order changed:** If we did `COPY . .` before `pip install`, changing any source file would invalidate the pip install cache. Every build would re-download and install all dependencies from scratch. + +**Security considerations:** +- Non-root user prevents privilege escalation +- Slim base image has fewer packages = smaller attack surface +- `.dockerignore` prevents secrets and unnecessary files from entering the image +- No shell login for the app user (`/sbin/nologin`) + +**How .dockerignore helps:** +- Excludes `venv/` (~100+ MB) from build context +- Excludes `.git/` (repository history, potentially large) +- Faster `docker build` since less data is sent to the daemon + +## Challenges & Solutions + +1. **Choosing between slim and alpine** — Alpine images are smaller (~50 MB) but use musl libc, which can cause issues with Python packages that depend on glibc. Chose slim for reliability. +2. **File permissions** — Application files are copied as root, so `chown` is needed before switching to the non-root user, otherwise the app can't read its own files. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..8efd95653b --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,64 @@ +# Lab 03 — CI/CD + +## Overview + +- **Testing framework:** pytest — simple syntax, powerful fixtures, works great with FastAPI's `TestClient`. No reason to use verbose `unittest` for a project this size. +- **Tests cover:** `GET /` (8 tests), `GET /health` (6 tests), error handling (2 tests) — 16 tests total. +- **CI triggers:** push to `master`/`lab03` and PRs to `master`, only when `app_python/**` files change. +- **Versioning:** CalVer (`YYYY.MM.RUN_NUMBER`) — this is a service, not a library. Date-based tags make it obvious when an image was built. + +## Workflow Evidence + +- Successful workflow run: https://github.com/4hellboy4/DevOps-Core-Course/actions/runs/21961258045 +- Tests passing locally: see `screenshots/13-running-tests.png` +- Docker Hub: https://hub.docker.com/r/4hellboy4/devops-info-service +- Status badge is at the top of `app_python/README.md` + +## Best Practices Implemented + +- **Dependency caching:** `actions/setup-python` caches pip packages keyed on `requirements-dev.txt` hash. First run downloads everything, subsequent runs skip installation. Saves ~15-20 seconds per run. +- **Job dependencies:** Docker build (`needs: test`) only runs if lint + tests pass. No point pushing a broken image. +- **Path filters:** Workflow only triggers on `app_python/**` changes. Editing Go code or docs doesn't waste CI minutes. +- **Concurrency control:** `cancel-in-progress: true` kills outdated runs when you push again quickly. No zombie workflows. +- **Conditional Docker push:** `if: github.event_name == 'push'` — PRs run tests only, don't push to Docker Hub. +- **Snyk scanning:** Runs after tests with `continue-on-error: true` and `--severity-threshold=high`. Warns about vulnerable dependencies without blocking the build for low-severity issues. + +## Key Decisions + +- **CalVer over SemVer:** This service doesn't have "breaking changes" — it's an info endpoint. CalVer (`2026.02.5`) tells you exactly when it was built. SemVer would be overkill. +- **Docker tags:** Each push creates `YYYY.MM.RUN_NUMBER` + `latest`. Two tags so you can pin a specific version or always get the newest. +- **Triggers:** Push + PR on `app_python/**` only. PRs run tests (catch bugs before merge), pushes also build and push Docker images. +- **Test coverage:** All three endpoints tested — `/` checks every JSON field and type, `/health` checks status and uptime, 404 checks the custom error handler. Not testing internal service functions directly because the endpoint tests already exercise them. + +## Challenges + +- Had to add `sys.path.insert` in test files because pytest runs from the repo root but the app modules are inside `app_python/`. +- Snyk needs a separate API token — created a free account and added `SNYK_TOKEN` to GitHub Secrets. + +--- + +## Bonus: Multi-App CI + Coverage + +### Go CI Workflow + +Created `.github/workflows/go-ci.yml` with the same structure as the Python workflow: +- **Lint:** `golangci-lint` (standard Go linter) +- **Test:** `go test -v ./...` +- **Docker:** Multi-stage build, same CalVer tagging, pushes to `4hellboy4/devops-info-service-go` + +Path filters ensure Go CI only runs on `app_go/**` changes. Both workflows run in parallel when both apps change in one commit. + +### Path Filters + +Each workflow only triggers on its own app directory: +- `python-ci.yml` → `app_python/**` +- `go-ci.yml` → `app_go/**` + +This avoids wasting CI minutes. If you edit only Go code, the Python workflow doesn't run, and vice versa. + +### Coverage + +- Integrated `pytest-cov` into the Python CI — runs `pytest --cov=. --cov-report=xml` +- Coverage reports uploaded to Codecov via `codecov/codecov-action@v4` +- Coverage badge added to `app_python/README.md` +- Testing all endpoints through the TestClient gives good coverage of routes, services, and models. Config and `__main__` block are intentionally untested (startup code, not logic). diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..fc2105be8c Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..bc2eb447bc Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..332b099ef2 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/04-the-running-app.png b/app_python/docs/screenshots/04-the-running-app.png new file mode 100644 index 0000000000..86bde124c7 Binary files /dev/null and b/app_python/docs/screenshots/04-the-running-app.png differ diff --git a/app_python/docs/screenshots/05-created-docker-python-container.png b/app_python/docs/screenshots/05-created-docker-python-container.png new file mode 100644 index 0000000000..6d532b58b5 Binary files /dev/null and b/app_python/docs/screenshots/05-created-docker-python-container.png differ diff --git a/app_python/docs/screenshots/06-deploying-python-container.png b/app_python/docs/screenshots/06-deploying-python-container.png new file mode 100644 index 0000000000..95936fbd3e Binary files /dev/null and b/app_python/docs/screenshots/06-deploying-python-container.png differ diff --git a/app_python/docs/screenshots/07-checking-python-container-work.png b/app_python/docs/screenshots/07-checking-python-container-work.png new file mode 100644 index 0000000000..a3b7ffd81f Binary files /dev/null and b/app_python/docs/screenshots/07-checking-python-container-work.png differ diff --git a/app_python/docs/screenshots/08-deploying-go-container.png b/app_python/docs/screenshots/08-deploying-go-container.png new file mode 100644 index 0000000000..3183a2830c Binary files /dev/null and b/app_python/docs/screenshots/08-deploying-go-container.png differ diff --git a/app_python/docs/screenshots/09-running-go-container.png b/app_python/docs/screenshots/09-running-go-container.png new file mode 100644 index 0000000000..be702af35b Binary files /dev/null and b/app_python/docs/screenshots/09-running-go-container.png differ diff --git a/app_python/docs/screenshots/10-checking-go-container.png b/app_python/docs/screenshots/10-checking-go-container.png new file mode 100644 index 0000000000..1d02476694 Binary files /dev/null and b/app_python/docs/screenshots/10-checking-go-container.png differ diff --git a/app_python/docs/screenshots/11-pushing-image-to-docker-hub.png b/app_python/docs/screenshots/11-pushing-image-to-docker-hub.png new file mode 100644 index 0000000000..f31f839279 Binary files /dev/null and b/app_python/docs/screenshots/11-pushing-image-to-docker-hub.png differ diff --git a/app_python/docs/screenshots/12-docker-hub-page.png b/app_python/docs/screenshots/12-docker-hub-page.png new file mode 100644 index 0000000000..96133d250d Binary files /dev/null and b/app_python/docs/screenshots/12-docker-hub-page.png differ diff --git a/app_python/docs/screenshots/13-running-tests.png b/app_python/docs/screenshots/13-running-tests.png new file mode 100644 index 0000000000..bf1b066b74 Binary files /dev/null and b/app_python/docs/screenshots/13-running-tests.png differ diff --git a/app_python/docs/screenshots/14-pushed-docker-updated-image.png b/app_python/docs/screenshots/14-pushed-docker-updated-image.png new file mode 100644 index 0000000000..7cfc4074fa Binary files /dev/null and b/app_python/docs/screenshots/14-pushed-docker-updated-image.png differ diff --git a/app_python/models/__init__.py b/app_python/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/models/health_responses.py b/app_python/models/health_responses.py new file mode 100644 index 0000000000..0c8225ac4c --- /dev/null +++ b/app_python/models/health_responses.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str + timestamp: str + uptime_seconds: int diff --git a/app_python/models/root_responses.py b/app_python/models/root_responses.py new file mode 100644 index 0000000000..243ab543c5 --- /dev/null +++ b/app_python/models/root_responses.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel + +class ServiceInfo(BaseModel): + name: str + version: str + description: str + framework: str + +class SystemInfo(BaseModel): + hostname: str + platform: str + platform_version: str + architecture: str + cpu_count: int + python_version: str + +class RuntimeInfo(BaseModel): + uptime_seconds: int + uptime_human: str + current_time: str + timezone: str + +class RequestInfo(BaseModel): + client_ip: str + user_agent: str + method: str + path: str + +class EndpointInfo(BaseModel): + path: str + method: str + description: str + +class RootResponse(BaseModel): + service: ServiceInfo + system: SystemInfo + runtime: RuntimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..b6071a363b --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest==8.3.4 +httpx==0.28.1 +pytest-cov==6.0.0 +ruff==0.9.6 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..792449289f --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/app_python/routes/__init__.py b/app_python/routes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/routes/health.py b/app_python/routes/health.py new file mode 100644 index 0000000000..99a7db9461 --- /dev/null +++ b/app_python/routes/health.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter + +from models.health_responses import HealthResponse +from services.uptime import get_uptime + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +def health(): + return HealthResponse( + status="healthy", + timestamp=datetime.now(timezone.utc).isoformat(), + uptime_seconds=get_uptime()["seconds"], + ) diff --git a/app_python/routes/root.py b/app_python/routes/root.py new file mode 100644 index 0000000000..594b25f209 --- /dev/null +++ b/app_python/routes/root.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Request + +from models.root_responses import RootResponse +from services.system_info import ( + get_endpoints, + get_request_info, + get_runtime_info, + get_service_info, + get_system_info, +) + +router = APIRouter() + + +@router.get("/", response_model=RootResponse) +def index(request: Request): + return RootResponse( + service=get_service_info(), + system=get_system_info(), + runtime=get_runtime_info(), + request=get_request_info(request), + endpoints=get_endpoints(), + ) diff --git a/app_python/services/__init__.py b/app_python/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/services/system_info.py b/app_python/services/system_info.py new file mode 100644 index 0000000000..9a55ef04b1 --- /dev/null +++ b/app_python/services/system_info.py @@ -0,0 +1,65 @@ +import os +import socket +import platform +from datetime import datetime, timezone + +from fastapi import Request + +from models.root_responses import ( + EndpointInfo, + RequestInfo, + RuntimeInfo, + ServiceInfo, + SystemInfo, +) +from services.uptime import get_uptime + + +def get_service_info() -> ServiceInfo: + return ServiceInfo( + name="devops-info-service", + version="1.0.0", + description="DevOps course info service", + framework="FastAPI", + ) + + +def get_system_info() -> SystemInfo: + return SystemInfo( + hostname=socket.gethostname(), + platform=platform.system(), + platform_version=platform.platform(), + architecture=platform.machine(), + cpu_count=os.cpu_count() or 0, + python_version=platform.python_version(), + ) + + +def get_runtime_info() -> RuntimeInfo: + uptime = get_uptime() + return RuntimeInfo( + uptime_seconds=uptime["seconds"], + uptime_human=uptime["human"], + current_time=datetime.now(timezone.utc).isoformat(), + timezone="UTC", + ) + + +def get_request_info(request: Request) -> RequestInfo: + return RequestInfo( + client_ip=request.client.host if request.client else "unknown", + user_agent=request.headers.get("user-agent", "unknown"), + method=request.method, + path=str(request.url.path), + ) + + +def get_endpoints() -> list[EndpointInfo]: + return [ + EndpointInfo( + path="/", method="GET", description="Service information" + ), + EndpointInfo( + path="/health", method="GET", description="Health check" + ), + ] diff --git a/app_python/services/uptime.py b/app_python/services/uptime.py new file mode 100644 index 0000000000..a99d53d525 --- /dev/null +++ b/app_python/services/uptime.py @@ -0,0 +1,20 @@ +from datetime import datetime, timezone +from typing import TypedDict + +START_TIME = datetime.now(timezone.utc) + + +class UptimeInfo(TypedDict): + seconds: int + human: str + + +def get_uptime() -> UptimeInfo: + 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", + } diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_errors.py b/app_python/tests/test_errors.py new file mode 100644 index 0000000000..f984137fa6 --- /dev/null +++ b/app_python/tests/test_errors.py @@ -0,0 +1,23 @@ +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_404_returns_json_error(): + response = client.get("/bebra") + assert response.status_code == 404 + data = response.json() + assert data["error"] == "Not Found" + assert data["message"] == "Endpoint does not exist" + + +def test_404_content_type(): + response = client.get("/bebra") + assert response.headers["content-type"] == "application/json" diff --git a/app_python/tests/test_health.py b/app_python/tests/test_health.py new file mode 100644 index 0000000000..9689a87174 --- /dev/null +++ b/app_python/tests/test_health.py @@ -0,0 +1,44 @@ +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_health_status_code(): + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_status_is_healthy(): + data = client.get("/health").json() + assert data["status"] == "healthy" + + +def test_health_has_all_fields(): + data = client.get("/health").json() + assert "status" in data + assert "timestamp" in data + assert "uptime_seconds" in data + + +def test_health_uptime_is_non_negative_int(): + data = client.get("/health").json() + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +def test_health_timestamp_is_iso_format(): + data = client.get("/health").json() + assert isinstance(data["timestamp"], str) + assert "T" in data["timestamp"] + + +def test_health_content_type(): + response = client.get("/health") + assert response.headers["content-type"] == "application/json" diff --git a/app_python/tests/test_root.py b/app_python/tests/test_root.py new file mode 100644 index 0000000000..c5972ffbfc --- /dev/null +++ b/app_python/tests/test_root.py @@ -0,0 +1,84 @@ +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_root_status_code(): + response = client.get("/") + assert response.status_code == 200 + + +def test_root_has_all_top_level_keys(): + response = client.get("/") + data = response.json() + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + +def test_root_service_fields(): + data = client.get("/").json()["service"] + assert data["name"] == "devops-info-service" + assert data["version"] == "1.0.0" + assert data["description"] == "DevOps course info service" + assert data["framework"] == "FastAPI" + + +def test_root_system_fields(): + data = client.get("/").json()["system"] + assert isinstance(data["hostname"], str) + assert len(data["hostname"]) > 0 + assert isinstance(data["platform"], str) + assert isinstance(data["platform_version"], str) + assert isinstance(data["architecture"], str) + assert isinstance(data["cpu_count"], int) + assert data["cpu_count"] > 0 + assert isinstance(data["python_version"], str) + + +def test_root_runtime_fields(): + data = client.get("/").json()["runtime"] + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + assert isinstance(data["uptime_human"], str) + assert isinstance(data["current_time"], str) + assert data["timezone"] == "UTC" + + +def test_root_request_fields(): + data = client.get("/").json()["request"] + assert isinstance(data["client_ip"], str) + assert isinstance(data["user_agent"], str) + assert data["method"] == "GET" + assert data["path"] == "/" + + +def test_root_endpoints_list(): + data = client.get("/").json()["endpoints"] + assert isinstance(data, list) + assert len(data) >= 2 + paths = [e["path"] for e in data] + assert "/" in paths + assert "/health" in paths + + +def test_root_endpoint_entries_have_required_fields(): + data = client.get("/").json()["endpoints"] + for endpoint in data: + assert "path" in endpoint + assert "method" in endpoint + assert "description" in endpoint + + +def test_root_content_type(): + response = client.get("/") + assert response.headers["content-type"] == "application/json"