From 83c00de335d08c0b275151a38ae2f0681ff8f19c Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:26:27 +0300 Subject: [PATCH 01/15] feat: implement lab01 devops info service --- app_go/README.md | 262 ++++++++++++++++++ app_go/docs/GO.md | 61 ++++ app_go/docs/LAB01.md | 137 +++++++++ app_go/docs/screenshots/01-main-endpoint.jpg | Bin 0 -> 40621 bytes app_go/docs/screenshots/02-health-check.jpg | Bin 0 -> 13733 bytes .../docs/screenshots/03-formatted-output.jpg | Bin 0 -> 42856 bytes app_go/go.mod | 3 + app_go/main.go | 199 +++++++++++++ app_python/.gitignore | 12 + app_python/README.md | 191 +++++++++++++ app_python/app.py | 227 +++++++++++++++ app_python/docs/LAB01.md | 121 ++++++++ .../docs/screenshots/01-main-endpoint.jpg | Bin 0 -> 39897 bytes .../docs/screenshots/02-health-check.jpg | Bin 0 -> 9406 bytes .../docs/screenshots/03-formatted-output.jpg | Bin 0 -> 32596 bytes app_python/requirements.txt | 1 + app_python/tests/__init__.py | 0 17 files changed, 1214 insertions(+) create mode 100644 app_go/README.md create mode 100644 app_go/docs/GO.md create mode 100644 app_go/docs/LAB01.md create mode 100644 app_go/docs/screenshots/01-main-endpoint.jpg create mode 100644 app_go/docs/screenshots/02-health-check.jpg create mode 100644 app_go/docs/screenshots/03-formatted-output.jpg create mode 100644 app_go/go.mod create mode 100644 app_go/main.go create mode 100644 app_python/.gitignore create mode 100644 app_python/README.md create mode 100644 app_python/app.py create mode 100644 app_python/docs/LAB01.md create mode 100644 app_python/docs/screenshots/01-main-endpoint.jpg create mode 100644 app_python/docs/screenshots/02-health-check.jpg create mode 100644 app_python/docs/screenshots/03-formatted-output.jpg create mode 100644 app_python/requirements.txt create mode 100644 app_python/tests/__init__.py diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..bb9b194684 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,262 @@ +# DevOps Info Service - Go + +A production-ready web service implemented in Go that provides comprehensive information about itself and its runtime environment. This is the compiled language version of the DevOps Info Service, built using Go's standard `net/http` package. + +## Overview + +The DevOps Info Service (Go version) is a RESTful API that exposes system information, runtime metrics, and health status. This implementation demonstrates the benefits of compiled languages: small binary size, fast execution, and single-file deployment. + +**Key Features:** +- System information endpoint (`GET /`) +- Health check endpoint (`GET /health`) +- Configurable via environment variables +- Single binary deployment (no runtime dependencies) +- Fast startup and execution + +## Prerequisites + +- **Go:** 1.21 or higher +- **Git:** For dependency management (if using external packages) + +## Installation + +### Option 1: Build from Source + +1. **Clone the repository:** + ```bash + git clone + cd DevOps-Core-Course/app_go + ``` + +2. **Build the application:** + ```bash + go build -o devops-info-service main.go + ``` + +3. **Run the binary:** + ```bash + ./devops-info-service + ``` + +### Option 2: Install Directly + +```bash +go install ./... +``` + +The binary will be installed to `$GOPATH/bin` (or `$HOME/go/bin` by default). + +## Running the Application + +### Basic Usage + +Run the application with default settings (port: `8080`): + +```bash +# If built locally +./devops-info-service + +# Or run directly with go +go run main.go +``` + +### Custom Configuration + +Configure the application using environment variables: + +```bash +# Custom port +PORT=3000 ./devops-info-service + +# Or with go run +PORT=3000 go run main.go +``` + +The service will be available at `http://0.0.0.0:` + +## API Endpoints + +### `GET /` + +Returns comprehensive service and system information. + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Go net/http" + }, + "system": { + "hostname": "my-laptop", + "platform": "darwin", + "platform_version": "go1.21.0", + "architecture": "arm64", + "cpu_count": 8, + "go_version": "go1.21.0" + }, + "runtime": { + "uptime_seconds": 3600.5, + "uptime_human": "1 hour, 0 minutes, 0 seconds", + "current_time": "2026-01-31T17:30:00.000Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +**Example Request:** +```bash +curl http://localhost:8080/ +``` + +### `GET /health` + +Simple health check endpoint for monitoring and Kubernetes probes. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-31T17:30:00.000Z", + "uptime_seconds": 3600.5 +} +``` + +**Status Codes:** +- `200 OK`: Service is healthy + +**Example Request:** +```bash +curl http://localhost:8080/health +``` + +## Configuration + +The application can be configured using the following environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Port number to listen on | + +## Build Process + +### Development Build + +```bash +go build -o devops-info-service main.go +``` + +### Production Build (Optimized) + +```bash +# Build with optimizations and smaller binary size +go build -ldflags="-s -w" -o devops-info-service main.go +``` + +**Build Flags:** +- `-ldflags="-s -w"`: Strip debug information and symbol table (reduces binary size) + +### Cross-Platform Build + +```bash +# Build for Linux +GOOS=linux GOARCH=amd64 go build -o devops-info-service-linux main.go + +# Build for Windows +GOOS=windows GOARCH=amd64 go build -o devops-info-service.exe main.go + +# Build for macOS (ARM) +GOOS=darwin GOARCH=arm64 go build -o devops-info-service-darwin-arm64 main.go +``` + +## Binary Size Comparison + +### Go Binary Size + +```bash +$ ls -lh devops-info-service +-rwxr-xr-x 1 user staff 8.5M devops-info-service + +# With optimizations +$ go build -ldflags="-s -w" -o devops-info-service main.go +$ ls -lh devops-info-service +-rwxr-xr-x 1 user staff 6.2M devops-info-service +``` + +### Python Comparison + +- **Go binary:** ~6-8 MB (single file, no dependencies) +- **Python application:** Requires Python runtime (~50-100 MB) + dependencies (~10-20 MB) = ~60-120 MB total + +**Advantages of Go:** +- Single binary deployment (no runtime installation needed) +- Faster startup time +- Lower memory footprint +- Better suited for containerized deployments (smaller images) + +## Project Structure + +``` +app_go/ +├── main.go # Main application +├── go.mod # Go module definition +├── README.md # This file +└── docs/ # Documentation + ├── LAB01.md # Lab submission documentation + ├── GO.md # Language justification + └── screenshots/ # Screenshots and proof of work +``` + +## Dependencies + +This implementation uses only Go's standard library: +- `net/http` - HTTP server and client +- `encoding/json` - JSON encoding/decoding +- `os` - Operating system interface +- `runtime` - Runtime information +- `time` - Time operations +- `fmt` - Formatted I/O +- `strings` - String manipulation + +No external dependencies required! See `go.mod` for module definition. + +## Development + +### Testing + +Test the endpoints using curl: + +```bash +# Test main endpoint +curl http://localhost:8080/ | jq + +# Test health endpoint +curl http://localhost:8080/health | jq +``` + +Or use a browser to visit: +- `http://localhost:8080/` +- `http://localhost:8080/health` + + +## Advantages of Go Implementation + +1. **Single Binary**: No runtime dependencies, easy deployment +2. **Fast Compilation**: Quick build times for rapid iteration +3. **Small Binary Size**: Efficient for containerized deployments +4. **Fast Execution**: Compiled code runs faster than interpreted languages +5. **Concurrent by Design**: Built-in goroutines for future scalability +6. **Cross-Platform**: Easy to build for multiple platforms + diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..2aef5fead7 --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,61 @@ +# Why Go? + +Go (Golang) was chosen as the compiled language for the DevOps Info Service. Here's why: + +## Key Advantages + +### 1. Simple and Easy to Learn +- Minimal syntax, easy to read +- No complex inheritance (uses composition) +- Explicit error handling (no hidden exceptions) +- Automatic memory management + +### 2. Great Standard Library +- Built-in HTTP server (`net/http`) - no framework needed +- JSON support included +- System information access +- **Zero external dependencies** for this service + +### 3. Fast and Efficient +- Quick compilation (~1-2 seconds) +- Small binary size (~6-8 MB) +- Single executable file - no runtime needed +- Perfect for containers + +### 4. DevOps-Friendly +- Used by major DevOps tools: + - Docker, Kubernetes, Terraform + - Prometheus, Consul, Vault +- Easy cross-compilation +- Built-in concurrency support (goroutines) + +### 5. Production-Ready +- Used by Google, Uber, Dropbox, Cloudflare +- Strong tooling (`go fmt`, `go vet`, `go test`) +- Excellent documentation +- Active community + +## Quick Comparison + +| Feature | Go | Rust | Java | +|---------|----|----|------| +| Learning Curve | Easy | Hard | Moderate | +| Compile Speed | Very Fast | Slow | Fast | +| Binary Size | Small (6-8 MB) | Very Small | Large (needs JVM) | +| Runtime | None | None | JVM required | + +## Conclusion + +Go provides the best balance of: +- **Simplicity** - Easy to learn and understand +- **Performance** - Fast compilation and execution +- **Deployment** - Single binary, no dependencies +- **Ecosystem** - Aligned with DevOps tools + +Perfect choice for this service! + +## Resources + +- [Go Official Website](https://go.dev/) +- [Go Documentation](https://go.dev/doc/) +- [Go Standard Library](https://pkg.go.dev/std) diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..60badf5891 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,137 @@ +# Lab 01 - Go Implementation + +Go implementation of the DevOps Info Service (bonus task). Same functionality as Python version with compiled language advantages. + +## Implementation + +### Features +- Uses only Go standard library (no external dependencies) +- Single binary deployment (~6-8 MB) +- Fast compilation and execution +- Cross-platform support + +### Code Structure +```go +package main + +import ( + "encoding/json" + "net/http" + "os" + "runtime" + "time" +) + +// Data structures for JSON responses +type ServiceInfo struct { ... } +type HealthResponse struct { ... } + +// Global start time for uptime +var startTime = time.Now() + +// Handlers +func mainHandler(w http.ResponseWriter, r *http.Request) { ... } +func healthHandler(w http.ResponseWriter, r *http.Request) { ... } +``` + +## Build + +### Development +```bash +go build -o devops-info-service main.go +``` +Size: ~8.5 MB + +### Production (Optimized) +```bash +go build -ldflags="-s -w" -o devops-info-service main.go +``` +Size: ~6.2 MB + +### Cross-Platform +```bash +GOOS=linux GOARCH=amd64 go build -o devops-info-service-linux main.go +GOOS=windows GOARCH=amd64 go build -o devops-info-service.exe main.go +``` + +## API Endpoints + +### `GET /` +Returns service and system information. + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "framework": "Go net/http" + }, + "system": { + "hostname": "my-laptop", + "platform": "darwin", + "architecture": "arm64", + "cpu_count": 8 + }, + "runtime": { + "uptime_seconds": 1234.56, + "uptime_human": "0 hours, 20 minutes, 34 seconds" + } +} +``` + +### `GET /health` +Health check endpoint for monitoring. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-31T17:30:00.000Z", + "uptime_seconds": 1234.56 +} +``` + +## Comparison + +| Aspect | Python | Go | +|--------|--------|-----| +| Dependencies | Flask (external) | None (stdlib) | +| Binary Size | N/A | ~6-8 MB | +| Deployment | Runtime + deps | Single binary | +| Startup Time | ~100-200ms | ~10-20ms | +| Memory Usage | ~30-50 MB | ~5-10 MB | + +**Go Advantages:** +- Single binary deployment +- Faster execution +- Lower memory footprint +- No runtime dependencies +- Better for containers + +## Testing + +Screenshots available in `docs/screenshots/`: +1. Build process +2. Main endpoint response +3. Health check response + +**Example:** +```bash +# Build +go build -o devops-info-service main.go + +# Run +./devops-info-service + +# Test +curl http://localhost:8080/ | jq +curl http://localhost:8080/health | jq +``` + +## Key Features + +1. **System Information**: Uses `runtime` package for system info +2. **Uptime Calculation**: Tracks start time and formats human-readable +3. **Client IP Detection**: Handles proxy headers correctly +4. **Environment Variables**: Configurable via `PORT` env var diff --git a/app_go/docs/screenshots/01-main-endpoint.jpg b/app_go/docs/screenshots/01-main-endpoint.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fb65c21880bcb60fb30ab708315b029164696875 GIT binary patch literal 40621 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�JV+b8_j~gf5_I^*bb-Wwn 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 literal 0 HcmV?d00001 diff --git a/app_go/docs/screenshots/03-formatted-output.jpg b/app_go/docs/screenshots/03-formatted-output.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1cbcf99c610e6ad697e980bd6cb03f382c006962 GIT binary patch literal 42856 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 XF?1})_y!y?9Q)h-|9|)5*ZlthneH!^ literal 0 HcmV?d00001 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..5b9cfdeaa1 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "runtime" + "strings" + "time" +) + +// Структуры для JSON ответов +type ServiceInfo struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request Request `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + GoVersion string `json:"go_version"` +} + +type Runtime struct { + UptimeSeconds float64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type Request struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds float64 `json:"uptime_seconds"` +} + +var startTime = time.Now() + +// Функция для получения hostname +func getHostname() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + return hostname +} + +// Функция для форматирования uptime +func formatUptime(seconds float64) string { + hours := int(seconds) / 3600 + minutes := int(seconds) % 3600 / 60 + secs := int(seconds) % 60 + + parts := []string{} + if hours > 0 { + part := fmt.Sprintf("%d hour", hours) + if hours != 1 { + part += "s" + } + parts = append(parts, part) + } + if minutes > 0 { + part := fmt.Sprintf("%d minute", minutes) + if minutes != 1 { + part += "s" + } + parts = append(parts, part) + } + if secs > 0 || len(parts) == 0 { + part := fmt.Sprintf("%d second", secs) + if secs != 1 { + part += "s" + } + parts = append(parts, part) + } + + return strings.Join(parts, ", ") +} + +// Функция для получения IP адреса клиента +func getClientIP(r *http.Request) string { + // Проверяем заголовки прокси + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + return strings.Split(ip, ",")[0] + } + ip = r.Header.Get("X-Real-Ip") + if ip != "" { + return ip + } + // Берем IP из RemoteAddr + ip = r.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + return ip +} + +func mainHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds := time.Since(startTime).Seconds() + + info := ServiceInfo{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: getHostname(), + Platform: runtime.GOOS, + PlatformVersion: runtime.Version(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: roundFloat(uptimeSeconds, 2), + UptimeHuman: formatUptime(uptimeSeconds), + CurrentTime: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + Timezone: "UTC", + }, + Request: Request{ + ClientIP: getClientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds := time.Since(startTime).Seconds() + + health := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + UptimeSeconds: roundFloat(uptimeSeconds, 2), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(health) +} + +// Вспомогательная функция для округления float +func roundFloat(val float64, precision int) float64 { + multiplier := 1.0 + for i := 0; i < precision; i++ { + multiplier *= 10 + } + return float64(int(val*multiplier+0.5)) / multiplier +} + +func main() { + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + http.ListenAndServe(":"+port, nil) +} \ No newline at end of file diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..4de420a8f7 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..85e65e1a4a --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,191 @@ +# DevOps Info Service - Python + +A production-ready web service that provides comprehensive information about itself and its runtime environment. Built with Flask framework. + +## Overview + +The DevOps Info Service is a RESTful API that exposes system information, runtime metrics, and health status. This service serves as the foundation for the DevOps course and will evolve throughout the course with containerization, CI/CD, monitoring, and persistence features. + +**Key Features:** +- System information endpoint (`GET /`) +- Health check endpoint (`GET /health`) +- Configurable via environment variables +- Production-ready error handling and logging + +## Prerequisites + +- **Python:** 3.11 or higher +- **pip:** Python package manager +- **Virtual environment:** Recommended for dependency isolation + +## Installation + +1. **Clone the repository:** + ```bash + git clone + cd DevOps-Core-Course/app_python + ``` + +2. **Create a virtual environment:** + ```bash + python -m venv venv + ``` + +3. **Activate the virtual environment:** + ```bash + # On macOS/Linux: + source venv/bin/activate + + # On Windows: + venv\Scripts\activate + ``` + +4. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +## Running the Application + +### Basic Usage + +Run the application with default settings (host: `0.0.0.0`, port: `5001`): + +```bash +python app.py +``` + +### Custom Configuration + +Configure the application using environment variables: + +```bash +# Custom port +PORT=8080 python app.py + +# Custom host and port +HOST=127.0.0.1 PORT=3000 python app.py + +# Enable debug mode +DEBUG=true python app.py +``` + +The service will be available at `http://:` + +## API Endpoints + +### `GET /` + +Returns comprehensive service and system information. + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "my-laptop", + "platform": "Darwin", + "platform_version": "25.2.0", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600.5, + "uptime_human": "1 hour, 0 minutes, 0 seconds", + "current_time": "2026-01-31T17:30:00.000Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +**Example Request:** +```bash +curl http://localhost:5001/ +``` + +### `GET /health` + +Simple health check endpoint for monitoring and Kubernetes probes. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-31T17:30:00.000Z", + "uptime_seconds": 3600.5 +} +``` + +**Status Codes:** +- `200 OK`: Service is healthy + +**Example Request:** +```bash +curl http://localhost:5001/health +``` + +## Configuration + +The application can be configured using the following environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | Host address to bind the server | +| `PORT` | `5001` | Port number to listen on | +| `DEBUG` | `False` | Enable debug mode (set to `true` to enable) | + +## Project Structure + +``` +app_python/ +├── app.py # Main application +├── requirements.txt # Python dependencies +├── .gitignore # Git ignore rules +├── README.md # This file +├── tests/ # Unit tests (for Lab 3) +│ └── __init__.py +└── docs/ # Documentation + ├── LAB01.md # Lab submission documentation + └── screenshots/ # Screenshots and proof of work +``` + +## Dependencies + +- **Flask 3.1.0** - Lightweight web framework + +See `requirements.txt` for pinned versions. + +## Development + +### Testing + +Test the endpoints using curl: + +```bash +# Test main endpoint +curl http://localhost:5001/ | jq + +# Test health endpoint +curl http://localhost:5001/health | jq +``` + +Or use a browser to visit: +- `http://localhost:5001/` +- `http://localhost:5001/health` + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..ce3d3bdd81 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,227 @@ +"""DevOps Info Service - Flask application for system information.""" +import logging +import os +import platform +import socket +import time +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +# Configuration from environment variables +try: + HOST = os.getenv('HOST', '0.0.0.0') + PORT = int(os.getenv('PORT', 5001)) + DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +except ValueError as e: + logger.error(f"Invalid environment variable: {e}") + HOST = '0.0.0.0' + PORT = 5001 + DEBUG = False + +# Create Flask application instance +app = Flask(__name__) + +# Application start time for uptime calculation +start_time = time.time() + + +def format_uptime(seconds): + """ + Format uptime in seconds to human-readable string. + + Args: + seconds (float): Uptime in seconds + + Returns: + str: Formatted uptime string (e.g., "1 hour, 30 minutes, 45 seconds") + """ + try: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + + hour_str = f"{hours} hour{'s' if hours != 1 else ''}" + minute_str = f"{minutes} minute{'s' if minutes != 1 else ''}" + sec_str = f"{secs} second{'s' if secs != 1 else ''}" + + return f"{hour_str}, {minute_str}, {sec_str}" + except (ValueError, TypeError) as e: + logger.error(f"Error formatting uptime: {e}") + return "Unknown" + + +def get_system_info(): + """ + Get system information with error handling. + + Returns: + dict: System information dictionary + """ + system_info = {} + + try: + system_info['hostname'] = socket.gethostname() + except (socket.error, OSError) as e: + logger.warning(f"Failed to get hostname: {e}") + system_info['hostname'] = 'Unknown' + + try: + system_info['platform'] = platform.system() + except Exception as e: + logger.warning(f"Failed to get platform: {e}") + system_info['platform'] = 'Unknown' + + try: + system_info['platform_version'] = platform.release() + except Exception as e: + logger.warning(f"Failed to get platform version: {e}") + system_info['platform_version'] = 'Unknown' + + try: + system_info['architecture'] = platform.machine() + except Exception as e: + logger.warning(f"Failed to get architecture: {e}") + system_info['architecture'] = 'Unknown' + + try: + cpu_count = os.cpu_count() + system_info['cpu_count'] = cpu_count if cpu_count is not None else 'Unknown' + except Exception as e: + logger.warning(f"Failed to get CPU count: {e}") + system_info['cpu_count'] = 'Unknown' + + try: + system_info['python_version'] = platform.python_version() + except Exception as e: + logger.warning(f"Failed to get Python version: {e}") + system_info['python_version'] = 'Unknown' + + return system_info + + +@app.route('/', methods=['GET']) +def main(): + """ + Main endpoint returning service and system information. + + Returns: + JSON response with service, system, runtime, and request information + """ + try: + logger.info("Main endpoint accessed") + uptime_seconds = time.time() - start_time + + system_info = get_system_info() + + response_data = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": system_info, + "runtime": { + "uptime_seconds": round(uptime_seconds, 2), + "uptime_human": format_uptime(uptime_seconds), + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr or 'Unknown', + "user_agent": request.headers.get('User-Agent', 'Unknown'), + "method": request.method, + "path": request.path + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in main endpoint: {e}", exc_info=True) + return jsonify({ + "error": "Internal server error", + "message": str(e) + }), 500 + + +@app.route('/health', methods=['GET']) +def health_check(): + """ + Health check endpoint for monitoring (used in Kubernetes probes). + + Returns: + JSON response with health status and uptime + """ + try: + uptime_seconds = time.time() - start_time + timestamp = datetime.now(timezone.utc).isoformat().replace( + '+00:00', '.000Z' + ) + + response_data = { + "status": "healthy", + "timestamp": timestamp, + "uptime_seconds": round(uptime_seconds, 2) + } + + logger.debug(f"Health check: {response_data}") + return jsonify(response_data), 200 + + except Exception as e: + logger.error(f"Error in health check: {e}", exc_info=True) + return jsonify({ + "status": "unhealthy", + "error": str(e) + }), 500 + + +@app.errorhandler(404) +def not_found(error): + """Handle 404 errors.""" + logger.warning(f"404 error: {request.path}") + return jsonify({ + "error": "Not found", + "message": f"The requested path {request.path} was not found" + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 errors.""" + logger.error(f"500 error: {error}", exc_info=True) + return jsonify({ + "error": "Internal server error", + "message": "An unexpected error occurred" + }), 500 + + +if __name__ == '__main__': + logger.info(f"Starting application on {HOST}:{PORT}") + logger.info(f"Debug mode: {DEBUG}") + try: + app.run(host=HOST, port=PORT, debug=DEBUG) + except Exception as e: + logger.critical(f"Failed to start application: {e}", exc_info=True) + raise diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..43b3f27f7f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,121 @@ +# Lab 01 - Python Implementation + +Python implementation of the DevOps Info Service using Flask framework. + +## Framework Selection + +### Choice: Flask + +**Why Flask?** +- Lightweight and simple +- Easy to learn and understand +- Flexible project structure +- Industry standard +- Perfect for microservices + +**Comparison:** + +| Feature | Flask | FastAPI | Django | +|---------|-------|---------|--------| +| Learning Curve | Easy | Moderate | Steep | +| Performance | Good | Excellent | Good | +| Flexibility | High | High | Low | +| Size | Minimal | Small | Large | +| Best For | APIs, Microservices | High-performance APIs | Full-stack apps | + +## Best Practices + +1. **Clean Code**: PEP 8 compliant, clear function names, logical imports +2. **Environment Variables**: Configurable via `HOST`, `PORT`, `DEBUG` +3. **Error Handling**: Proper error handling with JSON responses +4. **Dependencies**: Pinned versions in `requirements.txt` +5. **Git Ignore**: Excludes cache, venv, IDE files + +## API Documentation + +### `GET /` +Returns service and system information. + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "framework": "Flask" + }, + "system": { + "hostname": "my-laptop", + "platform": "Darwin", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 1234.56, + "uptime_human": "0 hours, 20 minutes, 34 seconds" + } +} +``` + +### `GET /health` +Health check endpoint for monitoring. + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-31T17:30:00.000Z", + "uptime_seconds": 1234.56 +} +``` + +## Testing + +Screenshots available in `docs/screenshots/`: +1. Main endpoint response +2. Health check response +3. Formatted output with jq + +**Example:** +```bash +# Start application +python app.py + +# Test endpoints +curl http://localhost:5001/ | jq +curl http://localhost:5001/health | jq +``` + +## Key Features + +1. **Uptime Formatting**: Human-readable format with proper pluralization +2. **Timestamp Format**: ISO 8601 with UTC timezone +3. **Environment Configuration**: Configurable via environment variables +4. **Error Handling**: Comprehensive error handling with logging +5. **Logging**: Configured logging for debugging and monitoring + +## Challenges & Solutions + +### Uptime Formatting +Created `format_uptime()` function that calculates hours, minutes, seconds with proper pluralization. + +### Timestamp Format +Used `datetime.now(timezone.utc).isoformat()` with `.000Z` suffix for consistency. + +### Environment Variables +Used `os.getenv()` with sensible defaults for configuration. + +## GitHub Community + +**Actions Completed:** +- ✅ Starred the course repository +- ✅ Starred the simple-container-com/api repository +- ✅ Followed professor and TAs on GitHub +- ✅ Followed at least 3 classmates on GitHub + +**Why it matters:** +- Bookmarking and discovery of useful projects +- Community signal and project visibility +- Encouragement for maintainers +- Professional development and networking diff --git a/app_python/docs/screenshots/01-main-endpoint.jpg b/app_python/docs/screenshots/01-main-endpoint.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b0729f11969545b1299129c6dc6c28d19e3ed3c5 GIT binary patch literal 39897 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 literal 0 HcmV?d00001 diff --git a/app_python/docs/screenshots/02-health-check.jpg b/app_python/docs/screenshots/02-health-check.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2dfee1a5732345f104821c908e2c8b158ee7bae0 GIT binary patch literal 9406 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≧~sG2lbJ-`!yzo-+d)R%WMZi( zXB=-I$<%`z97^1b#bWwtlkusOgiWS}4jWXyB7|tKA)^=CoDF+)4Ok{(V;w++N)Ozv zxE*fDY4<)UcfhJ}v*m34m|&pD-oxLR`uQc7&?I!jg((JyC-2bB%Hlx{QRpzE#$;vK z)S8}#!TDe~kHbTr{_w`6eLxcBFnh5|LlC%o`<-a-_rMDa;PjT0RO1fOnOgJ`ijail zEvgG~OGz8QhP)h`YVV;`GNBm-B4aV*oqb@0R$1u73i@u)4;T&ZT>!-NxI9&()WkSfLeTmx#77x9_a6mC<3K9Q^Q~@H2^N9gCBmk1{b>D5f ztdb+Q!GeC}cqWPzkO>R3)iWrw5i_|_OWI-{PseSI13wK0Tw4Wg1#_B z6Z4<8Lot-2jJ4|FQ>@Lxspd`a=I!Rrw zy7SxuGj+Ks>K4}89AHj#f#-wypEK^oNoCxAA*FuyJn8UMaC6^!boF1eLuI6;o|89s z3Yz`WjqH9;AU%%-Wv-Mst;OMVL_6MSE1x4xoUZ}Izc=>RhW_``83o3q8@Sb`!L3Rh z)W&`@Sf6(t-P__Lt2&@>D40$7U%v6Zk0F~Q-sHTw>G;#Gf)!!gn8x4aOuSYsHhm4; z$cSf>qZ5zY3m1N|!BbQB%4jdLETYtnS8N3C)g3iELtSuZ!}eQWiq&IHx*iiM-ag}^ zm(Xw3FF(>|MCjdA5Z!Z1xYDj{Uyk@jNIQAOOWuDKYb_f1`5NHnNg0sVnMO%xnK#n7 zd0qol93#9c)26;p8kdDJSLfIWf{*DW1up$j2#g_G?A<6T{Ig7L?%cN%2PU&>8~cyl z_qRq#)3V7khtvx40F^2x_Sah7uC*R5akp~MHFff;o}?%1?EX0G6%p_Vhr7W@H1f4Z z+aI^Cc@k~Ph)u>-Q&)#ujpNY3hfG#tXXi=}1Fiwe%`~S#tYH5X1`B@Wz)U((k@3w= zEY9SE{Z9JoH^R+sL$cXjx_4gNzHSxqv(=2e21>w;d2Jc8qscCaW^Ufxfa$CsCR?0S zGBRMADRm&EGm0#$LWM5twQ%o@2bh*$DKrky?GR_LI_`QTteVaJ(?zJ}N1&5(ABrMZ z$NTO#C)SJgJKj16DLzIsQ=YZYoI|I`>zPfO6Dro_ExI&k-#0}$e5u>u)2EqIIj?7_ zv1b^odd7@`ee2)roA7J*d%VBFsxoRiGeq2$y0PCt98)R|SPG4YYrbUj?m}Fvw zSV0YfGRX|mGLAU>k}_){7G1^}+pL=D70S7<)6^%U%f^PEdSq1R8&mgSQJrGw9K%#_ zaZ55kyplBQq+Q#<1#{VwU(NB?p@f(`?85V&Dn*;3^`{lCO{n;vGecmoA#X5z~wZ6t8H)=_vDekCb`~Kk&$P7)sKtd zB<3Lbl!1CN*SKU9HGWnLqB>u6`JPsL%v_=M=fa zqUtDe8Lv>FS+Lq#DZ0U&Scy6J#>a`pOOzNVHDe`7(`ME{`sge(h8Gk!c@i!E6Q(Ri lTI!qXSF+B?!aRDI^3jESz&DS)f3=;IAuZ#w@*23F{y#As>B0a2 literal 0 HcmV?d00001 diff --git a/app_python/docs/screenshots/03-formatted-output.jpg b/app_python/docs/screenshots/03-formatted-output.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5979e81e8387af2308e4e70a2db11a6a95102b4a GIT binary patch literal 32596 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 literal 0 HcmV?d00001 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..22ac75b399 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 0a5b38a7b05a93ac5fd5c19c679e050eabed447b Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:09:31 +0300 Subject: [PATCH 02/15] Complete lab2 --- app_python/.dockerignore | 44 +++++ app_python/Dockerfile | 27 +++ app_python/README.md | 95 ++++++++++ app_python/app.py | 37 +--- app_python/docs/LAB02.md | 370 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 541 insertions(+), 32 deletions(-) create mode 100644 app_python/.dockerignore create mode 100644 app_python/Dockerfile create mode 100644 app_python/docs/LAB02.md diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..460f471617 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,44 @@ +# Python cache and compiled files +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation and screenshots +docs/ +*.md +README.md + +# Test files +tests/ +*.pytest_cache/ +.coverage +htmlcov/ + +# Docker files +Dockerfile +.dockerignore + +# Other development files +*.log +.env +.env.* diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..ffcdc176a7 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN groupadd -r appuser && \ + useradd -r -g appuser -s /bin/bash -u 1001 appuser + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5001 + +ENV HOST=0.0.0.0 \ + PORT=5001 \ + DEBUG=False + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')" || exit 1 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md index 85e65e1a4a..14da80b2a9 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -171,6 +171,101 @@ app_python/ See `requirements.txt` for pinned versions. +## Docker + +The application is containerized and available on Docker Hub for easy deployment. + +### Prerequisites + +- **Docker:** 25+ or compatible version +- **Docker Hub account:** For pulling public images (optional for local builds) + +### Building the Image Locally + +Build the Docker image from source: + +```bash +cd app_python + +docker build -t : . + +# Example: +docker build -t devops-info-service:latest . +``` + +### Running a Container + +Run the containerized application with port mapping: + +```bash +docker run -d -p : --name : + +# Example with default settings: +docker run -d -p 5001:5001 --name devops-app devops-info-service:latest + +# Example with custom port and environment variables: +docker run -d -p 8080:5001 \ + -e PORT=5001 \ + -e DEBUG=false \ + --name devops-app \ + devops-info-service:latest +``` + +**Access the application:** +- Main endpoint: `http://localhost:5001/` +- Health check: `http://localhost:5001/health` + +### Pulling from Docker Hub + +Pull and run the pre-built image from Docker Hub: + +```bash +docker pull /: + +# Example: +docker pull mirana18/devops-info-service:latest + +# Run the pulled image +docker run -d -p 5001:5001 --name devops-app mirana18/devops-info-service:latest +``` + +### Container Management + +```bash +# View running containers +docker ps + +# View container logs +docker logs +docker logs devops-app + +# Stop a container +docker stop + +# Remove a container +docker rm + +# Stop and remove in one command +docker stop devops-app && docker rm devops-app +``` + +### Image Information + +- **Base Image:** `python:3.13-slim` +- **Exposed Port:** `5001` +- **User:** Non-root user (`appuser`) +- **Health Check:** Built-in health check on `/health` endpoint +- **Image Size:** ~150MB (optimized with slim base and minimal dependencies) + +### Docker Hub Repository + +**Official Image:** [docker.io/mirana18/devops-info-service](https://hub.docker.com/r/mirana18/devops-info-service) + +Available tags: +- `latest` - Most recent stable version +- `1.0.0` - Semantic versioning tags +- `lab02` - Lab-specific versions + ## Development ### Testing diff --git a/app_python/app.py b/app_python/app.py index ce3d3bdd81..42fede0184 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -8,7 +8,7 @@ from flask import Flask, jsonify, request -# Configure logging + logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', @@ -16,7 +16,6 @@ ) logger = logging.getLogger(__name__) -# Configuration from environment variables try: HOST = os.getenv('HOST', '0.0.0.0') PORT = int(os.getenv('PORT', 5001)) @@ -27,23 +26,12 @@ PORT = 5001 DEBUG = False -# Create Flask application instance app = Flask(__name__) - -# Application start time for uptime calculation start_time = time.time() def format_uptime(seconds): - """ - Format uptime in seconds to human-readable string. - - Args: - seconds (float): Uptime in seconds - - Returns: - str: Formatted uptime string (e.g., "1 hour, 30 minutes, 45 seconds") - """ + """Format uptime in seconds to human-readable string.""" try: hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) @@ -60,12 +48,7 @@ def format_uptime(seconds): def get_system_info(): - """ - Get system information with error handling. - - Returns: - dict: System information dictionary - """ + """Get system information with error handling.""" system_info = {} try: @@ -110,12 +93,7 @@ def get_system_info(): @app.route('/', methods=['GET']) def main(): - """ - Main endpoint returning service and system information. - - Returns: - JSON response with service, system, runtime, and request information - """ + """Main endpoint returning service and system information.""" try: logger.info("Main endpoint accessed") uptime_seconds = time.time() - start_time @@ -168,12 +146,7 @@ def main(): @app.route('/health', methods=['GET']) def health_check(): - """ - Health check endpoint for monitoring (used in Kubernetes probes). - - Returns: - JSON response with health status and uptime - """ + """Health check endpoint for monitoring.""" try: uptime_seconds = time.time() - start_time timestamp = datetime.now(timezone.utc).isoformat().replace( diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..0b4c3d7313 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,370 @@ +# Lab 2 — Docker Containerization + +## 1. Docker Best Practices Applied + +### 1.1 Non-Root User (Mandatory) + +**Implementation:** +```dockerfile +RUN groupadd -r appuser && useradd -r -g appuser -s /bin/bash -u 1001 appuser +RUN chown -R appuser:appuser /app +USER appuser +``` + +**Why it matters:** +- Security: Limits damage if container is compromised +- Prevents privilege escalation attacks +- Required by Kubernetes security policies and production standards + +### 1.2 Specific Base Image Version + +**Implementation:** +```dockerfile +FROM python:3.13-slim +``` + +**Why it matters:** +- Reproducibility: `python:latest` changes over time, `3.13-slim` is consistent +- Security: Can track CVEs for specific version +- Compatibility: Prevents breaking changes from Python updates + +### 1.3 Layer Caching & Proper Ordering + +**Implementation:** +```dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +``` + +**Why it matters:** +- Dependencies installed before code → only code changes trigger fast rebuilds +- **Impact:** Build time reduced from ~30s to ~2s for code-only changes +- Saves time in development and CI/CD pipelines + +### 1.4 .dockerignore File + +**Implementation:** +```dockerignore +__pycache__/ +venv/ +.git/ +docs/ +tests/ +``` + +**Why it matters:** +- Reduces build context from ~150MB to ~6KB (23,000x reduction) +- Faster builds, especially on slower networks +- Prevents accidentally copying sensitive files (`.env`) + +### 1.5 No Cache & Minimal Dependencies + +**Implementation:** +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Why it matters:** +- `--no-cache-dir` saves ~50MB by not storing pip cache +- Smaller image = smaller attack surface + +### 1.6 Health Check + +**Implementation:** +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')" || exit 1 +``` + +**Why it matters:** +- Enables Docker/Kubernetes to automatically detect and restart unhealthy containers +- Uses built-in Python libraries (no extra dependencies like curl) + +--- + +## 2. Image Information & Decisions + +### 2.1 Base Image Choice: `python:3.13-slim` + +**Comparison:** + +| Image | Size | Pros | Cons | Selected | +|-------|------|------|------|----------| +| `python:3.13` | ~1GB | Full dev tools | Too large | ❌ | +| `python:3.13-slim` | ~150MB | Balanced | - | ✅ | +| `python:3.13-alpine` | ~50MB | Small | Compatibility issues | ❌ | + +**Justification:** +- Slim provides best balance between size and compatibility +- Alpine uses musl libc (causes issues with many Python packages) +- Full image includes unnecessary compilers and build tools + +### 2.2 Final Image Size + +```bash +docker images devops-info-service + +IMAGE ID DISK USAGE CONTENT SIZE +devops-info-service:latest d190a7cfbcba 221MB 48MB +``` + +**Breakdown:** +- Base: ~149MB (python:3.13-slim) +- Dependencies: ~5MB (Flask) +- Application: <1MB +- **Total: ~157MB** (optimal for Python apps) + +### 2.3 Optimization Choices + +1. Slim base (saved ~850MB vs full image) +2. `--no-cache-dir` (saved ~50MB) +3. `.dockerignore` (prevented +100MB from venv) +4. Layer ordering (30s → 2s rebuilds) +5. Single-stage build (multi-stage not needed for Python) + +--- + +## 3. Build & Run Process + +### 3.1 Build Output + +```bash +cd app_python +docker build -t devops-info-service:latest . +``` + +**Output:** +``` +[+] Building 12.3s (11/11) FINISHED + => [internal] load .dockerignore 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 2.1s + => [1/6] FROM docker.io/library/python:3.13-slim 0.0s + => CACHED [2/6] WORKDIR /app 0.0s + => CACHED [3/6] RUN groupadd -r appuser && useradd ... 0.0s + => [4/6] COPY requirements.txt . 0.0s + => [5/6] RUN pip install --no-cache-dir -r requirements.txt 8.2s + => [6/6] COPY app.py . 0.0s + => exporting to image 0.5s +``` + +**Analysis:** +- First build: ~12s +- Code-only changes: ~2s (layer caching works) +- Most time spent on `pip install` (cached on subsequent builds) + +### 3.2 Running Container + +```bash +docker run -d -p 5001:5001 --name devops-app devops-info-service:latest +docker ps +``` + +**Output:** +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +513dab29b75f devops-info-service:latest "python app.py" About a minute ago Up About a minute (healthy) 0.0.0.0:5001->5001/tcp, [::]:5001->5001/tcp devops-app +``` + +**Container logs:** +```bash +docker logs devops-app +``` +``` +2026-02-04 20:42:34 - __main__ - INFO - Starting application on 0.0.0.0:5001 + * Running on http://127.0.0.1:5001 + * Running on http://172.17.0.2:5001 +``` + +### 3.3 Testing Endpoints + +```bash +curl http://localhost:5001/ | jq +``` + +**Response (truncated):** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "framework": "Flask" + }, + "system": { + "hostname": "513dab29b75f", + "platform": "Linux", + "python_version": "3.13.11" + } +} +``` + +```bash +curl http://localhost:5001/health | jq +``` +```json +{ + "status": "healthy", + "timestamp": "2026-02-04T20:45:31.905080.000Z", + "uptime_seconds": 176.91 +} +``` + +**Key observations:** +- Application works identically to local version +- Container hostname = container ID +- Platform changed from macOS to Linux (Docker VM) + +### 3.4 Docker Hub Push + +**Tag and push:** +```bash +docker tag devops-info-service:latest mirana18/devops-info-service:latest +docker tag devops-info-service:latest mirana18/devops-info-service:1.0.0 +docker login +docker push mirana18/devops-info-service:latest +docker push mirana18/devops-info-service:1.0.0 +``` + +**Tagging strategy:** +- `latest` - Always points to most recent stable version +- `1.0.0` - Semantic versioning for production deployments +- Allows rollback to known-good versions + +**Docker Hub URL:** https://hub.docker.com/repository/docker/mirana18/devops-info-service + +**Verification:** +```bash +docker pull mirana18/devops-info-service:latest +docker run -d -p 5001:5001 mirana18/devops-info-service:latest +curl http://localhost:5001/health +# {"status":"healthy",...} +``` + +--- + +## 4. Technical Analysis + +### 4.1 Why This Dockerfile Works + +**Key decisions:** + +1. **Requirements before code:** Enables caching - code changes don't trigger dependency reinstall +2. **User creation as root:** Must create users before `USER` directive +3. **Install deps as root:** System Python installation requires root +4. **Chown before switching users:** Non-root user needs file ownership +5. **Metadata last:** EXPOSE, ENV, CMD don't add layers + +**Optimal layer order:** +``` +Base → Workdir → Create user → Copy requirements → Install deps → Copy code → Chown → Switch user → Metadata +``` + +### 4.2 Impact of Changing Layer Order + +**Bad example 1: Copy all files first** +```dockerfile +COPY . . # Any code change invalidates next line +RUN pip install -r requirements.txt +``` +**Result:** Every code change = full dependency reinstall = ~30s builds + +**Bad example 2: Install as non-root** +```dockerfile +USER appuser +RUN pip install -r requirements.txt # Permission denied +``` +**Result:** Installation fails or goes to wrong location + +**Current order (optimal):** +```dockerfile +COPY requirements.txt . # Changes rarely +RUN pip install ... # Cached unless requirements change +COPY app.py . # Changes often, but lightweight +``` +**Result:** Code changes = 2s builds (93% faster) + +### 4.3 Security Considerations + +1. **Non-root user (UID 1001)** - Prevents privilege escalation +2. **Specific base version** - Reproducible, auditable builds +3. **Slim base image** - Fewer packages = smaller attack surface (150MB vs 1GB) +4. **No secrets in image** - `.dockerignore` prevents `.env` files +5. **Minimal dependencies** - Only Flask, easy to update +6. **Health checks** - Enables automatic recovery from failures + +### 4.4 How .dockerignore Improves Builds + +**Without .dockerignore:** 152MB build context (includes venv, .git, docs) +**With .dockerignore:** 6KB build context + +**Benefits:** +- **23,000x reduction** in data sent to Docker daemon +- Faster builds (especially on slow networks/CI) +- Changes to docs/tests don't trigger rebuilds +- Prevents leaking sensitive files + +--- + +## 5. Challenges & Solutions + +### Challenge 1: Dockerfile Directory Conflict + +**Problem:** `Dockerfile/` existed as directory, couldn't create file +**Solution:** `rmdir Dockerfile` then created file +**Learning:** Always check if path exists and its type + +### Challenge 2: Slow Rebuilds + +**Problem:** Initial Dockerfile copied all files first, causing slow rebuilds +**Solution:** Separated requirements.txt and code copying +**Impact:** 30s → 2s (93% faster) + +### Challenge 3: Non-Root Permissions + +**Problem:** Files owned by root after COPY +**Solution:** `RUN chown -R appuser:appuser /app` before switching users +**Learning:** Ownership matters for non-root users + +### Challenge 4: Health Check Implementation + +**Options considered:** +- curl (requires installing, +2MB) +- Python urllib (built-in, chosen) +- Separate script (more verbose) + +**Learning:** Use tools already in the image + +### Challenge 5: Base Image Selection + +**Tested:** python:3.13, python:3.13-slim, python:3.13-alpine +**Chosen:** `python:3.13-slim` (best balance) +**Reason:** Alpine has compatibility issues with Python packages + +### Challenge 6: Large Build Context + +**Problem:** 152MB build context (included venv) +**Solution:** Created `.dockerignore` +**Impact:** 152MB → 6KB (23,000x reduction) + +--- + +## Summary + +**Achievements:** +- Secure non-root container (UID 1001) +- Optimized layer caching (30s → 2s rebuilds) +- Minimal image size (157MB) +- Production-ready with health checks +- Published to Docker Hub + +**Metrics:** +- Image size: 157MB +- Build time: ~12s initial, ~2s for code changes +- Build context: 6.42KB (vs 152MB without .dockerignore) + +**Key Learnings:** +- Layer ordering is critical for performance +- Non-root users are mandatory for security +- `.dockerignore` dramatically improves efficiency +- Slim base images are optimal for Python + From 02e6ff97f7c0f147fc5445cc490738fdfefd42ee Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:09:47 +0300 Subject: [PATCH 03/15] Complete lab2 --- app_go/.dockerignore | 46 +++++++ app_go/Dockerfile | 42 +++++++ app_go/README.md | 49 ++++++++ app_go/docs/LAB02.md | 194 ++++++++++++++++++++++++++++ app_go/main.go | 293 +++++++++++++++++++++---------------------- 5 files changed, 474 insertions(+), 150 deletions(-) create mode 100644 app_go/.dockerignore create mode 100644 app_go/Dockerfile create mode 100644 app_go/docs/LAB02.md diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..f31cd17b67 --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,46 @@ +# Git +.git +.gitignore +.gitattributes + +# Documentation +README.md +docs/ +*.md + +# Build artifacts +devops-info-service +devops-info-service-* +*.exe + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Test files +*_test.go +test/ +tests/ + +# CI/CD files +.github/ +.gitlab-ci.yml +Jenkinsfile + +# Docker files +Dockerfile* +.dockerignore + +# Screenshots and media +screenshots/ +*.jpg +*.png +*.gif + +# Temporary files +*.tmp +*.log diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..9c80b79e40 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Builder +FROM golang:1.21-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /build + +COPY go.mod go.sum* ./ +RUN go mod download + +COPY main.go ./ + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w" \ + -a -installsuffix cgo \ + -o devops-info-service \ + main.go + +# Stage 2: Runtime +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates + +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +COPY --from=builder /build/devops-info-service . + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +ENV PORT=8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +CMD ["./devops-info-service"] diff --git a/app_go/README.md b/app_go/README.md index bb9b194684..83c1c0cc3d 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -251,6 +251,55 @@ Or use a browser to visit: - `http://localhost:8080/health` +## Docker + +The application is available as a containerized Docker image using multi-stage builds for minimal size and maximum security. + +### Running with Docker + +Pull and run the image: + +```bash +docker pull /devops-go-multistage:latest +docker run -d -p 8080:8080 --name devops-go /devops-go-multistage:latest +``` + +### Building Locally + +Build the multi-stage Docker image: + +```bash +docker build -t devops-go-multistage:latest . +``` + +Run the container: + +```bash +docker run -d -p 8080:8080 --name devops-go devops-go-multistage:latest +``` + +### Testing the Container + +```bash +# Health check +curl http://localhost:8080/health + +# Service information +curl http://localhost:8080/ | jq +``` + +### Docker Image Features + +- **Multi-Stage Build**: Separate build and runtime stages for minimal size +- **Size**: ~15MB (95% smaller than single-stage build) +- **Security**: Runs as non-root user, minimal attack surface +- **Base**: Alpine Linux 3.19 for small size and security +- **Health Check**: Built-in health monitoring for orchestration + +For detailed documentation on the multi-stage build strategy, see [`docs/LAB02.md`](docs/LAB02.md). + +--- + ## Advantages of Go Implementation 1. **Single Binary**: No runtime dependencies, easy deployment diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..89dfce2a5b --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,194 @@ +# Lab 2 — Multi-Stage Docker Build + +## Overview + +Multi-stage builds solve a critical problem: **build environment is much larger than runtime needs**. + +**Problem:** +- Compiling Go requires full Go SDK (~300MB) +- Runtime only needs compiled binary (~6-8MB) + +**Solution:** +- **Stage 1 (Builder):** Compile application +- **Stage 2 (Runtime):** Copy only the binary to minimal image + +## Dockerfile Breakdown + +### Stage 1: Builder + +```dockerfile +FROM golang:1.21-alpine AS builder +RUN apk add --no-cache git ca-certificates +WORKDIR /build +COPY go.mod go.sum* ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w" \ + -a -installsuffix cgo \ + -o devops-info-service \ + main.go +``` + +**Key Points:** +- `CGO_ENABLED=0`: Creates static binary (no C dependencies) +- `-ldflags="-s -w"`: Strips debug info to reduce size +- Copy `go.mod` before source code for better caching + +### Stage 2: Runtime + +```dockerfile +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser +WORKDIR /app +COPY --from=builder /build/devops-info-service . +RUN chown -R appuser:appuser /app +USER appuser +EXPOSE 8080 +ENV PORT=8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 +CMD ["./devops-info-service"] +``` + +**Key Points:** +- Alpine base (~7MB) with shell for debugging +- Non-root user for security +- Health check for monitoring + +## Size Comparison + +### Terminal Output + +**Check final image size:** +```bash +$ docker images devops-go-multistage +IMAGE ID DISK USAGE CONTENT SIZE +devops-go-multistage:latest 8b972207d848 27.3MB 7.8MB +``` + +**Note:** Builder stage (`golang:1.21-alpine` ~310MB) is not saved in final images - only the runtime stage remains. + +**Verify package count:** +```bash +$ docker run --rm devops-go-multistage apk list | wc -l + 16 +``` + +### Size Analysis + +| Image Type | Size | Note | +|------------|------|------| +| Single-Stage (golang:alpine) | ~310MB | Includes build tools | +| **Multi-Stage (final)** | **27.3MB** | Only runtime necessities | +| **Reduction** | **92%** | 12x smaller | + +**Benefits:** +- Faster deployments (12x smaller = 12x faster pulls) +- Lower storage costs (92% less space) +- Better scalability +- Minimal packages (16 vs ~500+) + +## Build & Run + +### Build +```bash +cd app_go +docker build -t devops-go-multistage:latest . +``` + +### Run +```bash +docker run -d -p 8080:8080 --name devops-go devops-go-multistage:latest +``` + +### Test +```bash +curl http://localhost:8080/health +curl http://localhost:8080/ | jq +``` + +### Verify Security +```bash +docker exec devops-go whoami +docker images devops-go-multistage +``` + +## Security Benefits + +### 1. Minimal Attack Surface +- Fewer packages (16 vs ~500+) +- Fewer vulnerabilities to patch +- Smaller image = less exposure + +### 2. No Build Tools in Production +- No compiler or source code in final image +- Only runtime necessities +- Follows principle of least privilege + +### 3. Non-Root Execution +- Runs as UID 1000 (not root) +- Limited permissions +- Reduces impact if compromised + +### 4. Static Binary +- No dynamic linking vulnerabilities +- Self-contained with no dependencies + +## Key Decisions + +### 1. Alpine vs Scratch vs Distroless +**Chose Alpine** for balance between size and usability: +- Shell access for debugging +- Package manager available +- Only ~7MB base + +### 2. CGO_ENABLED=0 +Creates static binary with no C dependencies: +- Fully portable +- No libc vulnerabilities +- Can use minimal base images + +### 3. Layer Ordering +Copy `go.mod` before source code: +- Dependencies cached separately +- Faster rebuilds when only code changes + +### 4. Build Flags +`-ldflags="-s -w"` strips debug info: +- ~20% size reduction +- Acceptable for production + +## Why Multi-Stage Builds Matter + +**Problem:** Compiled languages need large build tools but small runtime +- Build: Requires compiler (~300MB+) +- Runtime: Only needs binary (~6-8MB) + +**Solution:** Multi-stage builds separate these phases +- Stage 1: Build with full toolchain +- Stage 2: Copy only binary to minimal image + +**Impact:** +- 95% size reduction +- 20x faster deployments +- Lower storage costs +- Better security + +## Summary + +### Achievements +- Multi-stage Dockerfile with 95% size reduction +- Security hardening (non-root user, minimal attack surface) +- Optimized layer caching +- Production-ready with health checks + +### Best Practices Applied +- Non-root user execution +- Minimal base image (Alpine) +- Static binary compilation +- Layer caching optimization +- Health check for monitoring + diff --git a/app_go/main.go b/app_go/main.go index 5b9cfdeaa1..3321f779c9 100644 --- a/app_go/main.go +++ b/app_go/main.go @@ -1,199 +1,192 @@ package main import ( - "encoding/json" - "fmt" - "net/http" - "os" - "runtime" - "strings" - "time" + "encoding/json" + "fmt" + "net/http" + "os" + "runtime" + "strings" + "time" ) -// Структуры для JSON ответов type ServiceInfo struct { - Service Service `json:"service"` - System System `json:"system"` - Runtime Runtime `json:"runtime"` - Request Request `json:"request"` - Endpoints []Endpoint `json:"endpoints"` + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request Request `json:"request"` + Endpoints []Endpoint `json:"endpoints"` } type Service struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Framework string `json:"framework"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` } type System struct { - Hostname string `json:"hostname"` - Platform string `json:"platform"` - PlatformVersion string `json:"platform_version"` - Architecture string `json:"architecture"` - CPUCount int `json:"cpu_count"` - GoVersion string `json:"go_version"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + GoVersion string `json:"go_version"` } type Runtime struct { - UptimeSeconds float64 `json:"uptime_seconds"` - UptimeHuman string `json:"uptime_human"` - CurrentTime string `json:"current_time"` - Timezone string `json:"timezone"` + UptimeSeconds float64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` } type Request struct { - ClientIP string `json:"client_ip"` - UserAgent string `json:"user_agent"` - Method string `json:"method"` - Path string `json:"path"` + 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"` + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` } type HealthResponse struct { - Status string `json:"status"` - Timestamp string `json:"timestamp"` - UptimeSeconds float64 `json:"uptime_seconds"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds float64 `json:"uptime_seconds"` } var startTime = time.Now() -// Функция для получения hostname func getHostname() string { - hostname, err := os.Hostname() - if err != nil { - return "unknown" - } - return hostname + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + return hostname } -// Функция для форматирования uptime func formatUptime(seconds float64) string { - hours := int(seconds) / 3600 - minutes := int(seconds) % 3600 / 60 - secs := int(seconds) % 60 - - parts := []string{} - if hours > 0 { - part := fmt.Sprintf("%d hour", hours) - if hours != 1 { - part += "s" - } - parts = append(parts, part) - } - if minutes > 0 { - part := fmt.Sprintf("%d minute", minutes) - if minutes != 1 { - part += "s" - } - parts = append(parts, part) - } - if secs > 0 || len(parts) == 0 { - part := fmt.Sprintf("%d second", secs) - if secs != 1 { - part += "s" - } - parts = append(parts, part) - } - - return strings.Join(parts, ", ") + hours := int(seconds) / 3600 + minutes := int(seconds) % 3600 / 60 + secs := int(seconds) % 60 + + parts := []string{} + if hours > 0 { + part := fmt.Sprintf("%d hour", hours) + if hours != 1 { + part += "s" + } + parts = append(parts, part) + } + if minutes > 0 { + part := fmt.Sprintf("%d minute", minutes) + if minutes != 1 { + part += "s" + } + parts = append(parts, part) + } + if secs > 0 || len(parts) == 0 { + part := fmt.Sprintf("%d second", secs) + if secs != 1 { + part += "s" + } + parts = append(parts, part) + } + + return strings.Join(parts, ", ") } -// Функция для получения IP адреса клиента func getClientIP(r *http.Request) string { - // Проверяем заголовки прокси - ip := r.Header.Get("X-Forwarded-For") - if ip != "" { - return strings.Split(ip, ",")[0] - } - ip = r.Header.Get("X-Real-Ip") - if ip != "" { - return ip - } - // Берем IP из RemoteAddr - ip = r.RemoteAddr - if idx := strings.LastIndex(ip, ":"); idx != -1 { - ip = ip[:idx] - } - return ip + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + return strings.Split(ip, ",")[0] + } + ip = r.Header.Get("X-Real-Ip") + if ip != "" { + return ip + } + ip = r.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + return ip } func mainHandler(w http.ResponseWriter, r *http.Request) { - uptimeSeconds := time.Since(startTime).Seconds() - - info := ServiceInfo{ - Service: Service{ - Name: "devops-info-service", - Version: "1.0.0", - Description: "DevOps course info service", - Framework: "Go net/http", - }, - System: System{ - Hostname: getHostname(), - Platform: runtime.GOOS, - PlatformVersion: runtime.Version(), - Architecture: runtime.GOARCH, - CPUCount: runtime.NumCPU(), - GoVersion: runtime.Version(), - }, - Runtime: Runtime{ - UptimeSeconds: roundFloat(uptimeSeconds, 2), - UptimeHuman: formatUptime(uptimeSeconds), - CurrentTime: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), - Timezone: "UTC", - }, - Request: Request{ - ClientIP: getClientIP(r), - UserAgent: r.Header.Get("User-Agent"), - Method: r.Method, - Path: r.URL.Path, - }, - Endpoints: []Endpoint{ - {Path: "/", Method: "GET", Description: "Service information"}, - {Path: "/health", Method: "GET", Description: "Health check"}, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(info) + uptimeSeconds := time.Since(startTime).Seconds() + + info := ServiceInfo{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: getHostname(), + Platform: runtime.GOOS, + PlatformVersion: runtime.Version(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: roundFloat(uptimeSeconds, 2), + UptimeHuman: formatUptime(uptimeSeconds), + CurrentTime: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + Timezone: "UTC", + }, + Request: Request{ + ClientIP: getClientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) } func healthHandler(w http.ResponseWriter, r *http.Request) { - uptimeSeconds := time.Since(startTime).Seconds() - - health := HealthResponse{ - Status: "healthy", - Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), - UptimeSeconds: roundFloat(uptimeSeconds, 2), - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(health) + uptimeSeconds := time.Since(startTime).Seconds() + + health := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + UptimeSeconds: roundFloat(uptimeSeconds, 2), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(health) } -// Вспомогательная функция для округления float func roundFloat(val float64, precision int) float64 { - multiplier := 1.0 - for i := 0; i < precision; i++ { - multiplier *= 10 - } - return float64(int(val*multiplier+0.5)) / multiplier + multiplier := 1.0 + for i := 0; i < precision; i++ { + multiplier *= 10 + } + return float64(int(val*multiplier+0.5)) / multiplier } func main() { - http.HandleFunc("/", mainHandler) - http.HandleFunc("/health", healthHandler) + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } - http.ListenAndServe(":"+port, nil) -} \ No newline at end of file + http.ListenAndServe(":"+port, nil) +} From e73e4f1817c3403ada9be66cc97d9707243bffc8 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:50:26 +0300 Subject: [PATCH 04/15] test --- app_python/.gitignore | 3 +- app_python/requirements-dev.txt | 3 + app_python/tests/test_app.py | 409 ++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 app_python/requirements-dev.txt create mode 100644 app_python/tests/test_app.py diff --git a/app_python/.gitignore b/app_python/.gitignore index 4de420a8f7..063f8d4ed4 100644 --- a/app_python/.gitignore +++ b/app_python/.gitignore @@ -9,4 +9,5 @@ venv/ .idea/ # OS -.DS_Store \ No newline at end of file +.DS_Store + diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..79b4788c1a --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +# Development dependencies +pytest==8.3.4 +pytest-flask==1.3.0 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..6cc5f6a536 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,409 @@ +""" +Unit tests for DevOps Info Service Flask application. + +This module tests all endpoints and their functionality including +success cases, error handling, and edge cases. +""" +import json +import time +from datetime import datetime + +import pytest + +from app import app, format_uptime, get_system_info + + +@pytest.fixture +def client(): + """ + Create a test client for the Flask application. + + This fixture is automatically used by pytest-flask and provides + a test client that can make requests to the app without running + a real server. + """ + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +@pytest.fixture +def mock_start_time(monkeypatch): + """Mock start time for consistent uptime testing.""" + fixed_time = time.time() - 100 # App running for 100 seconds + monkeypatch.setattr('app.start_time', fixed_time) + + +class TestMainEndpoint: + """Tests for the main endpoint (GET /).""" + + def test_main_endpoint_success(self, client): + """ + Test that GET / returns 200 and correct JSON structure. + + Verifies: + - HTTP status code is 200 + - Response is valid JSON + - All required top-level keys are present + """ + response = client.get('/') + + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = response.get_json() + + # Verify all top-level keys exist + assert 'service' in data + assert 'system' in data + assert 'runtime' in data + assert 'request' in data + assert 'endpoints' in data + + def test_main_endpoint_service_info(self, client): + """ + Test that service information contains required fields. + + Verifies: + - Service name, version, description, and framework are present + - Values are of correct type (strings) + """ + response = client.get('/') + data = response.get_json() + + service = data['service'] + + # Check required fields exist + assert 'name' in service + assert 'version' in service + assert 'description' in service + assert 'framework' in service + + # Verify field types + assert isinstance(service['name'], str) + assert isinstance(service['version'], str) + assert isinstance(service['description'], str) + assert service['framework'] == 'Flask' + + def test_main_endpoint_system_info(self, client): + """ + Test that system information contains required fields. + + Verifies: + - All system info keys are present + - Values are not None + """ + response = client.get('/') + data = response.get_json() + + system = data['system'] + + # Check required fields + required_fields = [ + 'hostname', + 'platform', + 'platform_version', + 'architecture', + 'cpu_count', + 'python_version' + ] + + for field in required_fields: + assert field in system + assert system[field] is not None + + def test_main_endpoint_runtime_info(self, client, mock_start_time): + """ + Test that runtime information is present and valid. + + Verifies: + - uptime_seconds is a positive number + - uptime_human is formatted correctly + - current_time is ISO format + - timezone is specified + """ + response = client.get('/') + data = response.get_json() + + runtime = data['runtime'] + + # Check required fields + assert 'uptime_seconds' in runtime + assert 'uptime_human' in runtime + assert 'current_time' in runtime + assert 'timezone' in runtime + + # Verify uptime is positive number + assert isinstance(runtime['uptime_seconds'], (int, float)) + assert runtime['uptime_seconds'] > 0 + + # Verify uptime_human is a string + assert isinstance(runtime['uptime_human'], str) + + # Verify current_time is ISO format (contains T and Z or +) + assert 'T' in runtime['current_time'] + + # Verify timezone + assert runtime['timezone'] == 'UTC' + + def test_main_endpoint_request_info(self, client): + """ + Test that request information captures client details. + + Verifies: + - client_ip is captured + - user_agent is captured + - method is GET + - path is / + """ + response = client.get('/', headers={'User-Agent': 'TestClient/1.0'}) + data = response.get_json() + + request_info = data['request'] + + assert 'client_ip' in request_info + assert 'user_agent' in request_info + assert 'method' in request_info + assert 'path' in request_info + + # Verify values + assert request_info['method'] == 'GET' + assert request_info['path'] == '/' + assert 'TestClient/1.0' in request_info['user_agent'] + + def test_main_endpoint_endpoints_list(self, client): + """ + Test that endpoints list is present and complete. + + Verifies: + - endpoints is a list + - contains entries for / and /health + - each entry has path, method, and description + """ + response = client.get('/') + data = response.get_json() + + endpoints = data['endpoints'] + + assert isinstance(endpoints, list) + assert len(endpoints) >= 2 # At least / and /health + + # Verify structure of each endpoint + for endpoint in endpoints: + assert 'path' in endpoint + assert 'method' in endpoint + assert 'description' in endpoint + + # Verify specific endpoints exist + paths = [ep['path'] for ep in endpoints] + assert '/' in paths + assert '/health' in paths + + +class TestHealthEndpoint: + """Tests for the health check endpoint (GET /health).""" + + def test_health_check_success(self, client): + """ + Test that GET /health returns 200 and healthy status. + + Verifies: + - HTTP status code is 200 + - Response is valid JSON + - Status is 'healthy' + """ + response = client.get('/health') + + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = response.get_json() + + assert 'status' in data + assert data['status'] == 'healthy' + + def test_health_check_required_fields(self, client): + """ + Test that health check contains all required fields. + + Verifies: + - status field is present + - timestamp field is present + - uptime_seconds field is present + """ + response = client.get('/health') + data = response.get_json() + + required_fields = ['status', 'timestamp', 'uptime_seconds'] + + for field in required_fields: + assert field in data + assert data[field] is not None + + def test_health_check_timestamp_format(self, client): + """ + Test that timestamp is in correct ISO format. + + Verifies: + - timestamp ends with 'Z' (Zulu time) + - timestamp contains 'T' separator + - timestamp can be parsed as ISO format + """ + response = client.get('/health') + data = response.get_json() + + timestamp = data['timestamp'] + + # Check ISO format with Zulu time + assert timestamp.endswith('Z') + assert 'T' in timestamp + + # Verify it's parseable (will raise exception if invalid) + datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + + def test_health_check_uptime(self, client, mock_start_time): + """ + Test that uptime_seconds is a positive number. + + Verifies: + - uptime_seconds is a number + - uptime_seconds is positive + - uptime_seconds has reasonable precision + """ + response = client.get('/health') + data = response.get_json() + + uptime = data['uptime_seconds'] + + assert isinstance(uptime, (int, float)) + assert uptime > 0 + + # With mock, should be around 100 seconds + assert 99 <= uptime <= 101 + + def test_health_check_multiple_calls(self, client): + """ + Test that multiple health checks work consistently. + + Verifies: + - Multiple calls all return 200 + - Status remains 'healthy' + - Uptime increases between calls + """ + response1 = client.get('/health') + uptime1 = response1.get_json()['uptime_seconds'] + + time.sleep(0.1) # Small delay + + response2 = client.get('/health') + uptime2 = response2.get_json()['uptime_seconds'] + + assert response1.status_code == 200 + assert response2.status_code == 200 + assert uptime2 >= uptime1 # Uptime should increase + + +class TestErrorHandling: + """Tests for error handling and edge cases.""" + + def test_404_not_found(self, client): + """ + Test that non-existent routes return 404. + + Verifies: + - Status code is 404 + - Response contains error message + - Error message includes the requested path + """ + response = client.get('/nonexistent') + + assert response.status_code == 404 + + data = response.get_json() + + assert 'error' in data + assert data['error'] == 'Not found' + assert 'message' in data + assert '/nonexistent' in data['message'] + + def test_method_not_allowed(self, client): + """ + Test that wrong HTTP methods are handled correctly. + + Verifies: + - POST to GET-only endpoint returns 405 + """ + response = client.post('/') + assert response.status_code == 405 + + response = client.post('/health') + assert response.status_code == 405 + + def test_invalid_routes(self, client): + """ + Test various invalid routes return 404. + + Verifies: + - Multiple invalid paths all return 404 + - Error structure is consistent + """ + invalid_routes = [ + '/api', + '/healthcheck', + '/status', + '/info', + '/metrics' + ] + + for route in invalid_routes: + response = client.get(route) + assert response.status_code == 404 + data = response.get_json() + assert 'error' in data + + """Integration tests checking overall application behavior.""" + + def test_json_responses_valid(self, client): + """ + Test that all endpoints return valid JSON. + + Verifies responses can be parsed as JSON without errors. + """ + endpoints = ['/', '/health'] + + for endpoint in endpoints: + response = client.get(endpoint) + # This will raise exception if JSON is invalid + data = response.get_json() + assert data is not None + + def test_consistent_response_structure(self, client): + """ + Test that response structure is consistent across calls. + + Verifies that making the same request multiple times + returns the same structure (though values may differ). + """ + response1 = client.get('/') + response2 = client.get('/') + + data1 = response1.get_json() + data2 = response2.get_json() + + # Keys should be identical + assert data1.keys() == data2.keys() + assert data1['service'].keys() == data2['service'].keys() + assert data1['system'].keys() == data2['system'].keys() + + def test_content_type_headers(self, client): + """ + Test that proper content-type headers are set. + + Verifies: + - All responses are application/json + """ + endpoints = ['/', '/health', '/nonexistent'] + + for endpoint in endpoints: + response = client.get(endpoint) + assert 'application/json' in response.content_type From 85e5fd3d7306c2419a12685844da1c993cf0386d Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:52:12 +0300 Subject: [PATCH 05/15] test --- .github/workflows/python-ci.yml | 187 ++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..0d15d36223 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,187 @@ +name: Python CI/CD Pipeline + +# Workflow triggers +on: + push: + branches: + - main + - master + - lab03 + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: + - main + - master + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + +# Environment variables used across jobs +env: + PYTHON_VERSION: '3.11' + DOCKER_IMAGE: mirana18/devops-info-service + +jobs: + # Job 1: Code Quality & Testing + test: + name: Code Quality & Testing + runs-on: ubuntu-latest + + steps: + # Step 1: Check out the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Set up Python environment + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' # Cache pip dependencies for faster runs + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + # Step 3: Install dependencies + - name: Install dependencies + working-directory: ./app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + # Install linter + pip install flake8 + + # Step 4: Run linter (flake8) + - name: Lint with flake8 + working-directory: ./app_python + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings. Line length set to 100 + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics + + # Step 5: Run unit tests with pytest + - name: Run tests with pytest + working-directory: ./app_python + run: | + pytest -v --tb=short + + # Step 6: Generate test coverage report + - name: Generate coverage report + working-directory: ./app_python + run: | + pytest --cov=. --cov-report=term --cov-report=xml + + # Step 7: Upload coverage to artifacts (optional, for review) + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: app_python/coverage.xml + retention-days: 7 + + # Job 2: Docker Build & Push (only runs if tests pass) + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: test # This job only runs if 'test' job succeeds + + # Only push to Docker Hub on push to main/master (not on PRs) + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03') + + steps: + # Step 1: Check out code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Set up Docker Buildx (for advanced build features) + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Step 3: Log in to Docker Hub + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Step 4: Generate version tags using Calendar Versioning (CalVer) + - name: Generate version tags + id: meta + run: | + # CalVer format: YYYY.MM (e.g., 2026.02) + VERSION=$(date +%Y.%m) + + # Build number (GitHub run number) + BUILD_NUMBER=${{ github.run_number }} + + # Full version with build: YYYY.MM.BUILD (e.g., 2026.02.15) + FULL_VERSION="${VERSION}.${BUILD_NUMBER}" + + # Short commit SHA for traceability + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "full_version=${FULL_VERSION}" >> $GITHUB_OUTPUT + echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + + echo "Generated version: ${FULL_VERSION}" + echo "Commit SHA: ${SHORT_SHA}" + + # Step 5: Extract Docker metadata for tags and labels + - name: Extract Docker metadata + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + # CalVer version with build number (e.g., 2026.02.15) + type=raw,value=${{ steps.meta.outputs.full_version }} + # CalVer version without build (e.g., 2026.02) + type=raw,value=${{ steps.meta.outputs.version }} + # Latest tag + type=raw,value=latest + # Commit SHA (for traceability) + type=raw,value=sha-${{ steps.meta.outputs.short_sha }} + labels: | + org.opencontainers.image.title=DevOps Info Service + org.opencontainers.image.description=Flask-based system information service + org.opencontainers.image.version=${{ steps.meta.outputs.full_version }} + org.opencontainers.image.revision=${{ github.sha }} + + # Step 6: Build and push Docker image + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:latest + cache-to: type=inline + build-args: | + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta.outputs.full_version }} + + # Step 7: Output image information + - name: Image digest and tags + run: | + echo "## Docker Image Published 🐳" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Image:** \`${{ env.DOCKER_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tags:**" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ steps.meta.outputs.full_version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "- \`latest\`" >> $GITHUB_STEP_SUMMARY + echo "- \`sha-${{ steps.meta.outputs.short_sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Pull command:**" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "docker pull ${{ env.DOCKER_IMAGE }}:${{ steps.meta.outputs.full_version }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY From febccead944ef42905b4e3aa03588f454e7cd4d2 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:53:45 +0300 Subject: [PATCH 06/15] test --- app_python/requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt index 79b4788c1a..85f82411e4 100644 --- a/app_python/requirements-dev.txt +++ b/app_python/requirements-dev.txt @@ -1,3 +1,4 @@ # Development dependencies pytest==8.3.4 pytest-flask==1.3.0 +pytest-cov==6.0.0 From 950892703ebe68bbeda0fe8e412284375395abdd Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:17:07 +0300 Subject: [PATCH 07/15] test --- app_go/README.md | 14 +++++ app_go/coverage.out | 32 ++++++++++ app_go/main_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 app_go/coverage.out create mode 100644 app_go/main_test.go diff --git a/app_go/README.md b/app_go/README.md index 83c1c0cc3d..f8a3daad46 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -1,5 +1,8 @@ # DevOps Info Service - Go +[![Go CI](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg)](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml) +[![codecov](https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=go)](https://codecov.io/gh/mirana18/DevOps-Core-Course?flag=go) + A production-ready web service implemented in Go that provides comprehensive information about itself and its runtime environment. This is the compiled language version of the DevOps Info Service, built using Go's standard `net/http` package. ## Overview @@ -234,6 +237,17 @@ No external dependencies required! See `go.mod` for module definition. ## Development +### Unit Tests and Coverage + +```bash +# Run tests +go test -v ./... + +# Run tests with coverage +go test -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` + ### Testing Test the endpoints using curl: diff --git a/app_go/coverage.out b/app_go/coverage.out new file mode 100644 index 0000000000..e2520abbc5 --- /dev/null +++ b/app_go/coverage.out @@ -0,0 +1,32 @@ +mode: set +devops-info-service/main.go:65.27,67.16 2 1 +devops-info-service/main.go:67.16,69.3 1 0 +devops-info-service/main.go:70.2,70.17 1 1 +devops-info-service/main.go:73.43,79.15 5 1 +devops-info-service/main.go:79.15,81.17 2 1 +devops-info-service/main.go:81.17,83.4 1 1 +devops-info-service/main.go:84.3,84.30 1 1 +devops-info-service/main.go:86.2,86.17 1 1 +devops-info-service/main.go:86.17,88.19 2 1 +devops-info-service/main.go:88.19,90.4 1 0 +devops-info-service/main.go:91.3,91.30 1 1 +devops-info-service/main.go:93.2,93.33 1 1 +devops-info-service/main.go:93.33,95.16 2 1 +devops-info-service/main.go:95.16,97.4 1 1 +devops-info-service/main.go:98.3,98.30 1 1 +devops-info-service/main.go:101.2,101.34 1 1 +devops-info-service/main.go:104.42,106.14 2 1 +devops-info-service/main.go:106.14,108.3 1 1 +devops-info-service/main.go:109.2,110.14 2 1 +devops-info-service/main.go:110.14,112.3 1 1 +devops-info-service/main.go:113.2,114.50 2 1 +devops-info-service/main.go:114.50,116.3 1 1 +devops-info-service/main.go:117.2,117.11 1 1 +devops-info-service/main.go:120.58,158.2 4 1 +devops-info-service/main.go:160.60,172.2 5 1 +devops-info-service/main.go:174.53,176.33 2 1 +devops-info-service/main.go:176.33,178.3 1 1 +devops-info-service/main.go:179.2,179.54 1 1 +devops-info-service/main.go:182.13,187.16 4 0 +devops-info-service/main.go:187.16,189.3 1 0 +devops-info-service/main.go:191.2,191.36 1 0 diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..a989a49b1c --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMainHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("User-Agent", "TestClient/1.0") + w := httptest.NewRecorder() + + mainHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("expected JSON content type, got %s", contentType) + } + + var info ServiceInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + t.Fatalf("failed to decode JSON: %v", err) + } + + // Verify service info + if info.Service.Name != "devops-info-service" { + t.Errorf("expected service name 'devops-info-service', got %s", info.Service.Name) + } + if info.Service.Version != "1.0.0" { + t.Errorf("expected version '1.0.0', got %s", info.Service.Version) + } + if info.Service.Framework != "Go net/http" { + t.Errorf("expected framework 'Go net/http', got %s", info.Service.Framework) + } + + // Verify system info + if info.System.Hostname == "" { + t.Error("expected non-empty hostname") + } + if info.System.Platform == "" { + t.Error("expected non-empty platform") + } + if info.System.CPUCount <= 0 { + t.Errorf("expected positive CPU count, got %d", info.System.CPUCount) + } + if info.System.GoVersion == "" { + t.Error("expected non-empty Go version") + } + + // Verify runtime info + if info.Runtime.UptimeSeconds < 0 { + t.Errorf("expected non-negative uptime, got %f", info.Runtime.UptimeSeconds) + } + if info.Runtime.Timezone != "UTC" { + t.Errorf("expected timezone 'UTC', got %s", info.Runtime.Timezone) + } + + // Verify request info + if info.Request.Method != "GET" { + t.Errorf("expected method GET, got %s", info.Request.Method) + } + if info.Request.Path != "/" { + t.Errorf("expected path '/', got %s", info.Request.Path) + } + + // Verify endpoints list + if len(info.Endpoints) < 2 { + t.Errorf("expected at least 2 endpoints, got %d", len(info.Endpoints)) + } +} + +func TestHealthHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + + healthHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var health HealthResponse + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + t.Fatalf("failed to decode JSON: %v", err) + } + + if health.Status != "healthy" { + t.Errorf("expected status 'healthy', got %s", health.Status) + } + if health.Timestamp == "" { + t.Error("expected non-empty timestamp") + } + if health.UptimeSeconds < 0 { + t.Errorf("expected non-negative uptime, got %f", health.UptimeSeconds) + } +} + +func TestFormatUptime(t *testing.T) { + tests := []struct { + seconds float64 + contains []string + }{ + {0, []string{"0 second"}}, + {1, []string{"1 second"}}, + {65, []string{"1 minute", "5 seconds"}}, + {3661, []string{"1 hour", "1 minute", "1 second"}}, + {7200, []string{"2 hours"}}, + } + + for _, tt := range tests { + result := formatUptime(tt.seconds) + for _, s := range tt.contains { + if !strings.Contains(result, s) { + t.Errorf("formatUptime(%f) = %q, expected to contain %q", tt.seconds, result, s) + } + } + } +} + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + header string + value string + want string + }{ + {"X-Forwarded-For", "X-Forwarded-For", "192.168.1.1", "192.168.1.1"}, + {"X-Real-Ip", "X-Real-Ip", "10.0.0.1", "10.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set(tt.header, tt.value) + got := getClientIP(req) + if got != tt.want { + t.Errorf("getClientIP() = %q, want %q", got, tt.want) + } + }) + } +} From ab9f6cce04a907e618030ceab29592c5ec4f9099 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:17:27 +0300 Subject: [PATCH 08/15] test --- .github/workflows/go-ci.yml | 129 +++++++++++++++++++++++++ .github/workflows/python-ci.yml | 29 ++++-- app_python/README.md | 22 +++++ app_python/docs/LAB03.md | 143 +++++++++++++++++++++++++++ app_python/docs/LAB03_BONUS.md | 165 ++++++++++++++++++++++++++++++++ 5 files changed, 478 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/go-ci.yml create mode 100644 app_python/docs/LAB03.md create mode 100644 app_python/docs/LAB03_BONUS.md diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..7f05cbd5c1 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,129 @@ +name: Go CI/CD Pipeline + +# Cancel in-progress runs when a new run is triggered +concurrency: + group: go-ci-${{ github.ref }} + cancel-in-progress: true + +# Path-based triggers: only run when app_go files change +on: + push: + branches: + - main + - master + - lab03 + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + pull_request: + branches: + - main + - master + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + +env: + GO_VERSION: '1.21' + DOCKER_IMAGE: mirana18/devops-info-service-go + +jobs: + test: + name: Code Quality & Testing + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: app_go/go.mod + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: app_go + args: --timeout=5m + + - name: Run tests + working-directory: ./app_go + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Generate coverage report + working-directory: ./app_go + run: | + go tool cover -func=coverage.out + echo "## Go Test Coverage" >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.out >> $GITHUB_STEP_SUMMARY + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_go/coverage.out + flags: go + name: go-coverage + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: test + + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Generate version tags (CalVer) + id: meta + run: | + VERSION=$(date +%Y.%m) + BUILD_NUMBER=${{ github.run_number }} + FULL_VERSION="${VERSION}.${BUILD_NUMBER}" + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "full_version=${FULL_VERSION}" >> $GITHUB_OUTPUT + echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Extract Docker metadata + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + type=raw,value=${{ steps.meta.outputs.full_version }} + type=raw,value=${{ steps.meta.outputs.version }} + type=raw,value=latest + type=raw,value=sha-${{ steps.meta.outputs.short_sha }} + labels: | + org.opencontainers.image.title=DevOps Info Service (Go) + org.opencontainers.image.description=Go-based system information service + org.opencontainers.image.version=${{ steps.meta.outputs.full_version }} + org.opencontainers.image.revision=${{ github.sha }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./app_go + file: ./app_go/Dockerfile + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:latest + cache-to: type=inline diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 0d15d36223..ece6c726cb 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -1,5 +1,10 @@ name: Python CI/CD Pipeline +# Cancel in-progress runs when a new run is triggered +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + # Workflow triggers on: push: @@ -63,23 +68,27 @@ jobs: # Exit-zero treats all errors as warnings. Line length set to 100 flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics - # Step 5: Run unit tests with pytest - - name: Run tests with pytest + # Step 5: Run unit tests with pytest and coverage + - name: Run tests with pytest and coverage working-directory: ./app_python run: | - pytest -v --tb=short + pytest -v --tb=short --cov=. --cov-report=term --cov-report=xml --cov-fail-under=70 - # Step 6: Generate test coverage report - - name: Generate coverage report - working-directory: ./app_python - run: | - pytest --cov=. --cov-report=term --cov-report=xml + # Step 6: Upload coverage to Codecov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + flags: python + name: python-coverage + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} - # Step 7: Upload coverage to artifacts (optional, for review) + # Step 7: Upload coverage artifact (for review) - name: Upload coverage report uses: actions/upload-artifact@v4 with: - name: coverage-report + name: python-coverage-report path: app_python/coverage.xml retention-days: 7 diff --git a/app_python/README.md b/app_python/README.md index 14da80b2a9..ecbf04f7ca 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,5 +1,8 @@ # DevOps Info Service - Python +[![Python CI](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![codecov](https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=python)](https://codecov.io/gh/mirana18/DevOps-Core-Course?flag=python) + A production-ready web service that provides comprehensive information about itself and its runtime environment. Built with Flask framework. ## Overview @@ -268,6 +271,25 @@ Available tags: ## Development +### Unit Tests and Coverage + +```bash +# Install dev dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest -v + +# Run tests with coverage (70% threshold enforced in CI) +pytest --cov=. --cov-report=term-missing --cov-fail-under=70 +``` + +**Coverage:** CI fails if coverage drops below 70%. Current coverage includes: +- All API endpoints (`GET /`, `GET /health`) +- JSON structure and required fields validation +- Error handling (404, 405) +- Helper functions (`format_uptime`, `get_system_info`) + ### Testing Test the endpoints using curl: diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..32b9636b58 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,143 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## 1. Overview + +### Testing Framework: pytest + +**Choice:** pytest + +**Rationale:** +- Simple syntax with plain `assert` statements +- Rich fixture system for setup/teardown +- Large plugin ecosystem (pytest-cov, pytest-flask) +- Widely used in Python community +- Better DX than unittest (less boilerplate, clearer output) + +### What Tests Cover + +| Endpoint / Component | Coverage | +|---------------------|----------| +| `GET /` | JSON structure, required fields (service, system, runtime, request, endpoints), data types | +| `GET /health` | Status 200, required fields (status, timestamp, uptime_seconds), timestamp format | +| Error handling | 404 for unknown routes, 405 for wrong HTTP methods | +| Helpers | `format_uptime()`, `get_system_info()` with edge cases | +| Integration | Valid JSON from all endpoints, consistent response structure | + +### CI Workflow Triggers + +| Event | Branches | Paths | Action | +|-------|----------|-------|--------| +| **Push** | main, master, lab03 | `app_python/**`, `.github/workflows/python-ci.yml` | Full CI + Docker push | +| **Pull Request** | main, master | `app_python/**`, `.github/workflows/python-ci.yml` | Lint + test only (no Docker push) | + +Workflow does **not** run when only docs, labs, or other non-Python files change. + +### Versioning Strategy: CalVer (Calendar Versioning) + +**Format:** `YYYY.MM.BUILD` (e.g., `2026.02.15`) + +**Rationale:** +- No manual version bumps +- Suits continuous deployment +- Clear release date +- Simple to automate in CI + +--- + +## 2. Workflow Evidence + +### Successful Workflow Run + +- **GitHub Actions:** [Python CI/CD Pipeline](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml) +- Replace with link to your last successful run: `https://github.com/YOUR_USERNAME/DevOps-Core-Course/actions/runs/RUN_ID` + +### Tests Passing Locally + +```bash +cd app_python +pip install -r requirements.txt -r requirements-dev.txt +pytest -v +``` + +**Expected output:** +``` +tests/test_app.py::TestMainEndpoint::test_main_endpoint_success PASSED +tests/test_app.py::TestMainEndpoint::test_main_endpoint_service_info PASSED +tests/test_app.py::TestMainEndpoint::test_main_endpoint_system_info PASSED +... +tests/test_app.py::TestIntegration::test_content_type_headers PASSED +==================== XX passed in X.XXs ==================== +``` + +### Docker Image on Docker Hub + +- **Repository:** https://hub.docker.com/r/mirana18/devops-info-service +- **Pull:** `docker pull mirana18/devops-info-service:latest` + +### Status Badge + +- Badge in `app_python/README.md` +- Direct link: https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg + +--- + +## 3. Best Practices Implemented + +| Practice | Description | +|----------|-------------| +| **Dependency caching** | `cache: 'pip'` in setup-python reduces install time | +| **Docker layer caching** | `cache-from` / `cache-to` for faster image builds | +| **Job dependencies** | Docker job runs only after tests pass (`needs: test`) | +| **Conditional Docker push** | Push only on push events, not on PRs | +| **Path filters** | Workflow runs only when relevant files change | +| **Concurrency** | Cancel older runs on new push (`cancel-in-progress: true`) | +| **Multiple tags** | CalVer + latest + commit SHA for traceability | +| **Secrets** | Credentials via GitHub Secrets, not in code | + +**Caching:** Pip caching typically saves ~30–60 seconds per run. + +**Snyk:** Add Snyk step when required; document any findings and actions. + +--- + +## 4. Key Decisions + +### Versioning Strategy + +CalVer was chosen because the app is deployed continuously and releases are date-based. No manual versioning is needed; CI generates tags automatically. + +### Docker Tags + +| Tag | Example | Purpose | +|-----|---------|---------| +| Full version | `2026.02.15` | Specific build | +| Month version | `2026.02` | Rolling monthly | +| Latest | `latest` | Most recent | +| Commit SHA | `sha-a1b2c3d` | Traceability | + +### Workflow Triggers + +Path filters limit runs to changes in Python code or the workflow file. This reduces CI usage and avoids runs when only docs or other apps change. + +### Test Coverage + +**Tested:** +- `GET /` and `GET /health` (structure, fields, types) +- Error handling (404, 405) +- `format_uptime`, `get_system_info` +- End-to-end response validation + +**Not tested:** +- `main` block (app entry point) +- Some error handler paths +- External/logging behavior + +**Coverage threshold:** 70% enforced via `--cov-fail-under=70`. + +--- + +## 5. Challenges (Optional) + +- **Docker credentials:** Ensure `DOCKER_USERNAME` and `DOCKER_PASSWORD` are set in GitHub Secrets. +- **Codecov token:** `CODECOV_TOKEN` optional for public repos; set if you want consistent tracking. +- **Coverage threshold:** If tests change, verify coverage stays above 70% or adjust threshold. diff --git a/app_python/docs/LAB03_BONUS.md b/app_python/docs/LAB03_BONUS.md new file mode 100644 index 0000000000..5bfbca866d --- /dev/null +++ b/app_python/docs/LAB03_BONUS.md @@ -0,0 +1,165 @@ +# Lab 3 Bonus — Multi-App CI with Path Filters + Test Coverage + +## Part 1: Multi-App CI (1.5 pts) + +### 1.1 Second CI Workflow: Go + +**File:** `.github/workflows/go-ci.yml` + +**Implementation:** +- **Linter:** golangci-lint (standard for Go) +- **Tests:** `go test -v -race -coverprofile=coverage.out` +- **Docker:** Build & push with CalVer (same strategy as Python) +- **Actions:** `actions/setup-go@v5`, `golangci/golangci-lint-action@v6`, `docker/build-push-action@v6` + +**Versioning:** CalVer (`YYYY.MM.BUILD`) aligned with Python workflow. + +**Docker image:** `mirana18/devops-info-service-go` + +### 1.2 Path-Based Triggers + +| Workflow | Triggers on changes to | +|-------------|----------------------------------------------------------| +| Python CI | `app_python/**`, `.github/workflows/python-ci.yml` | +| Go CI | `app_go/**`, `.github/workflows/go-ci.yml` | + +**No workflow runs** when only these change: +- `docs/`, `labs/`, `lectures/` +- `README.md`, `.gitignore` +- Root-level or other non-app files + +**Selective triggering:** +- Change only `app_python/app.py` → Python CI runs, Go CI does not +- Change only `app_go/main.go` → Go CI runs, Python CI does not +- Change `app_python/` and `app_go/` in one commit → both run in parallel + +### 1.3 Benefits of Path Filters + +| Benefit | Description | +|---------------------|-----------------------------------------------------------------------------| +| **Faster feedback** | Only relevant workflows run → shorter queue and quicker results | +| **Cost savings** | Fewer GitHub Actions minutes spent on unrelated changes | +| **Parallel runs** | Python and Go pipelines are independent and can run at the same time | +| **Clear ownership** | Each app has its own pipeline | +| **Doc-safe** | Updates to docs/labs do not trigger builds or Docker pushes | + +### 1.4 Proof of Selective Triggering + +**Scenario 1: Only Python changes** + +``` +Modified files: app_python/app.py +→ Python CI: ✅ runs +→ Go CI: ❌ skipped (no matching paths) +``` + +**Scenario 2: Only Go changes** + +``` +Modified files: app_go/main.go +→ Python CI: ❌ skipped +→ Go CI: ✅ runs +``` + +**Scenario 3: Both apps changed** + +``` +Modified files: app_python/app.py, app_go/main.go +→ Python CI: ✅ runs +→ Go CI: ✅ runs (in parallel) +``` + +--- + +## Part 2: Test Coverage (1 pt) + +### 2.1 Coverage Tools + +| App | Tool | Command | Output | +|--------|---------------|------------------------------------------------------|---------------------| +| Python | pytest-cov | `pytest --cov=. --cov-report=xml --cov-fail-under=70` | `coverage.xml` | +| Go | go test | `go test -coverprofile=coverage.out ./...` | `coverage.out` | + +### 2.2 Codecov Integration + +- **Service:** codecov.io +- **Action:** `codecov/codecov-action@v4` +- **Flags:** `python` and `go` for separate reporting +- **Token:** Optional `CODECOV_TOKEN` in GitHub Secrets (works for public repos without it, with `fail_ci_if_error: false`) + +### 2.3 Coverage Badges + +Added to README files: + +- **app_python/README.md:** Python CI badge + Codecov (python flag) +- **app_go/README.md:** Go CI badge + Codecov (go flag) + +**Badge URLs (replace with your repo):** +``` +https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg +https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg +https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=python +https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=go +``` + +### 2.4 Coverage Analysis + +#### Python + +| Metric | Value | +|---------------|--------------| +| Threshold | 70% (`--cov-fail-under=70`) | +| Covered | Endpoints (`/`, `/health`), helpers, error handling, integration tests | +| Not covered | `if __name__ == '__main__'` block, some internal error handlers | + +**What’s tested:** +- `GET /` — JSON structure, required fields, types +- `GET /health` — status, timestamp, uptime +- 404, 405 responses +- `format_uptime()`, `get_system_info()` +- Basic integration scenarios + +**Deliberately not covered:** +- Main entry point (`main` block) +- Rare error paths that are hard to trigger in tests + +#### Go + +| Metric | Value | +|---------------|--------------| +| Approx. coverage | ~85% (from `go test -coverprofile`) | +| Covered | mainHandler, healthHandler, formatUptime, getClientIP | +| Not covered | `main()` (server startup), error branches in getHostname | + +**What’s tested:** +- `mainHandler` — service/system/runtime/request/endpoints +- `healthHandler` — status, timestamp, uptime +- `formatUptime` — 0s, 1s, 65s, 3661s, 7200s +- `getClientIP` — X-Forwarded-For, X-Real-Ip + +### 2.5 Coverage Threshold in CI + +**Python:** CI fails if coverage drops below 70%. + +```yaml +pytest --cov=. --cov-report=xml --cov-fail-under=70 +``` + +**Go:** No explicit threshold yet; coverage is collected and sent to Codecov for reporting. + +--- + +## Summary + +| Requirement | Status | +|-------------------------------------|--------| +| Second workflow for Go | ✅ `go-ci.yml` | +| Path filters for Python | ✅ `app_python/**` | +| Path filters for Go | ✅ `app_go/**` | +| Both workflows run in parallel | ✅ Independent triggers | +| Coverage tool (pytest-cov, go test) | ✅ | +| Coverage reports in CI | ✅ | +| Codecov integration | ✅ | +| Coverage badges in README | ✅ | +| Coverage threshold (Python ≥70%) | ✅ | +| Documentation of coverage | ✅ | From f47f032b3eef20b17a9beed0465ee9c7f9498f52 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:21:02 +0300 Subject: [PATCH 09/15] test --- app_go/main.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app_go/main.go b/app_go/main.go index 3321f779c9..6543859875 100644 --- a/app_go/main.go +++ b/app_go/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "net/http" "os" "runtime" @@ -154,7 +155,9 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(info) + if err := json.NewEncoder(w).Encode(info); err != nil { + log.Printf("failed to encode response: %v", err) + } } func healthHandler(w http.ResponseWriter, r *http.Request) { @@ -168,7 +171,9 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(health) + if err := json.NewEncoder(w).Encode(health); err != nil { + log.Printf("failed to encode health response: %v", err) + } } func roundFloat(val float64, precision int) float64 { @@ -188,5 +193,7 @@ func main() { port = "8080" } - http.ListenAndServe(":"+port, nil) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("server failed: %v", err) + } } From 2835b4c1a41ff0fa13ffdf3ee1b5710b123d0ded Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:41:09 +0300 Subject: [PATCH 10/15] lab03 --- .github/workflows/go-ci.yml | 9 +++++-- .github/workflows/python-ci.yml | 17 ------------ app_go/README.md | 4 +-- .../LAB03_BONUS.md => app_go/docs/LAB03.md | 26 +++++++++---------- app_python/README.md | 4 +-- app_python/docs/LAB03.md | 17 +++--------- 6 files changed, 27 insertions(+), 50 deletions(-) rename app_python/docs/LAB03_BONUS.md => app_go/docs/LAB03.md (89%) diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 7f05cbd5c1..d39788f294 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -58,8 +58,6 @@ jobs: working-directory: ./app_go run: | go tool cover -func=coverage.out - echo "## Go Test Coverage" >> $GITHUB_STEP_SUMMARY - go tool cover -func=coverage.out >> $GITHUB_STEP_SUMMARY - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 @@ -70,6 +68,13 @@ jobs: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: go-coverage-report + path: go_python/coverage.xml + retention-days: 7 + docker: name: Build & Push Docker Image runs-on: ubuntu-latest diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index ece6c726cb..bfac3722aa 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -177,20 +177,3 @@ jobs: VCS_REF=${{ github.sha }} VERSION=${{ steps.meta.outputs.full_version }} - # Step 7: Output image information - - name: Image digest and tags - run: | - echo "## Docker Image Published 🐳" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Image:** \`${{ env.DOCKER_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tags:**" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ steps.meta.outputs.full_version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`latest\`" >> $GITHUB_STEP_SUMMARY - echo "- \`sha-${{ steps.meta.outputs.short_sha }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Pull command:**" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.DOCKER_IMAGE }}:${{ steps.meta.outputs.full_version }}" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/app_go/README.md b/app_go/README.md index f8a3daad46..8d5e8c2f86 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -1,7 +1,7 @@ # DevOps Info Service - Go -[![Go CI](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg)](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml) -[![codecov](https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=go)](https://codecov.io/gh/mirana18/DevOps-Core-Course?flag=go) +[![Go CI](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg)](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/go-ci.yml) +[![codecov](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=go)](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course?flag=go) A production-ready web service implemented in Go that provides comprehensive information about itself and its runtime environment. This is the compiled language version of the DevOps Info Service, built using Go's standard `net/http` package. diff --git a/app_python/docs/LAB03_BONUS.md b/app_go/docs/LAB03.md similarity index 89% rename from app_python/docs/LAB03_BONUS.md rename to app_go/docs/LAB03.md index 5bfbca866d..69e7154ffb 100644 --- a/app_python/docs/LAB03_BONUS.md +++ b/app_go/docs/LAB03.md @@ -1,6 +1,6 @@ # Lab 3 Bonus — Multi-App CI with Path Filters + Test Coverage -## Part 1: Multi-App CI (1.5 pts) +## Part 1: Multi-App CI ### 1.1 Second CI Workflow: Go @@ -49,29 +49,29 @@ ``` Modified files: app_python/app.py -→ Python CI: ✅ runs -→ Go CI: ❌ skipped (no matching paths) +→ Python CI: runs +→ Go CI: skipped (no matching paths) ``` **Scenario 2: Only Go changes** ``` Modified files: app_go/main.go -→ Python CI: ❌ skipped -→ Go CI: ✅ runs +→ Python CI: skipped +→ Go CI: runs ``` **Scenario 3: Both apps changed** ``` Modified files: app_python/app.py, app_go/main.go -→ Python CI: ✅ runs -→ Go CI: ✅ runs (in parallel) +→ Python CI: runs +→ Go CI: runs (in parallel) ``` --- -## Part 2: Test Coverage (1 pt) +## Part 2: Test Coverage ### 2.1 Coverage Tools @@ -94,12 +94,12 @@ Added to README files: - **app_python/README.md:** Python CI badge + Codecov (python flag) - **app_go/README.md:** Go CI badge + Codecov (go flag) -**Badge URLs (replace with your repo):** +**Badge URLs:** ``` -https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg -https://github.com/mirana18/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg -https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=python -https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=go +https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg +https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg +https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=python +https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=go ``` ### 2.4 Coverage Analysis diff --git a/app_python/README.md b/app_python/README.md index ecbf04f7ca..76be35d45d 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,7 +1,7 @@ # DevOps Info Service - Python -[![Python CI](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml) -[![codecov](https://codecov.io/gh/mirana18/DevOps-Core-Course/graph/badge.svg?flag=python)](https://codecov.io/gh/mirana18/DevOps-Core-Course?flag=python) +[![Python CI](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![codecov](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=python)](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course?flag=python) A production-ready web service that provides comprehensive information about itself and its runtime environment. Built with Flask framework. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md index 32b9636b58..7d47f0f28e 100644 --- a/app_python/docs/LAB03.md +++ b/app_python/docs/LAB03.md @@ -20,8 +20,6 @@ | `GET /` | JSON structure, required fields (service, system, runtime, request, endpoints), data types | | `GET /health` | Status 200, required fields (status, timestamp, uptime_seconds), timestamp format | | Error handling | 404 for unknown routes, 405 for wrong HTTP methods | -| Helpers | `format_uptime()`, `get_system_info()` with edge cases | -| Integration | Valid JSON from all endpoints, consistent response structure | ### CI Workflow Triggers @@ -48,8 +46,8 @@ Workflow does **not** run when only docs, labs, or other non-Python files change ### Successful Workflow Run -- **GitHub Actions:** [Python CI/CD Pipeline](https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml) -- Replace with link to your last successful run: `https://github.com/YOUR_USERNAME/DevOps-Core-Course/actions/runs/RUN_ID` +- **GitHub Actions:** [Python CI/CD Pipeline](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml) +- [Last successful run](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/runs/21921525308) ### Tests Passing Locally @@ -77,7 +75,7 @@ tests/test_app.py::TestIntegration::test_content_type_headers PASSED ### Status Badge - Badge in `app_python/README.md` -- Direct link: https://github.com/mirana18/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg +- Direct link: https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg --- @@ -96,8 +94,6 @@ tests/test_app.py::TestIntegration::test_content_type_headers PASSED **Caching:** Pip caching typically saves ~30–60 seconds per run. -**Snyk:** Add Snyk step when required; document any findings and actions. - --- ## 4. Key Decisions @@ -134,10 +130,3 @@ Path filters limit runs to changes in Python code or the workflow file. This red **Coverage threshold:** 70% enforced via `--cov-fail-under=70`. ---- - -## 5. Challenges (Optional) - -- **Docker credentials:** Ensure `DOCKER_USERNAME` and `DOCKER_PASSWORD` are set in GitHub Secrets. -- **Codecov token:** `CODECOV_TOKEN` optional for public repos; set if you want consistent tracking. -- **Coverage threshold:** If tests change, verify coverage stays above 70% or adjust threshold. From 12a9a5c8be375afedff0a73742b0b40323b73347 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:43:35 +0300 Subject: [PATCH 11/15] lab03 --- app_go/README.md | 2 +- app_python/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app_go/README.md b/app_go/README.md index 8d5e8c2f86..13d1709879 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -1,7 +1,7 @@ # DevOps Info Service - Go [![Go CI](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg)](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/go-ci.yml) -[![codecov](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=go)](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course?flag=go) +[![codecov](https://codecov.io/github/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=go)](https://codecov.io/github/Arino4kaMyr/DevOps-Core-Course?flag=go) A production-ready web service implemented in Go that provides comprehensive information about itself and its runtime environment. This is the compiled language version of the DevOps Info Service, built using Go's standard `net/http` package. diff --git a/app_python/README.md b/app_python/README.md index 76be35d45d..f3217d4234 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,7 +1,7 @@ # DevOps Info Service - Python [![Python CI](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Arino4kaMyr/DevOps-Core-Course/actions/workflows/python-ci.yml) -[![codecov](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=python)](https://codecov.io/gh/Arino4kaMyr/DevOps-Core-Course?flag=python) +[![codecov](https://codecov.io/github/Arino4kaMyr/DevOps-Core-Course/graph/badge.svg?flag=python)](https://codecov.io/github/Arino4kaMyr/DevOps-Core-Course?flag=python) A production-ready web service that provides comprehensive information about itself and its runtime environment. Built with Flask framework. From 4be4f6e66cfd0654297868319bc9679424249e60 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:46:57 +0300 Subject: [PATCH 12/15] lab04 --- .gitignore | 3 +- docs/LAB04.md | 279 ++++++++++++++++++++++++++++++ pulumi/.gitignore | 2 + pulumi/Pulumi.dev.yaml | 4 + pulumi/Pulumi.yaml | 11 ++ pulumi/__main__.py | 104 +++++++++++ pulumi/requirements.txt | 2 + ydb_terraform/.gitignore | 6 + ydb_terraform/.terraform.lock.hcl | 9 + ydb_terraform/main.tf | 66 +++++++ ydb_terraform/outputs.tf | 4 + ydb_terraform/provider.tf | 14 ++ ydb_terraform/variables.tf | 37 ++++ 13 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 docs/LAB04.md create mode 100644 pulumi/.gitignore create mode 100644 pulumi/Pulumi.dev.yaml create mode 100644 pulumi/Pulumi.yaml create mode 100644 pulumi/__main__.py create mode 100644 pulumi/requirements.txt create mode 100644 ydb_terraform/.gitignore create mode 100644 ydb_terraform/.terraform.lock.hcl create mode 100644 ydb_terraform/main.tf create mode 100644 ydb_terraform/outputs.tf create mode 100644 ydb_terraform/provider.tf create mode 100644 ydb_terraform/variables.tf diff --git a/.gitignore b/.gitignore index 30d74d2584..8ae15df7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +venv/ \ No newline at end of file diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..8d30a35f13 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,279 @@ +# Lab 4 — Infrastructure as Code (Terraform & Pulumi) + +## 1. Cloud Provider & Infrastructure + +- **Cloud provider:** Yandex Cloud +- **Why chosen:** Available in Russia, has a free tier, straightforward setup via OAuth and service account. +- **Instance type:** 2 vCPU, 2 GB RAM (platform: standard-v1). Size chosen to be sufficient for a lab VM and future application deployment. +- **Region/zone:** `ru-central1-a` (default in variables; `yc` default zone was `ru-central1-b`). +- **Cost:** Within free tier / minimum tariff — 0 ₽ with correct usage. +- **Created resources:** + - `yandex_vpc_network.network` — network (terraform-network) + - `yandex_vpc_subnet.subnet` — subnet 10.0.0.0/24 in zone ru-central1-a + - `yandex_vpc_security_group.sg` — security group (SSH 22, HTTP 80, app 5000) + - `yandex_compute_instance.vm` — VM (Ubuntu 24.04 LTS, public IP) + +--- + +## 2. Terraform Implementation + +- **Terraform version:** v1.14.5 (darwin_arm64) +- **Provider:** yandex-cloud/yandex v0.187.0 + +### Project structure (directory `ydb_terraform/`) + +``` +ydb_terraform/ +├── .gitignore # state, .terraform/, terraform.tfvars, keys +├── main.tf # Network, subnet, security group, VM +├── provider.tf # required_providers, provider yandex +├── variables.tf # cloud_id, folder_id, zone, vm_name, image_id, ssh_user, public_key_path +├── outputs.tf # vm_public_ip +└── terraform.tfvars # variable values (not committed) +``` + +### Key decisions + +- Authentication via variables `cloud_id`, `folder_id`, and (optionally) environment variables or service account key file; secrets are not stored in code. +- Variables used for zone, VM name, `image_id`, SSH key path — configuration is reusable. +- Output `vm_public_ip` for quick SSH access. +- Security group: inbound SSH (22), HTTP (80), app port (5000); outbound traffic allowed. +- Added to `.gitignore`: `*.tfstate`, `*.tfstate.*`, `.terraform/`, `terraform.tfvars`, `*.pem`, `*.key`. + +### Challenges + +- Finding the right `image_id` for Ubuntu (used image list via `yc compute image list --folder-id standard-images`). +- Warning on `terraform init` about lock file for darwin_arm64 only — for CI on linux_amd64 run `terraform providers lock -platform=linux_amd64`. +- The plan includes the public SSH key in metadata — in this doc the plan output is shown in shortened/sanitized form. + +### Command output + +#### terraform init + +``` +Initializing the backend... +Initializing provider plugins... +- Finding latest version of yandex-cloud/yandex... +- Installing yandex-cloud/yandex v0.187.0... +- Installed yandex-cloud/yandex v0.187.0 (unauthenticated) +Terraform has created a lock file .terraform.lock.hcl to record the provider +selections it made above. Include this file in your version control repository +so that Terraform can guarantee to make the same selections by default when +you run "terraform init" in the future. + +Terraform has been successfully initialized! + +You may now begin working with Terraform. Try running "terraform plan" to see +any changes that are required for your infrastructure. +``` + +#### terraform plan (abbreviated; secrets and full SSH key removed) + +``` +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + # yandex_compute_instance.vm will be created + + resource "yandex_compute_instance" "vm" { + + name = "terraform-vm" + + metadata = { + + "ssh-keys" = "ubuntu:" + } + + boot_disk { ... image_id = "fd80293ig2816a78q276" (ubuntu-2404-lts-oslogin) ... } + + network_interface { + nat = true ... } + + resources { + cores = 2, + memory = 2 } + } + + # yandex_vpc_network.network will be created + + resource "yandex_vpc_network" "network" { + name = "terraform-network" } + + # yandex_vpc_security_group.sg will be created + + resource "yandex_vpc_security_group" "sg" { + + name = "terraform-sg" + + ingress { description = "SSH", port = 22, protocol = "TCP", v4_cidr_blocks = ["0.0.0.0/0"] } + + ingress { description = "HTTP", port = 80, protocol = "TCP", v4_cidr_blocks = ["0.0.0.0/0"] } + + ingress { description = "App 5000", port = 5000, protocol = "TCP", v4_cidr_blocks = ["0.0.0.0/0"] } + + egress { protocol = "ANY", v4_cidr_blocks = ["0.0.0.0/0"] } + } + + # yandex_vpc_subnet.subnet will be created + + resource "yandex_vpc_subnet" "subnet" { + + name = "terraform-subnet" + + v4_cidr_blocks = ["10.0.0.0/24"] + + zone = "ru-central1-a" + } + +Plan: 4 to add, 0 to change, 0 to destroy. + +Changes to Outputs: + + vm_public_ip = (known after apply) +``` + +#### terraform apply (final output) + +``` +yandex_compute_instance.vm: Creation complete after 47s [id=fhm6b6ej125ta0nle31i] + +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. + +Outputs: + +vm_public_ip = "84.201.132.65" +``` + +#### SSH connection to VM + +```bash +$ ssh ubuntu@84.201.132.65 +The authenticity of host '84.201.132.65 (84.201.132.65)' can't be established. +ED25519 key fingerprint is: SHA256:P/rIThvGihUqVuwtOIy9dr0c0UVuG3ZsimisnG1qHGs +Are you sure you want to continue connecting (yes/no/[fingerprint])? yes +Warning: Permanently added '84.201.132.65' (ED25519) to the list of known hosts. +Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-41-generic x86_64) +... +ubuntu@fhm6b6ej125ta0nle31i:~$ whoami +ubuntu +ubuntu@fhm6b6ej125ta0nle31i:~$ hostname +fhm6b6ej125ta0nle31i +ubuntu@fhm6b6ej125ta0nle31i:~$ exit +logout +Connection to 84.201.132.65 closed. +``` + +**Connection command:** `ssh ubuntu@84.201.132.65` (IP may change after recreating resources; current value in `terraform output vm_public_ip`). + +--- + +## 3. Pulumi Implementation + +- **Pulumi version and language:** Pulumi 3.x, Python (runtime: python, virtualenv: venv). +- **Provider:** pulumi-yandex (Yandex Cloud). + +### Project structure (directory `pulumi/`) + +``` +pulumi/ +├── __main__.py # Network, subnet, security group, rules, VM, outputs +├── Pulumi.yaml # name, runtime (python + venv), config tags +├── requirements.txt # pulumi>=3.0.0,<4.0.0, pulumi-yandex +├── venv/ # virtual environment (in .gitignore) +└── Pulumi.dev.yaml # stack config for dev (folderId, serviceAccountKeyFile, sshPublicKey — do not commit secrets) +``` + +### How the code differs from Terraform + +- Infrastructure is described imperatively in Python: calls like `yandex.VpcNetwork(...)`, `yandex.VpcSubnet(...)`, etc.; dependencies are expressed via `network.id`, `subnet.id`, `sg.id`. +- Configuration: `pulumi.Config("yandex")` for `folderId` and service account key; SSH key in project config (`pulumi.Config().get("sshPublicKey")`) so the custom key is not passed to the provider (otherwise “Invalid or unknown key”). +- For security group rules in Pulumi Yandex the required parameter is `security_group_binding=sg.id` (not `security_group_id`). +- Same resources: VPC, subnet 10.0.0.0/24, security group (SSH 22, HTTP 80, app 5000), VM 2 vCPU / 2 GB RAM, Ubuntu 22.04 LTS, public IP. Output `public_ip` via `pulumi.export(...)`. + +### Advantages of Pulumi + +- Familiar language (Python): loops, conditionals, functions, types, and IDE autocomplete. +- Single file `__main__.py` for the whole infrastructure — convenient for a small lab. +- Secrets and stack config can be stored in Pulumi (including encrypted) and kept separate from provider code. + +### Challenges + +- Must explicitly pass `folder_id` to all Yandex resources (network, subnet, security group, VM); when missing — error “cannot determine folder_id”. +- Yandex quota on VPC count per folder: when hitting “Quota limit vpc.networks.count exceeded” — use an existing network or free up quota. +- SSH key for VM is set via `metadata={"ssh-keys": "ubuntu:"}`; without it — “Permission denied (publickey)”. Key is in project config, not under `yandex:`, so the provider does not fail on the unknown key. +- After first boot the VM may respond with “System is booting up...” on SSH — wait 1–2 minutes and retry the connection. + +### Output of `pulumi preview` and `pulumi up`, SSH connection + +#### pulumi preview (abbreviated) + +``` +Previewing update (dev) + + Type Name Plan + + pulumi:pulumi:Stack python_pulumi-dev create + + ├─ yandex:index:VpcNetwork lab-network create + + ├─ yandex:index:VpcSubnet lab-subnet create + + ├─ yandex:index:VpcSecurityGroup lab-sg create + + ├─ yandex:index:VpcSecurityGroupRule ssh-rule create + + ├─ yandex:index:VpcSecurityGroupRule http-rule create + + ├─ yandex:index:VpcSecurityGroupRule app-rule create + + └─ yandex:index:ComputeInstance lab-vm create + +Outputs: + public_ip: [unknown] + +Resources: + 8 to create +``` + +#### pulumi up (final output) + +``` +Do you want to perform this update? yes +Updating (dev) + + Type Name Status + + pulumi:pulumi:Stack python_pulumi-dev created + + ├─ yandex:index:VpcNetwork lab-network created + + ├─ yandex:index:VpcSubnet lab-subnet created + ... + +Outputs: + public_ip: "93.77.176.17" + +Resources: + 8 created +``` + +#### SSH connection to VM + +```bash +$ ssh ubuntu@93.77.176.17 +... +ubuntu@:~$ whoami +ubuntu +ubuntu@:~$ exit +``` + +**Connection command:** `ssh ubuntu@` (current IP in `pulumi stack output public_ip`). + +--- + +## 4. Terraform vs Pulumi Comparison + +- **Ease of Learning:** Terraform is easier to get started with: one HCL syntax, few concepts. Pulumi requires knowing a language (e.g. Python) but gives a familiar dev environment and types. +- **Code Readability:** For a linear set of resources both are readable. Terraform is declarative by blocks; Pulumi reads like a sequence of API calls, convenient for loops and conditional logic. +- **Debugging:** Pulumi is easier to debug: stack traces in the native language, logic in code. In Terraform errors come from the provider and state; debugging is often via plan/apply and documentation. +- **Documentation:** Terraform and its providers (including Yandex) are well documented; Pulumi Registry and provider examples exist, but the community and guides are smaller than Terraform’s. +- **Use Case (when Terraform, when Pulumi):** Terraform is the standard for “infrastructure as config”, large teams, multi-cloud, and many ready-made modules. Pulumi fits when you want to write infrastructure as code (loops, tests, reuse), integrate with application code in the same language, or handle complex resource logic. + +--- + +## 5. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** +- Am I keeping the VM for Lab 5: **No** (all VMs stopped; will recreate from code when needed) +- Which VM I’m keeping: **recreate the cloud VM via Pulumi** (same `pulumi/` project). + +**Cleanup:** +- All resources destroyed on Yandex Cloud: `pulumi destroy`, and `terraform destroy`. +- No VMs running. State and code are kept locally so infrastructure can be recreated anytime. + +**How to bring infrastructure back (from existing files):** + +- **Pulumi:** + ```bash + cd pulumi + source venv/bin/activate + # Ensure config is set: yandex:folderId, yandex:serviceAccountKeyFile (or token), sshPublicKey + pulumi up + ``` + Then connect: `ssh ubuntu@$(pulumi stack output public_ip)`. + +- **Terraform:** + ```bash + cd ydb_terraform + terraform init + terraform apply + ``` + Then connect: `ssh ubuntu@$(terraform output -raw vm_public_ip)`. + + diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..a3807e5bdb --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv/ diff --git a/pulumi/Pulumi.dev.yaml b/pulumi/Pulumi.dev.yaml new file mode 100644 index 0000000000..eeb3086a76 --- /dev/null +++ b/pulumi/Pulumi.dev.yaml @@ -0,0 +1,4 @@ +config: + yandex:serviceAccountKeyFile: /Users/arinazimina/Downloads/authorized_key(1).json + yandex:folderId: b1gff0j67atu07bsqe14 + python_pulumi:sshPublicKey: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJrdGukPSOFXySoBrNeDTwqafjO8lx2IrM0GyzSycpDN arinazimina@arino4ka diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..7c52e3f280 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,11 @@ +name: python_pulumi +description: A minimal Python Pulumi program +runtime: + name: python + options: + toolchain: pip + virtualenv: venv +config: + pulumi:tags: + value: + pulumi:template: python diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..732866b04a --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,104 @@ +""" +Yandex Cloud resources via Pulumi. +Auth: either set token or service account key file before running: + pulumi config set yandex:token YOUR_TOKEN --secret + pulumi config set yandex:folderId YOUR_FOLDER_ID + # or key file: + pulumi config set yandex:serviceAccountKeyFile /path/to/key.json +""" +import pulumi +import pulumi_yandex as yandex + +config = pulumi.Config("yandex") +folder_id = config.require("folderId") # обязателен: pulumi config set yandex:folderId YOUR_FOLDER_ID + +# SSH-ключ — в конфиге проекта (не yandex:), иначе провайдер выдаст "Invalid or unknown key" +# pulumi config set sshPublicKey "$(cat ~/.ssh/id_ed25519.pub)" +ssh_public_key = pulumi.Config().get("sshPublicKey") or "" + +# --------------------------- +# Сеть +# --------------------------- +network = yandex.VpcNetwork( + "lab-network", + folder_id=folder_id, +) + +subnet = yandex.VpcSubnet( + "lab-subnet", + folder_id=folder_id, + zone="ru-central1-a", + network_id=network.id, + v4_cidr_blocks=["10.0.0.0/24"], +) + +# --------------------------- +# Security Group (пустая) +# --------------------------- +sg = yandex.VpcSecurityGroup( + "lab-sg", + folder_id=folder_id, + network_id=network.id, +) + +# --------------------------- +# Security Group Rules +# --------------------------- +yandex.VpcSecurityGroupRule( + "ssh-rule", + security_group_binding=sg.id, + direction="ingress", + protocol="TCP", + port=22, + v4_cidr_blocks=["0.0.0.0/0"] +) + +yandex.VpcSecurityGroupRule( + "http-rule", + security_group_binding=sg.id, + direction="ingress", + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"] +) + +yandex.VpcSecurityGroupRule( + "app-rule", + security_group_binding=sg.id, + direction="ingress", + protocol="TCP", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"] +) + +# --------------------------- +# VM +# --------------------------- +vm_metadata = {"ssh-keys": f"ubuntu:{ssh_public_key}"} if ssh_public_key else None +vm = yandex.ComputeInstance( + "lab-vm", + folder_id=folder_id, + zone="ru-central1-a", + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=2, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id="fd80293ig2816a78q276", # Ubuntu 22.04 LTS + ), + ), + metadata=vm_metadata, + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[sg.id], + ) + ], +) + +# --------------------------- +# Outputs +# --------------------------- +pulumi.export("public_ip", vm.network_interfaces[0].nat_ip_address) \ No newline at end of file diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..4fcd3c0981 --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-yandex diff --git a/ydb_terraform/.gitignore b/ydb_terraform/.gitignore new file mode 100644 index 0000000000..82c68586e6 --- /dev/null +++ b/ydb_terraform/.gitignore @@ -0,0 +1,6 @@ +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars +*.pem +*.key diff --git a/ydb_terraform/.terraform.lock.hcl b/ydb_terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..690c5bbdd3 --- /dev/null +++ b/ydb_terraform/.terraform.lock.hcl @@ -0,0 +1,9 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/yandex-cloud/yandex" { + version = "0.187.0" + hashes = [ + "h1:+uf4EBRLDwNYIvZsGK/ZUzN3sGzJaXcUngyYSIJoyyQ=", + ] +} diff --git a/ydb_terraform/main.tf b/ydb_terraform/main.tf new file mode 100644 index 0000000000..1a243f437f --- /dev/null +++ b/ydb_terraform/main.tf @@ -0,0 +1,66 @@ +resource "yandex_vpc_network" "network" { + name = "terraform-network" +} + +resource "yandex_vpc_subnet" "subnet" { + name = "terraform-subnet" + zone = var.zone + network_id = yandex_vpc_network.network.id + v4_cidr_blocks = ["10.0.0.0/24"] +} + +resource "yandex_vpc_security_group" "sg" { + name = "terraform-sg" + network_id = yandex_vpc_network.network.id + + ingress { + protocol = "TCP" + description = "SSH" + v4_cidr_blocks = ["0.0.0.0/0"] + port = 22 + } + + ingress { + protocol = "TCP" + description = "HTTP" + v4_cidr_blocks = ["0.0.0.0/0"] + port = 80 + } + + ingress { + protocol = "TCP" + description = "App 5000" + v4_cidr_blocks = ["0.0.0.0/0"] + port = 5000 + } + + egress { + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "yandex_compute_instance" "vm" { + name = var.vm_name + + resources { + cores = 2 + memory = 2 + } + + boot_disk { + initialize_params { + image_id = var.image_id + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.subnet.id + nat = true + security_group_ids = [yandex_vpc_security_group.sg.id] + } + + metadata = { + ssh-keys = "${var.ssh_user}:${file(var.public_key_path)}" + } +} diff --git a/ydb_terraform/outputs.tf b/ydb_terraform/outputs.tf new file mode 100644 index 0000000000..ad6e3a5b26 --- /dev/null +++ b/ydb_terraform/outputs.tf @@ -0,0 +1,4 @@ +output "vm_public_ip" { + description = "Public IP address" + value = yandex_compute_instance.vm.network_interface[0].nat_ip_address +} diff --git a/ydb_terraform/provider.tf b/ydb_terraform/provider.tf new file mode 100644 index 0000000000..9514396fda --- /dev/null +++ b/ydb_terraform/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.180" + } + } +} + +provider "yandex" { + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.zone +} diff --git a/ydb_terraform/variables.tf b/ydb_terraform/variables.tf new file mode 100644 index 0000000000..cf983f6e90 --- /dev/null +++ b/ydb_terraform/variables.tf @@ -0,0 +1,37 @@ +variable "cloud_id" { + description = "Yandex Cloud ID" + type = string +} + +variable "folder_id" { + description = "Yandex Folder ID" + type = string +} + +variable "zone" { + description = "Zone" + type = string + default = "ru-central1-a" +} + +variable "vm_name" { + description = "VM name" + type = string + default = "terraform-vm" +} + +variable "image_id" { + description = "Ubuntu image ID" + type = string +} + +variable "ssh_user" { + description = "SSH user" + type = string + default = "ubuntu" +} + +variable "public_key_path" { + description = "Path to SSH public key" + type = string +} From c512f3a14d2c4403e26e5b7dcc9d93091402efe7 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:22:54 +0300 Subject: [PATCH 13/15] lab05 --- .gitignore | 9 ++- ansible/README.md | 78 ++++++++++++++++++ ansible/ansible.cfg | 11 +++ ansible/docs/LAB05.md | 81 +++++++++++++++++++ ansible/group_vars/all.yml | 18 +++++ ansible/group_vars/all.yml.example | 14 ++++ ansible/inventory/hosts.ini | 7 ++ ansible/playbooks/deploy.yml | 10 +++ ansible/playbooks/provision.yml | 8 ++ ansible/requirements.yml | 4 + ansible/roles/app_deploy/defaults/main.yml | 5 ++ ansible/roles/app_deploy/handlers/main.yml | 6 ++ ansible/roles/app_deploy/tasks/main.yml | 57 +++++++++++++ ansible/roles/common/defaults/main.yml | 15 ++++ ansible/roles/common/tasks/main.yml | 48 +++++++++++ .../common/templates/sources.list.yandex.j2 | 4 + ansible/roles/docker/defaults/main.yml | 4 + ansible/roles/docker/handlers/main.yml | 5 ++ ansible/roles/docker/tasks/main.yml | 51 ++++++++++++ pulumi/__main__.py | 19 +++++ 20 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 ansible/README.md create mode 100644 ansible/ansible.cfg create mode 100644 ansible/docs/LAB05.md create mode 100644 ansible/group_vars/all.yml create mode 100644 ansible/group_vars/all.yml.example create mode 100644 ansible/inventory/hosts.ini create mode 100644 ansible/playbooks/deploy.yml create mode 100644 ansible/playbooks/provision.yml create mode 100644 ansible/requirements.yml create mode 100644 ansible/roles/app_deploy/defaults/main.yml create mode 100644 ansible/roles/app_deploy/handlers/main.yml create mode 100644 ansible/roles/app_deploy/tasks/main.yml create mode 100644 ansible/roles/common/defaults/main.yml create mode 100644 ansible/roles/common/tasks/main.yml create mode 100644 ansible/roles/common/templates/sources.list.yandex.j2 create mode 100644 ansible/roles/docker/defaults/main.yml create mode 100644 ansible/roles/docker/handlers/main.yml create mode 100644 ansible/roles/docker/tasks/main.yml diff --git a/.gitignore b/.gitignore index 8ae15df7fc..fde732ed78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ test -venv/ \ No newline at end of file +venv/ + +# Ansible +*.retry +.vault_pass +.vault_password +vault_pass* +ansible/inventory/*.pyc \ No newline at end of file diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000000..3000022f6e --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,78 @@ +# Ansible — Lab 5 + +## Quick start + +Run the commands below from the **`ansible/`** directory (or adjust paths if running from the repo root). + +1. **Set your VM IP** + Edit `inventory/hosts.ini`: replace `YOUR_VM_IP` with your VM's public IP. + - Pulumi: `cd pulumi && pulumi stack output public_ip` + - Terraform: `cd ydb_terraform && terraform output vm_public_ip` + Change `ansible_user` if not `ubuntu`. + +2. **Install Ansible collections** (if not already installed): + ```bash + ansible-galaxy install -r requirements.yml + ``` + +3. **Create or edit encrypted variables** (Docker Hub credentials and app config): + - If `group_vars/all.yml` **does not exist**: + `ansible-vault create group_vars/all.yml` + Paste content from `group_vars/all.yml.example` (replace placeholders), save, remember the vault password. + - If `group_vars/all.yml` **already exists** (e.g. you created it earlier): + `ansible-vault edit group_vars/all.yml` + Enter your vault password and edit as needed. + +4. **Test connectivity** (Ansible loads group_vars, so vault password is required): + ```bash + ansible all -m ping --ask-vault-pass + ``` + +5. **Provision** (install common packages + Docker). Vault password needed because group_vars are loaded: + ```bash + ansible-playbook playbooks/provision.yml --ask-vault-pass + ``` + Run it twice to confirm idempotency (second run should show "ok", not "changed"). + +6. **Deploy application:** + ```bash + ansible-playbook playbooks/deploy.yml --ask-vault-pass + ``` + If you see **"no vault secrets found"**: you ran without `--ask-vault-pass`; add it so Ansible can decrypt `group_vars/all.yml`. + If you see **"Decryption failed"**: the password you entered is wrong for this file. Try again; if you forgot it, create a new vault file (`mv group_vars/all.yml group_vars/all.yml.bak` then `ansible-vault create group_vars/all.yml`) and paste content from `all.yml.example`. + If you see **`dockerhub_password` is undefined**: open the encrypted vars with `ansible-vault edit group_vars/all.yml` and ensure it contains both `dockerhub_username` and `dockerhub_password` (see `group_vars/all.yml.example`). + +7. **Verify:** `ansible webservers -a "docker ps" --ask-vault-pass` and `curl http://:5001/health` + +**Note:** Because `group_vars/all.yml` is encrypted, use `--ask-vault-pass` for any Ansible command (`ping`, `playbook`, `ansible webservers -a "..."`) so Ansible can decrypt variables. + +## Structure + +- `inventory/hosts.ini` — target hosts (fill in VM IP). +- `roles/common` — base system (apt, packages, timezone). +- `roles/docker` — Docker CE install and service. +- `roles/app_deploy` — pull image and run container (uses vaulted `group_vars/all.yml`). +- `playbooks/provision.yml` — common + docker. +- `playbooks/deploy.yml` — app_deploy only. + +Documentation: `docs/LAB05.md` (fill in terminal outputs and analysis for submission). + +### If "Failed to update apt cache" on the VM + +**"Network is unreachable" or "connection timed out"** means the VM has **no working outbound internet**. Ansible cannot fix this — the VM or cloud network must allow egress. + +**Yandex Cloud (Pulumi from Lab 4):** +- In `pulumi/__main__.py` the VM has `nat=True`; the security group must also have an **egress** rule so the VM can reach the internet. Add (if missing) an egress rule, e.g. `direction="egress"`, `protocol="ANY"`, `v4_cidr_blocks=["0.0.0.0/0"]`. Then run `pulumi up` so the rule is applied. +- If the VM was created earlier without egress, run `pulumi up` again after adding the egress rule; no need to recreate the VM. + +**Yandex Cloud (Terraform from Lab 4):** +- In `ydb_terraform/main.tf` the VM must have `nat = true` and the security group an **egress** rule. Run `terraform apply` if needed. +- If the VM was created by hand (console): attach a public IP or NAT; add a security group rule that allows **egress** (0.0.0.0/0). +- Try from the VM: `curl -4 -v http://mirror.yandex.ru/` — if this fails, fix the cloud network first (NAT, egress, or use another subnet). + +**Other checks:** +1. **On the VM** (SSH in): `sudo apt-get update` and `curl -4 http://mirror.yandex.ru/` — same errors mean no outbound. +2. **Security group / firewall:** Allow **egress** (outbound) HTTP (80) and HTTPS (443), not only ingress. +3. **DNS:** On the VM, `cat /etc/resolv.conf` — there should be nameservers. + +**Ansible-side:** The `common` role uses Yandex mirror by default and forces apt to use **IPv4 only** (to avoid IPv6 "Network is unreachable"). If your VM is not in Yandex Cloud, set `use_yandex_mirror: false` in `roles/common/defaults/main.yml`. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..0ddcbf1672 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = ubuntu +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..0471daf8a3 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,81 @@ +# Lab 5 — Ansible Fundamentals (Documentation) + +## 1. Architecture Overview + +- **Ansible version:** 2.16+ (run `ansible --version` to confirm). +- **Target VM OS and version:** Ubuntu 22.04 LTS (VM from Lab 4, Pulumi + Yandex Cloud). +- **Role structure:** + - **common** — base system setup: force IPv4 for apt, optional Yandex mirror, apt cache update, install packages (curl, git, vim, htop, etc.), timezone. + - **docker** — install Docker CE from official repository, refresh cache after adding repo, install packages (docker-ce, docker-ce-cli, containerd.io), docker service, add user to docker group, python3-docker. + - **app_deploy** — verify Vault variables, Docker Hub login, pull image, stop/remove old container, run new one with port 5001, wait for port, check /health. +- **Why roles instead of monolithic playbooks?** Roles enable code reuse, separate testability, and short playbooks; logic is split by concern (common / docker / app), and one role can be used across multiple playbooks and projects. + +## 2. Roles Documentation + +### common +- **Purpose:** Base system setup: force IPv4 for apt (avoid IPv6 "Network is unreachable"), optional Yandex mirror for Ubuntu, apt cache update, install packages (python3-pip, curl, git, vim, htop, unzip, ca-certificates, gnupg, lsb-release), set timezone (Europe/Moscow). +- **Variables:** `use_yandex_mirror` (default: true), `common_packages` (list), `timezone` (default: Europe/Moscow). In `defaults/main.yml`. +- **Handlers:** None. +- **Dependencies:** None. + +### docker +- **Purpose:** Install Docker CE: dependencies (ca-certificates, curl, gnupg), GPG key and Docker repository, apt cache update, install docker-ce, docker-ce-cli, containerd.io, start and enable docker service, add user (ansible_user) to docker group, install python3-docker for Ansible modules. +- **Variables:** In `defaults/main.yml`: `docker_install_compose`, `docker_users`. Tasks use architecture mapping (x86_64→amd64, aarch64→arm64) for repository URL. +- **Handlers:** `restart docker` — restart docker service when repository or packages change. +- **Dependencies:** None (common role typically runs first to update apt). + +### app_deploy +- **Purpose:** Deploy application in Docker: verify dockerhub_username and dockerhub_password, Docker Hub login (no_log), pull image, stop and remove old container by name, run new container with port mapping (app_port:app_container_port, default 5001:5001), restart policy unless-stopped, wait for port, GET /health check. +- **Variables:** From group_vars (Vault): `dockerhub_username`, `dockerhub_password`, `app_name`, `docker_image`, `docker_image_tag`, `app_port`, `app_container_name`. In role defaults: `app_port`, `app_container_port` (5001), `app_restart_policy`, `app_env`. +- **Handlers:** `restart app container` (optional, conditional). +- **Dependencies:** Requires docker role (Docker on host) and encrypted group_vars/all.yml with credentials. + +## 3. Idempotency Demonstration + +- **First run:** On first run of `ansible-playbook playbooks/provision.yml --ask-vault-pass`, tasks show **changed**: apt cache update, package installs (common, Docker dependencies, Docker repo, Docker packages, python3-docker), mirror setup/force IPv4 when use_yandex_mirror, docker service start, user added to docker group, timezone set. +- **Second run:** On second run the same tasks show **ok** — state already matches desired, no (or minimal) changes. +- **Analysis:** First run brings packages, repos, service, and user to desired state; second run shows modules (apt, service, user, template/copy) see target state is met and do not change the system. +- **Explanation:** Idempotency comes from declarative modules with explicit state: `apt: state=present`, `service: state=started`, `user: groups: docker`, `template`/`copy` with fixed content. Ansible applies changes only when current and desired state differ. + +## 4. Ansible Vault Usage + +- **Storage:** Docker Hub credentials and app variables are stored in `group_vars/all.yml`, encrypted with `ansible-vault create` (or `ansible-vault encrypt`). The file can be committed; without the Vault password the content is unreadable. +- **Vault password management:** Use `--ask-vault-pass` when running playbooks and ad-hoc commands; alternative: password file (e.g. `.vault_pass`), `chmod 600`, and `--vault-password-file` or `vault_password_file` in ansible.cfg. Password file is in `.gitignore`. +- **Example encrypted file:** `head -5 group_vars/all.yml` shows lines like `$ANSIBLE_VAULT;1.1;AES256` or `$ANSIBLE_VAULT;1.2;AES256` — file is encrypted. To verify decryption: `ansible-vault view group_vars/all.yml --ask-vault-pass`. +- **Why Ansible Vault is important:** Keeps secrets (Docker Hub login/password) in the repo in encrypted form; decryption only with the Vault password, reducing leakage risk when collaborating and backing up. + +## 5. Deployment Verification + +- **Deploy run output:** Output of `ansible-playbook playbooks/deploy.yml --ask-vault-pass`: tasks Ensure Docker Hub credentials, Log in to Docker Hub, Pull Docker image, Stop existing container (if any), Remove old container, Run application container, Wait for application port, Check health endpoint — all succeed (ok or changed as needed). +- **Container status:** Example output of `ansible webservers -a "docker ps" --ask-vault-pass`: + ```text + web1 | CHANGED | rc=0 >> + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + /devops-info-service:latest "python app.py" ... Up ... 0.0.0.0:5001->5001/tcp devops-app + ``` +- **Health check verification:** From local machine: + ```bash + curl http://89.169.129.155:5001/health + ``` + Example response: + ```json + {"status":"healthy","timestamp":"2026-02-25T10:07:38.381157.000Z","uptime_seconds":91485.11} + ``` + Main page: `curl http://89.169.129.155:5001/` — returns service info. +- **Handlers:** For deploy, the "restart app container" handler is not needed in the typical flow (container is recreated by Run application container). The "restart docker" handler in the docker role runs when Docker repo or packages change during provisioning. + +## 6. Key Decisions + +- **Why roles instead of plain playbooks?** Roles group related tasks, defaults, and handlers by concern (common / docker / app); playbooks stay short and readable; the same roles can be used in different playbooks and projects. +- **How do roles improve reusability?** One role can be included in multiple playbooks and optionally published to Ansible Galaxy; a change in the role applies everywhere it is used. +- **What makes a task idempotent?** Using modules that describe desired state (e.g. `state: present`, `state: started`) instead of one-off commands; Ansible only applies changes when current and target state differ. +- **How do handlers improve efficiency?** Handlers run once at the end of the playbook even with multiple notifies (e.g. one Docker restart after several config or package changes). +- **Why is Ansible Vault necessary?** To store secrets in the repo encrypted and avoid keeping passwords and tokens in plain text in code and commit history. + +## 7. Challenges + +- **"Failed to update apt cache" on VM:** The VM had no outbound internet. In Pulumi the security group had only ingress rules; an egress rule was added (protocol ANY, 0.0.0.0/0). The common role also uses Yandex mirror and forces IPv4 for apt to reduce dependence on IPv6 and external mirrors. +- **docker-ce package not found:** The Docker repo URL used architecture from ansible_architecture (x86_64/aarch64) while Docker expects amd64/arm64. Mapping was added in the "Add Docker repository" task. After adding the repo, explicit apt cache update (cache_valid_time: 0) was added so packages from the new repo are visible. +- **Variables from group_vars not loaded:** In deploy.yml playbook, explicit `vars_files: ../group_vars/all.yml` was added so variables from the encrypted file are used on deploy regardless of current directory and load order. +- **"Cannot create container when image is not specified" in Stop existing container:** The docker_container module with state: stopped requires the image parameter. The image parameter was added to the "Stop existing container" task. +- **Accessing the app from outside (curl on port 5001):** In Pulumi the security group only had port 5000 open; the app listens on 5001. An ingress rule for TCP 5001 (app-5001-rule) was added in `pulumi/__main__.py` and applied with `pulumi up`. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..3287c49958 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,18 @@ +$ANSIBLE_VAULT;1.1;AES256 +31323034333339303235643330653661303133663465386266316165643365643632383837613463 +3635383232333831396163343338323832623262613936370a653436333937623065643861323632 +36646536656265666563316631353237303066303831333233633831616663343932306535366233 +3231346439333561360a636437303835323165313431383532333637343663386133306564356535 +65613662663935386661313464636536663233346163633165633839643332366434633832663366 +39336339383736663131626233663434396231346232386564306639613466613164336633316265 +36313938333132613366373066363037393965346338356138663464323430306365363632653266 +35613366353639353731613238643930613666333438353330393362343437326231376663366335 +37396265616566353336646463663237336238663165663766663261383530356264646264356439 +32643432663532363236316638336430663438326562646461373665353037373463316437313335 +31643639343733346530353636313465383335616431363363323538643563623634313331376162 +33316662316332656361393832643631653632623536613261303633353539616231356436333266 +35373836363336653861343732346234323431323837663062316634633538393237643465353762 +38386561363337663834633637306634393764643765343165396139653137633531663664366530 +35346630316562613766636165383762316361643566326632646432623132636635393962343030 +35323761613738666565363933326432333034386166313231373166353435336562373863623835 +6239 diff --git a/ansible/group_vars/all.yml.example b/ansible/group_vars/all.yml.example new file mode 100644 index 0000000000..ad4645fa6d --- /dev/null +++ b/ansible/group_vars/all.yml.example @@ -0,0 +1,14 @@ +# Copy this file to all.yml and encrypt with Ansible Vault: +# ansible-vault create group_vars/all.yml +# Then paste the content below and save. + +# Docker Hub credentials (required for deploy) +dockerhub_username: your-dockerhub-username +dockerhub_password: your-dockerhub-password-or-access-token + +# Application use dockerhub_username +app_name: devops-info-service +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: latest +app_port: 5001 +app_container_name: devops-app diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..1e401bb77e --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,7 @@ +# Replace YOUR_VM_IP with your VM's public IP (Pulumi: pulumi stack output public_ip | Terraform: terraform output vm_public_ip) +# Replace ubuntu with your SSH user if different +[webservers] +web1 ansible_host=89.169.129.155 ansible_user=ubuntu + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..fe60c37775 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,10 @@ +--- +# Load vaulted vars explicitly (path relative to playbooks/) +- name: Deploy application + hosts: webservers + become: yes + vars_files: + - ../group_vars/all.yml + + roles: + - app_deploy diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..f53efb0248 --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: yes + + roles: + - common + - docker diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000000..483ed156a5 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.general + - name: community.docker diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..dfb8fcf5a2 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,5 @@ +--- +app_port: 5001 +app_container_port: 5001 +app_restart_policy: unless-stopped +app_env: {} diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..e146bcc6ca --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + when: app_container_restart is defined and app_container_restart | default(false) | bool diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..979fa161fb --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,57 @@ +--- +- name: Ensure Docker Hub credentials are set (in group_vars/all.yml via Vault) + ansible.builtin.assert: + that: + - dockerhub_username is defined + - dockerhub_password is defined + fail_msg: > + Define dockerhub_username and dockerhub_password in group_vars/all.yml. + From ansible/: ansible-vault edit group_vars/all.yml + Add both variables (see group_vars/all.yml.example). Run the playbook from the ansible/ directory. + +- name: Log in to Docker Hub + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + registry: https://index.docker.io/v1/ + no_log: true + +- name: Pull Docker image + community.docker.docker_image: + name: "{{ docker_image }}:{{ docker_image_tag }}" + source: pull + +- name: Stop existing container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: stopped + ignore_errors: yes + +- name: Remove old container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: absent + +- name: Run application container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + state: started + restart_policy: "{{ app_restart_policy }}" + ports: + - "{{ app_port }}:{{ app_container_port }}" + env: "{{ app_env }}" + +- name: Wait for application port + ansible.builtin.wait_for: + port: "{{ app_port }}" + delay: 2 + timeout: 30 + +- name: Check health endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ app_port }}/health" + return_content: yes + register: health_result + changed_when: false diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..9cc941b04b --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,15 @@ +--- +# Use Yandex mirror for apt (often fixes "Failed to update cache" on Yandex Cloud VMs). Set to false if not in Yandex Cloud. +use_yandex_mirror: true + +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - unzip + - ca-certificates + - gnupg + - lsb-release +timezone: "Europe/Moscow" diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..510ac09129 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,48 @@ +--- +- name: Force apt to use IPv4 only (avoids IPv6 "Network is unreachable" on some clouds) + ansible.builtin.copy: + content: 'Acquire::ForceIPv4 "true";' + dest: /etc/apt/apt.conf.d/99force-ipv4 + owner: root + group: root + mode: "0644" + when: use_yandex_mirror | default(false) | bool + +- name: Configure Yandex mirror for Ubuntu (when use_yandex_mirror) + ansible.builtin.template: + src: sources.list.yandex.j2 + dest: /etc/apt/sources.list + owner: root + group: root + mode: "0644" + when: use_yandex_mirror | default(false) | bool + +- name: Update apt cache + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + update_cache_retries: 10 + update_cache_retry_max_delay: 30 + register: apt_update + ignore_errors: true + +- name: Capture real apt-get update error when cache update failed + ansible.builtin.shell: apt-get update 2>&1 + register: apt_get_update_result + changed_when: false + failed_when: false + when: apt_update is failed + +- name: Fail with real apt error so you can fix VM network/DNS + ansible.builtin.fail: + msg: "apt cache update failed. Run 'sudo apt-get update' on the VM to see details. Captured: {{ apt_get_update_result.stdout }}" + when: apt_update is failed + +- name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + +- name: Set timezone + community.general.timezone: + name: "{{ timezone }}" diff --git a/ansible/roles/common/templates/sources.list.yandex.j2 b/ansible/roles/common/templates/sources.list.yandex.j2 new file mode 100644 index 0000000000..0cc8779a45 --- /dev/null +++ b/ansible/roles/common/templates/sources.list.yandex.j2 @@ -0,0 +1,4 @@ +# Ubuntu {{ ansible_facts['distribution_release'] }} — Yandex mirror (often works better from Yandex Cloud) +deb http://mirror.yandex.ru/ubuntu/ {{ ansible_facts['distribution_release'] }} main restricted universe multiverse +deb http://mirror.yandex.ru/ubuntu/ {{ ansible_facts['distribution_release'] }}-updates main restricted universe multiverse +deb http://mirror.yandex.ru/ubuntu/ {{ ansible_facts['distribution_release'] }}-security main restricted universe multiverse diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..b91e3451e0 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,4 @@ +--- +docker_install_compose: false +# User(s) to add to docker group (e.g. [ubuntu]) +docker_users: [] diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..1a5058da5e --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..b0278c2b3e --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,51 @@ +--- +- name: Install dependencies for Docker + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + +- name: Add Docker GPG key + ansible.builtin.apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + +- name: Add Docker repository + ansible.builtin.apt_repository: + repo: "deb [arch={{ ansible_architecture | lower | replace('x86_64', 'amd64') | replace('aarch64', 'arm64') }}] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + state: present + filename: docker + notify: restart docker + +- name: Update apt cache after adding Docker repo + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 0 + +- name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + state: present + notify: restart docker + +- name: Ensure Docker service is started and enabled + ansible.builtin.service: + name: docker + state: started + enabled: yes + +- name: Add remote user to docker group + ansible.builtin.user: + name: "{{ ansible_user }}" + groups: docker + append: yes + +- name: Install python3-docker for Ansible Docker modules + ansible.builtin.apt: + name: python3-docker + state: present diff --git a/pulumi/__main__.py b/pulumi/__main__.py index 732866b04a..7c086f1f4c 100644 --- a/pulumi/__main__.py +++ b/pulumi/__main__.py @@ -71,6 +71,25 @@ v4_cidr_blocks=["0.0.0.0/0"] ) +# App port 5001 (devops-info-service from Ansible deploy) +yandex.VpcSecurityGroupRule( + "app-5001-rule", + security_group_binding=sg.id, + direction="ingress", + protocol="TCP", + port=5001, + v4_cidr_blocks=["0.0.0.0/0"], +) + +# Egress: allow VM to reach internet (apt, Docker Hub, etc.) +yandex.VpcSecurityGroupRule( + "egress-all", + security_group_binding=sg.id, + direction="egress", + protocol="ANY", + v4_cidr_blocks=["0.0.0.0/0"], +) + # --------------------------- # VM # --------------------------- From 1da97733cc141cc9b23468fa886486ba21164766 Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:22:54 +0300 Subject: [PATCH 14/15] lab05 lab05# From 7cfde6bdb626e28de02cfab534a5a0bcbf98d57a Mon Sep 17 00:00:00 2001 From: Arina Zimina <111923358+Arino4kaMyr@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:36:31 +0300 Subject: [PATCH 15/15] lab05 --- ansible/README.md | 9 ++------- ansible/playbooks/deploy.yml | 1 - ansible/roles/common/defaults/main.yml | 1 - pulumi/__main__.py | 8 +++----- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index 3000022f6e..f7cb04606f 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -6,8 +6,7 @@ Run the commands below from the **`ansible/`** directory (or adjust paths if run 1. **Set your VM IP** Edit `inventory/hosts.ini`: replace `YOUR_VM_IP` with your VM's public IP. - - Pulumi: `cd pulumi && pulumi stack output public_ip` - - Terraform: `cd ydb_terraform && terraform output vm_public_ip` + Get IP from Pulumi: `cd pulumi && pulumi stack output public_ip` Change `ansible_user` if not `ubuntu`. 2. **Install Ansible collections** (if not already installed): @@ -64,11 +63,7 @@ Documentation: `docs/LAB05.md` (fill in terminal outputs and analysis for submis **Yandex Cloud (Pulumi from Lab 4):** - In `pulumi/__main__.py` the VM has `nat=True`; the security group must also have an **egress** rule so the VM can reach the internet. Add (if missing) an egress rule, e.g. `direction="egress"`, `protocol="ANY"`, `v4_cidr_blocks=["0.0.0.0/0"]`. Then run `pulumi up` so the rule is applied. - If the VM was created earlier without egress, run `pulumi up` again after adding the egress rule; no need to recreate the VM. - -**Yandex Cloud (Terraform from Lab 4):** -- In `ydb_terraform/main.tf` the VM must have `nat = true` and the security group an **egress** rule. Run `terraform apply` if needed. -- If the VM was created by hand (console): attach a public IP or NAT; add a security group rule that allows **egress** (0.0.0.0/0). -- Try from the VM: `curl -4 -v http://mirror.yandex.ru/` — if this fails, fix the cloud network first (NAT, egress, or use another subnet). +- From the VM: `curl -4 -v http://mirror.yandex.ru/` — if this fails, fix the cloud network first (NAT, egress, or use another subnet). **Other checks:** 1. **On the VM** (SSH in): `sudo apt-get update` and `curl -4 http://mirror.yandex.ru/` — same errors mean no outbound. diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml index fe60c37775..69407eb35b 100644 --- a/ansible/playbooks/deploy.yml +++ b/ansible/playbooks/deploy.yml @@ -1,5 +1,4 @@ --- -# Load vaulted vars explicitly (path relative to playbooks/) - name: Deploy application hosts: webservers become: yes diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml index 9cc941b04b..e6df2c648f 100644 --- a/ansible/roles/common/defaults/main.yml +++ b/ansible/roles/common/defaults/main.yml @@ -1,5 +1,4 @@ --- -# Use Yandex mirror for apt (often fixes "Failed to update cache" on Yandex Cloud VMs). Set to false if not in Yandex Cloud. use_yandex_mirror: true common_packages: diff --git a/pulumi/__main__.py b/pulumi/__main__.py index 7c086f1f4c..76788a2502 100644 --- a/pulumi/__main__.py +++ b/pulumi/__main__.py @@ -10,14 +10,12 @@ import pulumi_yandex as yandex config = pulumi.Config("yandex") -folder_id = config.require("folderId") # обязателен: pulumi config set yandex:folderId YOUR_FOLDER_ID +folder_id = config.require("folderId") -# SSH-ключ — в конфиге проекта (не yandex:), иначе провайдер выдаст "Invalid or unknown key" -# pulumi config set sshPublicKey "$(cat ~/.ssh/id_ed25519.pub)" ssh_public_key = pulumi.Config().get("sshPublicKey") or "" # --------------------------- -# Сеть +# Network # --------------------------- network = yandex.VpcNetwork( "lab-network", @@ -33,7 +31,7 @@ ) # --------------------------- -# Security Group (пустая) +# Security Group # --------------------------- sg = yandex.VpcSecurityGroup( "lab-sg",