diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000..35c588c955 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..e186973ae3 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,71 @@ +name: Python CI + +on: + push: + paths: + - 'app_python/**' + pull_request: + paths: + - 'app_python/**' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + pip install -r app_python/requirements-dev.txt + + - name: Run linter + run: ruff check . + + - name: Run pytest tests + run: | + pytest -v + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Run Snyk Test + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test app_python --severity-threshold=high + + + docker: + if: github.ref == 'refs/heads/main' + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate version + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_ENV + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./app_python + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/.gitignore b/.gitignore index 30d74d2584..24fa533aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -test \ No newline at end of file +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +app_python/venv/ +app_python/__pycache__/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..1035e07719 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app + +FROM alpine:3.19 + +WORKDIR /app + +RUN adduser -D appuser +USER appuser + +COPY --from=builder /app/app . + +EXPOSE 5000 + +CMD ["./app"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..8d6fcf25ca --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,164 @@ +# Lab01 — DevOps Info Service + +## Overview + +**DevOps Info Service (Go version)** is a lightweight web application written in Go that provides detailed information about the service itself, the system it runs on, and its runtime environment. + +This implementation is part of the **bonus task** for Lab 01 and is intended to demonstrate the advantages of using a compiled language in DevOps workflows, especially for containerization and multi-stage Docker builds. + +**Features:** + +* `GET /` — returns service, system, runtime, and request information +* `GET /health` — simple health check endpoint +* Configurable via environment variables + +--- + +## Prerequisites + +* Go **1.24.5** + +--- + +## Installation + +1. Clone the repository: + +```bash +git clone https://github.com/Daniil20xx/DevOps-Core-Course.git +``` + +2. Navigate to the Go application directory: + +```bash +cd app_go +``` + +3. Initialize Go module (if not already initialized): + +```bash +go mod init devops-info-service +``` + +--- + +## Running the Application + +### Run directly + +```bash +cd app_go +go run main.go +``` + +By default, the service runs on: + +``` +http://0.0.0.0:5000 +``` + +### Run with custom configuration + +```bash +HOST=127.0.0.1 PORT=8080 go run main.go +``` + +--- + +## API Endpoints + +### `GET /` + +Returns detailed information about the service and the system. + +**Example:** + +```json +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "::1", + "method": "GET", + "path": "/", + "user_agent": "Mozilla/5.0 (Windows NT; Windows NT 10.0; ru-RU) WindowsPowerShell/5.1.26100.7462" + }, + "runtime": { + "current_time": "2026-01-28T08:32:20Z", + "timezone": "UTC", + "uptime_human": "0 hours, 24 minutes", + "uptime_seconds": 1452 + }, + "service": { + "description": "DevOps course info service", + "framework": "net/http", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "amd64", + "cpu_count": 16, + "go_version": "go1.24.5", + "hostname": "Daniil", + "platform": "windows", + "platform_version": "go1.24.5" + } +} +``` + +--- + +### `GET /health` + +Returns service health status. + +**Example:** + +```bash +curl http://localhost:8080/health +``` + +**Response:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T08:33:26Z", + "uptime_seconds": 1518 +} +``` + +--- + +## Configuration + +The application can be configured using environment variables: + +| Environment Variable | Default | Description | +| -------------------- | --------- | ---------------------------------- | +| `HOST` | `0.0.0.0` | Host address to bind the server | +| `PORT` | `8080` | Port to run the application on | + +--- + +## Project Structure + +``` +app_go/ +├── main.go +├── go.mod +├── README.md +└── docs/ + ├── LAB01.md + └── screenshots/ +``` diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..222904676b --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,60 @@ +# GO — Language Selection Justification + +## Overview + +For the bonus part of Lab 01, the DevOps Info Service was reimplemented using **Go**, a compiled programming language widely used in modern DevOps. +The goal of this implementation is to demonstrate the advantages of compiled languages in terms of performance, deployment, and containerization. + +--- + +## Why Go? + +Go was selected for the following reasons: + +### 1. Compiled Language + +Go compiles source code into a **single native binary**, which eliminates the need for a runtime interpreter (unlike Python). +This results in: + +* Faster application startup +* Lower runtime overhead +* Simpler deployment process + +--- + +### 2. Standard Library for Web Services + +Go provides a powerful and production-ready HTTP server through the standard `net/http` package. +This allows building web services without relying on external frameworks, reducing dependencies and potential security risks. + +--- + +### 3. Performance and Resource Efficiency + +Compared to interpreted languages, Go applications: + +* Use less memory +* Handle concurrent requests efficiently +* Scale well under load + +This makes Go a popular choice for infrastructure tools, monitoring systems, and backend services. + +--- + +## Comparison with Python Implementation + +| Aspect | Python (Flask) | Go | +| --------------------- | ----------------------- | ----------------------- | +| Language Type | Interpreted | Compiled | +| Startup Time | Slower | Faster | +| Deployment | Requires Python runtime | Single binary | +| Docker Image Size | Larger | Smaller | +| Performance | Good for small services | High | +| Dependency Management | External packages | Mostly standard library | + +--- + +## Conclusion + +Go was chosen for the bonus implementation because it provides a clean, efficient, and production-ready approach to building web services. +Using Go alongside Python in this lab demonstrates the trade-offs between interpreted and compiled languages and prepares the project for future DevOps tasks such as containerization, CI/CD pipelines, and Kubernetes deployments. diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..a9ff176781 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,143 @@ +# LAB01 — DevOps Info Service (Go Version) + +## 1. Framework / Language Selection + +**Chosen Language:** Go (Golang) 1.24.5 + +**Justification:** +Go is a compiled language suitable for building lightweight, high-performance web services. + +--- + +## 2. Best Practices Applied + +**1. Clean Code Structure** + +* Separation of concerns: utility functions, handlers, and main server logic +* `getUptime()` function calculates runtime +* Route handlers: `mainHandler` for `/`, `healthHandler` for `/health` +* Consistent logging of requests + +**2. Error Handling** + +* Returns default page `/` if error happens + +**3. Logging** + +* Uses Go’s standard `log` package +* Optional verbose logging via `DEBUG` environment variable + +**4. Environment Configuration** + +* `HOST`, `PORT`, `DEBUG` are configurable through environment variables + +**5. Dependency Management** + +* Uses Go modules (`go.mod`) to track dependencies + +--- + +## 3. API Documentation + +### `GET /` + +Returns service, system, runtime, and request information. + +**Example Request:** + +```bash +curl http://localhost:8080/ +``` + +**Sample Response:** + +```json +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "::1", + "method": "GET", + "path": "/", + "user_agent": "Mozilla/5.0 (Windows NT; Windows NT 10.0; ru-RU) WindowsPowerShell/5.1.26100.7462" + }, + "runtime": { + "current_time": "2026-01-28T08:32:20Z", + "timezone": "UTC", + "uptime_human": "0 hours, 24 minutes", + "uptime_seconds": 1452 + }, + "service": { + "description": "DevOps course info service", + "framework": "net/http", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "amd64", + "cpu_count": 16, + "go_version": "go1.24.5", + "hostname": "Daniil", + "platform": "windows", + "platform_version": "go1.24.5" + } +} +``` + +--- + +### `GET /health` + +Returns health status. + +**Example Request:** + +```bash +curl http://localhost:8080/health +``` + +**Sample Response:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T08:33:26Z", + "uptime_seconds": 1518 +} +``` + +--- + +## 4. Testing Evidence + +* **Main Endpoint:** + ![Main endpoint](screenshots/01-main-endpoint.png) + +* **Health Check:** + ![Health endpoint](screenshots/02-health-check.png) + +* **Command-line Test Example:** + +```bash +curl http://localhost:8080/ +curl http://localhost:8080/health +``` + +--- + +## 6. Summary + +The Go implementation mirrors the Python (Flask) version of the DevOps Info Service: + +* Same endpoints and JSON structure +* Faster startup and compiled binary \ No newline at end of file diff --git a/app_go/docs/screenshots/01-main-endpoint.png b/app_go/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..87aa89e304 Binary files /dev/null and b/app_go/docs/screenshots/01-main-endpoint.png differ diff --git a/app_go/docs/screenshots/02-health-check.png b/app_go/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..f41883ad78 Binary files /dev/null and b/app_go/docs/screenshots/02-health-check.png differ diff --git a/app_go/docs/screenshots/03-formatted-output.png b/app_go/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..fef60e9aa8 Binary files /dev/null and b/app_go/docs/screenshots/03-formatted-output.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..2281baaf71 --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module app_go + +go 1.24.5 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..69d4a5f3ed --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "time" +) + +var startTime = time.Now().UTC() + +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +func getUptime() (int64, string) { + seconds := int64(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + return seconds, + formatUptime(hours, minutes) +} + +func formatUptime(hours, minutes int64) string { + return fmt.Sprintf("%d hours, %d minutes", hours, minutes) +} + +func getHostname() string { + hostname, err := os.Hostname() + if err != nil { + return "unknown" + } + return hostname +} + +func getClientIP(r *http.Request) string { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, _ := getUptime() + + response := map[string]interface{}{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "uptime_seconds": uptimeSeconds, + } + + log.Printf("Health check from %s", getClientIP(r)) + writeJSON(w, http.StatusOK, response) +} + +func mainHandler(w http.ResponseWriter, r *http.Request) { + uptimeSeconds, uptimeHuman := getUptime() + + response := map[string]interface{}{ + "service": map[string]interface{}{ + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "net/http", + }, + "system": map[string]interface{}{ + "hostname": getHostname(), + "platform": runtime.GOOS, + "platform_version": runtime.Version(), + "architecture": runtime.GOARCH, + "cpu_count": runtime.NumCPU(), + "go_version": runtime.Version(), + }, + "runtime": map[string]interface{}{ + "uptime_seconds": uptimeSeconds, + "uptime_human": uptimeHuman, + "current_time": time.Now().UTC().Format(time.RFC3339), + "timezone": "UTC", + }, + "request": map[string]interface{}{ + "client_ip": getClientIP(r), + "user_agent": r.UserAgent(), + "method": r.Method, + "path": r.URL.Path, + }, + "endpoints": []map[string]string{ + { + "path": "/", + "method": "GET", + "description": "Service information", + }, + { + "path": "/health", + "method": "GET", + "description": "Health check", + }, + }, + } + + log.Printf("%s %s from %s", r.Method, r.URL.Path, getClientIP(r)) + writeJSON(w, http.StatusOK, response) +} + +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func main() { + host := getEnv("HOST", "0.0.0.0") + port := getEnv("PORT", "5000") + + log.Printf("Starting Go DevOps Info Service on %s:%s", host, port) + + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) + + err := http.ListenAndServe(host+":"+port, nil) + if err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..0528093881 --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func setupServer() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/", mainHandler) + mux.HandleFunc("/health", healthHandler) + return mux +} + +func TestHealthEndpoint(t *testing.T) { + server := httptest.NewServer(setupServer()) + defer server.Close() + + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + var data map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + t.Fatalf("Invalid JSON response") + } + + if data["status"] != "healthy" { + t.Errorf("Expected status 'healthy'") + } + + if data["uptime_seconds"] == nil { + t.Errorf("Missing uptime_seconds") + } +} + +func TestMainEndpoint(t *testing.T) { + server := httptest.NewServer(setupServer()) + defer server.Close() + + req, _ := http.NewRequest(http.MethodGet, server.URL+"/", nil) + req.Header.Set("User-Agent", "test-agent") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + var data map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + t.Fatalf("Invalid JSON") + } + + requiredBlocks := []string{ + "service", + "system", + "runtime", + "request", + "endpoints", + } + + for _, block := range requiredBlocks { + if data[block] == nil { + t.Errorf("Missing block: %s", block) + } + } +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..e96a348868 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,19 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +.git/ +.gitignore + +.venv/ +venv/ + +.env +.idea/ +.vscode/ + +docs/ +tests/ + +README.md diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..a0c33d54e7 Binary files /dev/null and b/app_python/.gitignore differ diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..64eabc356e --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN useradd -m dockeruser + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chown -R dockeruser:dockeruser /app +USER dockeruser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..efa95697ab Binary files /dev/null and b/app_python/README.md differ diff --git a/app_python/__init__.py b/app_python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..f51e2c92f9 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,110 @@ +import os +import socket +from flask import Flask, jsonify, request +import platform +import logging +from datetime import datetime, timezone + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't') + +app = Flask(__name__) + +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s" +) +logger = logging.getLogger(__name__) + +START_TIME = datetime.now(timezone.utc) + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + +def get_response(): + uptime = get_uptime() + + response = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + }, + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + } + return response + +@app.route('/health') +def health(): + logger.info(f"Health check from {request.remote_addr}") + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + +@app.route("/", methods=["GET"]) +def index(): + logger.info(f"{request.method} {request.path} from {request.remote_addr}") + return jsonify(get_response()) + +@app.errorhandler(404) +def not_found(error): + return jsonify({ + "error": "Not Found", + "message": "Endpoint does not exist" + }), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({ + "error": "Internal Server Error", + "message": "Unexpected server error" + }), 500 + +@app.errorhandler(Exception) +def handle_exception(e): + logger.exception("Unexpected error") + return jsonify({ + "error": "Internal Server Error", + "message": str(e) + }), 500 + +if __name__ == "__main__": + logger.info("Starting application") + app.run(host=HOST, port=PORT, debug=DEBUG) + + + diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..a2e791554f Binary files /dev/null and b/app_python/docs/LAB01.md differ diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..0a00c46b48 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,130 @@ +# LAB02 — Docker Containerization + +## 1. Docker Best Practices Applied + +### Non-root user + +The container runs under a non-root user instead of the default root user. This significantly improves security because even if an attacker gains access to the container, they will not have full administrative privileges. + +In the Dockerfile, a dedicated user is created and activated using the `USER` directive. This follows Docker security best practices and reduces the potential impact of vulnerabilities. + +### Specific base image version + +The image is based on `python:3.12-slim`. Using a specific version instead of `latest` ensures build reproducibility and prevents unexpected breaking changes when the base image is updated. + +The `slim` variant was chosen because it provides a good balance between minimal size and compatibility with Python dependencies. + +### Layer caching optimization + +Dependencies are installed before copying the application source code. This allows Docker to reuse cached layers when only the application code changes, which significantly speeds up rebuilds during development. + +### .dockerignore usage + +A `.dockerignore` file is used to exclude unnecessary files such as virtual environments, Git metadata, cache files, and IDE configuration. This reduces the build context size, speeds up the build process, and helps keep the final image smaller and cleaner. + +--- + +## 2. Image Information & Decisions + +### Base image choice + +The base image used is `python:3.12-slim`. + +**Justification:** + +* Matches the Python version used during local development +* Smaller image size compared to full Python images +* Official image with regular security updates + +### Final image size + +The final image size is approximately **42.68 MB**, which is acceptable for a Python web application with Flask and demonstrates reasonable optimization. + +### Layer structure + +The image layers are structured as follows: + +1. Base Python image +2. System setup and non-root user creation +3. Dependency installation (`requirements.txt`) +4. Application source code + +This structure maximizes cache reuse and minimizes rebuild time. + +### Optimization choices + +* Used `python:slim` instead of a full image +* Excluded unnecessary files using `.dockerignore` +* Installed only required dependencies + +--- + +## 3. Build & Run Process + +### Build process + +The image was built locally using Docker. Below is the terminal output from the build process: + +![Build Stage](screenshots/lab02-docker-build.png) + +### Run process + +The container was started with port mapping so the service is accessible from the host: + +```bash +$ docker run -p 5000:5000 lab02-python:1.0.0 +``` +![Docker Run](screenshots/lab02-docker-run.png) + +### Endpoint testing + +The application endpoints were tested using browser: + +![Testing Docker](screenshots/lab02-docker-testing.png) + +### Docker Hub + +The image was pushed to Docker Hub and is publicly available: + +**Repository URL:** + +``` +https://hub.docker.com/r/daniil20xx/lab02-python +``` + +--- + +## 4. Technical Analysis + +### Dockerfile behavior + +The Dockerfile works by first preparing a secure and minimal runtime environment, then installing dependencies, and finally copying the application code. This ensures both security and efficiency. + +### Layer order importance + +If the application code were copied before installing dependencies, any code change would invalidate the cache and force dependency reinstallation, significantly slowing down rebuilds. + +### Security considerations + +* The container does not run as root +* Uses an official Python base image +* Minimal image size reduces attack surface + +### .dockerignore benefits + +By excluding unnecessary files from the build context, `.dockerignore` improves build speed, reduces image size, and prevents accidental inclusion of sensitive or irrelevant files. + +--- + +## 5. Challenges & Solutions + +### Issue: Port not accessible + +Initially, the application was not accessible from the host machine because the container port was not correctly mapped. + +**Solution:** +The issue was resolved by explicitly mapping the container port to the host port using the `-p` option in `docker run`. + +### Learning outcome + +Through this lab, I gained a deeper understanding of Dockerfile structure, image optimization, security best practices, and the full workflow of building, running, and publishing Docker images. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..82df5e630e --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,61 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## 1. Overview + +**Testing Framework:** `pytest` +**Why it was chosen:** +- Simple and straightforward syntax +- Integrates well with Flask via test client +- Supports plugins, including `pytest-cov` for code coverage +- Modern standard for Python projects + +**What cover tests:** +- `GET /health` — status checks, JSON structure, uptime +- `GET /` — JSON structure, blocks: service, system, runtime, request, endpoints +- Error Handlers: + - 404 Not Found + - 500 Internal Server Error +- Edge cases: + - Different Methods (For example, POST on GET endpoint) +- Additional: + - checks User-Agent and IP in request in block + +**CI Workflow Trigger:** +- **push:** 'app_python/' +- **pull_request:** 'app_python/' + +**Versioning Strategy:** Calendar Versioning (CalVer) +- Version format: `YYYY.MM.DD` +- Docker image tags: `2026.02.11` and `latest` +- Why chosen: allows you to quickly and easily understand the build date, convenient for daily service updates + +--- + +## 2. Workflow Evidence + +**GitHub Actions:** + +- Workflow file: `.github/workflows/python-ci.yml` +- Status of steps: + - Linting (ruff) + - Unit tests (pytest) + - Docker build & push +- Workflow run (example): [![Python CI](https://github.com/Daniil20xx/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/Daniil20xx/DevOps-Core-Course/actions/workflows/python-ci.yml) + +**Local tests:** + +![pytest debug](screenshots/lab03-pytest.png) +![ruff debug](screenshots/lab03-ruff.png) +![pytest cov debug](screenshots/lab03-pytest-cov.png) + +3. Best Practices Implemented +- **Dependency caching**: speeds up pip dependency installation in CI +- **Fail fast**: CI stops if tests fail, Docker does not build +- **Path filters (for bonus)**: Python workflow only runs when app_python/ changes +- **Linting (ruff)**: automatic code quality check +- **Docker push only from main**: prevents accidental publication of unstable builds +- **Snyk** (security scanning): + - Integration into workflow + - Checks dependencies for known vulnerabilities + - No critical vulnerabilities found at this time + diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..f1fc65b032 Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..ec2a50f2c9 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..92851d9afe Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/docs/screenshots/lab02-docker-build.png b/app_python/docs/screenshots/lab02-docker-build.png new file mode 100644 index 0000000000..78d189e0a3 Binary files /dev/null and b/app_python/docs/screenshots/lab02-docker-build.png differ diff --git a/app_python/docs/screenshots/lab02-docker-run.png b/app_python/docs/screenshots/lab02-docker-run.png new file mode 100644 index 0000000000..e200df43c0 Binary files /dev/null and b/app_python/docs/screenshots/lab02-docker-run.png differ diff --git a/app_python/docs/screenshots/lab02-docker-testing.png b/app_python/docs/screenshots/lab02-docker-testing.png new file mode 100644 index 0000000000..5ea7c52d72 Binary files /dev/null and b/app_python/docs/screenshots/lab02-docker-testing.png differ diff --git a/app_python/docs/screenshots/lab03-pytest-cov.png b/app_python/docs/screenshots/lab03-pytest-cov.png new file mode 100644 index 0000000000..514b94284d Binary files /dev/null and b/app_python/docs/screenshots/lab03-pytest-cov.png differ diff --git a/app_python/docs/screenshots/lab03-pytest.png b/app_python/docs/screenshots/lab03-pytest.png new file mode 100644 index 0000000000..df6a9ca5b3 Binary files /dev/null and b/app_python/docs/screenshots/lab03-pytest.png differ diff --git a/app_python/docs/screenshots/lab03-ruff.png b/app_python/docs/screenshots/lab03-ruff.png new file mode 100644 index 0000000000..c4c25018a6 Binary files /dev/null and b/app_python/docs/screenshots/lab03-ruff.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..132ff97744 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.3.5 +pytest-cov==7.0.0 +ruff==0.15.0 \ No newline at end of file diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..11e9a868d4 Binary files /dev/null and b/app_python/requirements.txt differ diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/__pycache__/__init__.cpython-312.pyc b/app_python/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000..90ba723636 Binary files /dev/null and b/app_python/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/app_python/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc b/app_python/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000..95e9006067 Binary files /dev/null and b/app_python/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc differ diff --git a/app_python/tests/__pycache__/test_errors.cpython-312-pytest-8.3.5.pyc b/app_python/tests/__pycache__/test_errors.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000..d87f5390f3 Binary files /dev/null and b/app_python/tests/__pycache__/test_errors.cpython-312-pytest-8.3.5.pyc differ diff --git a/app_python/tests/__pycache__/test_health.cpython-312-pytest-8.3.5.pyc b/app_python/tests/__pycache__/test_health.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000..0157b345b3 Binary files /dev/null and b/app_python/tests/__pycache__/test_health.cpython-312-pytest-8.3.5.pyc differ diff --git a/app_python/tests/__pycache__/test_index.cpython-312-pytest-8.3.5.pyc b/app_python/tests/__pycache__/test_index.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000..c218becd60 Binary files /dev/null and b/app_python/tests/__pycache__/test_index.cpython-312-pytest-8.3.5.pyc differ diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py new file mode 100644 index 0000000000..66351c9584 --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,9 @@ + +import pytest +from app_python.app import app + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as client: + yield client diff --git a/app_python/tests/test_errors.py b/app_python/tests/test_errors.py new file mode 100644 index 0000000000..ad0dbfee6c --- /dev/null +++ b/app_python/tests/test_errors.py @@ -0,0 +1,15 @@ +def test_404_handler(client): + response = client.get("/ifyoureaditthenyouaregoodta") + assert response.status_code == 404 + + data = response.get_json() + + assert "message" in data + assert "error" in data + + assert data["error"] == "Not Found" + + +def test_method_not_allowed(client): + response = client.post("/") + assert response.status_code in (405, 500) \ No newline at end of file diff --git a/app_python/tests/test_health.py b/app_python/tests/test_health.py new file mode 100644 index 0000000000..9b8c1e3ab7 --- /dev/null +++ b/app_python/tests/test_health.py @@ -0,0 +1,14 @@ +def test_health_status_code(client): + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_response_structure(client): + response = client.get("/health") + data = response.get_json() + + assert "status" in data + assert "timestamp" in data + assert "uptime_seconds" in data + + assert data["status"] == "healthy" diff --git a/app_python/tests/test_index.py b/app_python/tests/test_index.py new file mode 100644 index 0000000000..ef788c730a --- /dev/null +++ b/app_python/tests/test_index.py @@ -0,0 +1,25 @@ +def test_index_status_code(client): + response = client.get("/") + assert response.status_code == 200 + + +def test_index_response_structure(client): + response = client.get("/") + data = response.get_json() + + assert "endpoints" in data + assert "request" in data + assert "runtime" in data + assert "service" in data + assert "system" in data + + assert data["service"]["name"] == "devops-info-service" + assert data["service"]["framework"] == "Flask" + + assert "hostname" in data["system"] + assert "python_version" in data["system"] + + assert isinstance(data["runtime"]["uptime_seconds"], int) + + assert data["request"]["method"] == "GET" + assert data["request"]["path"] == "/"