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
3 changes: 3 additions & 0 deletions .github/workflows/go-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/go-snyk.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/python-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-snyk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 3 additions & 4 deletions app_go/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 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 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+
Expand All @@ -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

Expand Down
17 changes: 16 additions & 1 deletion app_go/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
36 changes: 36 additions & 0 deletions app_go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
136 changes: 130 additions & 6 deletions app_go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -276,17 +349,41 @@ 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,
}

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",
Expand All @@ -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)
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
Expand Down
Loading