diff --git a/.github/workflows/go-docker.yml b/.github/workflows/go-docker.yml index a1fcc9268f..2fc2d8121e 100644 --- a/.github/workflows/go-docker.yml +++ b/.github/workflows/go-docker.yml @@ -31,6 +31,7 @@ jobs: lab_number=$((10#$lab_number)) short_sha="${GITHUB_SHA::7}" echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT" + echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT" else echo "Failed to extract lab number from branch: $source_branch" >&2 exit 1 @@ -48,6 +49,8 @@ jobs: push: true tags: | ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:${{ steps.version.outputs.branch_dev_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-go:dev build-and-push: if: github.event.pull_request.merged == true diff --git a/.github/workflows/go-snyk.yml b/.github/workflows/go-snyk.yml new file mode 100644 index 0000000000..d9d1819e0c --- /dev/null +++ b/.github/workflows/go-snyk.yml @@ -0,0 +1,40 @@ +name: Go Snyk Scan + +on: + push: + paths: + - app_go/** + - .github/workflows/go-snyk.yml + pull_request: + branches: + - master + paths: + - app_go/** + - .github/workflows/go-snyk.yml + +jobs: + snyk: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_go + steps: + - uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: ./app_go/go.mod + cache-dependency-path: ./app_go/go.sum + - name: Download Go modules + run: go mod download + - name: Setup Snyk CLI + uses: snyk/actions/setup@master + - name: Run Snyk dependency scan (or skip) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + if [ -z "${SNYK_TOKEN:-}" ]; then + echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan." + exit 0 + fi + snyk test --severity-threshold=medium --fail-on=upgradable diff --git a/.github/workflows/python-docker.yml b/.github/workflows/python-docker.yml index dc486928df..047be1e6c7 100644 --- a/.github/workflows/python-docker.yml +++ b/.github/workflows/python-docker.yml @@ -31,6 +31,7 @@ jobs: lab_number=$((10#$lab_number)) short_sha="${GITHUB_SHA::7}" echo "branch_tag=1.${lab_number}.${short_sha}" >> "$GITHUB_OUTPUT" + echo "branch_dev_tag=1.${lab_number}-dev" >> "$GITHUB_OUTPUT" else echo "Failed to extract lab number from branch: $source_branch" >&2 exit 1 @@ -48,6 +49,8 @@ jobs: push: true tags: | ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:${{ steps.version.outputs.branch_dev_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-app-py:dev build-and-push: if: github.event.pull_request.merged == true diff --git a/.github/workflows/python-snyk.yml b/.github/workflows/python-snyk.yml index a9967bc9b9..e389c1065c 100644 --- a/.github/workflows/python-snyk.yml +++ b/.github/workflows/python-snyk.yml @@ -34,4 +34,4 @@ jobs: echo "SNYK_TOKEN secret not set; skipping Snyk dependency scan." exit 0 fi - snyk test --severity-threshold=high + snyk test --severity-threshold=medium --fail-on=upgradable diff --git a/app_go/Dockerfile b/app_go/Dockerfile index aa269d06e2..09e812af80 100644 --- a/app_go/Dockerfile +++ b/app_go/Dockerfile @@ -1,9 +1,8 @@ FROM golang:1.25-alpine AS build WORKDIR /app -# Uncomment for dependency installation -# COPY go.mod go.sum ./ -# RUN go mod download -COPY go.mod *.go ./ +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -o devops-info-service.out FROM scratch diff --git a/app_go/README.md b/app_go/README.md index 22a27fa787..1e8a5d867a 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -1,7 +1,7 @@ # DevOps Info Service (Go) ## Overview -Simple Go web service that exposes system/runtime details and a health check. +Simple Go web service that exposes system/runtime details, health and readiness checks, Prometheus metrics, and structured JSON logs. ## Prerequisites - Go 1.25+ @@ -21,6 +21,8 @@ HOST=127.0.0.1 PORT=8080 ./devops-info-service.out ## Endpoints - `GET /` - service + system + runtime + request info - `GET /health` - health check +- `GET /ready` - readiness check +- `GET /metrics` - Prometheus metrics exposition ## Configuration diff --git a/app_go/go.mod b/app_go/go.mod index ed5d7b4f3e..364c72ba68 100644 --- a/app_go/go.mod +++ b/app_go/go.mod @@ -1,3 +1,18 @@ module example.com/devops-info-service -go 1.25 +go 1.25.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/sys v0.42.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/app_go/go.sum b/app_go/go.sum new file mode 100644 index 0000000000..2895e9cec2 --- /dev/null +++ b/app_go/go.sum @@ -0,0 +1,36 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app_go/main.go b/app_go/main.go index 14aa5441d8..898f940d7b 100644 --- a/app_go/main.go +++ b/app_go/main.go @@ -9,14 +9,18 @@ import ( "net/http" "os" "runtime" + "strconv" "strings" "sync" "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" ) const ( serviceName = "devops-info-service" - serviceVersion = "1.7.0" + serviceVersion = "1.8.0" serviceDescription = "DevOps course info service" serviceFramework = "Go net/http" serviceLoggerName = "devops_info_service" @@ -65,7 +69,7 @@ type RootResponse struct { Endpoints []EndpointInfo `json:"endpoints"` } -type HealthResponse struct { +type StatusResponse struct { Status string `json:"status"` Timestamp string `json:"timestamp"` UptimeSeconds int64 `json:"uptime_seconds"` @@ -76,13 +80,65 @@ var ( startTime = time.Now().UTC() logMu sync.Mutex logOutput io.Writer = os.Stdout + // metricsRegistry only exposes service metrics, matching the Python app. + metricsRegistry = prometheus.NewRegistry() + httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total HTTP requests handled by the service.", + }, + []string{"method", "endpoint", "status_code"}, + ) + httpRequestDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request duration in seconds.", + }, + []string{"method", "endpoint", "status_code"}, + ) + httpRequestsInProgress = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "http_requests_in_progress", + Help: "HTTP requests currently being processed.", + }, + []string{"method", "endpoint"}, + ) + endpointCallsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "devops_info_endpoint_calls_total", + Help: "Total calls to application endpoints.", + }, + []string{"endpoint"}, + ) + systemInfoDurationSeconds = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "devops_info_system_info_duration_seconds", + Help: "Time spent collecting system information.", + }, + ) + metricsHTTPHandler = promhttp.HandlerFor( + metricsRegistry, + promhttp.HandlerOpts{}, + ) // endpoints is a static list used to mirror the Python app output. endpoints = []EndpointInfo{ {Path: "/", Method: http.MethodGet, Description: "Service information."}, - {Path: "/health", Method: http.MethodGet, Description: "Health check endpoint."}, + {Path: "/health", Method: http.MethodGet, Description: "Health check."}, + {Path: "/ready", Method: http.MethodGet, Description: "Readiness check."}, + {Path: "/metrics", Method: http.MethodGet, Description: "Prometheus metrics."}, } ) +func init() { + metricsRegistry.MustRegister( + httpRequestsTotal, + httpRequestDurationSeconds, + httpRequestsInProgress, + endpointCallsTotal, + systemInfoDurationSeconds, + ) +} + type responseRecorder struct { http.ResponseWriter statusCode int @@ -101,6 +157,9 @@ func getServiceInfo() ServiceInfo { // getSystemInfo returns host and runtime information. func getSystemInfo() SystemInfo { + startedAt := time.Now() + defer systemInfoDurationSeconds.Observe(time.Since(startedAt).Seconds()) + hostname, err := os.Hostname() if err != nil { hostname = "unknown" @@ -206,6 +265,19 @@ func listEndpoints() []EndpointInfo { return endpoints } +func normalizeEndpointLabel(path string) string { + switch path { + case "/", "/health", "/metrics", "/ready": + return path + default: + return "unmatched" + } +} + +func recordEndpointCall(endpoint string) { + endpointCallsTotal.WithLabelValues(endpoint).Inc() +} + func newResponseRecorder(w http.ResponseWriter) *responseRecorder { return &responseRecorder{ ResponseWriter: w, @@ -263,6 +335,7 @@ func queryString(r *http.Request) string { // mainHandler serves GET /. func mainHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/") payload := RootResponse{ Service: getServiceInfo(), System: getSystemInfo(), @@ -276,8 +349,19 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { // healthHandler serves GET /health. func healthHandler(w http.ResponseWriter, r *http.Request) { - payload := HealthResponse{ - Status: "healthy", + recordEndpointCall("/health") + writeStatusResponse(w, "healthy") +} + +// readinessHandler serves GET /ready. +func readinessHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/ready") + writeStatusResponse(w, "ready") +} + +func writeStatusResponse(w http.ResponseWriter, status string) { + payload := StatusResponse{ + Status: status, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05.000000-07:00"), UptimeSeconds: getUptime().Seconds, } @@ -285,8 +369,21 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, payload) } +// metricsHandler serves GET /metrics. +func metricsHandler(w http.ResponseWriter, r *http.Request) { + recordEndpointCall("/metrics") + metricsHTTPHandler.ServeHTTP(w, r) +} + // notFound returns a JSON 404. func notFound(w http.ResponseWriter, r *http.Request) { + emitLog("WARNING", serviceLoggerName, "request returned not found", map[string]any{ + "client_ip": clientIP(r), + "method": r.Method, + "path": r.URL.Path, + "status_code": http.StatusNotFound, + "user_agent": r.Header.Get("User-Agent"), + }) writeJSON(w, http.StatusNotFound, map[string]string{ "error": "Not Found", "message": "Endpoint does not exist", @@ -300,6 +397,10 @@ func router(w http.ResponseWriter, r *http.Request) { mainHandler(w, r) case r.URL.Path == "/health" && r.Method == http.MethodGet: healthHandler(w, r) + case r.URL.Path == "/metrics" && r.Method == http.MethodGet: + metricsHandler(w, r) + case r.URL.Path == "/ready" && r.Method == http.MethodGet: + readinessHandler(w, r) default: notFound(w, r) } @@ -348,6 +449,27 @@ func requestLoggingMiddleware(next http.Handler) http.Handler { }) } +func metricsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + endpoint := normalizeEndpointLabel(r.URL.Path) + httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Inc() + defer httpRequestsInProgress.WithLabelValues(r.Method, endpoint).Dec() + + startedAt := time.Now() + recorder := newResponseRecorder(w) + + next.ServeHTTP(recorder, r) + + statusCode := strconv.Itoa(recorder.statusCode) + httpRequestsTotal.WithLabelValues(r.Method, endpoint, statusCode).Inc() + httpRequestDurationSeconds.WithLabelValues( + r.Method, + endpoint, + statusCode, + ).Observe(time.Since(startedAt).Seconds()) + }) +} + // writeJSON serializes a payload with the given status code. func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") @@ -378,7 +500,9 @@ func main() { "version": serviceVersion, }) - handler := requestLoggingMiddleware(recoverMiddleware(http.HandlerFunc(router))) + handler := requestLoggingMiddleware( + metricsMiddleware(recoverMiddleware(http.HandlerFunc(router))), + ) if err := http.ListenAndServe(addr, handler); err != nil { emitLog("ERROR", serviceLoggerName, "server error", map[string]any{ "error": err.Error(), diff --git a/app_go/main_test.go b/app_go/main_test.go index 2622645ec9..32d1784ed3 100644 --- a/app_go/main_test.go +++ b/app_go/main_test.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" ) @@ -22,20 +24,270 @@ func captureLogOutput(w io.Writer) func() { } } -func decodeLogEntry(t *testing.T, buffer *bytes.Buffer) map[string]any { +func decodeLogEntries(t *testing.T, buffer *bytes.Buffer) []map[string]any { t.Helper() lines := bytes.Split(bytes.TrimSpace(buffer.Bytes()), []byte("\n")) - if len(lines) != 1 { - t.Fatalf("expected exactly one log line, got %d", len(lines)) + entries := make([]map[string]any, 0, len(lines)) + + for _, line := range lines { + if len(line) == 0 { + continue + } + + var entry map[string]any + if err := json.Unmarshal(line, &entry); err != nil { + t.Fatalf("failed to decode log entry: %v", err) + } + entries = append(entries, entry) + } + + if len(entries) == 0 { + t.Fatal("expected at least one log entry") + } + + return entries +} + +func decodeLogEntry(t *testing.T, buffer *bytes.Buffer) map[string]any { + t.Helper() + + entries := decodeLogEntries(t, buffer) + if len(entries) != 1 { + t.Fatalf("expected exactly one log line, got %d", len(entries)) + } + + return entries[0] +} + +func decodeJSONResponse[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { + t.Helper() + + var payload T + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode JSON response: %v", err) + } + + return payload +} + +func performRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, nil) + request.RemoteAddr = "203.0.113.7:4321" + request.Header.Set("User-Agent", "go-test") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func metricValue(metricsText, sampleName string, labels map[string]string) (float64, bool) { + for _, line := range strings.Split(metricsText, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + + metricName, metricLabels := parseMetricSample(fields[0]) + if metricName != sampleName { + continue + } + if !labelsMatch(metricLabels, labels) { + continue + } + + value, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return 0, false + } + return value, true + } + + return 0, false +} + +func parseMetricSample(sample string) (string, map[string]string) { + openBrace := strings.Index(sample, "{") + if openBrace == -1 { + return sample, map[string]string{} + } + + name := sample[:openBrace] + labelText := strings.TrimSuffix(sample[openBrace+1:], "}") + labels := map[string]string{} + if labelText == "" { + return name, labels + } + + for _, part := range strings.Split(labelText, ",") { + key, value, found := strings.Cut(part, "=") + if !found { + continue + } + labels[key] = strings.Trim(value, "\"") + } + + return name, labels +} + +func labelsMatch(actual map[string]string, expected map[string]string) bool { + for key, value := range expected { + if actual[key] != value { + return false + } + } + return true +} + +func scrapeMetrics(t *testing.T) string { + t.Helper() + + recorder := performRequest(http.HandlerFunc(metricsHandler), http.MethodGet, "/metrics") + if recorder.Code != http.StatusOK { + t.Fatalf("expected metrics status %d, got %d", http.StatusOK, recorder.Code) + } + + return recorder.Body.String() +} + +func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[RootResponse](t, recorder) + if payload.Service.Name != serviceName { + t.Fatalf("expected service name %q, got %q", serviceName, payload.Service.Name) + } + if payload.Service.Framework != serviceFramework { + t.Fatalf("expected framework %q, got %q", serviceFramework, payload.Service.Framework) + } + if payload.Service.Version == "" { + t.Fatal("expected non-empty version") + } + if payload.System.Hostname == "" { + t.Fatal("expected hostname to be populated") + } + if payload.System.CPUCount < 1 { + t.Fatalf("expected cpu_count >= 1, got %d", payload.System.CPUCount) + } + if payload.Runtime.Seconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.Runtime.Seconds) + } + if payload.Request.ClientIP != "203.0.113.7" { + t.Fatalf("expected client_ip %q, got %q", "203.0.113.7", payload.Request.ClientIP) + } + + routeIndex := map[string]bool{} + for _, endpoint := range payload.Endpoints { + routeIndex[endpoint.Method+" "+endpoint.Path] = true } - var entry map[string]any - if err := json.Unmarshal(lines[0], &entry); err != nil { - t.Fatalf("failed to decode log entry: %v", err) + for _, route := range []string{ + http.MethodGet + " /", + http.MethodGet + " /health", + http.MethodGet + " /ready", + http.MethodGet + " /metrics", + } { + if !routeIndex[route] { + t.Fatalf("expected endpoint %q to be listed", route) + } + } +} + +func TestHealthReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/health") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[StatusResponse](t, recorder) + if payload.Status != "healthy" { + t.Fatalf("expected status %q, got %q", "healthy", payload.Status) + } + if payload.UptimeSeconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds) + } + if payload.Timestamp == "" { + t.Fatal("expected non-empty timestamp") + } +} + +func TestReadyReturnsExpectedJSONStructureAndTypes(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/ready") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + + payload := decodeJSONResponse[StatusResponse](t, recorder) + if payload.Status != "ready" { + t.Fatalf("expected status %q, got %q", "ready", payload.Status) + } + if payload.UptimeSeconds < 0 { + t.Fatalf("expected non-negative uptime, got %d", payload.UptimeSeconds) + } + if payload.Timestamp == "" { + t.Fatal("expected non-empty timestamp") + } +} + +func TestUnknownEndpointReturnsJSON404(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing") + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code) + } + + payload := decodeJSONResponse[map[string]string](t, recorder) + expected := map[string]string{ + "error": "Not Found", + "message": "Endpoint does not exist", + } + if payload["error"] != expected["error"] || payload["message"] != expected["message"] { + t.Fatalf("expected %#v, got %#v", expected, payload) + } +} + +func TestNotFoundEmitsJSONWarningLog(t *testing.T) { + var buffer bytes.Buffer + restore := captureLogOutput(&buffer) + defer restore() + + recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/missing") + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code) } - return entry + entry := decodeLogEntry(t, &buffer) + if entry["level"] != "WARNING" { + t.Fatalf("expected WARNING level, got %#v", entry["level"]) + } + if entry["logger"] != serviceLoggerName { + t.Fatalf("expected logger %q, got %#v", serviceLoggerName, entry["logger"]) + } + if entry["message"] != "request returned not found" { + t.Fatalf("expected message to be logged, got %#v", entry["message"]) + } + if entry["status_code"] != float64(http.StatusNotFound) { + t.Fatalf("expected status_code %d, got %#v", http.StatusNotFound, entry["status_code"]) + } } func TestRequestLoggingMiddlewareEmitsJSONAccessLog(t *testing.T) { @@ -138,3 +390,76 @@ func TestRecoverMiddlewareEmitsJSONPanicLog(t *testing.T) { t.Fatalf("expected client_ip to be logged, got %#v", entry["client_ip"]) } } + +func TestMetricsEndpointExposesHTTPAndApplicationMetrics(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + handler := metricsMiddleware(http.HandlerFunc(router)) + + performRequest(handler, http.MethodGet, "/") + performRequest(handler, http.MethodGet, "/health") + performRequest(handler, http.MethodGet, "/ready") + performRequest(handler, http.MethodGet, "/does-not-exist") + + recorder := performRequest(handler, http.MethodGet, "/metrics") + if recorder.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) + } + if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", recorder.Header().Get("Content-Type")) + } + + metricsText := recorder.Body.String() + for _, tc := range []struct { + name string + labels map[string]string + }{ + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/health", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "/ready", "status_code": "200"}}, + {name: "http_requests_total", labels: map[string]string{"method": "GET", "endpoint": "unmatched", "status_code": "404"}}, + {name: "http_request_duration_seconds_count", labels: map[string]string{"method": "GET", "endpoint": "/", "status_code": "200"}}, + {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/"}}, + {name: "devops_info_endpoint_calls_total", labels: map[string]string{"endpoint": "/ready"}}, + {name: "devops_info_system_info_duration_seconds_count", labels: map[string]string{}}, + } { + value, ok := metricValue(metricsText, tc.name, tc.labels) + if !ok || value < 1.0 { + t.Fatalf("expected %s with labels %#v to be >= 1, got ok=%v value=%v", tc.name, tc.labels, ok, value) + } + } + + value, ok := metricValue( + metricsText, + "http_requests_in_progress", + map[string]string{"method": "GET", "endpoint": "/"}, + ) + if !ok || value != 0.0 { + t.Fatalf("expected in-progress gauge to be 0, got ok=%v value=%v", ok, value) + } +} + +func TestMetricsCountInternalServerErrorsWithStatusLabels(t *testing.T) { + restore := captureLogOutput(io.Discard) + defer restore() + + labels := map[string]string{"method": "GET", "endpoint": "/", "status_code": "500"} + before, _ := metricValue(scrapeMetrics(t), "http_requests_total", labels) + + handler := metricsMiddleware(recoverMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }))) + recorder := performRequest(handler, http.MethodGet, "/") + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, recorder.Code) + } + + after, ok := metricValue(scrapeMetrics(t), "http_requests_total", labels) + if !ok { + t.Fatalf("expected %s with labels %#v to exist after panic request", "http_requests_total", labels) + } + if after != before+1.0 { + t.Fatalf("expected counter to increase by 1, got before=%v after=%v", before, after) + } +} diff --git a/app_python/Dockerfile b/app_python/Dockerfile index 2c59bb0ca7..a123b9e3be 100644 --- a/app_python/Dockerfile +++ b/app_python/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.14-alpine +RUN apk upgrade -U ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 diff --git a/app_python/README.md b/app_python/README.md index dd63b7a64a..78a0b12cc8 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -4,7 +4,7 @@ ## Overview -Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes health and Prometheus metrics endpoints for monitoring. +Small Flask web service that reports service metadata, system information, runtime uptime, and basic request details. Includes health, readiness, and Prometheus metrics endpoints for monitoring. ## Prerequisites @@ -54,6 +54,7 @@ Gunicorn access logs are emitted as JSON so Loki can parse request fields cleanl - `GET /` - Service and system information - `GET /health` - Health check +- `GET /ready` - Readiness check - `GET /metrics` - Prometheus metrics exposition ## Configuration diff --git a/app_python/poetry.lock b/app_python/poetry.lock index 748bfe2685..5eb5476447 100644 --- a/app_python/poetry.lock +++ b/app_python/poetry.lock @@ -353,14 +353,14 @@ dotenv = ["python-dotenv"] [[package]] name = "gunicorn" -version = "25.1.0" +version = "25.3.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b"}, - {file = "gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616"}, + {file = "gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660"}, + {file = "gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889"}, ] [package.dependencies] @@ -368,6 +368,7 @@ packaging = "*" [package.extras] eventlet = ["eventlet (>=0.40.3)"] +fast = ["gunicorn_h1c (>=0.6.3)"] gevent = ["gevent (>=24.10.1)"] http2 = ["h2 (>=4.1.0)"] setproctitle = ["setproctitle"] @@ -663,14 +664,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] @@ -683,25 +684,26 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, + {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "urllib3" @@ -723,14 +725,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "werkzeug" -version = "3.1.6" +version = "3.1.7" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"}, - {file = "werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25"}, + {file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"}, + {file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"}, ] [package.dependencies] diff --git a/app_python/src/router.py b/app_python/src/router.py index 294be6246d..bd9315d8b5 100644 --- a/app_python/src/router.py +++ b/app_python/src/router.py @@ -147,9 +147,21 @@ def index(): def health(): """Health check.""" record_endpoint_call("/health") + return _status_response("healthy") + + +@app.route("/ready") +def readiness(): + """Readiness check.""" + record_endpoint_call("/ready") + return _status_response("ready") + + +def _status_response(status: str): + """Return a shared JSON payload for health-style endpoints.""" return jsonify( { - "status": "healthy", + "status": status, "timestamp": datetime.now(timezone.utc).isoformat(), "uptime_seconds": get_uptime()["seconds"], } diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py index 97c217b476..6299a7c1dd 100644 --- a/app_python/tests/test_endpoints.py +++ b/app_python/tests/test_endpoints.py @@ -62,6 +62,7 @@ def test_index_returns_expected_json_structure_and_types(client): route_index = {(endpoint["method"], endpoint["path"]) for endpoint in endpoints} assert ("GET", "/") in route_index assert ("GET", "/health") in route_index + assert ("GET", "/ready") in route_index assert ("GET", "/metrics") in route_index @@ -82,6 +83,23 @@ def test_health_returns_expected_json_structure_and_types(client): assert parsed_timestamp.tzinfo is not None +def test_ready_returns_expected_json_structure_and_types(client): + """GET /ready should report ready status and typed runtime metadata.""" + response = client.get("/ready") + + assert response.status_code == 200 + payload = response.get_json() + assert payload is not None + + assert {"status", "timestamp", "uptime_seconds"} <= payload.keys() + assert payload["status"] == "ready" + assert isinstance(payload["uptime_seconds"], int) + assert payload["uptime_seconds"] >= 0 + + parsed_timestamp = datetime.fromisoformat(payload["timestamp"]) + assert parsed_timestamp.tzinfo is not None + + def test_unknown_endpoint_returns_json_404(client): """Unknown routes should be handled by JSON 404 error handler.""" response = client.get("/definitely-does-not-exist") @@ -117,3 +135,16 @@ def test_health_returns_json_500_when_uptime_probe_fails(client, monkeypatch): "error": "Internal Server Error", "message": "An unexpected error occurred", } + + +def test_ready_returns_json_500_when_uptime_probe_fails(client, monkeypatch): + """GET /ready should return JSON 500 when uptime collection crashes.""" + monkeypatch.setattr(router, "get_uptime", _raise_runtime_error) + + response = client.get("/ready") + + assert response.status_code == 500 + assert response.get_json() == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } diff --git a/app_python/tests/test_metrics.py b/app_python/tests/test_metrics.py index ac7da3ad0b..717f9320dc 100644 --- a/app_python/tests/test_metrics.py +++ b/app_python/tests/test_metrics.py @@ -40,6 +40,7 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client): """Metrics endpoint should expose HTTP RED data and app-specific metrics.""" client.get("/") client.get("/health") + client.get("/ready") client.get("/does-not-exist") response = client.get("/metrics") @@ -58,6 +59,11 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client): "http_requests_total", {"method": "GET", "endpoint": "/health", "status_code": "200"}, ) + ready_total = _metric_value( + metrics_text, + "http_requests_total", + {"method": "GET", "endpoint": "/ready", "status_code": "200"}, + ) unmatched_total = _metric_value( metrics_text, "http_requests_total", @@ -85,6 +91,7 @@ def test_metrics_endpoint_exposes_http_and_application_metrics(client): assert root_total is not None and root_total >= 1.0 assert health_total is not None and health_total >= 1.0 + assert ready_total is not None and ready_total >= 1.0 assert unmatched_total is not None and unmatched_total >= 1.0 assert root_duration_count is not None and root_duration_count >= 1.0 assert root_in_progress == 0.0 diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..01f5ed71a6 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,650 @@ +# Kubernetes Lab 9 + +## Task 1 - Local Kubernetes Setup + +I used `minikube` because it was in Arch Linux extra repo (`kind` is only in AUR), integrates cleanly with the Docker driver, and has more features. + +
+Cluster setup verification output + +```text +$ minikube status +minikube +type: Control Plane +host: Running +kubelet: Running +apiserver: Running +kubeconfig: Configured + + +$ kubectl cluster-info +Kubernetes control plane is running at https://192.168.49.2:8443 +CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy + +To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'. + +$ kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +minikube Ready control-plane 2m45s v1.35.1 192.168.49.2 Debian GNU/Linux 12 (bookworm) 6.19.10-1-cachyos docker://29.2.1 + +$ kubectl get namespaces +NAME STATUS AGE +default Active 3m9s +kube-node-lease Active 3m9s +kube-public Active 3m9s +kube-system Active 3m9s +``` + +
+ +## Task 2 - Application Deployment + +The initial Task 2 deployment used `localt0aster/devops-app-py:1.9-dev` with 3 replicas, rolling updates, and resource requests and limits. At that stage, the probes were `GET /health` for liveness and `GET /ready` for readiness. Task 4 later scaled the manifest to 5 replicas and tightened the rollout strategy. + +
+Deployment rollout verification output + +```text +$ kubectl delete deployment devops-app-py --cascade=foreground --wait=true +deployment.apps "devops-app-py" deleted from default namespace + +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py created + +$ kubectl rollout status deployment/devops-app-py --timeout=180s +Waiting for deployment "devops-app-py" rollout to finish: 0 of 3 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 1 of 3 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 2 of 3 updated replicas are available... +deployment "devops-app-py" successfully rolled out + +$ kubectl get deployment devops-app-py +NAME READY UP-TO-DATE AVAILABLE AGE +devops-app-py 3/3 3 3 8s + +$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-app-py-76fc7985df-jq2tr 1/1 Running 0 8s 10.244.0.14 minikube +devops-app-py-76fc7985df-jwpsf 1/1 Running 0 8s 10.244.0.13 minikube +devops-app-py-76fc7985df-nwr58 1/1 Running 0 8s 10.244.0.12 minikube + +$ kubectl describe deployment devops-app-py +Name: devops-app-py +Namespace: default +CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300 +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: deployment.kubernetes.io/revision: 1 +Selector: app.kubernetes.io/name=devops-app-py +Replicas: 3 desired | 3 updated | 3 total | 3 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 1 max unavailable, 1 max surge +Pod Template: + Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 + Containers: + devops-app-py: + Image: localt0aster/devops-app-py:1.9-dev + Port: 5000/TCP (http) + Host Port: 0/TCP (http) + Limits: + cpu: 250m + memory: 256Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3 + Environment: + HOST: 0.0.0.0 + PORT: 5000 + Mounts: + Volumes: + Node-Selectors: + Tolerations: +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +OldReplicaSets: +NewReplicaSet: devops-app-py-76fc7985df (3/3 replicas created) +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal ScalingReplicaSet 9s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3 +``` + +
+ +## Task 3 - Service Configuration + +The Service uses type `NodePort` and targets the Deployment Pods with the `app.kubernetes.io/name=devops-app-py` label. It exposes service port `80` and forwards traffic to container port `5000` on a fixed NodePort, `30080`. + +For connectivity verification, I used `kubectl port-forward service/devops-app-py-service 8080:80`. I tested `minikube service ... --url` first, but in this Docker-driver setup the returned node IP was not directly reachable from the host, so port-forward was the practical local-access path. + +
+Service verification output + +```text +$ kubectl apply -f k8s/service.yml +service/devops-app-py-service unchanged + +$ kubectl get services +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 32s +kubernetes ClusterIP 10.96.0.1 443/TCP 80m + +$ kubectl describe service devops-app-py-service +Name: devops-app-py-service +Namespace: default +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: +Selector: app.kubernetes.io/name=devops-app-py +Type: NodePort +IP Family Policy: SingleStack +IP Families: IPv4 +IP: 10.110.168.128 +IPs: 10.110.168.128 +Port: http 80/TCP +TargetPort: 5000/TCP +NodePort: http 30080/TCP +Endpoints: 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000 +Session Affinity: None +External Traffic Policy: Cluster +Internal Traffic Policy: Cluster +Events: + +$ kubectl get endpoints devops-app-py-service +Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice +NAME ENDPOINTS AGE +devops-app-py-service 10.244.0.12:5000,10.244.0.13:5000,10.244.0.14:5000 32s + +$ kubectl port-forward service/devops-app-py-service 8080:80 +Forwarding from 127.0.0.1:8080 -> 5000 +Forwarding from [::1]:8080 -> 5000 +Handling connection for 8080 +Handling connection for 8080 +Handling connection for 8080 +Handling connection for 8080 + +$ curl -fsSL 127.0.0.1:8080 | jq .service.name +"devops-info-service" + +$ curl -fsSL 127.0.0.1:8080/health | jq .status +"healthy" + +$ curl -fsSL 127.0.0.1:8080/ready | jq .status +"ready" + +$ curl -fsSL 127.0.0.1:8080/metrics | head -n 12 +# HELP http_requests_total Total HTTP requests handled by the service. +# TYPE http_requests_total counter +http_requests_total{endpoint="/ready",method="GET",status_code="200"} 180.0 +http_requests_total{endpoint="/health",method="GET",status_code="200"} 90.0 +http_requests_total{endpoint="/",method="GET",status_code="200"} 2.0 +http_requests_total{endpoint="/metrics",method="GET",status_code="200"} 1.0 +# HELP http_requests_created Total HTTP requests handled by the service. +# TYPE http_requests_created gauge +http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745777896655755e+09 +http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745778018120363e+09 +http_requests_created{endpoint="/",method="GET",status_code="200"} 1.7745779956714542e+09 +http_requests_created{endpoint="/metrics",method="GET",status_code="200"} 1.7745779957933705e+09 +``` + +
+ +## Task 4 - Scaling and Updates + +I scaled the Deployment declaratively to 5 replicas and verified that all 5 Pods were running. For the rolling-update portion, I changed the pod template with a temporary `LOG_LEVEL=DEBUG` environment variable. An in-cluster probe exposed a brief failed request with `maxUnavailable: 1`, so I changed the strategy to `maxUnavailable: 0` and reran the rollout. With that stricter strategy, the Service returned `200` for 35 consecutive `/ready` checks during the rollout. I then used `kubectl rollout undo` and returned the live Deployment to the baseline `1.9-dev` pod template while keeping the safer rollout strategy in the manifest. + +
+Scaling to 5 replicas + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl rollout status deployment/devops-app-py --timeout=180s +Waiting for deployment "devops-app-py" rollout to finish: 3 of 5 updated replicas are available... +Waiting for deployment "devops-app-py" rollout to finish: 4 of 5 updated replicas are available... +deployment "devops-app-py" successfully rolled out + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 21m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py + +$ kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +devops-app-py-76fc7985df-jmnrd 1/1 Running 0 13s 10.244.0.16 minikube +devops-app-py-76fc7985df-jq2tr 1/1 Running 0 21m 10.244.0.14 minikube +devops-app-py-76fc7985df-jrgms 1/1 Running 0 13s 10.244.0.15 minikube +devops-app-py-76fc7985df-jwpsf 1/1 Running 0 21m 10.244.0.13 minikube +devops-app-py-76fc7985df-nwr58 1/1 Running 0 21m 10.244.0.12 minikube +``` + +
+ +
+Rolling update with corrected zero-downtime strategy + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl rollout status deployment/devops-app-py --timeout=240s +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +deployment "devops-app-py" successfully rolled out + +$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-app-py-65fc658668 5 5 5 6m45s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668 +devops-app-py-76fc7985df 0 0 0 29m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 29m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py + +$ kubectl rollout history deployment/devops-app-py +deployment.apps/devops-app-py +REVISION CHANGE-CAUSE +5 +6 +``` + +
+ +
+In-cluster readiness probe during rollout + +```text +$ kubectl run task4-probe-zdt \ + --image=curlimages/curl --rm -i --command -- \ + sh -c ' + for i in $(seq 1 35); do + code=$(curl -sS -o /dev/null -w "%{http_code}" http://devops-app-py-service/ready) + printf "%s %s\n" "$(date +%H:%M:%S)" "$code" + sleep 1 + done + ' +All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt. +If you don't see a command prompt, try pressing enter. +02:44:47 200 +02:44:48 200 +02:44:49 200 +02:44:50 200 +02:44:51 200 +02:44:52 200 +02:44:53 200 +02:44:54 200 +02:44:55 200 +02:44:56 200 +02:44:57 200 +02:44:58 200 +02:44:59 200 +02:45:00 200 +02:45:01 200 +02:45:02 200 +02:45:03 200 +02:45:04 200 +02:45:05 200 +02:45:06 200 +02:45:07 200 +02:45:08 200 +02:45:09 200 +02:45:10 200 +02:45:11 200 +02:45:12 200 +02:45:13 200 +02:45:14 200 +02:45:15 200 +02:45:16 200 +02:45:17 200 +02:45:18 200 +02:45:19 200 +02:45:20 200 +pod "task4-probe-zdt" deleted from default namespace +``` + +
+ +
+Rollback and rollout history + +```text +$ kubectl rollout undo deployment/devops-app-py +deployment.apps/devops-app-py rolled back + +$ kubectl rollout status deployment/devops-app-py --timeout=240s +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 2 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 3 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 4 out of 5 new replicas have been updated... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +Waiting for deployment "devops-app-py" rollout to finish: 1 old replicas are pending termination... +deployment "devops-app-py" successfully rolled out + +$ kubectl get rs -l app.kubernetes.io/name=devops-app-py -o wide +NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR +devops-app-py-65fc658668 0 0 0 7m45s devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=65fc658668 +devops-app-py-76fc7985df 5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py,pod-template-hash=76fc7985df + +$ kubectl get deployment devops-app-py -o wide +NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +devops-app-py 5/5 5 5 30m devops-app-py localt0aster/devops-app-py:1.9-dev app.kubernetes.io/name=devops-app-py + +$ kubectl rollout history deployment/devops-app-py +deployment.apps/devops-app-py +REVISION CHANGE-CAUSE +6 +7 +``` + +
+ +## Task 5 - Documentation + +### Architecture Overview + +The final Kubernetes layout is one `Deployment` and one `NodePort` `Service` in the default namespace. The Deployment runs 5 Flask Pods from `localt0aster/devops-app-py:1.9-dev`, and the Service load-balances traffic from port `80` to container port `5000`. + +```mermaid +flowchart LR + Client[Client] + Service[NodePort Service
devops-app-py-service
80 -> 5000
30080/TCP] + Deployment[Deployment
devops-app-py
5 replicas] + Pod1[Pod
/] + Pod2[Pod
/health] + Pod3[Pod
/ready] + Pod4[Pod
/metrics] + Pod5[Pod
5000/TCP] + + Client --> Service + Service --> Deployment + Deployment --> Pod1 + Deployment --> Pod2 + Deployment --> Pod3 + Deployment --> Pod4 + Deployment --> Pod5 +``` + +The resource strategy is intentionally small and predictable for a local lab cluster: each Pod requests `100m` CPU and `128Mi` memory, with limits of `250m` CPU and `256Mi` memory. For rollouts, the Deployment now uses `maxSurge: 1` and `maxUnavailable: 0` to preserve availability during Pod replacement. + +### Manifest Files + +- `k8s/deployment.yml`: defines the `Deployment`, `5` replicas, the `1.9-dev` Python image, labels/selectors, container port, `HOST` and `PORT` environment variables, resource requests and limits, and liveness/readiness probes. `maxUnavailable: 0` was chosen after testing showed that `1` could still allow a transient failed request during rollout. +- `k8s/service.yml`: defines the `NodePort` `Service`, maps service port `80` to target port `5000`, uses node port `30080`, and selects Pods by `app.kubernetes.io/name=devops-app-py`. + +### Deployment Evidence + +
+Final cluster evidence + +```text +$ kubectl apply -f k8s/deployment.yml +deployment.apps/devops-app-py configured + +$ kubectl get all +NAME READY STATUS RESTARTS AGE +pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s +pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s +pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s +pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s +pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m +service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m + +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/devops-app-py 5/5 5 5 30m + +NAME DESIRED CURRENT READY AGE +replicaset.apps/devops-app-py-65fc658668 0 0 0 8m20s +replicaset.apps/devops-app-py-76fc7985df 5 5 5 30m + +$ kubectl get pods,svc -o wide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +pod/devops-app-py-76fc7985df-6hmn5 1/1 Running 0 52s 10.244.0.47 minikube +pod/devops-app-py-76fc7985df-6rk64 1/1 Running 0 69s 10.244.0.45 minikube +pod/devops-app-py-76fc7985df-hr29v 1/1 Running 0 61s 10.244.0.46 minikube +pod/devops-app-py-76fc7985df-ptjkm 1/1 Running 0 78s 10.244.0.44 minikube +pod/devops-app-py-76fc7985df-t6d7b 1/1 Running 0 44s 10.244.0.48 minikube + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR +service/devops-app-py-service NodePort 10.110.168.128 80:30080/TCP 27m app.kubernetes.io/name=devops-app-py +service/kubernetes ClusterIP 10.96.0.1 443/TCP 107m + +$ kubectl describe deployment devops-app-py +Name: devops-app-py +Namespace: default +CreationTimestamp: Fri, 27 Mar 2026 05:16:21 +0300 +Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 +Annotations: deployment.kubernetes.io/revision: 7 +Selector: app.kubernetes.io/name=devops-app-py +Replicas: 5 desired | 5 updated | 5 total | 5 available | 0 unavailable +StrategyType: RollingUpdate +MinReadySeconds: 0 +RollingUpdateStrategy: 0 max unavailable, 1 max surge +Pod Template: + Labels: app.kubernetes.io/name=devops-app-py + app.kubernetes.io/part-of=devops-core-s26 + Containers: + devops-app-py: + Image: localt0aster/devops-app-py:1.9-dev + Port: 5000/TCP (http) + Host Port: 0/TCP (http) + Limits: + cpu: 250m + memory: 256Mi + Requests: + cpu: 100m + memory: 128Mi + Liveness: http-get http://:http/health delay=10s timeout=2s period=10s #success=1 #failure=3 + Readiness: http-get http://:http/ready delay=5s timeout=2s period=5s #success=1 #failure=3 + Environment: + HOST: 0.0.0.0 + PORT: 5000 + Mounts: + Volumes: + Node-Selectors: + Tolerations: +Conditions: + Type Status Reason + ---- ------ ------ + Available True MinimumReplicasAvailable + Progressing True NewReplicaSetAvailable +OldReplicaSets: devops-app-py-65fc658668 (0/0 replicas created) +NewReplicaSet: devops-app-py-76fc7985df (5/5 replicas created) +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Normal ScalingReplicaSet 30m deployment-controller Scaled up replica set devops-app-py-76fc7985df from 0 to 3 + Normal ScalingReplicaSet 9m21s deployment-controller Scaled up replica set devops-app-py-76fc7985df from 3 to 5 + Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 0 to 1 + Normal ScalingReplicaSet 8m20s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 1 to 2 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 4 to 3 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 2 to 3 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 3 to 2 + Normal ScalingReplicaSet 8m12s deployment-controller Scaled up replica set devops-app-py-65fc658668 from 3 to 4 + Normal ScalingReplicaSet 8m3s deployment-controller Scaled down replica set devops-app-py-76fc7985df from 2 to 1 + Normal ScalingReplicaSet 4m58s (x2 over 8m20s) deployment-controller Scaled down replica set devops-app-py-76fc7985df from 5 to 4 + Normal ScalingReplicaSet 3m39s (x22 over 8m3s) deployment-controller (combined from similar events): Scaled up replica set devops-app-py-76fc7985df from 0 to 1 + +$ kubectl run task5-curl \ + --image=curlimages/curl \ + \ + --rm -i \ + --command -- \ + sh -c ' + curl -fsS http://devops-app-py-service + printf "\n\n" + curl -fsS http://devops-app-py-service/health + printf "\n\n" + curl -fsS http://devops-app-py-service/ready + printf "\n\n" + curl -fsS http://devops-app-py-service/metrics | head -n 12 + ' +{ + "endpoints": [ + { + "description": "Service information.", + "method": "GET", + "path": "/" + }, + { + "description": "Health check.", + "method": "GET", + "path": "/health" + }, + { + "description": "Prometheus metrics.", + "method": "GET", + "path": "/metrics" + }, + { + "description": "Readiness check.", + "method": "GET", + "path": "/ready" + } + ], + "request": { + "client_ip": "10.244.0.49", + "method": "GET", + "path": "/", + "user_agent": "curl/8.12.1" + }, + "runtime": { + "human": "0 hours, 1 minutes", + "seconds": 61 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.8.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 8, + "hostname": "devops-app-py-76fc7985df-6rk64", + "platform": "Linux", + "platform_version": "Alpine Linux v3.23", + "python_version": "3.14.3" + } +} + + +{ + "status": "healthy", + "timestamp": "2026-03-27T02:47:15.357513+00:00", + "uptime_seconds": 70 +} + + +{ + "status": "ready", + "timestamp": "2026-03-27T02:47:15.363373+00:00", + "uptime_seconds": 53 +} + + +# HELP http_requests_total Total HTTP requests handled by the service. +# TYPE http_requests_total counter +http_requests_total{endpoint="/ready",method="GET",status_code="200"} 13.0 +http_requests_total{endpoint="/health",method="GET",status_code="200"} 5.0 +http_requests_total{endpoint="/",method="GET",status_code="200"} 1.0 +# HELP http_requests_created Total HTTP requests handled by the service. +# TYPE http_requests_created gauge +http_requests_created{endpoint="/ready",method="GET",status_code="200"} 1.7745795737986815e+09 +http_requests_created{endpoint="/health",method="GET",status_code="200"} 1.7745795857890186e+09 +http_requests_created{endpoint="/",method="GET",status_code="200"} 1.774579635349531e+09 +# HELP http_request_duration_seconds HTTP request duration in seconds. +# TYPE http_request_duration_seconds histogram +pod "task5-curl" deleted from default namespace +``` + +
+ +### Operations Performed + +1. Deployment and verification: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py + kubectl get deployment devops-app-py -o wide + kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide + kubectl describe deployment devops-app-py + ``` + +2. Service setup and access checks: + + ```bash + kubectl apply -f k8s/service.yml + kubectl describe service devops-app-py-service + kubectl port-forward service/devops-app-py-service 8080:80 + kubectl run task5-curl --image=curlimages/curl --rm -i --command -- sh + ``` + +3. Scaling to 5 replicas: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py --timeout=180s + kubectl get pods -l app.kubernetes.io/name=devops-app-py -o wide + ``` + +4. Rolling updates and rollback: + + ```bash + kubectl apply -f k8s/deployment.yml + kubectl rollout status deployment/devops-app-py --timeout=240s + kubectl rollout history deployment/devops-app-py + kubectl rollout undo deployment/devops-app-py + ``` + +### Production Considerations + +- Health checks: `/health` is used for liveness and `/ready` is used for readiness so Kubernetes only sends traffic to Pods that are actually prepared to serve requests. +- Rollout safety: `maxUnavailable: 0` and `maxSurge: 1` were chosen to keep capacity available during updates. This was not just theoretical; the strategy was tightened after observing a transient failed request with `maxUnavailable: 1`. +- Resource limits: `100m/128Mi` requests and `250m/256Mi` limits are reasonable for a lab environment and protect the single-node cluster from noisy-neighbor behavior. +- Observability: the app exposes `/metrics`, which is ready for Prometheus scraping. `kubectl describe`, rollout history, and event inspection were enough for this lab, but production should add centralized logs and dashboards. +- Further improvements: use image digests instead of mutable tags, add a `PodDisruptionBudget`, enable `HorizontalPodAutoscaler`, isolate workloads in a dedicated namespace, and place an Ingress with TLS in front of the NodePort service. + +### Challenges & Solutions + +- `minikube service devops-app-py-service --url` returned a valid NodePort URL, but with the Docker driver that node IP was not directly reachable from the host. I used `kubectl port-forward` for local testing and an in-cluster curl Pod for authoritative service verification. +- The first zero-downtime test with `maxUnavailable: 1` produced a brief failed request during rollout. Instead of papering over it, I changed the Deployment strategy to `maxUnavailable: 0` and reran the test until the probe showed a clean `200` sequence. +- Host-side port-forward tests can introduce their own connection artifacts during fast backend replacement. The more reliable method here was probing the Kubernetes Service from inside the cluster. diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..d54bc202ff --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-app-py + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 +spec: + replicas: 5 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: devops-app-py + template: + metadata: + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 + spec: + containers: + - name: devops-app-py + image: localt0aster/devops-app-py:1.9-dev + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "5000" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..a6ebff9108 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-app-py-service + labels: + app.kubernetes.io/name: devops-app-py + app.kubernetes.io/part-of: devops-core-s26 +spec: + type: NodePort + selector: + app.kubernetes.io/name: devops-app-py + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 5000 + nodePort: 30080 diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml index 8b155f0c50..17e5da4e4d 100644 --- a/monitoring/docker-compose.yml +++ b/monitoring/docker-compose.yml @@ -133,7 +133,7 @@ services: restart: unless-stopped app-python: - image: localt0aster/devops-app-py:1.8.806c77e + image: localt0aster/devops-app-py:1.8 environment: HOST: "0.0.0.0" PORT: "8000"