From 0a06f2b4f226da1962280f89c88d1c405238d9ad Mon Sep 17 00:00:00 2001 From: Phoenix Date: Thu, 29 Jan 2026 15:49:28 +0300 Subject: [PATCH 1/8] Complete lab2 --- lab2c/app_go/.dockerignore | 7 + lab2c/app_go/Dockerfile | 21 +++ lab2c/app_go/README.md | 41 +++++ lab2c/app_go/docs/LAB02.md | 131 +++++++++++++++ lab2c/app_go/go.mod | 3 + lab2c/app_go/main.go | 257 +++++++++++++++++++++++++++++ lab2c/app_python/.dockerignore | 12 ++ lab2c/app_python/.gitignore | 14 ++ lab2c/app_python/Dockerfile | 19 +++ lab2c/app_python/README.md | 72 ++++++++ lab2c/app_python/app.py | 158 ++++++++++++++++++ lab2c/app_python/docs/LAB02.md | 111 +++++++++++++ lab2c/app_python/requirements.txt | 2 + lab2c/app_python/tests/__init__.py | 1 + 14 files changed, 849 insertions(+) create mode 100644 lab2c/app_go/.dockerignore create mode 100644 lab2c/app_go/Dockerfile create mode 100644 lab2c/app_go/README.md create mode 100644 lab2c/app_go/docs/LAB02.md create mode 100644 lab2c/app_go/go.mod create mode 100644 lab2c/app_go/main.go create mode 100644 lab2c/app_python/.dockerignore create mode 100644 lab2c/app_python/.gitignore create mode 100644 lab2c/app_python/Dockerfile create mode 100644 lab2c/app_python/README.md create mode 100644 lab2c/app_python/app.py create mode 100644 lab2c/app_python/docs/LAB02.md create mode 100644 lab2c/app_python/requirements.txt create mode 100644 lab2c/app_python/tests/__init__.py diff --git a/lab2c/app_go/.dockerignore b/lab2c/app_go/.dockerignore new file mode 100644 index 0000000000..55a3b7cb13 --- /dev/null +++ b/lab2c/app_go/.dockerignore @@ -0,0 +1,7 @@ +*.exe +*.log +.git/ +.gitignore +.idea/ +.vscode/ +docs/ diff --git a/lab2c/app_go/Dockerfile b/lab2c/app_go/Dockerfile new file mode 100644 index 0000000000..534bac98be --- /dev/null +++ b/lab2c/app_go/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.22 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o devops-info + +FROM gcr.io/distroless/static-debian12:nonroot + +WORKDIR /app +COPY --from=builder /src/devops-info /app/devops-info + +ENV HOST=0.0.0.0 \ + PORT=5000 + +EXPOSE 5000 + +ENTRYPOINT ["/app/devops-info"] diff --git a/lab2c/app_go/README.md b/lab2c/app_go/README.md new file mode 100644 index 0000000000..36e81eb856 --- /dev/null +++ b/lab2c/app_go/README.md @@ -0,0 +1,41 @@ +# DevOps Info Service (Go) + +## Overview +Compiled-language version of the DevOps info service. It exposes the same two endpoints as the Python app and keeps the JSON response structure consistent. + +## Prerequisites +- Go 1.22+ installed + +## Build and Run +Run directly: +```bash +go run main.go +``` + +Build a binary: +```bash +go build -o devops-info +./devops-info +``` + +Windows build/run: +```bash +go build -o devops-info.exe +.\devops-info.exe +``` + +Custom config examples: +```bash +PORT=8080 go run main.go +HOST=127.0.0.1 PORT=3000 go run main.go +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | diff --git a/lab2c/app_go/docs/LAB02.md b/lab2c/app_go/docs/LAB02.md new file mode 100644 index 0000000000..71a016acd1 --- /dev/null +++ b/lab2c/app_go/docs/LAB02.md @@ -0,0 +1,131 @@ +# LAB02 - Docker Containerization (Go, Multi-Stage) + +## Multi-Stage Build Strategy +I used a two-stage Dockerfile: +1. **Builder stage** (`golang:1.22`) to compile the binary. +2. **Runtime stage** (`distroless/static-debian12:nonroot`) to run only the binary. + +This keeps the final image small and removes the Go toolchain from production. + +Dockerfile snippet: +```dockerfile +FROM golang:1.22 AS builder +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o devops-info + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /src/devops-info /app/devops-info +ENTRYPOINT ["/app/devops-info"] +``` + + +Image size output: +```text +tsixphoenix/devops-info-go latest 7fc572b1d863 4 minutes ago 17.7MB +``` + +## Build and Run Evidence +Build output: +```text +docker build -t tsixphoenix/devops-info-go:latest . +[+] Building 35.3s (16/16) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 396B 0.0s + => [internal] load metadata for gcr.io/distroless/static-debian12:nonroot 1.8s + => [internal] load metadata for docker.io/library/golang:1.22 2.4s + => [auth] library/golang:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 91B 0.0s + => [builder 1/6] FROM docker.io/library/golang:1.22@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 22.6s + => => resolve docker.io/library/golang:1.22@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 0.0s + => => sha256:1451027d3c0ee892b96310c034788bbe22b30b8ea2d075edbd09acfeaaaa439f 126B / 126B 0.4s + => => sha256:afa154b433c7f72db064d19e1bcfa84ee196ad29120328f6bdb2c5fbd7b8eeac 69.36MB / 69.36MB 8.8s + => => sha256:3b7f19923e1501f025b9459750b20f5df37af452482f75b91205f345d1c0e1b5 92.33MB / 92.33MB 10.0s + => => sha256:35af2a7690f2b43e7237d1fae8e3f2350dfb25f3249e9cf65121866f9c56c772 64.39MB / 64.39MB 8.1s + => => sha256:32b550be6cb62359a0f3a96bc0dc289f8b45d097eaad275887f163c6780b4108 24.06MB / 24.06MB 3.8s + => => sha256:a492eee5e55976c7d3feecce4c564aaf6f14fb07fdc5019d06f4154eddc93fde 48.48MB / 48.48MB 5.2s + => => extracting sha256:a492eee5e55976c7d3feecce4c564aaf6f14fb07fdc5019d06f4154eddc93fde 2.3s + => => extracting sha256:32b550be6cb62359a0f3a96bc0dc289f8b45d097eaad275887f163c6780b4108 0.8s + => => extracting sha256:35af2a7690f2b43e7237d1fae8e3f2350dfb25f3249e9cf65121866f9c56c772 2.5s + => => extracting sha256:3b7f19923e1501f025b9459750b20f5df37af452482f75b91205f345d1c0e1b5 2.0s + => => extracting sha256:afa154b433c7f72db064d19e1bcfa84ee196ad29120328f6bdb2c5fbd7b8eeac 5.1s + => => extracting sha256:1451027d3c0ee892b96310c034788bbe22b30b8ea2d075edbd09acfeaaaa439f 0.0s + => => extracting sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 0.0s + => [internal] load build context 0.1s + => => transferring context: 6.51kB 0.0s + => [stage-1 1/3] FROM gcr.io/distroless/static-debian12:nonroot@sha256:cba10d7abd3e203428e86f5b2d7fd5eb7d8987c387864ae4996cf97191b33764 2.9s + => => resolve gcr.io/distroless/static-debian12:nonroot@sha256:cba10d7abd3e203428e86f5b2d7fd5eb7d8987c387864ae4996cf97191b33764 0.0s + => => sha256:069d1e267530c2e681fbd4d481553b4d05f98082b18fafac86e7f12996dddd0b 131.91kB / 131.91kB 0.6s + => => sha256:dcaa5a89b0ccda4b283e16d0b4d0891cd93d5fe05c6798f7806781a6a2d84354 314B / 314B 0.4s + => => sha256:4aa0ea1413d37a58615488592a0b827ea4b2e48fa5a77cf707d0e35f025e613f 385B / 385B 0.4s + => => sha256:dd64bf2dd177757451a98fcdc999a339c35dee5d9872d8f4dc69c8f3c4dd0112 80B / 80B 0.4s + => => sha256:52630fc75a18675c530ed9eba5f55eca09b03e91bd5bc15307918bbc1a7e7296 162B / 162B 0.3s + => => sha256:3214acf345c0cc6bbdb56b698a41ccdefc624a09d6beb0d38b5de0b2303ecaf4 123B / 123B 0.3s + => => sha256:7c12895b777bcaa8ccae0605b4de635b68fc32d60fa08f421dc3818bf55ee212 188B / 188B 0.3s + => => sha256:2780920e5dbfbe103d03a583ed75345306e572ec5a48cb10361f046767d9f29a 67B / 67B 0.3s + => => sha256:62de241dac5fe19d5f8f4defe034289006ddaa0f2cca735db4718fe2a23e504e 31.24kB / 31.24kB 0.6s + => => sha256:017886f7e1764618ffad6fbd503c42a60076c63adc16355cac80f0f311cae4c9 544.07kB / 544.07kB 0.7s + => => sha256:bfb59b82a9b65e47d485e53b3e815bca3b3e21a095bd0cb88ced9ac0b48062bf 13.36kB / 13.36kB 0.6s + => => sha256:fab8c4b3fa32236a59c44cc504a69b18788d5c17c045691c2d682267ae8cf468 104.22kB / 104.22kB 0.6s + => => extracting sha256:fab8c4b3fa32236a59c44cc504a69b18788d5c17c045691c2d682267ae8cf468 0.1s + => => extracting sha256:bfb59b82a9b65e47d485e53b3e815bca3b3e21a095bd0cb88ced9ac0b48062bf 0.1s + => => extracting sha256:017886f7e1764618ffad6fbd503c42a60076c63adc16355cac80f0f311cae4c9 0.5s + => => extracting sha256:62de241dac5fe19d5f8f4defe034289006ddaa0f2cca735db4718fe2a23e504e 0.1s + => => extracting sha256:2780920e5dbfbe103d03a583ed75345306e572ec5a48cb10361f046767d9f29a 0.0s + => => extracting sha256:7c12895b777bcaa8ccae0605b4de635b68fc32d60fa08f421dc3818bf55ee212 0.0s + => => extracting sha256:3214acf345c0cc6bbdb56b698a41ccdefc624a09d6beb0d38b5de0b2303ecaf4 0.1s + => => extracting sha256:52630fc75a18675c530ed9eba5f55eca09b03e91bd5bc15307918bbc1a7e7296 0.1s + => => extracting sha256:dd64bf2dd177757451a98fcdc999a339c35dee5d9872d8f4dc69c8f3c4dd0112 0.0s + => => extracting sha256:4aa0ea1413d37a58615488592a0b827ea4b2e48fa5a77cf707d0e35f025e613f 0.0s + => => extracting sha256:dcaa5a89b0ccda4b283e16d0b4d0891cd93d5fe05c6798f7806781a6a2d84354 0.0s + => => extracting sha256:069d1e267530c2e681fbd4d481553b4d05f98082b18fafac86e7f12996dddd0b 0.0s + => [stage-1 2/3] WORKDIR /app 0.1s + => [builder 2/6] WORKDIR /src 0.5s + => [builder 3/6] COPY go.mod ./ 0.1s + => [builder 4/6] RUN go mod download 0.5s + => [builder 5/6] COPY main.go ./ 0.1s + => [builder 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o devops-info 8.1s + => [stage-1 3/3] COPY --from=builder /src/devops-info /app/devops-info 0.1s + => exporting to image 0.6s + => => exporting layers 0.4s + => => exporting manifest sha256:39177489cedb41b9d9f566a8be5d09c8ffe938f98b590aa0ebb987f1cf38d7a6 0.0s + => => exporting config sha256:d86ea6d9a836253c87a0ac2232aa6f03cdc8198146f9acdba1f3d31c617bca82 0.0s + => => exporting attestation manifest sha256:79e9867f53966cbf5943864985b72aeed88ea8a8349789577aee72d45045e5af 0.0s + => => exporting manifest list sha256:7fc572b1d86304a2634962e06610c7cf4295c4a466b6e52aed34f93550555008 0.0s + => => naming to docker.io/tsixphoenix/devops-info-go:latest 0.0s + => => unpacking to docker.io/tsixphoenix/devops-info-go:latest 0.1s + +``` + +Run output: +```text +docker run --rm -p 5000:5000 --name devops-info-go tsixphoenix/devops-info-go:latest +2026/01/29 12:37:42 Starting DevOps Info Service on 0.0.0.0:5000 +``` + +Endpoint checks: +```text +curl http://localhost:5000/ +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"Go net/http"},"system":{"hostname":"50a30efde177","platform":"linux","platform_version":"Distroless","architecture":"amd64","cpu_count":12,"python_version":"go1.22.12"},"runtime":{"uptime_seconds":79,"uptime_human":"0 hours, 1 minute","current_time":"2026-01-29T12:39:02Z","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.16.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} + +curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-01-29T12:39:31Z","uptime_seconds":108} + +2026/01/29 12:39:02 Request: GET / +2026/01/29 12:39:02 Response: GET / -> 200 (418.191µs) +2026/01/29 12:39:31 Request: GET /health +2026/01/29 12:39:31 Response: GET /health -> 200 (114.664µs) +``` + +## Technical Analysis +- The builder stage contains the full Go toolchain; the runtime stage does not. +- If I shipped the builder stage, the image would be much larger and include tools that should not be in production. +- A static binary lets me use a minimal base image. +- The final image runs as a non-root user, which reduces risk. + +## Challenges and Solutions +- I made sure the binary was static (CGO disabled) so it works in a minimal runtime image. +- Distroless images do not include a shell, so debugging is done in the builder stage, not in the runtime image. diff --git a/lab2c/app_go/go.mod b/lab2c/app_go/go.mod new file mode 100644 index 0000000000..7a7fcedd1c --- /dev/null +++ b/lab2c/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.22 diff --git a/lab2c/app_go/main.go b/lab2c/app_go/main.go new file mode 100644 index 0000000000..2abcd3938a --- /dev/null +++ b/lab2c/app_go/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "strings" + "time" +) + +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + PythonVersion string `json:"python_version"` +} + +type Runtime struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type Response struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +var startTime = time.Now().UTC() + +func main() { + host := getenv("HOST", "0.0.0.0") + port := getenv("PORT", "5000") + addr := net.JoinHostPort(host, port) + + mux := http.NewServeMux() + mux.HandleFunc("/", rootHandler) + mux.HandleFunc("/health", healthHandler) + + handler := recoverMiddleware(loggingMiddleware(mux)) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("Starting DevOps Info Service on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, uptimeHuman := getUptime() + now := time.Now().UTC() + + hostname, _ := os.Hostname() + response := Response{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: getPlatformVersion(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + PythonVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: now.Format(time.RFC3339), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: getClientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + writeJSON(w, http.StatusOK, response) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, _ := getUptime() + payload := map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "uptime_seconds": uptimeSeconds, + } + + writeJSON(w, http.StatusOK, payload) +} + +func getUptime() (int, string) { + seconds := int(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + hourLabel := "hours" + if hours == 1 { + hourLabel = "hour" + } + minuteLabel := "minutes" + if minutes == 1 { + minuteLabel = "minute" + } + return seconds, fmt.Sprintf("%d %s, %d %s", hours, hourLabel, minutes, minuteLabel) +} + +func getClientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func getPlatformVersion() string { + if value := os.Getenv("OS"); value != "" { + return value + } + if data, err := os.ReadFile("/etc/os-release"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PRETTY_NAME=") { + return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + } + } + return "unknown" +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("json encode error: %v", err) + } +} + +func writeNotFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + }) +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{ + "error": "Method Not Allowed", + "message": "Only GET is supported for this endpoint", + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (recorder *statusRecorder) WriteHeader(code int) { + recorder.status = code + recorder.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + log.Printf("Request: %s %s", r.Method, r.URL.Path) + next.ServeHTTP(recorder, r) + log.Printf("Response: %s %s -> %d (%s)", r.Method, r.URL.Path, recorder.status, time.Since(start)) + }) +} + +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("panic recovered: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/lab2c/app_python/.dockerignore b/lab2c/app_python/.dockerignore new file mode 100644 index 0000000000..b7738de7b8 --- /dev/null +++ b/lab2c/app_python/.dockerignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.log +venv/ +.venv/ +.env +.git/ +.gitignore +.idea/ +.vscode/ +docs/ +tests/ diff --git a/lab2c/app_python/.gitignore b/lab2c/app_python/.gitignore new file mode 100644 index 0000000000..8052e93c8b --- /dev/null +++ b/lab2c/app_python/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.log +venv/ +.venv/ +.env + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/lab2c/app_python/Dockerfile b/lab2c/app_python/Dockerfile new file mode 100644 index 0000000000..76219e6c10 --- /dev/null +++ b/lab2c/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN useradd -m -u 10001 appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser app.py . + +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/lab2c/app_python/README.md b/lab2c/app_python/README.md new file mode 100644 index 0000000000..742a7439f4 --- /dev/null +++ b/lab2c/app_python/README.md @@ -0,0 +1,72 @@ +# DevOps Info Service (FastAPI) + +## Overview +Small service returning system info about the machine it runs on, plus a health check. + +## Prerequisites +- Python 3.11+ +- pip +- (Optional) venv tool + +## Installation +### Windows +```bash +python -m venv venv +.\venv\Scripts\Activate.ps1 +pip install -r requirements.txt +``` + +### macOS/Linux +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application +```bash +python app.py +``` + +Custom config examples: +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +``` + +FastAPI docs: +- `http://localhost:/docs` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable auto-reload | + +## Docker +Command patterns (replace the placeholders with your values): + +**Build locally** +```bash +docker build -t /: . +``` + +**Run container** +```bash +docker run --rm -p :5000 --name /: +``` + +**Pull from Docker Hub** +```bash +docker pull /: +``` + +Optional env overrides: +```bash +docker run --rm -e PORT=5000 -e HOST=0.0.0.0 -p :5000 /: +``` diff --git a/lab2c/app_python/app.py b/lab2c/app_python/app.py new file mode 100644 index 0000000000..8935b94091 --- /dev/null +++ b/lab2c/app_python/app.py @@ -0,0 +1,158 @@ +""" +DevOps Info Service +FastAPI application module. +""" + +from __future__ import annotations + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +# Config +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +SERVICE_NAME = "devops-info-service" +SERVICE_VERSION = "1.0.0" +SERVICE_DESCRIPTION = "DevOps course info service" +SERVICE_FRAMEWORK = "FastAPI" + +START_TIME = datetime.now(timezone.utc) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("devops-info-service") + +app = FastAPI( + title="DevOps Info Service", + version=SERVICE_VERSION, + description=SERVICE_DESCRIPTION, +) + + +def _format_uptime(seconds: int) -> str: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + hour_label = "hour" if hours == 1 else "hours" + minute_label = "minute" if minutes == 1 else "minutes" + return f"{hours} {hour_label}, {minutes} {minute_label}" + + +def get_uptime() -> dict[str, int | str]: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + return { + "seconds": seconds, + "human": _format_uptime(seconds), + } + + +def get_system_info() -> dict[str, str | int]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } + + +def isoformat_utc(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info("Request: %s %s", request.method, request.url.path) + response = await call_next(request) + logger.info("Response: %s %s -> %s", request.method, request.url.path, response.status_code) + return response + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code == 404: + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": "Endpoint does not exist", + }, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled error: %s", exc) + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + +@app.get("/") +async def root(request: Request): + uptime = get_uptime() + now = datetime.now(timezone.utc) + + response = { + "service": { + "name": SERVICE_NAME, + "version": SERVICE_VERSION, + "description": SERVICE_DESCRIPTION, + "framework": SERVICE_FRAMEWORK, + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": isoformat_utc(now), + "timezone": "UTC", + }, + "request": { + "client_ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent", "unknown"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + return response + + +@app.get("/health") +async def health(): + uptime = get_uptime() + return { + "status": "healthy", + "timestamp": isoformat_utc(datetime.now(timezone.utc)), + "uptime_seconds": uptime["seconds"], + } + + +if __name__ == "__main__": + logger.info("Starting DevOps Info Service on %s:%s", HOST, PORT) + uvicorn.run("app:app", host=HOST, port=PORT, reload=DEBUG, log_level="info") diff --git a/lab2c/app_python/docs/LAB02.md b/lab2c/app_python/docs/LAB02.md new file mode 100644 index 0000000000..dd91a49278 --- /dev/null +++ b/lab2c/app_python/docs/LAB02.md @@ -0,0 +1,111 @@ +# LAB02 - Docker Containerization (Python) + +## Docker Best Practices Applied +- **Pinned base image**: `python:3.13-slim` keeps the image small and reproducible. +- **Non-root user**: the container runs as `appuser`, so the service does not run as root. +- **Layer caching**: dependencies are installed before copying the app so rebuilds are faster. +- **Minimal copy**: only `requirements.txt` and `app.py` are copied into the image. +- **.dockerignore**: excluded tests, docs, and virtualenvs to keep the build context small. + +Dockerfile snippet: +```dockerfile +FROM python:3.13-slim +WORKDIR /app +RUN useradd -m -u 10001 appuser +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY --chown=appuser:appuser app.py . +USER appuser +``` + +## Image Information and Decisions +- **Base image choice**: `python:3.13-slim` is a good balance of size and compatibility. +- **Final image size**: `` +- **Layer structure**: dependencies are installed in their own layer to benefit from caching. +- **Optimization choices**: small base image, no extra build tools, only required files copied. + +Image size output: +```text +tsixphoenix/devops-info-python beta 04eec5e16beb 5 minutes ago 228MB +``` + +## Build and Run Process +Build output: +```text +docker build -t tsixphoenix/devops-info-python:beta . +[+] Building 16.7s (11/11) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 332B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 2.3s + => [internal] load .dockerignore 0.0s + => => transferring context: 133B 0.0s + => [1/6] FROM docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 2.4s + => => resolve docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 0.0s + => => sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 1.29MB / 1.29MB 0.5s + => => sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 249B / 249B 0.6s + => => sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 11.79MB / 11.79MB 1.5s + => => extracting sha256:8843ea38a07e15ac1b99c72108fbb492f737032986cc0b65ed351f84e5521879 0.3s + => => extracting sha256:0bee50492702eb5d822fbcbac8f545a25f5fe173ec8030f57691aefcc283bbc9 0.8s + => => extracting sha256:36b6de65fd8d6bd36071ea9efa7d078ebdc11ecc23d2426ec9c3e9f092ae824d 0.0s + => [internal] load build context 0.0s + => => transferring context: 4.60kB 0.0s + => [2/6] WORKDIR /app 0.1s + => [3/6] RUN useradd -m -u 10001 appuser 0.6s + => [4/6] COPY requirements.txt . 0.0s + => [5/6] RUN pip install --no-cache-dir -r requirements.txt 8.8s + => [6/6] COPY --chown=appuser:appuser app.py . 0.1s + => exporting to image 2.1s + => => exporting layers 1.4s + => => exporting manifest sha256:89257312508e9a26af1f7400253d9556816a0fc9230a414836bcedb8a4881c86 0.0s + => => exporting config sha256:a7d85cde725e6fdfb1dfbccbb9daadb4138561a5698ac01f5f6e2780b62994f3 0.0s + => => exporting attestation manifest sha256:82c962563c14aaa47813d2f1b62afb9806c83dbb0519256fd9954a50ea14fd3f 0.0s + => => exporting manifest list sha256:04eec5e16beb90a39cdac694238e9c6301410b6fa987d7b7788c03287ed57da0 0.0s + => => naming to docker.io/tsixphoenix/devops-info-python:beta 0.0s + => => unpacking to docker.io/tsixphoenix/devops-info-python:beta +``` + +Run output (container start): +```text +docker run --rm -p 5000:5000 --name devops-info tsixphoenix/devops-info-python:beta +2026-01-29 12:23:57,799 - INFO - Starting DevOps Info Service on 0.0.0.0:5000 +INFO: Started server process [1] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit) +``` + +Endpoint checks: +```text +curl http://localhost:5000/ +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"d65d9dfde3f9","platform":"Linux","platform_version":"6.6.87.2-microsoft-standard-WSL2","architecture":"x86_64","cpu_count":12,"python_version":"3.13.11"},"runtime":{"uptime_seconds":98,"uptime_human":"0 hours, 1 minute","current_time":"2026-01-29T12:25:35.964833Z","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.16.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} + +curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-01-29T12:25:56.660917Z","uptime_seconds":118} + +2026-01-29 12:25:35,964 - INFO - Request: GET / +2026-01-29 12:25:35,965 - INFO - Response: GET / -> 200 +INFO: 172.17.0.1:54462 - "GET / HTTP/1.1" 200 OK +2026-01-29 12:25:56,659 - INFO - Request: GET /health +2026-01-29 12:25:56,661 - INFO - Response: GET /health -> 200 +INFO: 172.17.0.1:57328 - "GET /health HTTP/1.1" 200 OK +``` + +Docker Hub repository URL: +``` +https://hub.docker.com/repository/docker/tsixphoenix/devops-info-python/general +``` + +Tagging strategy: +``` +version tag +``` + +## Technical Analysis +- The Dockerfile copies `requirements.txt` first so dependency layers are cached between builds. +- If I copied the whole project before installing dependencies, every code change would bust the cache. +- Running as a non-root user reduces risk if a container is compromised. +- `.dockerignore` keeps the build context small, which speeds up the build and reduces image size. + +## Challenges and Solutions +- I verified the app binds to `0.0.0.0` so it is reachable from outside the container. +- I double-checked that only the needed files are copied into the image to avoid bloating it. diff --git a/lab2c/app_python/requirements.txt b/lab2c/app_python/requirements.txt new file mode 100644 index 0000000000..792449289f --- /dev/null +++ b/lab2c/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/lab2c/app_python/tests/__init__.py b/lab2c/app_python/tests/__init__.py new file mode 100644 index 0000000000..792d600548 --- /dev/null +++ b/lab2c/app_python/tests/__init__.py @@ -0,0 +1 @@ +# From 90a03b930a867a6c226f7c46d6150a3f48046283 Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:14:07 +0300 Subject: [PATCH 2/8] feat: add lab3 CI pipeline --- .github/workflows/go-ci.yml | 76 ++++++++ .github/workflows/python-ci.yml | 104 +++++++++++ lab3c/app_go/.dockerignore | 7 + lab3c/app_go/Dockerfile | 21 +++ lab3c/app_go/README.md | 41 ++++ lab3c/app_go/docs/LAB03.md | 17 ++ lab3c/app_go/go.mod | 3 + lab3c/app_go/main.go | 257 ++++++++++++++++++++++++++ lab3c/app_go/main_test.go | 54 ++++++ lab3c/app_python/.dockerignore | 12 ++ lab3c/app_python/.gitignore | 14 ++ lab3c/app_python/Dockerfile | 19 ++ lab3c/app_python/README.md | 78 ++++++++ lab3c/app_python/app.py | 158 ++++++++++++++++ lab3c/app_python/docs/LAB03.md | 58 ++++++ lab3c/app_python/pyproject.toml | 3 + lab3c/app_python/requirements-dev.txt | 5 + lab3c/app_python/requirements.txt | 2 + lab3c/app_python/tests/__init__.py | 1 + lab3c/app_python/tests/test_app.py | 66 +++++++ 20 files changed, 996 insertions(+) create mode 100644 .github/workflows/go-ci.yml create mode 100644 .github/workflows/python-ci.yml create mode 100644 lab3c/app_go/.dockerignore create mode 100644 lab3c/app_go/Dockerfile create mode 100644 lab3c/app_go/README.md create mode 100644 lab3c/app_go/docs/LAB03.md create mode 100644 lab3c/app_go/go.mod create mode 100644 lab3c/app_go/main.go create mode 100644 lab3c/app_go/main_test.go create mode 100644 lab3c/app_python/.dockerignore create mode 100644 lab3c/app_python/.gitignore create mode 100644 lab3c/app_python/Dockerfile create mode 100644 lab3c/app_python/README.md create mode 100644 lab3c/app_python/app.py create mode 100644 lab3c/app_python/docs/LAB03.md create mode 100644 lab3c/app_python/pyproject.toml create mode 100644 lab3c/app_python/requirements-dev.txt create mode 100644 lab3c/app_python/requirements.txt create mode 100644 lab3c/app_python/tests/__init__.py create mode 100644 lab3c/app_python/tests/test_app.py diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..e09a65c488 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,76 @@ +name: Go CI (Lab03 Bonus) + +on: + push: + branches: [lab03, main, master] + paths: + - "lab3c/app_go/**" + - ".github/workflows/go-ci.yml" + pull_request: + branches: [lab03, main, master] + paths: + - "lab3c/app_go/**" + - ".github/workflows/go-ci.yml" + +concurrency: + group: go-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Lint and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + working-directory: lab3c/app_go + args: --timeout=5m + + - name: Run tests + working-directory: lab3c/app_go + run: go test ./... + + docker: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: test + if: ${{ github.event_name == 'push' }} + steps: + - name: Checkout + 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.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set version (CalVer) + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_ENV + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./lab3c/app_go + file: ./lab3c/app_go/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-go:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-go:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..5284899721 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,104 @@ +name: Python CI (Lab03) + +on: + push: + branches: [lab03, main, master] + paths: + - "lab3c/app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: [lab03, main, master] + paths: + - "lab3c/app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Lint and Test + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: | + lab3c/app_python/requirements.txt + lab3c/app_python/requirements-dev.txt + + - name: Install dependencies + working-directory: lab3c/app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint (ruff) + working-directory: lab3c/app_python + run: ruff check . + + - name: Run tests with coverage + working-directory: lab3c/app_python + run: pytest --cov=app --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + if: ${{ secrets.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@v4 + with: + files: lab3c/app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Snyk scan + if: ${{ secrets.SNYK_TOKEN != '' }} + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: test + args: --file=lab3c/app_python/requirements.txt + + docker: + name: Build and Push Docker Image + runs-on: ubuntu-latest + needs: test + if: ${{ github.event_name == 'push' }} + steps: + - name: Checkout + 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.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set version (CalVer) + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_ENV + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./lab3c/app_python + file: ./lab3c/app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-python:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-python:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/lab3c/app_go/.dockerignore b/lab3c/app_go/.dockerignore new file mode 100644 index 0000000000..55a3b7cb13 --- /dev/null +++ b/lab3c/app_go/.dockerignore @@ -0,0 +1,7 @@ +*.exe +*.log +.git/ +.gitignore +.idea/ +.vscode/ +docs/ diff --git a/lab3c/app_go/Dockerfile b/lab3c/app_go/Dockerfile new file mode 100644 index 0000000000..534bac98be --- /dev/null +++ b/lab3c/app_go/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.22 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o devops-info + +FROM gcr.io/distroless/static-debian12:nonroot + +WORKDIR /app +COPY --from=builder /src/devops-info /app/devops-info + +ENV HOST=0.0.0.0 \ + PORT=5000 + +EXPOSE 5000 + +ENTRYPOINT ["/app/devops-info"] diff --git a/lab3c/app_go/README.md b/lab3c/app_go/README.md new file mode 100644 index 0000000000..36e81eb856 --- /dev/null +++ b/lab3c/app_go/README.md @@ -0,0 +1,41 @@ +# DevOps Info Service (Go) + +## Overview +Compiled-language version of the DevOps info service. It exposes the same two endpoints as the Python app and keeps the JSON response structure consistent. + +## Prerequisites +- Go 1.22+ installed + +## Build and Run +Run directly: +```bash +go run main.go +``` + +Build a binary: +```bash +go build -o devops-info +./devops-info +``` + +Windows build/run: +```bash +go build -o devops-info.exe +.\devops-info.exe +``` + +Custom config examples: +```bash +PORT=8080 go run main.go +HOST=127.0.0.1 PORT=3000 go run main.go +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | diff --git a/lab3c/app_go/docs/LAB03.md b/lab3c/app_go/docs/LAB03.md new file mode 100644 index 0000000000..2fc772a8e3 --- /dev/null +++ b/lab3c/app_go/docs/LAB03.md @@ -0,0 +1,17 @@ +# LAB03 - CI/CD (Go Bonus) + +## Multi-App CI Summary +I added a separate workflow for the Go app with its own path filters. This keeps Python and Go CI independent and avoids running jobs that are not needed. + +## Path Filters +- Go workflow runs only when `lab3c/app_go/**` or its workflow file changes. +- Python workflow runs only when `lab3c/app_python/**` or its workflow file changes. + +## Workflow Evidence +Add real links after CI runs: +- ✅ **Go workflow run:** `` +- ✅ **Docker image on Docker Hub:** `` + +## Notes +- Go CI uses `go test` and a basic lint step. +- Docker builds use the same CalVer tag scheme as Python. diff --git a/lab3c/app_go/go.mod b/lab3c/app_go/go.mod new file mode 100644 index 0000000000..7a7fcedd1c --- /dev/null +++ b/lab3c/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.22 diff --git a/lab3c/app_go/main.go b/lab3c/app_go/main.go new file mode 100644 index 0000000000..2abcd3938a --- /dev/null +++ b/lab3c/app_go/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "strings" + "time" +) + +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type System struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + PythonVersion string `json:"python_version"` +} + +type Runtime struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type Response struct { + Service Service `json:"service"` + System System `json:"system"` + Runtime Runtime `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +var startTime = time.Now().UTC() + +func main() { + host := getenv("HOST", "0.0.0.0") + port := getenv("PORT", "5000") + addr := net.JoinHostPort(host, port) + + mux := http.NewServeMux() + mux.HandleFunc("/", rootHandler) + mux.HandleFunc("/health", healthHandler) + + handler := recoverMiddleware(loggingMiddleware(mux)) + + server := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("Starting DevOps Info Service on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, uptimeHuman := getUptime() + now := time.Now().UTC() + + hostname, _ := os.Hostname() + response := Response{ + Service: Service{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "Go net/http", + }, + System: System{ + Hostname: hostname, + Platform: runtime.GOOS, + PlatformVersion: getPlatformVersion(), + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + PythonVersion: runtime.Version(), + }, + Runtime: Runtime{ + UptimeSeconds: uptimeSeconds, + UptimeHuman: uptimeHuman, + CurrentTime: now.Format(time.RFC3339), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: getClientIP(r), + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + writeJSON(w, http.StatusOK, response) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + writeNotFound(w) + return + } + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + uptimeSeconds, _ := getUptime() + payload := map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "uptime_seconds": uptimeSeconds, + } + + writeJSON(w, http.StatusOK, payload) +} + +func getUptime() (int, string) { + seconds := int(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + hourLabel := "hours" + if hours == 1 { + hourLabel = "hour" + } + minuteLabel := "minutes" + if minutes == 1 { + minuteLabel = "minute" + } + return seconds, fmt.Sprintf("%d %s, %d %s", hours, hourLabel, minutes, minuteLabel) +} + +func getClientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func getPlatformVersion() string { + if value := os.Getenv("OS"); value != "" { + return value + } + if data, err := os.ReadFile("/etc/os-release"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PRETTY_NAME=") { + return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + } + } + return "unknown" +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("json encode error: %v", err) + } +} + +func writeNotFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + }) +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{ + "error": "Method Not Allowed", + "message": "Only GET is supported for this endpoint", + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (recorder *statusRecorder) WriteHeader(code int) { + recorder.status = code + recorder.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + log.Printf("Request: %s %s", r.Method, r.URL.Path) + next.ServeHTTP(recorder, r) + log.Printf("Response: %s %s -> %d (%s)", r.Method, r.URL.Path, recorder.status, time.Since(start)) + }) +} + +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("panic recovered: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/lab3c/app_go/main_test.go b/lab3c/app_go/main_test.go new file mode 100644 index 0000000000..b8ba60fefa --- /dev/null +++ b/lab3c/app_go/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRootHandlerOK(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + rootHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid json: %v", err) + } + + if _, ok := payload["service"]; !ok { + t.Fatal("missing service section") + } + if _, ok := payload["system"]; !ok { + t.Fatal("missing system section") + } + if _, ok := payload["runtime"]; !ok { + t.Fatal("missing runtime section") + } +} + +func TestHealthHandlerOK(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + healthHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid json: %v", err) + } + + if payload["status"] != "healthy" { + t.Fatalf("unexpected status: %v", payload["status"]) + } +} diff --git a/lab3c/app_python/.dockerignore b/lab3c/app_python/.dockerignore new file mode 100644 index 0000000000..b7738de7b8 --- /dev/null +++ b/lab3c/app_python/.dockerignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*.log +venv/ +.venv/ +.env +.git/ +.gitignore +.idea/ +.vscode/ +docs/ +tests/ diff --git a/lab3c/app_python/.gitignore b/lab3c/app_python/.gitignore new file mode 100644 index 0000000000..8052e93c8b --- /dev/null +++ b/lab3c/app_python/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.log +venv/ +.venv/ +.env + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/lab3c/app_python/Dockerfile b/lab3c/app_python/Dockerfile new file mode 100644 index 0000000000..76219e6c10 --- /dev/null +++ b/lab3c/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN useradd -m -u 10001 appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser app.py . + +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/lab3c/app_python/README.md b/lab3c/app_python/README.md new file mode 100644 index 0000000000..321559cad9 --- /dev/null +++ b/lab3c/app_python/README.md @@ -0,0 +1,78 @@ +# DevOps Info Service (FastAPI) + +[![Python CI](https://github.com/TsixPhoenix/DevOps-CC/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/TsixPhoenix/DevOps-CC/actions/workflows/python-ci.yml) +[![Coverage](https://codecov.io/gh/TsixPhoenix/DevOps-CC/branch/lab03/graph/badge.svg)](https://codecov.io/gh/TsixPhoenix/DevOps-CC) + +## Overview +Small service returning system info about the machine it runs on, plus a health check. + +## Prerequisites +- Python 3.11+ +- pip +- (Optional) venv tool + +## Installation +```bash +python -m venv venv +.\venv\Scripts\Activate.ps1 +pip install -r requirements.txt -r requirements-dev.txt +``` + +## Running the Application +```bash +python app.py +``` + +Custom config examples: +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 python app.py +``` + +FastAPI docs: +- `http://localhost:/docs` + +## Tests +Run locally: +```bash +pytest +``` + +Run with coverage: +```bash +pytest --cov=app --cov-report=term +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check + +## Configuration +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `0.0.0.0` | Bind address for the server | +| `PORT` | `5000` | Port to listen on | +| `DEBUG` | `False` | Enable auto-reload | + +## Docker +Command patterns (replace the placeholders with your values): + +**Build locally** +```bash +docker build -t /: . +``` + +**Run container** +```bash +docker run --rm -p :5000 --name /: +``` + +**Pull from Docker Hub** +```bash +docker pull /: +``` + +Optional env overrides: +```bash +docker run --rm -e PORT=5000 -e HOST=0.0.0.0 -p :5000 /: +``` diff --git a/lab3c/app_python/app.py b/lab3c/app_python/app.py new file mode 100644 index 0000000000..8935b94091 --- /dev/null +++ b/lab3c/app_python/app.py @@ -0,0 +1,158 @@ +""" +DevOps Info Service +FastAPI application module. +""" + +from __future__ import annotations + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +# Config +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +SERVICE_NAME = "devops-info-service" +SERVICE_VERSION = "1.0.0" +SERVICE_DESCRIPTION = "DevOps course info service" +SERVICE_FRAMEWORK = "FastAPI" + +START_TIME = datetime.now(timezone.utc) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("devops-info-service") + +app = FastAPI( + title="DevOps Info Service", + version=SERVICE_VERSION, + description=SERVICE_DESCRIPTION, +) + + +def _format_uptime(seconds: int) -> str: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + hour_label = "hour" if hours == 1 else "hours" + minute_label = "minute" if minutes == 1 else "minutes" + return f"{hours} {hour_label}, {minutes} {minute_label}" + + +def get_uptime() -> dict[str, int | str]: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + return { + "seconds": seconds, + "human": _format_uptime(seconds), + } + + +def get_system_info() -> dict[str, str | int]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 0, + "python_version": platform.python_version(), + } + + +def isoformat_utc(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info("Request: %s %s", request.method, request.url.path) + response = await call_next(request) + logger.info("Response: %s %s -> %s", request.method, request.url.path, response.status_code) + return response + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code == 404: + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": "Endpoint does not exist", + }, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": exc.detail}, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled error: %s", exc) + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }, + ) + + +@app.get("/") +async def root(request: Request): + uptime = get_uptime() + now = datetime.now(timezone.utc) + + response = { + "service": { + "name": SERVICE_NAME, + "version": SERVICE_VERSION, + "description": SERVICE_DESCRIPTION, + "framework": SERVICE_FRAMEWORK, + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": isoformat_utc(now), + "timezone": "UTC", + }, + "request": { + "client_ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent", "unknown"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + return response + + +@app.get("/health") +async def health(): + uptime = get_uptime() + return { + "status": "healthy", + "timestamp": isoformat_utc(datetime.now(timezone.utc)), + "uptime_seconds": uptime["seconds"], + } + + +if __name__ == "__main__": + logger.info("Starting DevOps Info Service on %s:%s", HOST, PORT) + uvicorn.run("app:app", host=HOST, port=PORT, reload=DEBUG, log_level="info") diff --git a/lab3c/app_python/docs/LAB03.md b/lab3c/app_python/docs/LAB03.md new file mode 100644 index 0000000000..3a1772878c --- /dev/null +++ b/lab3c/app_python/docs/LAB03.md @@ -0,0 +1,58 @@ +# LAB03 - CI/CD (Python) + +## 1. Overview +**Testing framework:** I used `pytest`. The syntax is clean, fixtures are easy to work with, and it is the default choice in most Python projects I see. + +**What tests cover:** The tests hit `GET /`, `GET /health`, a 404 case, and helper functions like uptime formatting. I focused on structure and types instead of exact machine values. + +**Workflow triggers:** CI runs on push and pull requests to `lab03`, `main`, or `master`, but only when `lab3c/app_python/**` or the workflow file changes. + +**Versioning strategy:** I chose CalVer (YYYY.MM.DD). It is simple, and this service is released continuously rather than as a library. + +## 2. Workflow Evidence +Add real links and outputs after you run CI: +- **Successful workflow run:** `` +- **Tests passing locally:** +pytest +============================================================================================ test session starts ============================================================================================ +platform win32 -- Python 3.12.2, pytest-9.0.2, pluggy-1.6.0 +rootdir: C:\Users\Phoenix\PycharmProjects\DevOps\DevOps-CC\lab3c\app_python +configfile: pyproject.toml +plugins: anyio-4.12.1, cov-7.0.0 +collected 5 items + +tests\test_app.py ..... [100%] + +============================================================================================= 5 passed in 0.36s ============================================================================================= +- **Docker image on Docker Hub:** `` +- **Status badge:** `` + +## 3. Best Practices Implemented +- **Dependency caching:** `actions/setup-python` caches pip packages to speed up installs. +- **Job separation:** tests run in one job, Docker build/push depends on test success. +- **Conditional push:** Docker images only push on `push` events (not on PRs). +- **Concurrency:** newer runs cancel older runs for the same branch. +- **Path filters:** CI runs only when the Python app changes (monorepo friendly). +- **Snyk scanning:** dependency scan runs in CI (requires token). + +Caching time saved: +``` + +``` + +Snyk result: +``` + +``` + +## 4. Key Decisions +**Versioning Strategy:** CalVer fits a small service that ships frequently. It is easy to read and does not require manual version bumps. + +**Docker Tags:** The workflow publishes `YYYY.MM.DD` and `latest` tags for the same image. + +**Workflow Triggers:** I used path filters to avoid running Python CI when only Go code changes. + +**Test Coverage:** Core endpoints and helper functions are tested. I did not try to cover every logging line. + +## 5. Challenges (Optional) +- Everything was clear, because of experience of setting up CI/CD in my company workspace. diff --git a/lab3c/app_python/pyproject.toml b/lab3c/app_python/pyproject.toml new file mode 100644 index 0000000000..efb9a85312 --- /dev/null +++ b/lab3c/app_python/pyproject.toml @@ -0,0 +1,3 @@ +[tool.ruff] +select = ["E", "F"] +ignore = ["E501"] diff --git a/lab3c/app_python/requirements-dev.txt b/lab3c/app_python/requirements-dev.txt new file mode 100644 index 0000000000..c6610506e3 --- /dev/null +++ b/lab3c/app_python/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest +pytest-cov +requests +ruff +httpx diff --git a/lab3c/app_python/requirements.txt b/lab3c/app_python/requirements.txt new file mode 100644 index 0000000000..792449289f --- /dev/null +++ b/lab3c/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 diff --git a/lab3c/app_python/tests/__init__.py b/lab3c/app_python/tests/__init__.py new file mode 100644 index 0000000000..792d600548 --- /dev/null +++ b/lab3c/app_python/tests/__init__.py @@ -0,0 +1 @@ +# diff --git a/lab3c/app_python/tests/test_app.py b/lab3c/app_python/tests/test_app.py new file mode 100644 index 0000000000..ff942f197e --- /dev/null +++ b/lab3c/app_python/tests/test_app.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +from app import _format_uptime, app, get_system_info, get_uptime, isoformat_utc + + +client = TestClient(app) + + +def test_root_endpoint_structure(): + response = client.get("/") + assert response.status_code == 200 + + data = response.json() + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["framework"] == "FastAPI" + + system = data["system"] + for key in ["hostname", "platform", "platform_version", "architecture", "cpu_count", "python_version"]: + assert key in system + + runtime = data["runtime"] + assert isinstance(runtime["uptime_seconds"], int) + assert isinstance(runtime["uptime_human"], str) + assert runtime["timezone"] == "UTC" + + +def test_health_endpoint_structure(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert "timestamp" in data + + +def test_not_found_returns_json(): + response = client.get("/does-not-exist") + assert response.status_code == 404 + data = response.json() + assert data["error"] == "Not Found" + + +def test_helpers_are_consistent(): + system = get_system_info() + assert system["hostname"] + assert system["platform"] + assert system["python_version"] + + uptime = get_uptime() + assert uptime["seconds"] >= 0 + assert "hours" in uptime["human"] or "hour" in uptime["human"] + + +def test_format_and_iso_helpers(): + assert _format_uptime(3660) == "1 hour, 1 minute" + test_dt = datetime(2024, 1, 1, tzinfo=timezone.utc) + assert isoformat_utc(test_dt) == "2024-01-01T00:00:00Z" From a2e9be9d5dd85027499d187c052e307defd6ddbe Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:16:40 +0300 Subject: [PATCH 3/8] fix: use env for secrets --- .github/workflows/python-ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 5284899721..2d040aed9a 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -23,6 +23,9 @@ jobs: test: name: Lint and Test runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} strategy: fail-fast: true matrix: @@ -55,17 +58,17 @@ jobs: run: pytest --cov=app --cov-report=xml --cov-report=term - name: Upload coverage to Codecov - if: ${{ secrets.CODECOV_TOKEN != '' }} + if: ${{ env.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v4 with: files: lab3c/app_python/coverage.xml - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ env.CODECOV_TOKEN }} - name: Snyk scan - if: ${{ secrets.SNYK_TOKEN != '' }} + if: ${{ env.SNYK_TOKEN != '' }} uses: snyk/actions/python@master env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + SNYK_TOKEN: ${{ env.SNYK_TOKEN }} with: command: test args: --file=lab3c/app_python/requirements.txt From c547e13a2cd676f1a398888aaa5a9ce554a34109 Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:20:13 +0300 Subject: [PATCH 4/8] trigger ci --- lab3c/app_python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lab3c/app_python/README.md b/lab3c/app_python/README.md index 321559cad9..e12a3ea6bc 100644 --- a/lab3c/app_python/README.md +++ b/lab3c/app_python/README.md @@ -1,7 +1,7 @@ # DevOps Info Service (FastAPI) [![Python CI](https://github.com/TsixPhoenix/DevOps-CC/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/TsixPhoenix/DevOps-CC/actions/workflows/python-ci.yml) -[![Coverage](https://codecov.io/gh/TsixPhoenix/DevOps-CC/branch/lab03/graph/badge.svg)](https://codecov.io/gh/TsixPhoenix/DevOps-CC) + ## Overview Small service returning system info about the machine it runs on, plus a health check. From 48163c47a621cfaa6f410530fa486187f31896de Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:21:49 +0300 Subject: [PATCH 5/8] fix: ci trigger path --- .github/workflows/python-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 2d040aed9a..7004955f17 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -2,12 +2,12 @@ name: Python CI (Lab03) on: push: - branches: [lab03, main, master] + branches: [lab3, main, master] paths: - "lab3c/app_python/**" - ".github/workflows/python-ci.yml" pull_request: - branches: [lab03, main, master] + branches: [lab3, main, master] paths: - "lab3c/app_python/**" - ".github/workflows/python-ci.yml" From dcf70721ea6c72e6bcd7b899e3f09c28b292b672 Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:25:44 +0300 Subject: [PATCH 6/8] fix: snyk fix --- .github/workflows/python-ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 7004955f17..d61adcda2b 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -64,14 +64,16 @@ jobs: files: lab3c/app_python/coverage.xml token: ${{ env.CODECOV_TOKEN }} + - name: Install Snyk CLI + if: ${{ env.SNYK_TOKEN != '' }} + run: npm install -g snyk + - name: Snyk scan if: ${{ env.SNYK_TOKEN != '' }} - uses: snyk/actions/python@master + working-directory: lab3c/app_python + run: snyk test --file=requirements.txt --package-manager=pip env: SNYK_TOKEN: ${{ env.SNYK_TOKEN }} - with: - command: test - args: --file=lab3c/app_python/requirements.txt docker: name: Build and Push Docker Image From 25f225d6653b95a6e3482c4a6d52eb721ab5a18b Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:32:15 +0300 Subject: [PATCH 7/8] fix: upgrade fastApi to resolve snyk issues --- lab3c/app_python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lab3c/app_python/requirements.txt b/lab3c/app_python/requirements.txt index 792449289f..01c3cb3565 100644 --- a/lab3c/app_python/requirements.txt +++ b/lab3c/app_python/requirements.txt @@ -1,2 +1,2 @@ -fastapi==0.115.0 +fastapi==0.128.6 uvicorn[standard]==0.32.0 From 38c2836de01ad0ea82bac61635fdd0f05c0c1a7e Mon Sep 17 00:00:00 2001 From: Phoenix Date: Tue, 10 Feb 2026 15:45:35 +0300 Subject: [PATCH 8/8] docs update --- lab3c/app_go/docs/LAB03.md | 5 ++--- lab3c/app_python/docs/LAB03.md | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lab3c/app_go/docs/LAB03.md b/lab3c/app_go/docs/LAB03.md index 2fc772a8e3..2ae68b20de 100644 --- a/lab3c/app_go/docs/LAB03.md +++ b/lab3c/app_go/docs/LAB03.md @@ -8,9 +8,8 @@ I added a separate workflow for the Go app with its own path filters. This keeps - Python workflow runs only when `lab3c/app_python/**` or its workflow file changes. ## Workflow Evidence -Add real links after CI runs: -- ✅ **Go workflow run:** `` -- ✅ **Docker image on Docker Hub:** `` +- **Go workflow run:** +- **Docker image on Docker Hub:** ## Notes - Go CI uses `go test` and a basic lint step. diff --git a/lab3c/app_python/docs/LAB03.md b/lab3c/app_python/docs/LAB03.md index 3a1772878c..530353eaba 100644 --- a/lab3c/app_python/docs/LAB03.md +++ b/lab3c/app_python/docs/LAB03.md @@ -11,7 +11,7 @@ ## 2. Workflow Evidence Add real links and outputs after you run CI: -- **Successful workflow run:** `` +- **Successful workflow run:** https://github.com/TsixPhoenix/DevOps-CC/actions/runs/21865003310/job/63103839665 - **Tests passing locally:** pytest ============================================================================================ test session starts ============================================================================================ @@ -24,8 +24,8 @@ collected 5 items tests\test_app.py ..... [100%] ============================================================================================= 5 passed in 0.36s ============================================================================================= -- **Docker image on Docker Hub:** `` -- **Status badge:** `` +- **Docker image on Docker Hub:** https://hub.docker.com/repository/docker/tsixphoenix/devops-info-python/general +- **Status badge:** Works, shows green check ## 3. Best Practices Implemented - **Dependency caching:** `actions/setup-python` caches pip packages to speed up installs. @@ -33,16 +33,24 @@ tests\test_app.py ..... - **Conditional push:** Docker images only push on `push` events (not on PRs). - **Concurrency:** newer runs cancel older runs for the same branch. - **Path filters:** CI runs only when the Python app changes (monorepo friendly). -- **Snyk scanning:** dependency scan runs in CI (requires token). +- **Snyk scanning:** dependency scan runs in CI. -Caching time saved: -``` - -``` Snyk result: ``` - +Run snyk test --file=requirements.txt --package-manager=pip + +Testing /home/runner/work/DevOps-CC/DevOps-CC/lab3c/app_python... + +Organization: tsixphoenix +Package manager: pip +Target file: requirements.txt +Project name: app_python +Open source: no +Project path: /home/runner/work/DevOps-CC/DevOps-CC/lab3c/app_python +Licenses: enabled + +✔ Tested 13 dependencies for known issues, no vulnerable paths found. ``` ## 4. Key Decisions @@ -54,5 +62,5 @@ Snyk result: **Test Coverage:** Core endpoints and helper functions are tested. I did not try to cover every logging line. -## 5. Challenges (Optional) +## 5. Challenges - Everything was clear, because of experience of setting up CI/CD in my company workspace.