Skip to content
This repository was archived by the owner on May 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion app_go/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# DevOps Info Service (Go)

## Overview
Simple Go web service that exposes system/runtime details, health and readiness checks, Prometheus metrics, and structured JSON logs.
Simple Go web service that exposes system/runtime details, a file-backed visits counter, health and readiness checks, Prometheus metrics, and structured JSON logs.

## Prerequisites
- Go 1.25+
Expand All @@ -20,10 +20,23 @@ HOST=127.0.0.1 PORT=8080 ./devops-info-service.out

## Endpoints
- `GET /` - service + system + runtime + request info
- `GET /visits` - current visits counter stored in `/data/visits`
- `GET /health` - health check
- `GET /ready` - readiness check
- `GET /metrics` - Prometheus metrics exposition

## Visits Counter
- The root handler increments the counter on every `GET /`.
- The counter is persisted as plain text in `/data/visits`.
- If the file is missing, the service starts from `0`.
- If the file is malformed, the service logs a warning and treats the value as `0`.

## Local Docker Check
For Lab 12, run the monitoring stack with a writable `/data` volume for the Go container and verify that:
- repeated `GET /` calls increment the counter
- `GET /visits` returns the current count
- the counter survives a container restart because the backing file is persisted on the host

## Configuration

| Variable | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion app_go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ require (
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
golang.org/x/sys v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
4 changes: 2 additions & 2 deletions app_go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ 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=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.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=
Expand Down
95 changes: 90 additions & 5 deletions app_go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
Expand All @@ -20,7 +21,7 @@ import (

const (
serviceName = "devops-info-service"
serviceVersion = "1.10.0"
serviceVersion = "1.12.0"
serviceDescription = "DevOps course info service"
serviceFramework = "Go net/http"
serviceLoggerName = "devops_info_service"
Expand Down Expand Up @@ -75,11 +76,17 @@ type StatusResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
}

type VisitsResponse struct {
Visits int `json:"visits"`
}

var (
// startTime is used for uptime calculations.
startTime = time.Now().UTC()
logMu sync.Mutex
logOutput io.Writer = os.Stdout
startTime = time.Now().UTC()
logMu sync.Mutex
visitsMu sync.Mutex
logOutput io.Writer = os.Stdout
visitsFilePath = "/data/visits"
// metricsRegistry only exposes service metrics, matching the Python app.
metricsRegistry = prometheus.NewRegistry()
httpRequestsTotal = prometheus.NewCounterVec(
Expand Down Expand Up @@ -123,6 +130,7 @@ var (
// endpoints is a static list used to mirror the Python app output.
endpoints = []EndpointInfo{
{Path: "/", Method: http.MethodGet, Description: "Service information."},
{Path: "/visits", Method: http.MethodGet, Description: "Visits counter."},
{Path: "/health", Method: http.MethodGet, Description: "Health check."},
{Path: "/ready", Method: http.MethodGet, Description: "Readiness check."},
{Path: "/metrics", Method: http.MethodGet, Description: "Prometheus metrics."},
Expand Down Expand Up @@ -260,14 +268,80 @@ func clientIP(r *http.Request) string {
return r.RemoteAddr
}

func readVisitsCount() int {
data, err := os.ReadFile(visitsFilePath)
if err != nil {
if os.IsNotExist(err) {
return 0
}

emitLog("WARNING", serviceLoggerName, "failed to read visits counter", map[string]any{
"error": err.Error(),
"path": visitsFilePath,
})
return 0
}

trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{
"path": visitsFilePath,
"value": "",
})
return 0
}

count, err := strconv.Atoi(trimmed)
if err != nil || count < 0 {
emitLog("WARNING", serviceLoggerName, "invalid visits counter, resetting to zero", map[string]any{
"path": visitsFilePath,
"value": trimmed,
})
return 0
}

return count
}

func writeVisitsCount(count int) error {
if err := os.MkdirAll(filepath.Dir(visitsFilePath), 0o755); err != nil {
return err
}

return os.WriteFile(visitsFilePath, []byte(fmt.Sprintf("%d\n", count)), 0o644)
}

func getVisitsCount() int {
visitsMu.Lock()
defer visitsMu.Unlock()

return readVisitsCount()
}

func incrementVisitsCount() int {
visitsMu.Lock()
defer visitsMu.Unlock()

count := readVisitsCount() + 1
if err := writeVisitsCount(count); err != nil {
emitLog("WARNING", serviceLoggerName, "failed to persist visits counter", map[string]any{
"error": err.Error(),
"path": visitsFilePath,
"value": count,
})
}

return count
}

// listEndpoints returns the advertised endpoints for the root response.
func listEndpoints() []EndpointInfo {
return endpoints
}

func normalizeEndpointLabel(path string) string {
switch path {
case "/", "/health", "/metrics", "/ready":
case "/", "/health", "/metrics", "/ready", "/visits":
return path
default:
return "unmatched"
Expand Down Expand Up @@ -336,6 +410,7 @@ func queryString(r *http.Request) string {
// mainHandler serves GET /.
func mainHandler(w http.ResponseWriter, r *http.Request) {
recordEndpointCall("/")
incrementVisitsCount()
payload := RootResponse{
Service: getServiceInfo(),
System: getSystemInfo(),
Expand All @@ -347,6 +422,14 @@ func mainHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}

// visitsHandler serves GET /visits.
func visitsHandler(w http.ResponseWriter, r *http.Request) {
recordEndpointCall("/visits")
writeJSON(w, http.StatusOK, VisitsResponse{
Visits: getVisitsCount(),
})
}

// healthHandler serves GET /health.
func healthHandler(w http.ResponseWriter, r *http.Request) {
recordEndpointCall("/health")
Expand Down Expand Up @@ -395,6 +478,8 @@ func router(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/" && r.Method == http.MethodGet:
mainHandler(w, r)
case r.URL.Path == "/visits" && r.Method == http.MethodGet:
visitsHandler(w, r)
case r.URL.Path == "/health" && r.Method == http.MethodGet:
healthHandler(w, r)
case r.URL.Path == "/metrics" && r.Method == http.MethodGet:
Expand Down
130 changes: 130 additions & 0 deletions app_go/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -81,6 +83,18 @@ func performRequest(handler http.Handler, method, path string) *httptest.Respons
return recorder
}

func withTempVisitsFile(t *testing.T) string {
t.Helper()

oldPath := visitsFilePath
visitsFilePath = filepath.Join(t.TempDir(), "visits")
t.Cleanup(func() {
visitsFilePath = oldPath
})

return visitsFilePath
}

func metricValue(metricsText, sampleName string, labels map[string]string) (float64, bool) {
for _, line := range strings.Split(metricsText, "\n") {
line = strings.TrimSpace(line)
Expand Down Expand Up @@ -194,6 +208,57 @@ func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) {

for _, route := range []string{
http.MethodGet + " /",
http.MethodGet + " /visits",
http.MethodGet + " /health",
http.MethodGet + " /ready",
http.MethodGet + " /metrics",
} {
if !routeIndex[route] {
t.Fatalf("expected endpoint %q to be listed", route)
}
}
}

func TestVisitsEndpointDefaultsToZeroWhenFileMissing(t *testing.T) {
restore := captureLogOutput(io.Discard)
defer restore()

withTempVisitsFile(t)

recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}

payload := decodeJSONResponse[VisitsResponse](t, recorder)
if payload.Visits != 0 {
t.Fatalf("expected visits to default to 0, got %d", payload.Visits)
}

if _, err := os.Stat(visitsFilePath); !os.IsNotExist(err) {
t.Fatalf("expected visits file to remain absent, got err=%v", err)
}
}

func TestRootIncrementsVisitsCounterAndPersistsFile(t *testing.T) {
restore := captureLogOutput(io.Discard)
defer restore()

withTempVisitsFile(t)

first := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
if first.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, first.Code)
}

payload := decodeJSONResponse[RootResponse](t, first)
routeIndex := map[string]bool{}
for _, endpoint := range payload.Endpoints {
routeIndex[endpoint.Method+" "+endpoint.Path] = true
}
for _, route := range []string{
http.MethodGet + " /",
http.MethodGet + " /visits",
http.MethodGet + " /health",
http.MethodGet + " /ready",
http.MethodGet + " /metrics",
Expand All @@ -202,6 +267,71 @@ func TestIndexReturnsExpectedJSONStructureAndTypes(t *testing.T) {
t.Fatalf("expected endpoint %q to be listed", route)
}
}

data, err := os.ReadFile(visitsFilePath)
if err != nil {
t.Fatalf("expected visits file to be created: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "1" {
t.Fatalf("expected visits file to contain 1 after first root request, got %q", got)
}

second := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
if second.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, second.Code)
}

data, err = os.ReadFile(visitsFilePath)
if err != nil {
t.Fatalf("expected visits file to remain readable: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "2" {
t.Fatalf("expected visits file to contain 2 after second root request, got %q", got)
}

visits := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
if visits.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, visits.Code)
}

count := decodeJSONResponse[VisitsResponse](t, visits)
if count.Visits != 2 {
t.Fatalf("expected visits endpoint to report 2, got %d", count.Visits)
}
}

func TestVisitsEndpointFallsBackToZeroForMalformedFile(t *testing.T) {
restore := captureLogOutput(io.Discard)
defer restore()

withTempVisitsFile(t)

if err := os.WriteFile(visitsFilePath, []byte("broken"), 0o644); err != nil {
t.Fatalf("failed to seed malformed visits file: %v", err)
}

recorder := performRequest(http.HandlerFunc(router), http.MethodGet, "/visits")
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}

payload := decodeJSONResponse[VisitsResponse](t, recorder)
if payload.Visits != 0 {
t.Fatalf("expected malformed counter to fall back to 0, got %d", payload.Visits)
}

after := performRequest(http.HandlerFunc(router), http.MethodGet, "/")
if after.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, after.Code)
}

data, err := os.ReadFile(visitsFilePath)
if err != nil {
t.Fatalf("expected visits file to be repaired by root request: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "1" {
t.Fatalf("expected repaired visits file to contain 1, got %q", got)
}
}

func TestHealthReturnsExpectedJSONStructureAndTypes(t *testing.T) {
Expand Down
Loading