diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84089ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.exe +*.test +.DS_Store +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30513c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# ---- Build stage ---- +FROM golang:1.22-alpine AS build +WORKDIR /src + +COPY go.mod go.sum* ./ +RUN go mod tidy && go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app . + +# ---- Runtime stage (distroless) ---- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /app /app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app", "-health-check"] + +ENTRYPOINT ["/app"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90fe725 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/kyndryl-platform/sonar-test-go2 + +go 1.22 + +require ( + github.com/prometheus/client_golang v1.19.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 + go.opentelemetry.io/otel v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 + go.opentelemetry.io/otel/sdk v1.27.0 + go.opentelemetry.io/otel/sdk/metric v1.27.0 +) diff --git a/k6/configmap.yaml b/k6/configmap.yaml new file mode 100644 index 0000000..07ea374 --- /dev/null +++ b/k6/configmap.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: k6-test-sonar-test-go2 + namespace: dev + labels: + app: sonar-test-go2 + app.kubernetes.io/managed-by: backstage + app.kubernetes.io/component: load-testing +data: + K6_OUT: opentelemetry + K6_OTEL_GRPC_EXPORTER_INSECURE: 'true' + K6_OTEL_GRPC_EXPORTER_ENDPOINT: otel-collector.monitoring.svc.cluster.local:4317 + K6_OTEL_METRIC_PREFIX: k6_ + K6_OTEL_FLUSH_INTERVAL: '1000' + K6_OTEL_EXPORT_INTERVAL: '5000' + K6_OTEL_SERVICE_NAME: k6-sonar-test-go2 + load-test.js: "import http from 'k6/http';\nimport { check, sleep, group } from\ + \ 'k6';\n\nconst vus = parseInt(__ENV.TEST_VUS || '10');\nconst duration = __ENV.TEST_DURATION\ + \ || '30s';\nconst targetUrl = __ENV.TARGET_URL || 'http://sonar-test-go2.dev.svc.cluster.local:8080';\n\ + \nexport const options = {\n scenarios: {\n load_test: {\n executor:\ + \ 'ramping-vus',\n startVUs: 0,\n stages: [\n { duration: '10s',\ + \ target: vus },\n { duration: duration, target: vus },\n { duration:\ + \ '5s', target: 0 },\n ],\n },\n },\n thresholds: {\n http_req_duration:\ + \ ['p(95)<500'],\n http_req_failed: ['rate<0.01'],\n },\n};\n\nhttp.setResponseCallback(http.expectedStatuses({\ + \ min: 200, max: 399 }));\n\nexport default function () {\n group('Health Check',\ + \ () => {\n const res = http.get(`${targetUrl}/health`);\n check(res, {\n\ + \ 'status is 200': (r) => r.status === 200,\n 'response time < 500ms':\ + \ (r) => r.timings.duration < 500,\n 'body contains status UP': (r) => r.json().status\ + \ === 'UP',\n });\n });\n\n sleep(0.5);\n\n group('Items API', () => {\n\ + \ // List all items\n const listRes = http.get(`${targetUrl}/api/items`);\n\ + \ check(listRes, {\n 'status is 200': (r) => r.status === 200,\n \ + \ 'response time < 500ms': (r) => r.timings.duration < 500,\n 'body is an\ + \ array': (r) => Array.isArray(r.json()),\n });\n\n // Create a new item\n\ + \ const createRes = http.post(`${targetUrl}/api/items`, JSON.stringify({ name:\ + \ 'Test Item', description: 'A test item description' }), {\n headers: {\ + \ 'Content-Type': 'application/json' },\n });\n check(createRes, {\n \ + \ 'status is 201': (r) => r.status === 201,\n 'response time < 500ms':\ + \ (r) => r.timings.duration < 500,\n 'body contains id': (r) => r.json().id\ + \ !== undefined,\n });\n\n const itemId = createRes.json().id;\n\n //\ + \ Retrieve the created item\n const getRes = http.get(`${targetUrl}/api/items/${itemId}`);\n\ + \ check(getRes, {\n 'status is 200': (r) => r.status === 200,\n 'response\ + \ time < 500ms': (r) => r.timings.duration < 500,\n 'body contains correct\ + \ id': (r) => r.json().id === itemId,\n });\n\n // Update the item\n \ + \ const updateRes = http.put(`${targetUrl}/api/items/${itemId}`, JSON.stringify({\ + \ name: 'Updated Item', description: 'Updated description' }), {\n headers:\ + \ { 'Content-Type': 'application/json' },\n });\n check(updateRes, {\n \ + \ 'status is 200': (r) => r.status === 200,\n 'response time < 500ms':\ + \ (r) => r.timings.duration < 500,\n 'body contains updated name': (r) =>\ + \ r.json().name === 'Updated Item',\n });\n\n // Delete the item\n const\ + \ deleteRes = http.del(`${targetUrl}/api/items/${itemId}`);\n check(deleteRes,\ + \ {\n 'status is 200': (r) => r.status === 200,\n 'response time < 500ms':\ + \ (r) => r.timings.duration < 500,\n 'body contains deleted id': (r) => r.json().deleted\ + \ === itemId,\n });\n });\n\n sleep(0.5);\n}" diff --git a/k6/load-test.js b/k6/load-test.js new file mode 100644 index 0000000..6414aab --- /dev/null +++ b/k6/load-test.js @@ -0,0 +1,89 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +const vus = parseInt(__ENV.TEST_VUS || '10'); +const duration = __ENV.TEST_DURATION || '30s'; +const targetUrl = __ENV.TARGET_URL || 'http://sonar-test-go2.dev.svc.cluster.local:8080'; + +export const options = { + scenarios: { + load_test: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: vus }, + { duration: duration, target: vus }, + { duration: '5s', target: 0 }, + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +http.setResponseCallback(http.expectedStatuses({ min: 200, max: 399 })); + +export default function () { + group('Health Check', () => { + const res = http.get(`${targetUrl}/health`); + check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body contains status UP': (r) => r.json().status === 'UP', + }); + }); + + sleep(0.5); + + group('Items API', () => { + // List all items + const listRes = http.get(`${targetUrl}/api/items`); + check(listRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body is an array': (r) => Array.isArray(r.json()), + }); + + // Create a new item + const createRes = http.post(`${targetUrl}/api/items`, JSON.stringify({ name: 'Test Item', description: 'A test item description' }), { + headers: { 'Content-Type': 'application/json' }, + }); + check(createRes, { + 'status is 201': (r) => r.status === 201, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body contains id': (r) => r.json().id !== undefined, + }); + + const itemId = createRes.json().id; + + // Retrieve the created item + const getRes = http.get(`${targetUrl}/api/items/${itemId}`); + check(getRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body contains correct id': (r) => r.json().id === itemId, + }); + + // Update the item + const updateRes = http.put(`${targetUrl}/api/items/${itemId}`, JSON.stringify({ name: 'Updated Item', description: 'Updated description' }), { + headers: { 'Content-Type': 'application/json' }, + }); + check(updateRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body contains updated name': (r) => r.json().name === 'Updated Item', + }); + + // Delete the item + const deleteRes = http.del(`${targetUrl}/api/items/${itemId}`); + check(deleteRes, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + 'body contains deleted id': (r) => r.json().deleted === itemId, + }); + }); + + sleep(0.5); +} \ No newline at end of file diff --git a/k6/testrun.yaml b/k6/testrun.yaml new file mode 100644 index 0000000..782d670 --- /dev/null +++ b/k6/testrun.yaml @@ -0,0 +1,24 @@ +apiVersion: k6.io/v1alpha1 +kind: TestRun +metadata: + name: k6-sonar-test-go2 + namespace: dev + labels: + app: sonar-test-go2 + backstage.io/component: sonar-test-go2 + app.kubernetes.io/managed-by: backstage + app.kubernetes.io/component: load-testing +spec: + parallelism: 1 + script: + configMap: + name: k6-test-sonar-test-go2 + file: load-test.js + runner: + image: grafana/k6:latest + envFrom: + - configMapRef: + name: k6-test-sonar-test-go2 + env: + - name: K6_OTEL_SERVICE_NAME + value: k6-sonar-test-go2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..dfb327e --- /dev/null +++ b/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +// --------------------------------------------------------------------------- +// Domain model +// --------------------------------------------------------------------------- + +type Item struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type ItemRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// --------------------------------------------------------------------------- +// In-memory store +// --------------------------------------------------------------------------- + +var ( + store sync.Map + counter atomic.Int64 +) + +func nextID() int { + return int(counter.Add(1)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func itemIDFromPath(path string) (int, bool) { + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + if len(parts) < 3 { + return 0, false + } + id, err := strconv.Atoi(parts[len(parts)-1]) + return id, err == nil +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "UP"}) +} + +func itemsHandler(w http.ResponseWriter, r *http.Request) { + // Route: /api/items or /api/items/{id} + hasSub := strings.Count(strings.TrimSuffix(r.URL.Path, "/"), "/") >= 3 + + if hasSub { + id, ok := itemIDFromPath(r.URL.Path) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) + return + } + switch r.Method { + case http.MethodGet: + v, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + writeJSON(w, http.StatusOK, v) + case http.MethodPut: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + existingRaw, exists := store.Load(id) + if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + existing := existingRaw.(Item) + if req.Name == "" { + req.Name = existing.Name + } + if req.Description == "" { + req.Description = existing.Description + } + updated := Item{ID: id, Name: req.Name, Description: req.Description} + store.Store(id, updated) + writeJSON(w, http.StatusOK, updated) + case http.MethodDelete: + store.Delete(id) + writeJSON(w, http.StatusOK, map[string]int{"deleted": id}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + + // /api/items — list or create + switch r.Method { + case http.MethodGet: + var items []Item + store.Range(func(_, v any) bool { + items = append(items, v.(Item)) + return true + }) + if items == nil { + items = []Item{} + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req ItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if req.Name == "" { + req.Name = "unnamed" + } + item := Item{ID: nextID(), Name: req.Name, Description: req.Description} + store.Store(item.ID, item) + writeJSON(w, http.StatusCreated, item) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// --------------------------------------------------------------------------- +// OpenTelemetry setup +// --------------------------------------------------------------------------- + +// setupOTel initialises the OTLP trace and metric pipelines from OTEL_* env +// vars (injected by score.yaml / Humanitec). Returns a no-op shutdown func +// when OTEL_EXPORTER_OTLP_ENDPOINT is not set (local dev). +func setupOTel(ctx context.Context) func() { + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" { + return func() {} + } + svcName := os.Getenv("OTEL_SERVICE_NAME") + if svcName == "" { + svcName = "unknown" + } + res, _ := resource.New(ctx, + resource.WithAttributes(semconv.ServiceName(svcName)), + resource.WithFromEnv(), + ) + traceExp, _ := otlptracehttp.New(ctx) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(traceExp), sdktrace.WithResource(res)) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, propagation.Baggage{}, + )) + metricExp, _ := otlpmetrichttp.New(ctx) + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + return func() { + _ = tp.Shutdown(context.Background()) + _ = mp.Shutdown(context.Background()) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +func main() { + ctx := context.Background() + shutdownOTel := setupOTel(ctx) + defer shutdownOTel() + + mux := http.NewServeMux() + + mux.HandleFunc("/health", healthHandler) + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/api/items/", itemsHandler) + mux.HandleFunc("/api/items", itemsHandler) + + addr := ":8080" + fmt.Printf("sonar-test-go2 listening on %s\n", addr) + log.Fatal(http.ListenAndServe(addr, otelhttp.NewHandler(mux, ""))) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..81ce4df --- /dev/null +++ b/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealth(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + healthHandler(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +}