From 4d4f9916087c588ed82b1e522bc8d1f76432fd8f Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:22:44 +1000 Subject: [PATCH 01/10] fix: better purpose docs plus test endpoints and probe knobs Readme now spells out what this is for (drop into an env and poke probes, routing, headers, params) next to cluster-utils. Also wired up the bits that were half done or missing: - /version with real ldflags Version/GitHash - controllable /health* and /ready* via HEALTHY/READY env or ?ok=0 - /status/:code, /delay/:seconds, /echo - /metrics actually registered + request counter middleware - AUTH_TOKEN env for a fixed bearer token - more tests, swagger regen, k8s readinessProbe --- README.md | 72 +++++--- docs/docs.go | 336 ++++++++++++++++++++++++++++++++++++-- docs/swagger.json | 336 ++++++++++++++++++++++++++++++++++++-- docs/swagger.yaml | 246 +++++++++++++++++++++++++--- handlers/handlers.go | 288 +++++++++++++++++++++++--------- k8s-cluster-util-apis.yml | 27 ++- main.go | 38 +++-- main_test.go | 137 ++++++++++++++-- routes/routes.go | 7 + 9 files changed, 1305 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index d0b5837..aebe733 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,18 @@ ## description -Simple docker image which will stand up a flexibile api that handles most entrypoints and has all the health variations. This allows me to deploy to a cluster, ecs/eks with any entrypoint or params and it will still run and respond to health checks. Great for testing cluster setup and has endpoints for debugging routing and headers. +HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github.com/donkeyx/cluster-utils) is the shell box you exec into, this is the **service you drop into an environment** to exercise the platform around it. -Default route redirects into the **swagger** docs so you can poke the endpoints from the browser. +Throw it into a namespace / ECS task / compose stack and use it to test: + +- **probes** — liveness / readiness style paths (`/health`, `/healthz`, `/ready`, `/readyz`, `/ping`) +- **routing & ingress** — hit it through a service, ingress, ALB, mesh; see what actually arrives +- **headers & identity** — what the proxy rewrote, client IP, host, path (`/headers`, `/debug`, `/echo`) +- **config / params in the env** — dump process env behind auth (`/a/env`) so you can check secrets, configmaps, task defs actually landed +- **bad / slow upstreams** — force status codes and delays (`/status/503`, `/delay/5`) +- **any entrypoint noise** — binary is also linked as `node` / `npm` so broken charts that call weird commands still come up and serve the api + +More endpoints will keep landing here as we need them. Default route dumps you into **swagger** so you can poke things from the browser without memorising paths. | dockerhub: https://hub.docker.com/r/donkeyx/cluster-utils-api @@ -12,9 +21,11 @@ Default route redirects into the **swagger** docs so you can poke the endpoints | github: https://github.com/donkeyx/cluster-utils-api +| pair with: https://github.com/donkeyx/cluster-utils (shell / toolkit image) + ## Usage -Most endpoints are open. Anything under `/a/` is authenticated — grab the bearer token from the container logs on startup (it rotates every restart). The app also logs a ready made curl for `/a/env`. +Most endpoints are open. Anything under `/a/` is authenticated — grab the bearer token from the container logs on startup (it rotates every restart unless you set `AUTH_TOKEN`). The app also logs a ready made curl for `/a/env`. Swagger UI: @@ -33,35 +44,34 @@ docker run -d -p 8080:8080 --name test-api donkeyx/cluster-utils-api:latest # quick route map curl -sS localhost:8080/help | jq +# what binary is this +curl -sS localhost:8080/version | jq + # health / ping curl -sS localhost:8080/healthz curl -sS localhost:8080/ping +# force probes to fail (query wins over env) +curl -sS -o /dev/null -w '%{http_code}\n' 'localhost:8080/ready?ok=0' +curl -sS -o /dev/null -w '%{http_code}\n' 'localhost:8080/healthz?healthy=false' + +# status / delay / echo — classic "is my ingress dumb" toolkit +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/418 +curl -sS localhost:8080/delay/1 +curl -sS -X POST -d '{"hi":1}' localhost:8080/echo | jq + # debug routing / headers curl -sS localhost:8080/headers | jq curl -sS localhost:8080/debug | jq -# env dump (needs the token from logs) +# prometheus +curl -sS localhost:8080/metrics | head + +# env dump (needs the token from logs, or AUTH_TOKEN you set) docker logs test-api 2>&1 | head -30 curl -sS -H "Authorization: Bearer " localhost:8080/a/env | jq ``` -`/help` looks roughly like: - -```json -{ - "/": "This can be used to redirect to the swagger docs for more details", - "/a/env": "GET", - "/debug": "GET", - "/headers": "GET", - "/health": "GET", - "/healthz": "GET", - "/ping": "GET", - "/ready": "GET", - "/readyz": "GET" -} -``` - ### Main endpoints | path | notes | @@ -69,13 +79,29 @@ curl -sS -H "Authorization: Bearer " localhost:8080/a/env | jq | `GET /` | redirect to swagger | | `GET /api-docs/*` | swagger ui | | `GET /help` | json list of routes | -| `GET /health` `/healthz` | returns `OK` | -| `GET /ready` `/readyz` | returns `Ready` | +| `GET /version` | version + git hash + hostname | +| `GET /health` `/healthz` | liveness; fail with `HEALTHY=false` or `?ok=0` | +| `GET /ready` `/readyz` | readiness; fail with `READY=false` or `?ok=0` | | `GET /ping` | `PONG` | | `GET /headers` | request headers | | `GET /debug` | hostname / ip / headers / uri | +| `GET /metrics` | prometheus | +| `GET /status/:code` | respond with that http status (100-599) | +| `GET /delay/:seconds` | sleep then 200 (capped at 30s) | +| `ANY /echo` | bounce method / query / headers / body | | `GET /a/env` | env vars, **bearer auth** | +### config knobs + +| env | default | what it does | +|-----|---------|----------------| +| `PORT` | `8080` | listen port | +| `AUTH_TOKEN` | random each start | fixed bearer token if set | +| `HEALTHY` | true | liveness; `false`/`0` → 503 on /health* | +| `READY` | true | readiness; `false`/`0` → 503 on /ready* | + +Query overrides env for a single request: `?ok=0`, `?healthy=false`, `?ready=false`. + ### run image in k8 cluster: You can run the pod in your cluster with the commands below. This will start a deployment and service but limited to cluster ip. If you want to expose with type loadbalancer you can do it yourself, I don't want you to get a bill from this. @@ -101,4 +127,4 @@ kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 curl -sS localhost:8080/debug | jq ``` -Probes in the manifest hit `/healthz` on container port 8080. +Manifest has liveness on `/healthz`, readiness on `/readyz`, startup on `/healthz` (port 8080). Flip `READY`/`HEALTHY` in the deployment if you want to watch the probes react. diff --git a/docs/docs.go b/docs/docs.go index 097c759..7fe8e88 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,7 +22,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Get the env variables available to the api. This is behind auth under /a/", + "description": "Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/", "produces": [ "application/json" ], @@ -62,7 +62,7 @@ const docTemplate = `{ }, "/debug": { "get": { - "description": "Get lots of info from running container headers/ips", + "description": "Hostname, client ip, headers, uri — good for routing tests", "produces": [ "application/json" ], @@ -71,6 +71,34 @@ const docTemplate = `{ "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/delay/{seconds}": { + "get": { + "description": "Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests", + "produces": [ + "text/plain" + ], + "summary": "Delay then OK", + "operationId": "delay", + "parameters": [ + { + "type": "number", + "description": "seconds to sleep (max 30)", + "name": "seconds", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "delayed", "schema": { "type": "string" } @@ -78,9 +106,111 @@ const docTemplate = `{ } } }, + "/echo": { + "get": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "patch": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/headers": { "get": { - "description": "Get the headers recieved by the api", + "description": "Headers as seen by the app (handy behind ingress/ALB)", "produces": [ "application/json" ], @@ -90,7 +220,10 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } @@ -98,46 +231,125 @@ const docTemplate = `{ }, "/health": { "get": { - "description": "Get the health of the api", + "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get health", "operationId": "health", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force unhealthy", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "healthy", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "string" } + }, + "503": { + "description": "Unhealthy", + "schema": { + "type": "string" + } } } } }, "/healthz": { "get": { - "description": "Get the health of the api", + "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get healthz", "operationId": "healthz", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force unhealthy", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "healthy", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "string" } + }, + "503": { + "description": "Unhealthy", + "schema": { + "type": "string" + } } } } }, - "/ping": { + "/help": { "get": { - "description": "Get the readyness of the api", + "description": "Quick map of useful routes", "produces": [ "application/json" ], + "summary": "Help", + "operationId": "help", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/metrics": { + "get": { + "description": "Prometheus scrape endpoint", + "produces": [ + "text/plain" + ], + "summary": "Prometheus metrics", + "operationId": "metrics", + "responses": { + "200": { + "description": "metrics", + "schema": { + "type": "string" + } + } + } + } + }, + "/ping": { + "get": { + "description": "Simple alive check", + "produces": [ + "text/plain" + ], "summary": "Get ping", "operationId": "ping", "responses": { @@ -152,15 +364,35 @@ const docTemplate = `{ }, "/ready": { "get": { - "description": "Get the readyness of the api", + "description": "Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod", "produces": [ - "application/json" + "text/plain" ], "summary": "Get ready", "operationId": "ready", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force not ready", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "ready", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "Not Ready", "schema": { "type": "string" } @@ -170,26 +402,94 @@ const docTemplate = `{ }, "/readyz": { "get": { - "description": "Get the readyness of the api", + "description": "Readiness check. Fail with env READY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get readyz", "operationId": "readyz", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force not ready", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "ready", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "Not Ready", + "schema": { + "type": "string" + } + } + } + } + }, + "/status/{code}": { + "get": { + "description": "Respond with whatever http status you pass (100-599). Great for ingress/retry testing", + "produces": [ + "text/plain" + ], + "summary": "Fixed status code", + "operationId": "status", + "parameters": [ + { + "type": "integer", + "description": "HTTP status code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "status body", "schema": { "type": "string" } } } } + }, + "/version": { + "get": { + "description": "What binary is running in this env", + "produces": [ + "application/json" + ], + "summary": "Version / build info", + "operationId": "version", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup.", + "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", "type": "apiKey", "name": "Authorization", "in": "header" @@ -204,7 +504,7 @@ var SwaggerInfo = &swag.Spec{ BasePath: "/", Schemes: []string{}, Title: "Cluster Util API", - Description: "This is a util api which lots of endpoints making it easy to test routing/ingress/egress", + Description: "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 16e847e..3b35f1c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "This is a util api which lots of endpoints making it easy to test routing/ingress/egress", + "description": "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster", "title": "Cluster Util API", "contact": {}, "version": "2.0" @@ -16,7 +16,7 @@ "BearerAuth": [] } ], - "description": "Get the env variables available to the api. This is behind auth under /a/", + "description": "Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/", "produces": [ "application/json" ], @@ -56,7 +56,7 @@ }, "/debug": { "get": { - "description": "Get lots of info from running container headers/ips", + "description": "Hostname, client ip, headers, uri — good for routing tests", "produces": [ "application/json" ], @@ -65,6 +65,34 @@ "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/delay/{seconds}": { + "get": { + "description": "Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests", + "produces": [ + "text/plain" + ], + "summary": "Delay then OK", + "operationId": "delay", + "parameters": [ + { + "type": "number", + "description": "seconds to sleep (max 30)", + "name": "seconds", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "delayed", "schema": { "type": "string" } @@ -72,9 +100,111 @@ } } }, + "/echo": { + "get": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "patch": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/headers": { "get": { - "description": "Get the headers recieved by the api", + "description": "Headers as seen by the app (handy behind ingress/ALB)", "produces": [ "application/json" ], @@ -84,7 +214,10 @@ "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } @@ -92,46 +225,125 @@ }, "/health": { "get": { - "description": "Get the health of the api", + "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get health", "operationId": "health", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force unhealthy", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "healthy", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "string" } + }, + "503": { + "description": "Unhealthy", + "schema": { + "type": "string" + } } } } }, "/healthz": { "get": { - "description": "Get the health of the api", + "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get healthz", "operationId": "healthz", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force unhealthy", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "healthy", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "string" } + }, + "503": { + "description": "Unhealthy", + "schema": { + "type": "string" + } } } } }, - "/ping": { + "/help": { "get": { - "description": "Get the readyness of the api", + "description": "Quick map of useful routes", "produces": [ "application/json" ], + "summary": "Help", + "operationId": "help", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/metrics": { + "get": { + "description": "Prometheus scrape endpoint", + "produces": [ + "text/plain" + ], + "summary": "Prometheus metrics", + "operationId": "metrics", + "responses": { + "200": { + "description": "metrics", + "schema": { + "type": "string" + } + } + } + } + }, + "/ping": { + "get": { + "description": "Simple alive check", + "produces": [ + "text/plain" + ], "summary": "Get ping", "operationId": "ping", "responses": { @@ -146,15 +358,35 @@ }, "/ready": { "get": { - "description": "Get the readyness of the api", + "description": "Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod", "produces": [ - "application/json" + "text/plain" ], "summary": "Get ready", "operationId": "ready", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force not ready", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "ready", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "Not Ready", "schema": { "type": "string" } @@ -164,26 +396,94 @@ }, "/readyz": { "get": { - "description": "Get the readyness of the api", + "description": "Readiness check. Fail with env READY=false or ?ok=0", "produces": [ - "application/json" + "text/plain" ], "summary": "Get readyz", "operationId": "readyz", + "parameters": [ + { + "type": "string", + "description": "set 0/false to force not ready", + "name": "ok", + "in": "query" + }, + { + "type": "string", + "description": "same as ok", + "name": "ready", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "Not Ready", + "schema": { + "type": "string" + } + } + } + } + }, + "/status/{code}": { + "get": { + "description": "Respond with whatever http status you pass (100-599). Great for ingress/retry testing", + "produces": [ + "text/plain" + ], + "summary": "Fixed status code", + "operationId": "status", + "parameters": [ + { + "type": "integer", + "description": "HTTP status code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "status body", "schema": { "type": "string" } } } } + }, + "/version": { + "get": { + "description": "What binary is running in this env", + "produces": [ + "application/json" + ], + "summary": "Version / build info", + "operationId": "version", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup.", + "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d0efd3f..b481c9e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2,14 +2,15 @@ basePath: / host: localhost:8080 info: contact: {} - description: This is a util api which lots of endpoints making it easy to test routing/ingress/egress + description: Drop-in HTTP util for testing probes, routing, headers, env/params + and more in a cluster title: Cluster Util API version: "2.0" paths: /a/env: get: - description: Get the env variables available to the api. This is behind auth - under /a/ + description: Env dump so you can check secrets/configmaps/task params actually + landed. Behind auth under /a/ operationId: env parameters: - default: Bearer @@ -38,7 +39,7 @@ paths: summary: Get environment variables /debug: get: - description: Get lots of info from running container headers/ips + description: Hostname, client ip, headers, uri — good for routing tests operationId: debug produces: - application/json @@ -46,11 +47,102 @@ paths: "200": description: OK schema: - type: string + additionalProperties: true + type: object summary: Debug + /delay/{seconds}: + get: + description: Sleep N seconds (max 30) then return 200. Useful for timeout / + slow upstream tests + operationId: delay + parameters: + - description: seconds to sleep (max 30) + in: path + name: seconds + required: true + type: number + produces: + - text/plain + responses: + "200": + description: delayed + schema: + type: string + summary: Delay then OK + /echo: + delete: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + get: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + patch: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + post: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + put: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request /headers: get: - description: Get the headers recieved by the api + description: Headers as seen by the app (handy behind ingress/ALB) operationId: headers produces: - application/json @@ -58,38 +150,92 @@ paths: "200": description: OK schema: - type: string + additionalProperties: + type: string + type: object summary: Get headers /health: get: - description: Get the health of the api + description: Liveness style check. Fail with env HEALTHY=false or ?ok=0 operationId: health + parameters: + - description: set 0/false to force unhealthy + in: query + name: ok + type: string + - description: same as ok + in: query + name: healthy + type: string produces: - - application/json + - text/plain responses: "200": description: OK schema: type: string + "503": + description: Unhealthy + schema: + type: string summary: Get health /healthz: get: - description: Get the health of the api + description: Liveness style check. Fail with env HEALTHY=false or ?ok=0 operationId: healthz + parameters: + - description: set 0/false to force unhealthy + in: query + name: ok + type: string + - description: same as ok + in: query + name: healthy + type: string produces: - - application/json + - text/plain responses: "200": description: OK schema: type: string + "503": + description: Unhealthy + schema: + type: string summary: Get healthz + /help: + get: + description: Quick map of useful routes + operationId: help + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Help + /metrics: + get: + description: Prometheus scrape endpoint + operationId: metrics + produces: + - text/plain + responses: + "200": + description: metrics + schema: + type: string + summary: Prometheus metrics /ping: get: - description: Get the readyness of the api + description: Simple alive check operationId: ping produces: - - application/json + - text/plain responses: "200": description: PONG @@ -98,32 +244,92 @@ paths: summary: Get ping /ready: get: - description: Get the readyness of the api + description: Readiness check. Fail with env READY=false or ?ok=0 so you can + watch k8s/ECS kick the pod operationId: ready + parameters: + - description: set 0/false to force not ready + in: query + name: ok + type: string + - description: same as ok + in: query + name: ready + type: string produces: - - application/json + - text/plain responses: "200": - description: OK + description: Ready + schema: + type: string + "503": + description: Not Ready schema: type: string summary: Get ready /readyz: get: - description: Get the readyness of the api + description: Readiness check. Fail with env READY=false or ?ok=0 operationId: readyz + parameters: + - description: set 0/false to force not ready + in: query + name: ok + type: string + - description: same as ok + in: query + name: ready + type: string produces: - - application/json + - text/plain responses: "200": - description: OK + description: Ready + schema: + type: string + "503": + description: Not Ready schema: type: string summary: Get readyz + /status/{code}: + get: + description: Respond with whatever http status you pass (100-599). Great for + ingress/retry testing + operationId: status + parameters: + - description: HTTP status code + in: path + name: code + required: true + type: integer + produces: + - text/plain + responses: + "200": + description: status body + schema: + type: string + summary: Fixed status code + /version: + get: + description: What binary is running in this env + operationId: version + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Version / build info securityDefinitions: BearerAuth: description: Type "Bearer" followed by a space and the token from the app logs - on startup. + on startup (or AUTH_TOKEN env). in: header name: Authorization type: apiKey diff --git a/handlers/handlers.go b/handlers/handlers.go index 9ca2442..fac7631 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,17 +1,25 @@ package handlers import ( - "encoding/json" + "io" "net" "net/http" "os" + "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) +// Build info injected from main (ldflags). +var ( + AppVersion = "dev" + AppGitHash = "unknown" +) + var ( requestsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ @@ -26,114 +34,169 @@ func init() { prometheus.MustRegister(requestsTotal) } +// SetBuildInfo lets main push version/git hash from ldflags. +func SetBuildInfo(version, gitHash string) { + if version != "" { + AppVersion = version + } + if gitHash != "" { + AppGitHash = gitHash + } +} + +// MetricsMiddleware counts requests for /metrics. +func MetricsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + requestsTotal.WithLabelValues(c.Request.Method, path, strconv.Itoa(c.Writer.Status())).Inc() + } +} + +// @Summary Prometheus metrics +// @Description Prometheus scrape endpoint +// @ID metrics +// @Produce plain +// @Success 200 {string} string "metrics" +// @Router /metrics [get] func PrometheusMetricsHandler() gin.HandlerFunc { + h := promhttp.Handler() return func(c *gin.Context) { - handler := promhttp.Handler() - handler.ServeHTTP(c.Writer, c.Request) + h.ServeHTTP(c.Writer, c.Request) } } +// @Summary Help +// @Description Quick map of useful routes +// @ID help +// @Produce json +// @Success 200 {object} map[string]string +// @Router /help [get] +func HelpHandler(c *gin.Context) { + c.JSON(http.StatusOK, map[string]string{ + "/": "redirect to swagger docs", + "/api-docs/*": "swagger ui", + "/help": "GET this list", + "/version": "GET build version / git hash", + "/health": "GET liveness (env HEALTHY, query ok/healthy)", + "/healthz": "GET same as /health", + "/ready": "GET readiness (env READY, query ok/ready)", + "/readyz": "GET same as /ready", + "/ping": "GET PONG", + "/headers": "GET request headers", + "/debug": "GET hostname / ip / headers / uri", + "/metrics": "GET prometheus metrics", + "/status/:code": "GET respond with that http status", + "/delay/:seconds": "GET sleep then 200 (max 30s)", + "/echo": "GET/POST echo method, headers, query, body", + "/a/env": "GET env vars (bearer auth)", + }) +} + +// @Summary Version / build info +// @Description What binary is running in this env +// @ID version +// @Produce json +// @Success 200 {object} map[string]string +// @Router /version [get] +func VersionHandler(c *gin.Context) { + hostname, _ := os.Hostname() + c.JSON(http.StatusOK, gin.H{ + "version": AppVersion, + "gitHash": AppGitHash, + "hostname": hostname, + }) +} + // @Summary Get healthz -// @Description Get the health of the api +// @Description Liveness style check. Fail with env HEALTHY=false or ?ok=0 // @ID healthz -// @Produce json +// @Produce plain +// @Param ok query string false "set 0/false to force unhealthy" +// @Param healthy query string false "same as ok" // @Success 200 {string} string "OK" +// @Failure 503 {string} string "Unhealthy" // @Router /healthz [get] -// HealthHandler handles GET requests to /health endpoint and returns a 200 status code with "OK" message. func HealthzHandler(c *gin.Context) { - requestsTotal.WithLabelValues("GET", "/healthz", "200").Inc() HealthHandler(c) } -// HelpHandler handles GET requests to /help endpoint and returns a list of available routes. -func HelpHandler(c *gin.Context) { - routes := make(map[string]string) - routes["/healthz"] = "GET" - routes["/health"] = "GET" - routes["/readyz"] = "GET" - routes["/ready"] = "GET" - routes["/ping"] = "GET" - routes["/headers"] = "GET" - routes["/a/env"] = "GET" - routes["/debug"] = "GET" - routes["/"] = "This can be used to redirect to the swagger docs for more details" - - c.JSON(http.StatusOK, routes) -} - // @Summary Get health -// @Description Get the health of the api +// @Description Liveness style check. Fail with env HEALTHY=false or ?ok=0 // @ID health -// @Produce json +// @Produce plain +// @Param ok query string false "set 0/false to force unhealthy" +// @Param healthy query string false "same as ok" // @Success 200 {string} string "OK" +// @Failure 503 {string} string "Unhealthy" // @Router /health [get] -// HealthHandler handles GET requests to /health endpoint and returns a 200 status code with "OK" message. func HealthHandler(c *gin.Context) { - requestsTotal.WithLabelValues("GET", "/health", "200").Inc() + if !probeOK(c, "HEALTHY", "healthy") { + c.String(http.StatusServiceUnavailable, "Unhealthy") + return + } c.String(http.StatusOK, "OK") } // @Summary Get readyz -// @Description Get the readyness of the api +// @Description Readiness check. Fail with env READY=false or ?ok=0 // @ID readyz -// @Produce json -// @Success 200 {string} string "OK" +// @Produce plain +// @Param ok query string false "set 0/false to force not ready" +// @Param ready query string false "same as ok" +// @Success 200 {string} string "Ready" +// @Failure 503 {string} string "Not Ready" // @Router /readyz [get] func ReadyzHandler(c *gin.Context) { ReadyHandler(c) } // @Summary Get ready -// @Description Get the readyness of the api +// @Description Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod // @ID ready -// @Produce json -// @Success 200 {string} string "OK" +// @Produce plain +// @Param ok query string false "set 0/false to force not ready" +// @Param ready query string false "same as ok" +// @Success 200 {string} string "Ready" +// @Failure 503 {string} string "Not Ready" // @Router /ready [get] func ReadyHandler(c *gin.Context) { - isReady := true // not sure what i will do here? - - if isReady { - c.String(http.StatusOK, "Ready") - } else { + if !probeOK(c, "READY", "ready") { c.String(http.StatusServiceUnavailable, "Not Ready") + return } + c.String(http.StatusOK, "Ready") } // @Summary Get ping -// @Description Get the readyness of the api +// @Description Simple alive check // @ID ping -// @Produce json +// @Produce plain // @Success 200 {string} string "PONG" // @Router /ping [get] -// PingHandler handles the ping endpoint and returns a "PONG" response. func PingHandler(c *gin.Context) { c.String(http.StatusOK, "PONG") } // @Summary Get headers -// @Description Get the headers recieved by the api +// @Description Headers as seen by the app (handy behind ingress/ALB) // @ID headers // @Produce json -// @Success 200 {string} string "OK" +// @Success 200 {object} map[string]string // @Router /headers [get] func HeadersHandler(c *gin.Context) { headers := make(map[string]string) for key, values := range c.Request.Header { headers[key] = values[0] } - - // Convert headers to JSON - headersJSON, err := json.Marshal(headers) - if err != nil { - c.String(http.StatusInternalServerError, "Error converting headers to JSON") - return - } - - c.Data(http.StatusOK, "application/json", headersJSON) + c.JSON(http.StatusOK, headers) } // @Summary Get environment variables -// @Description Get the env variables available to the api. This is behind auth under /a/ +// @Description Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/ // @ID env // @Produce json // @Security BearerAuth @@ -142,32 +205,89 @@ func HeadersHandler(c *gin.Context) { // @Router /a/env [get] // @Param Authorization header string true "Bearer token from app logs" default(Bearer ) func EnvHandler(c *gin.Context) { - envVariables, _ := json.Marshal(GetEnvironmentVariables()) - c.Data(http.StatusOK, "application/json", envVariables) + c.JSON(http.StatusOK, GetEnvironmentVariables()) } // @Summary Debug -// @Description Get lots of info from running container headers/ips +// @Description Hostname, client ip, headers, uri — good for routing tests // @ID debug // @Produce json -// @Success 200 {string} string "OK" +// @Success 200 {object} map[string]interface{} // @Router /debug [get] func DebugHandler(c *gin.Context) { - hostname, _ := os.Hostname() - sourceIP := getClientIP(c.Request) - headers := c.Request.Header - - debugInfo := gin.H{ + c.JSON(http.StatusOK, gin.H{ "Hostname": hostname, - "SourceIP": sourceIP, - "UserAgent": headers.Get("User-Agent"), - "Headers": headers, + "SourceIP": getClientIP(c.Request), + "UserAgent": c.Request.Header.Get("User-Agent"), + "Headers": c.Request.Header, "RequestURI": c.Request.RequestURI, + "Method": c.Request.Method, + }) +} + +// @Summary Fixed status code +// @Description Respond with whatever http status you pass (100-599). Great for ingress/retry testing +// @ID status +// @Produce plain +// @Param code path int true "HTTP status code" +// @Success 200 {string} string "status body" +// @Router /status/{code} [get] +func StatusHandler(c *gin.Context) { + code, err := strconv.Atoi(c.Param("code")) + if err != nil || code < 100 || code > 599 { + c.String(http.StatusBadRequest, "code must be an int between 100 and 599") + return } + c.String(code, "status=%d", code) +} - // Return the debug information in the response - c.JSON(http.StatusOK, debugInfo) +// @Summary Delay then OK +// @Description Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests +// @ID delay +// @Produce plain +// @Param seconds path number true "seconds to sleep (max 30)" +// @Success 200 {string} string "delayed" +// @Router /delay/{seconds} [get] +func DelayHandler(c *gin.Context) { + raw := c.Param("seconds") + secs, err := strconv.ParseFloat(raw, 64) + if err != nil || secs < 0 { + c.String(http.StatusBadRequest, "seconds must be a number >= 0") + return + } + const maxDelay = 30.0 + if secs > maxDelay { + secs = maxDelay + } + time.Sleep(time.Duration(secs * float64(time.Second))) + c.String(http.StatusOK, "delayed=%.3fs", secs) +} + +// @Summary Echo request +// @Description Bounce method, path, query, headers and body back as json +// @ID echo +// @Accept plain +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /echo [get] +// @Router /echo [post] +// @Router /echo [put] +// @Router /echo [patch] +// @Router /echo [delete] +func EchoHandler(c *gin.Context) { + body, _ := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB cap + headers := make(map[string][]string, len(c.Request.Header)) + for k, v := range c.Request.Header { + headers[k] = v + } + c.JSON(http.StatusOK, gin.H{ + "method": c.Request.Method, + "path": c.Request.URL.Path, + "query": c.Request.URL.Query(), + "headers": headers, + "body": string(body), + }) } // GetEnvironmentVariables returns a map of all environment variables @@ -182,16 +302,36 @@ func GetEnvironmentVariables() map[string]string { return envVariables } -// getClientIP extracts the client's IP address from the request. -func getClientIP(r *http.Request) string { - xForwardedFor := r.Header.Get("X-Forwarded-For") - if xForwardedFor != "" { - return xForwardedFor +// probeOK: query overrides env. Default true (healthy/ready). +// query keys: "ok" or the specific key (healthy/ready). env: HEALTHY / READY etc. +func probeOK(c *gin.Context, envKey, queryKey string) bool { + if q := c.Query("ok"); q != "" { + return isTruthy(q) + } + if q := c.Query(queryKey); q != "" { + return isTruthy(q) } + if v, ok := os.LookupEnv(envKey); ok { + return isTruthy(v) + } + return true +} + +func isTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": + return false + default: + return true + } +} +func getClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return ip } - - return "" + return r.RemoteAddr } diff --git a/k8s-cluster-util-apis.yml b/k8s-cluster-util-apis.yml index ffe8640..2dceeda 100644 --- a/k8s-cluster-util-apis.yml +++ b/k8s-cluster-util-apis.yml @@ -21,20 +21,35 @@ spec: - name: cluster-utils-api image: donkeyx/cluster-utils-api:latest imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 livenessProbe: httpGet: path: /healthz - port: 8080 + port: http periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: http + periodSeconds: 5 startupProbe: httpGet: path: /healthz - port: 8080 + port: http failureThreshold: 30 periodSeconds: 10 env: - name: PORT value: "8080" + # flip these to exercise probe behaviour: + # - name: READY + # value: "false" + # - name: HEALTHY + # value: "false" + # - name: AUTH_TOKEN + # value: "fixed-token-for-tests" resources: requests: cpu: "0.1" @@ -48,10 +63,14 @@ apiVersion: v1 kind: Service metadata: name: cluster-utils-api-svc + labels: + app: cluster-utils + type: api spec: selector: app: cluster-utils type: api ports: - - port: 8080 - targetPort: 8080 + - name: http + port: 8080 + targetPort: http diff --git a/main.go b/main.go index 36c2338..5ee32ed 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,12 @@ // @title Cluster Util API // @version 2.0 -// @description This is a util api which lots of endpoints making it easy to test routing/ingress/egress +// @description Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster // @host localhost:8080 // @BasePath / // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization -// @description Type "Bearer" followed by a space and the token from the app logs on startup. +// @description Type "Bearer" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env). package main @@ -16,6 +16,7 @@ import ( "os" "strconv" + "cu-api/handlers" "cu-api/middleware" "cu-api/routes" @@ -24,13 +25,20 @@ import ( "go.uber.org/zap/zapcore" ) +// Set via -ldflags at build time (see Makefile). +var ( + Version = "dev" + GitHash = "unknown" +) + var securityToken string func main() { - logger := setupLogger() defer logger.Sync() + handlers.SetBuildInfo(Version, GitHash) + gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(middleware.LoggerMiddleware(logger)) @@ -38,11 +46,20 @@ func main() { port := getEnvOrDefault("PORT", 8080) - securityToken = generateRandomToken(32) + // Optional fixed token for automated tests; otherwise random each start. + if t := os.Getenv("AUTH_TOKEN"); t != "" { + securityToken = t + } else { + securityToken = generateRandomToken(32) + } routes.SetupRouter(logger, securityToken, r) - logger.Info("App started on port:", zap.Int("port", port)) - logger.Info("Random Security Token", zap.String("token", securityToken)) + logger.Info("App started", + zap.Int("port", port), + zap.String("version", Version), + zap.String("gitHash", GitHash), + ) + logger.Info("Security Token", zap.String("token", securityToken)) logger.Info("Curl Command", zap.String("command", getCurlCommand(port, securityToken))) r.Run(fmt.Sprintf(":%d", port)) @@ -58,27 +75,22 @@ func generateRandomToken(length int) string { } func getCurlCommand(port int, securityToken string) string { - variable := fmt.Sprintf("curl -H 'Authorization: Bearer %s' http://localhost:%d/a/env | jq", securityToken, port) - return variable + return fmt.Sprintf("curl -H 'Authorization: Bearer %s' http://localhost:%d/a/env | jq", securityToken, port) } func setupLogger() *zap.Logger { - config := zap.NewProductionConfig() config.Encoding = "json" config.EncoderConfig.TimeKey = "timestamp" - config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // Optional: Use ISO8601 time format + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder logger, err := config.Build() if err != nil { panic(err) } - return logger } -// getEnvOrDefault retrieves the value of the environment variable named by the key -// or returns the default value if the environment variable is not set. func getEnvOrDefault(key string, defaultValue int) int { if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { diff --git a/main_test.go b/main_test.go index 235b9fc..6e53328 100644 --- a/main_test.go +++ b/main_test.go @@ -1,33 +1,146 @@ -// main_test.go package main import ( + "cu-api/handlers" "cu-api/routes" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestHealthEndpoint(t *testing.T) { - expectedResponse := "OK" - - // Create a new Gin router +func setupTestRouter(token string) *gin.Engine { + gin.SetMode(gin.TestMode) + handlers.SetBuildInfo("test-ver", "abc123") r := gin.New() logger := setupLogger() - routes.SetupRouter(logger, securityToken, r) + routes.SetupRouter(logger, token, r) + return r +} - // Create a test HTTP request to the "/hello" endpoint - req, err := http.NewRequest("GET", "/health", nil) - if err != nil { - t.Fatal(err) - } +func TestHealthEndpoint(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "OK", rr.Body.String()) +} + +func TestHealthUnhealthyQuery(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/healthz?ok=0", nil) rr := httptest.NewRecorder() r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) + assert.Equal(t, "Unhealthy", rr.Body.String()) +} + +func TestReadyNotReadyQuery(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/ready?ready=false", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) + assert.Equal(t, "Not Ready", rr.Body.String()) +} + +func TestVersion(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/version", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "test-ver") + assert.Contains(t, rr.Body.String(), "abc123") +} + +func TestStatusCode(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/status/418", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, 418, rr.Code) + assert.Contains(t, rr.Body.String(), "418") +} + +func TestStatusBadCode(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/status/999", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestDelay(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/delay/0", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "delayed=") +} + +func TestEcho(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodPost, "/echo?x=1", strings.NewReader(`{"hi":"there"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "POST") + assert.Contains(t, body, "hi") + assert.Contains(t, body, "x") +} + +func TestEnvAuth(t *testing.T) { + token := "secret-token" + r := setupTestRouter(token) + + // no auth + req := httptest.NewRequest(http.MethodGet, "/a/env", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + // with auth + req2 := httptest.NewRequest(http.MethodGet, "/a/env", nil) + req2.Header.Set("Authorization", "Bearer "+token) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusOK, rr2.Code) +} + +func TestPing(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "PONG", rr.Body.String()) +} + +func TestMetrics(t *testing.T) { + r := setupTestRouter("tok") + // hit something first so counter has labels + r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ping", nil)) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, expectedResponse, rr.Body.String()) + assert.Contains(t, rr.Body.String(), "http_requests_total") } diff --git a/routes/routes.go b/routes/routes.go index 01b46a2..e29f6cc 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -15,6 +15,7 @@ import ( ) func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { + r.Use(handlers.MetricsMiddleware()) // Redirect to swagger docs r.GET("/", func(c *gin.Context) { @@ -23,6 +24,8 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/api-docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) r.GET("/help", handlers.HelpHandler) + r.GET("/version", handlers.VersionHandler) + r.GET("/metrics", handlers.PrometheusMetricsHandler()) r.GET("/health", handlers.HealthHandler) r.GET("/healthz", handlers.HealthzHandler) @@ -33,6 +36,10 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/debug", handlers.DebugHandler) r.GET("/ping", handlers.PingHandler) + r.GET("/status/:code", handlers.StatusHandler) + r.GET("/delay/:seconds", handlers.DelayHandler) + r.Any("/echo", handlers.EchoHandler) + authGroup := r.Group("/a") authGroup.Use(middleware.AuthMiddleware(logger, st)) authGroup.GET("/env", handlers.EnvHandler) From 4f6926cd0cf9454c64d649a048079392ab9cee51 Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:34:21 +1000 Subject: [PATCH 02/10] fix: real startup latch plus live/ready probe control Probes now follow kube roles properly: - /startupz latches after cold start (STARTUP_BOOT_DELAY), sticky until reset - /livez and /readyz use mode+delay (env seed or PUT /a/control/probes) - fail = 503, delaySeconds can trip short probe timeouts - sample manifest uses startup/live/ready with timeoutSeconds: 1 Also kept /healthz /ready aliases and documented the whole thing. --- README.md | 136 ++++++++++----- handlers/handlers.go | 116 +++---------- handlers/probes.go | 348 ++++++++++++++++++++++++++++++++++++++ k8s-cluster-util-apis.yml | 41 +++-- main.go | 3 + main_test.go | 133 ++++++++++++++- routes/routes.go | 15 +- 7 files changed, 637 insertions(+), 155 deletions(-) create mode 100644 handlers/probes.go diff --git a/README.md b/README.md index aebe733..b80e583 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github Throw it into a namespace / ECS task / compose stack and use it to test: -- **probes** — liveness / readiness style paths (`/health`, `/healthz`, `/ready`, `/readyz`, `/ping`) +- **probes** — real kube-style `/startupz`, `/livez`, `/readyz` with fail + delay + runtime control - **routing & ingress** — hit it through a service, ingress, ALB, mesh; see what actually arrives - **headers & identity** — what the proxy rewrote, client IP, host, path (`/headers`, `/debug`, `/echo`) - **config / params in the env** — dump process env behind auth (`/a/env`) so you can check secrets, configmaps, task defs actually landed - **bad / slow upstreams** — force status codes and delays (`/status/503`, `/delay/5`) - **any entrypoint noise** — binary is also linked as `node` / `npm` so broken charts that call weird commands still come up and serve the api -More endpoints will keep landing here as we need them. Default route dumps you into **swagger** so you can poke things from the browser without memorising paths. +Default route dumps you into **swagger** so you can poke things from the browser without memorising paths. | dockerhub: https://hub.docker.com/r/donkeyx/cluster-utils-api @@ -25,7 +25,7 @@ More endpoints will keep landing here as we need them. Default route dumps you i ## Usage -Most endpoints are open. Anything under `/a/` is authenticated — grab the bearer token from the container logs on startup (it rotates every restart unless you set `AUTH_TOKEN`). The app also logs a ready made curl for `/a/env`. +Most endpoints are open. Anything under `/a/` is authenticated — grab the bearer token from the container logs on startup (it rotates every restart unless you set `AUTH_TOKEN`). The app also logs ready made curls. Swagger UI: @@ -38,41 +38,106 @@ Port defaults to `8080`, override with `PORT` if you need to. ```bash docker run -d -p 8080:8080 --name test-api donkeyx/cluster-utils-api:latest +# fixed token for demos: +# docker run -d -p 8080:8080 -e AUTH_TOKEN=dev donkeyx/cluster-utils-api:latest ``` ```bash -# quick route map curl -sS localhost:8080/help | jq - -# what binary is this curl -sS localhost:8080/version | jq -# health / ping -curl -sS localhost:8080/healthz -curl -sS localhost:8080/ping +# kube-style probes +curl -sS localhost:8080/startupz +curl -sS localhost:8080/livez +curl -sS localhost:8080/readyz + +# force ready fail without redeploy (token from logs) +TOKEN=... # or AUTH_TOKEN you set +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"fail"}}' localhost:8080/a/control/probes | jq +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/readyz + +# slow live so kube timeoutSeconds trips +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"live":{"mode":"delay","delaySeconds":3}}' localhost:8080/a/control/probes | jq -# force probes to fail (query wins over env) -curl -sS -o /dev/null -w '%{http_code}\n' 'localhost:8080/ready?ok=0' -curl -sS -o /dev/null -w '%{http_code}\n' 'localhost:8080/healthz?healthy=false' +# cold start again without restarting the process +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"startup":{"mode":"ok","bootDelaySeconds":10},"resetStartupLatch":true}' \ + localhost:8080/a/control/probes | jq +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/startupz -# status / delay / echo — classic "is my ingress dumb" toolkit +# status / delay / echo curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/418 curl -sS localhost:8080/delay/1 curl -sS -X POST -d '{"hi":1}' localhost:8080/echo | jq -# debug routing / headers -curl -sS localhost:8080/headers | jq curl -sS localhost:8080/debug | jq +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +``` + +## Probes (the main event) + +These follow the usual kube split. **Status codes matter more than bodies** — kube only cares 2xx vs not (and timeouts). + +| path | kube role | fail means | aliases | +|------|-----------|------------|---------| +| `GET /startupz` | **startupProbe** | still starting / forced fail | `/startup` | +| `GET /livez` | **livenessProbe** | **restart** the container | `/healthz`, `/health` | +| `GET /readyz` | **readinessProbe** | leave **Service endpoints** (process stays up) | `/ready` | + +### Startup behaves like a real startup endpoint + +1. While `bootDelaySeconds` has not elapsed (from process start, or after a latch reset) → **503** `starting` +2. First success → **latches** to started +3. After latch → always **200** `started` (fast), until process restart or `resetStartupLatch` +4. `mode=fail` → **503** `startup failed` and **never** latches + +That matches what kube expects: spam startup until it works, then stop and only run live/ready. + +### Modes + delay (live / ready / startup) + +| mode | after optional delay | +|------|----------------------| +| `ok` | 200 | +| `fail` | 503 | +| `delay` | 200 (same as ok — use with `delaySeconds` > probe `timeoutSeconds` to force **timeouts**) | -# prometheus -curl -sS localhost:8080/metrics | head +`delaySeconds` is always applied on live/ready (capped at 30s). On startup it applies until latched; once latched answers are immediate. -# env dump (needs the token from logs, or AUTH_TOKEN you set) -docker logs test-api 2>&1 | head -30 -curl -sS -H "Authorization: Bearer " localhost:8080/a/env | jq +### Seed from env (steady state at deploy) + +| env | default | notes | +|-----|---------|--------| +| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `false`/`fail` → live 503 | +| `LIVE_DELAY` / `HEALTHY_DELAY` | 0 | seconds before live answers | +| `READY_MODE` / `READY` | ok | | +| `READY_DELAY` | 0 | | +| `STARTUP_MODE` / `STARTUP` | ok | | +| `STARTUP_DELAY` | 0 | per-request sleep while not latched | +| `STARTUP_BOOT_DELAY` | 0 | wall clock from start before first success allowed | + +### Flip at runtime (no redeploy) + +Auth required (same bearer as `/a/env`): + +```bash +# read +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq + +# write (partial update) +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{ + "ready": {"mode":"fail","delaySeconds":0}, + "live": {"mode":"ok","delaySeconds":0}, + "startup": {"mode":"ok","delaySeconds":0,"bootDelaySeconds":5}, + "resetStartupLatch": true + }' localhost:8080/a/control/probes | jq ``` -### Main endpoints +Query `?ok=0` still works on live/ready for quick curl hacks — **kube will never send that**, so use env or the control API for real probe demos. + +### Other endpoints | path | notes | |------|--------| @@ -80,34 +145,28 @@ curl -sS -H "Authorization: Bearer " localhost:8080/a/env | jq | `GET /api-docs/*` | swagger ui | | `GET /help` | json list of routes | | `GET /version` | version + git hash + hostname | -| `GET /health` `/healthz` | liveness; fail with `HEALTHY=false` or `?ok=0` | -| `GET /ready` `/readyz` | readiness; fail with `READY=false` or `?ok=0` | -| `GET /ping` | `PONG` | +| `GET /ping` | `PONG` (not a kube probe) | | `GET /headers` | request headers | | `GET /debug` | hostname / ip / headers / uri | | `GET /metrics` | prometheus | | `GET /status/:code` | respond with that http status (100-599) | -| `GET /delay/:seconds` | sleep then 200 (capped at 30s) | +| `GET /delay/:seconds` | sleep then 200 (generic; prefer probe delays for kube) | | `ANY /echo` | bounce method / query / headers / body | | `GET /a/env` | env vars, **bearer auth** | +| `GET/PUT /a/control/probes` | probe state, **bearer auth** | -### config knobs +### other config | env | default | what it does | |-----|---------|----------------| | `PORT` | `8080` | listen port | | `AUTH_TOKEN` | random each start | fixed bearer token if set | -| `HEALTHY` | true | liveness; `false`/`0` → 503 on /health* | -| `READY` | true | readiness; `false`/`0` → 503 on /ready* | - -Query overrides env for a single request: `?ok=0`, `?healthy=false`, `?ready=false`. ### run image in k8 cluster: You can run the pod in your cluster with the commands below. This will start a deployment and service but limited to cluster ip. If you want to expose with type loadbalancer you can do it yourself, I don't want you to get a bill from this. ```bash -# apply pod config kubectl -n default \ apply -f https://raw.githubusercontent.com/donkeyx/cluster-utils-api/master/k8s-cluster-util-apis.yml ``` @@ -117,14 +176,15 @@ kubectl get pods,svc -n default # service is cluster-utils-api-svc on 8080 ``` -### Now you can use port forwarding to curl your apis inside the cluster +Sample manifest uses: + +- **startupProbe** → `/startupz` (timeout 1s, period 2s) +- **livenessProbe** → `/livez` (timeout 1s) +- **readinessProbe** → `/readyz` (timeout 1s) + +Short timeouts make `delaySeconds: 3` an obvious timeout fail. Uncomment the env examples in the yaml to break things on purpose, or flip them live via `/a/control/probes`. ```bash -# in one window forward the ports to the service kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 - -# then curl the service -> pod -curl -sS localhost:8080/debug | jq +curl -sS localhost:8080/readyz ``` - -Manifest has liveness on `/healthz`, readiness on `/readyz`, startup on `/healthz` (port 8080). Flip `READY`/`HEALTHY` in the deployment if you want to watch the probes react. diff --git a/handlers/handlers.go b/handlers/handlers.go index fac7631..75bb7e6 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -77,22 +77,22 @@ func PrometheusMetricsHandler() gin.HandlerFunc { // @Router /help [get] func HelpHandler(c *gin.Context) { c.JSON(http.StatusOK, map[string]string{ - "/": "redirect to swagger docs", - "/api-docs/*": "swagger ui", - "/help": "GET this list", - "/version": "GET build version / git hash", - "/health": "GET liveness (env HEALTHY, query ok/healthy)", - "/healthz": "GET same as /health", - "/ready": "GET readiness (env READY, query ok/ready)", - "/readyz": "GET same as /ready", - "/ping": "GET PONG", - "/headers": "GET request headers", - "/debug": "GET hostname / ip / headers / uri", - "/metrics": "GET prometheus metrics", - "/status/:code": "GET respond with that http status", - "/delay/:seconds": "GET sleep then 200 (max 30s)", - "/echo": "GET/POST echo method, headers, query, body", - "/a/env": "GET env vars (bearer auth)", + "/": "redirect to swagger docs", + "/api-docs/*": "swagger ui", + "/help": "GET this list", + "/version": "GET build version / git hash", + "/livez|/healthz|/health": "GET liveness (LIVE_MODE / LIVE_DELAY)", + "/readyz|/ready": "GET readiness (READY_MODE / READY_DELAY)", + "/startupz|/startup": "GET startup latch (STARTUP_* / boot delay)", + "/a/control/probes": "GET/PUT probe state (bearer auth)", + "/ping": "GET PONG", + "/headers": "GET request headers", + "/debug": "GET hostname / ip / headers / uri", + "/metrics": "GET prometheus metrics", + "/status/:code": "GET respond with that http status", + "/delay/:seconds": "GET sleep then 200 (max 30s)", + "/echo": "GET/POST echo method, headers, query, body", + "/a/env": "GET env vars (bearer auth)", }) } @@ -111,68 +111,8 @@ func VersionHandler(c *gin.Context) { }) } -// @Summary Get healthz -// @Description Liveness style check. Fail with env HEALTHY=false or ?ok=0 -// @ID healthz -// @Produce plain -// @Param ok query string false "set 0/false to force unhealthy" -// @Param healthy query string false "same as ok" -// @Success 200 {string} string "OK" -// @Failure 503 {string} string "Unhealthy" -// @Router /healthz [get] -func HealthzHandler(c *gin.Context) { - HealthHandler(c) -} - -// @Summary Get health -// @Description Liveness style check. Fail with env HEALTHY=false or ?ok=0 -// @ID health -// @Produce plain -// @Param ok query string false "set 0/false to force unhealthy" -// @Param healthy query string false "same as ok" -// @Success 200 {string} string "OK" -// @Failure 503 {string} string "Unhealthy" -// @Router /health [get] -func HealthHandler(c *gin.Context) { - if !probeOK(c, "HEALTHY", "healthy") { - c.String(http.StatusServiceUnavailable, "Unhealthy") - return - } - c.String(http.StatusOK, "OK") -} - -// @Summary Get readyz -// @Description Readiness check. Fail with env READY=false or ?ok=0 -// @ID readyz -// @Produce plain -// @Param ok query string false "set 0/false to force not ready" -// @Param ready query string false "same as ok" -// @Success 200 {string} string "Ready" -// @Failure 503 {string} string "Not Ready" -// @Router /readyz [get] -func ReadyzHandler(c *gin.Context) { - ReadyHandler(c) -} - -// @Summary Get ready -// @Description Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod -// @ID ready -// @Produce plain -// @Param ok query string false "set 0/false to force not ready" -// @Param ready query string false "same as ok" -// @Success 200 {string} string "Ready" -// @Failure 503 {string} string "Not Ready" -// @Router /ready [get] -func ReadyHandler(c *gin.Context) { - if !probeOK(c, "READY", "ready") { - c.String(http.StatusServiceUnavailable, "Not Ready") - return - } - c.String(http.StatusOK, "Ready") -} - // @Summary Get ping -// @Description Simple alive check +// @Description Simple alive check (not a kube probe — use /livez for that) // @ID ping // @Produce plain // @Success 200 {string} string "PONG" @@ -243,7 +183,7 @@ func StatusHandler(c *gin.Context) { } // @Summary Delay then OK -// @Description Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests +// @Description Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead // @ID delay // @Produce plain // @Param seconds path number true "seconds to sleep (max 30)" @@ -256,10 +196,7 @@ func DelayHandler(c *gin.Context) { c.String(http.StatusBadRequest, "seconds must be a number >= 0") return } - const maxDelay = 30.0 - if secs > maxDelay { - secs = maxDelay - } + secs = clampDelay(secs) time.Sleep(time.Duration(secs * float64(time.Second))) c.String(http.StatusOK, "delayed=%.3fs", secs) } @@ -302,21 +239,6 @@ func GetEnvironmentVariables() map[string]string { return envVariables } -// probeOK: query overrides env. Default true (healthy/ready). -// query keys: "ok" or the specific key (healthy/ready). env: HEALTHY / READY etc. -func probeOK(c *gin.Context, envKey, queryKey string) bool { - if q := c.Query("ok"); q != "" { - return isTruthy(q) - } - if q := c.Query(queryKey); q != "" { - return isTruthy(q) - } - if v, ok := os.LookupEnv(envKey); ok { - return isTruthy(v) - } - return true -} - func isTruthy(s string) bool { switch strings.ToLower(strings.TrimSpace(s)) { case "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": diff --git a/handlers/probes.go b/handlers/probes.go new file mode 100644 index 0000000..d76caa3 --- /dev/null +++ b/handlers/probes.go @@ -0,0 +1,348 @@ +package handlers + +import ( + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// Probe modes — delay is orthogonal (always applied before the status decision). +const ( + ProbeModeOK = "ok" + ProbeModeFail = "fail" + ProbeModeDelay = "delay" // same outcome as ok; name makes "timeout testing" configs obvious +) + +const maxProbeDelaySec = 30.0 + +// ProbeConfig is the knobs for one probe type (live / ready / startup). +type ProbeConfig struct { + Mode string `json:"mode"` // ok | fail | delay + DelaySeconds float64 `json:"delaySeconds"` // sleep before answering (0–30) + // Startup only: wall-clock seconds from process start before a success is allowed. + // Simulates real cold start — kube keeps hitting startup until this elapses (or control forces ok). + BootDelaySeconds float64 `json:"bootDelaySeconds,omitempty"` +} + +// ProbeSnapshot is what /a/control/probes returns (includes runtime bits). +type ProbeSnapshot struct { + Live ProbeConfig `json:"live"` + Ready ProbeConfig `json:"ready"` + Startup ProbeConfig `json:"startup"` + StartupLatched bool `json:"startupLatched"` + UptimeSeconds float64 `json:"uptimeSeconds"` + // True when startup would succeed *right now* (after boot delay / latch rules). + StartupWouldPass bool `json:"startupWouldPass"` +} + +// ProbeUpdate is the body for PUT /a/control/probes (all fields optional). +type ProbeUpdate struct { + Live *ProbeConfig `json:"live,omitempty"` + Ready *ProbeConfig `json:"ready,omitempty"` + Startup *ProbeConfig `json:"startup,omitempty"` + // Clear the startup latch so the next checks behave like a fresh process again. + ResetStartupLatch *bool `json:"resetStartupLatch,omitempty"` +} + +type probeState struct { + mu sync.RWMutex + live ProbeConfig + ready ProbeConfig + startup ProbeConfig + startupLatched bool + startedAt time.Time +} + +var probes = &probeState{ + live: ProbeConfig{Mode: ProbeModeOK}, + ready: ProbeConfig{Mode: ProbeModeOK}, + startup: ProbeConfig{Mode: ProbeModeOK}, + startedAt: time.Now(), +} + +// InitProbesFromEnv seeds probe config from environment (call once at process start). +// +// LIVE_MODE / HEALTHY_MODE / HEALTHY + LIVE_DELAY / HEALTHY_DELAY +// READY_MODE / READY + READY_DELAY +// STARTUP_MODE / STARTUP + STARTUP_DELAY + STARTUP_BOOT_DELAY +func InitProbesFromEnv() { + probes.mu.Lock() + defer probes.mu.Unlock() + + probes.startedAt = time.Now() + probes.startupLatched = false + + probes.live = ProbeConfig{ + Mode: envMode("LIVE_MODE", "HEALTHY_MODE", "HEALTHY"), + DelaySeconds: envDelay("LIVE_DELAY", "HEALTHY_DELAY"), + } + probes.ready = ProbeConfig{ + Mode: envMode("READY_MODE", "READY"), + DelaySeconds: envDelay("READY_DELAY"), + } + probes.startup = ProbeConfig{ + Mode: envMode("STARTUP_MODE", "STARTUP"), + DelaySeconds: envDelay("STARTUP_DELAY"), + BootDelaySeconds: envDelay("STARTUP_BOOT_DELAY"), + } +} + +func envMode(keys ...string) string { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + return normalizeMode(v) + } + } + return ProbeModeOK +} + +func envDelay(keys ...string) float64 { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + f, err := strconv.ParseFloat(v, 64) + if err == nil { + return clampDelay(f) + } + } + } + return 0 +} + +func normalizeMode(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case ProbeModeFail, "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": + return ProbeModeFail + case ProbeModeDelay, "slow": + return ProbeModeDelay + default: + return ProbeModeOK + } +} + +func clampDelay(d float64) float64 { + if d < 0 { + return 0 + } + if d > maxProbeDelaySec { + return maxProbeDelaySec + } + return d +} + +func (c ProbeConfig) normalized() ProbeConfig { + c.Mode = normalizeMode(c.Mode) + c.DelaySeconds = clampDelay(c.DelaySeconds) + c.BootDelaySeconds = clampDelay(c.BootDelaySeconds) + return c +} + +func (c ProbeConfig) shouldFail() bool { + return c.Mode == ProbeModeFail +} + +// applyProbe runs delay + status for live/ready (not startup). +// Query overrides: ?ok=0 / ?ok=false force fail for this request only (handy for curl; kube never sends these). +func applyProbe(c *gin.Context, cfg ProbeConfig, queryKey, okBody, failBody string) { + cfg = cfg.normalized() + + // one-shot query overrides (debug / manual only) + if q := c.Query("ok"); q != "" { + if !isTruthy(q) { + sleepDelay(cfg.DelaySeconds) + c.String(http.StatusServiceUnavailable, failBody) + return + } + } else if q := c.Query(queryKey); q != "" { + if !isTruthy(q) { + sleepDelay(cfg.DelaySeconds) + c.String(http.StatusServiceUnavailable, failBody) + return + } + } + + sleepDelay(cfg.DelaySeconds) + + if cfg.shouldFail() { + c.String(http.StatusServiceUnavailable, failBody) + return + } + c.String(http.StatusOK, okBody) +} + +func sleepDelay(seconds float64) { + if seconds > 0 { + time.Sleep(time.Duration(seconds * float64(time.Second))) + } +} + +// --- HTTP handlers: live / ready / startup --- + +// @Summary Liveness (livez) +// @Description Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health +// @ID livez +// @Produce plain +// @Param ok query string false "one-shot force fail with 0/false (curl only; kube does not send this)" +// @Success 200 {string} string "ok" +// @Failure 503 {string} string "unhealthy" +// @Router /livez [get] +// @Router /healthz [get] +// @Router /health [get] +func LiveHandler(c *gin.Context) { + probes.mu.RLock() + cfg := probes.live + probes.mu.RUnlock() + applyProbe(c, cfg, "healthy", "ok", "unhealthy") +} + +// HealthHandler kept as name for older tests/docs — same as live. +func HealthHandler(c *gin.Context) { LiveHandler(c) } +func HealthzHandler(c *gin.Context) { LiveHandler(c) } + +// @Summary Readiness (readyz) +// @Description Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready +// @ID readyz +// @Produce plain +// @Param ok query string false "one-shot force fail (curl only)" +// @Success 200 {string} string "ready" +// @Failure 503 {string} string "not ready" +// @Router /readyz [get] +// @Router /ready [get] +func ReadyHandler(c *gin.Context) { + probes.mu.RLock() + cfg := probes.ready + probes.mu.RUnlock() + applyProbe(c, cfg, "ready", "ready", "not ready") +} + +func ReadyzHandler(c *gin.Context) { ReadyHandler(c) } + +// @Summary Startup (startupz) +// @Description Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches. +// @ID startupz +// @Produce plain +// @Success 200 {string} string "started" +// @Failure 503 {string} string "starting" +// @Router /startupz [get] +// @Router /startup [get] +func StartupHandler(c *gin.Context) { + // snapshot under read lock, sleep without holding the lock + probes.mu.RLock() + cfg := probes.startup.normalized() + latched := probes.startupLatched + delay := cfg.DelaySeconds + if latched && !cfg.shouldFail() { + delay = 0 // finished init: answer immediately + } + probes.mu.RUnlock() + + sleepDelay(delay) + + probes.mu.Lock() + defer probes.mu.Unlock() + cfg = probes.startup.normalized() + + // forced fail: never latch + if cfg.shouldFail() { + probes.startupLatched = false + c.String(http.StatusServiceUnavailable, "startup failed") + return + } + + // already started — sticky success (real startup endpoint behaviour) + if probes.startupLatched { + c.String(http.StatusOK, "started") + return + } + + // still in cold-start window + elapsed := time.Since(probes.startedAt).Seconds() + if cfg.BootDelaySeconds > 0 && elapsed < cfg.BootDelaySeconds { + c.String(http.StatusServiceUnavailable, "starting") + return + } + + // first success → latch until reset / process death + probes.startupLatched = true + c.String(http.StatusOK, "started") +} + +// --- control API --- + +// @Summary Get probe control state +// @Description Current live/ready/startup config + startup latch + uptime +// @ID getProbes +// @Produce json +// @Security BearerAuth +// @Success 200 {object} ProbeSnapshot +// @Failure 401 {object} map[string]string +// @Router /a/control/probes [get] +func GetProbesHandler(c *gin.Context) { + c.JSON(http.StatusOK, snapshotProbes()) +} + +// @Summary Update probe control state +// @Description Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process. +// @ID putProbes +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body ProbeUpdate true "probe update" +// @Success 200 {object} ProbeSnapshot +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /a/control/probes [put] +func PutProbesHandler(c *gin.Context) { + var upd ProbeUpdate + if err := c.ShouldBindJSON(&upd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()}) + return + } + + probes.mu.Lock() + if upd.Live != nil { + probes.live = upd.Live.normalized() + } + if upd.Ready != nil { + probes.ready = upd.Ready.normalized() + } + if upd.Startup != nil { + // preserve boot delay if client omitted it (0 is valid though — use pointer fields ideally; + // for simplicity: always take normalized startup update as full replacement of those fields) + probes.startup = upd.Startup.normalized() + } + if upd.ResetStartupLatch != nil && *upd.ResetStartupLatch { + probes.startupLatched = false + probes.startedAt = time.Now() // restart the boot clock for another cold-start demo + } + // if startup forced to fail, drop latch + if probes.startup.shouldFail() { + probes.startupLatched = false + } + probes.mu.Unlock() + + c.JSON(http.StatusOK, snapshotProbes()) +} + +func snapshotProbes() ProbeSnapshot { + probes.mu.RLock() + defer probes.mu.RUnlock() + + uptime := time.Since(probes.startedAt).Seconds() + cfg := probes.startup.normalized() + wouldPass := probes.startupLatched || + (!cfg.shouldFail() && (cfg.BootDelaySeconds <= 0 || uptime >= cfg.BootDelaySeconds)) + + return ProbeSnapshot{ + Live: probes.live.normalized(), + Ready: probes.ready.normalized(), + Startup: cfg, + StartupLatched: probes.startupLatched, + UptimeSeconds: uptime, + StartupWouldPass: wouldPass && !cfg.shouldFail(), + } +} diff --git a/k8s-cluster-util-apis.yml b/k8s-cluster-util-apis.yml index 2dceeda..95fea8a 100644 --- a/k8s-cluster-util-apis.yml +++ b/k8s-cluster-util-apis.yml @@ -24,30 +24,47 @@ spec: ports: - name: http containerPort: 8080 + # Short timeouts so LIVE/READY/STARTUP delaySeconds are easy to trip. + # startup runs until first success, then kube moves on to live+ready. + startupProbe: + httpGet: + path: /startupz + port: http + periodSeconds: 2 + timeoutSeconds: 1 + failureThreshold: 30 livenessProbe: httpGet: - path: /healthz + path: /livez port: http periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 readinessProbe: httpGet: path: /readyz port: http periodSeconds: 5 - startupProbe: - httpGet: - path: /healthz - port: http - failureThreshold: 30 - periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 2 env: - name: PORT value: "8080" - # flip these to exercise probe behaviour: - # - name: READY - # value: "false" - # - name: HEALTHY - # value: "false" + # --- probe demos (uncomment what you want to break) --- + # cold start: stay "starting" for 15s then latch + # - name: STARTUP_BOOT_DELAY + # value: "15" + # fail readiness (pod stays up, leaves Service endpoints) + # - name: READY_MODE + # value: "fail" + # slow readiness → probe timeout (timeoutSeconds: 1) + # - name: READY_MODE + # value: "delay" + # - name: READY_DELAY + # value: "3" + # fail liveness → restarts + # - name: LIVE_MODE + # value: "fail" # - name: AUTH_TOKEN # value: "fixed-token-for-tests" resources: diff --git a/main.go b/main.go index 5ee32ed..e215736 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ func main() { defer logger.Sync() handlers.SetBuildInfo(Version, GitHash) + handlers.InitProbesFromEnv() gin.SetMode(gin.ReleaseMode) r := gin.New() @@ -61,6 +62,8 @@ func main() { ) logger.Info("Security Token", zap.String("token", securityToken)) logger.Info("Curl Command", zap.String("command", getCurlCommand(port, securityToken))) + logger.Info("Probe control", zap.String("command", + fmt.Sprintf("curl -sS -H 'Authorization: Bearer %s' http://localhost:%d/a/control/probes | jq", securityToken, port))) r.Run(fmt.Sprintf(":%d", port)) } diff --git a/main_test.go b/main_test.go index 6e53328..9b692d3 100644 --- a/main_test.go +++ b/main_test.go @@ -1,12 +1,15 @@ package main import ( + "bytes" "cu-api/handlers" "cu-api/routes" + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -16,12 +19,33 @@ import ( func setupTestRouter(token string) *gin.Engine { gin.SetMode(gin.TestMode) handlers.SetBuildInfo("test-ver", "abc123") + handlers.InitProbesFromEnv() + // reset to known defaults for tests (env may not be set) + resetProbesForTest() r := gin.New() logger := setupLogger() routes.SetupRouter(logger, token, r) return r } +func resetProbesForTest() { + ok := true + body, _ := json.Marshal(handlers.ProbeUpdate{ + Live: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0}, + Ready: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0}, + Startup: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0, BootDelaySeconds: 0}, + ResetStartupLatch: &ok, + }) + // use handler internals via HTTP would need router; call Put via package by spinning mini router + // simpler: hit control after router exists — for setup, use PUT through a throwaway engine + r := gin.New() + r.PUT("/p", handlers.PutProbesHandler) + req := httptest.NewRequest(http.MethodPut, "/p", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) +} + func TestHealthEndpoint(t *testing.T) { r := setupTestRouter("tok") req := httptest.NewRequest(http.MethodGet, "/health", nil) @@ -29,7 +53,16 @@ func TestHealthEndpoint(t *testing.T) { r.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "OK", rr.Body.String()) + assert.Equal(t, "ok", rr.Body.String()) +} + +func TestLivezAlias(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/livez", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "ok", rr.Body.String()) } func TestHealthUnhealthyQuery(t *testing.T) { @@ -39,7 +72,7 @@ func TestHealthUnhealthyQuery(t *testing.T) { r.ServeHTTP(rr, req) assert.Equal(t, http.StatusServiceUnavailable, rr.Code) - assert.Equal(t, "Unhealthy", rr.Body.String()) + assert.Equal(t, "unhealthy", rr.Body.String()) } func TestReadyNotReadyQuery(t *testing.T) { @@ -49,7 +82,98 @@ func TestReadyNotReadyQuery(t *testing.T) { r.ServeHTTP(rr, req) assert.Equal(t, http.StatusServiceUnavailable, rr.Code) - assert.Equal(t, "Not Ready", rr.Body.String()) + assert.Equal(t, "not ready", rr.Body.String()) +} + +func TestReadyFailViaControl(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + body := `{"ready":{"mode":"fail","delaySeconds":0}}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "not ready", rr2.Body.String()) +} + +func TestStartupLatchAndBootDelay(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + // set boot delay into the future + body := `{"startup":{"mode":"ok","delaySeconds":0,"bootDelaySeconds":2},"resetStartupLatch":true}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + // still starting + req2 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "starting", rr2.Body.String()) + + time.Sleep(2100 * time.Millisecond) + + // first success latches + req3 := httptest.NewRequest(http.MethodGet, "/startup", nil) + rr3 := httptest.NewRecorder() + r.ServeHTTP(rr3, req3) + assert.Equal(t, http.StatusOK, rr3.Code) + assert.Equal(t, "started", rr3.Body.String()) + + // sticky after latch even if we can't easily re-apply boot without reset + req4 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr4 := httptest.NewRecorder() + r.ServeHTTP(rr4, req4) + assert.Equal(t, http.StatusOK, rr4.Code) +} + +func TestStartupFailNeverLatches(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + body := `{"startup":{"mode":"fail","delaySeconds":0,"bootDelaySeconds":0},"resetStartupLatch":true}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "startup failed", rr2.Body.String()) +} + +func TestGetProbesAuth(t *testing.T) { + token := "secret" + r := setupTestRouter(token) + + req := httptest.NewRequest(http.MethodGet, "/a/control/probes", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/a/control/probes", nil) + req2.Header.Set("Authorization", "Bearer "+token) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusOK, rr2.Code) + assert.Contains(t, rr2.Body.String(), "startupLatched") } func TestVersion(t *testing.T) { @@ -110,13 +234,11 @@ func TestEnvAuth(t *testing.T) { token := "secret-token" r := setupTestRouter(token) - // no auth req := httptest.NewRequest(http.MethodGet, "/a/env", nil) rr := httptest.NewRecorder() r.ServeHTTP(rr, req) assert.Equal(t, http.StatusUnauthorized, rr.Code) - // with auth req2 := httptest.NewRequest(http.MethodGet, "/a/env", nil) req2.Header.Set("Authorization", "Bearer "+token) rr2 := httptest.NewRecorder() @@ -135,7 +257,6 @@ func TestPing(t *testing.T) { func TestMetrics(t *testing.T) { r := setupTestRouter("tok") - // hit something first so counter has labels r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ping", nil)) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) diff --git a/routes/routes.go b/routes/routes.go index e29f6cc..b4ae450 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -27,10 +27,19 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/version", handlers.VersionHandler) r.GET("/metrics", handlers.PrometheusMetricsHandler()) - r.GET("/health", handlers.HealthHandler) + // Kube-style probes (plus older aliases) + // live = liveness → restart on fail + // ready = readiness → leave Service endpoints on fail + // startup = cold start latch → kube only until first success + r.GET("/livez", handlers.LiveHandler) r.GET("/healthz", handlers.HealthzHandler) - r.GET("/ready", handlers.ReadyHandler) + r.GET("/health", handlers.HealthHandler) + r.GET("/readyz", handlers.ReadyzHandler) + r.GET("/ready", handlers.ReadyHandler) + + r.GET("/startupz", handlers.StartupHandler) + r.GET("/startup", handlers.StartupHandler) r.GET("/headers", handlers.HeadersHandler) r.GET("/debug", handlers.DebugHandler) @@ -43,4 +52,6 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { authGroup := r.Group("/a") authGroup.Use(middleware.AuthMiddleware(logger, st)) authGroup.GET("/env", handlers.EnvHandler) + authGroup.GET("/control/probes", handlers.GetProbesHandler) + authGroup.PUT("/control/probes", handlers.PutProbesHandler) } From 40435e6d5496d9522736bc1decedb19e4b8933db Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:34:48 +1000 Subject: [PATCH 03/10] chore: swagger primary probe paths only (aliases still routed) --- docs/docs.go | 272 +++++++++++++++++++++++++++++---------------- docs/swagger.json | 272 +++++++++++++++++++++++++++++---------------- docs/swagger.yaml | 210 ++++++++++++++++++++++------------ handlers/probes.go | 4 - 4 files changed, 490 insertions(+), 268 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 7fe8e88..4facc22 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,91 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/a/control/probes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Current live/ready/startup config + startup latch + uptime", + "produces": [ + "application/json" + ], + "summary": "Get probe control state", + "operationId": "getProbes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Update probe control state", + "operationId": "putProbes", + "parameters": [ + { + "description": "probe update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProbeUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/a/env": { "get": { "security": [ @@ -81,7 +166,7 @@ const docTemplate = `{ }, "/delay/{seconds}": { "get": { - "description": "Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests", + "description": "Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", "produces": [ "text/plain" ], @@ -229,75 +314,52 @@ const docTemplate = `{ } } }, - "/health": { + "/help": { "get": { - "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", + "description": "Quick map of useful routes", "produces": [ - "text/plain" - ], - "summary": "Get health", - "operationId": "health", - "parameters": [ - { - "type": "string", - "description": "set 0/false to force unhealthy", - "name": "ok", - "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "healthy", - "in": "query" - } + "application/json" ], + "summary": "Help", + "operationId": "help", "responses": { "200": { "description": "OK", "schema": { - "type": "string" - } - }, - "503": { - "description": "Unhealthy", - "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/healthz": { + "/livez": { "get": { - "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", + "description": "Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health", "produces": [ "text/plain" ], - "summary": "Get healthz", - "operationId": "healthz", + "summary": "Liveness (livez)", + "operationId": "livez", "parameters": [ { "type": "string", - "description": "set 0/false to force unhealthy", + "description": "one-shot force fail with 0/false (curl only; kube does not send this)", "name": "ok", "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "healthy", - "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "ok", "schema": { "type": "string" } }, "503": { - "description": "Unhealthy", + "description": "unhealthy", "schema": { "type": "string" } @@ -305,27 +367,6 @@ const docTemplate = `{ } } }, - "/help": { - "get": { - "description": "Quick map of useful routes", - "produces": [ - "application/json" - ], - "summary": "Help", - "operationId": "help", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/metrics": { "get": { "description": "Prometheus scrape endpoint", @@ -346,7 +387,7 @@ const docTemplate = `{ }, "/ping": { "get": { - "description": "Simple alive check", + "description": "Simple alive check (not a kube probe — use /livez for that)", "produces": [ "text/plain" ], @@ -362,37 +403,31 @@ const docTemplate = `{ } } }, - "/ready": { + "/readyz": { "get": { - "description": "Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod", + "description": "Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready", "produces": [ "text/plain" ], - "summary": "Get ready", - "operationId": "ready", + "summary": "Readiness (readyz)", + "operationId": "readyz", "parameters": [ { "type": "string", - "description": "set 0/false to force not ready", + "description": "one-shot force fail (curl only)", "name": "ok", "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "ready", - "in": "query" } ], "responses": { "200": { - "description": "Ready", + "description": "ready", "schema": { "type": "string" } }, "503": { - "description": "Not Ready", + "description": "not ready", "schema": { "type": "string" } @@ -400,37 +435,23 @@ const docTemplate = `{ } } }, - "/readyz": { + "/startupz": { "get": { - "description": "Readiness check. Fail with env READY=false or ?ok=0", + "description": "Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches.", "produces": [ "text/plain" ], - "summary": "Get readyz", - "operationId": "readyz", - "parameters": [ - { - "type": "string", - "description": "set 0/false to force not ready", - "name": "ok", - "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "ready", - "in": "query" - } - ], + "summary": "Startup (startupz)", + "operationId": "startupz", "responses": { "200": { - "description": "Ready", + "description": "started", "schema": { "type": "string" } }, "503": { - "description": "Not Ready", + "description": "starting", "schema": { "type": "string" } @@ -487,6 +508,67 @@ const docTemplate = `{ } } }, + "definitions": { + "handlers.ProbeConfig": { + "type": "object", + "properties": { + "bootDelaySeconds": { + "description": "Startup only: wall-clock seconds from process start before a success is allowed.\nSimulates real cold start — kube keeps hitting startup until this elapses (or control forces ok).", + "type": "number" + }, + "delaySeconds": { + "description": "sleep before answering (0–30)", + "type": "number" + }, + "mode": { + "description": "ok | fail | delay", + "type": "string" + } + } + }, + "handlers.ProbeSnapshot": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startupLatched": { + "type": "boolean" + }, + "startupWouldPass": { + "description": "True when startup would succeed *right now* (after boot delay / latch rules).", + "type": "boolean" + }, + "uptimeSeconds": { + "type": "number" + } + } + }, + "handlers.ProbeUpdate": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "resetStartupLatch": { + "description": "Clear the startup latch so the next checks behave like a fresh process again.", + "type": "boolean" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + } + } + } + }, "securityDefinitions": { "BearerAuth": { "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", diff --git a/docs/swagger.json b/docs/swagger.json index 3b35f1c..d689d8e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -9,6 +9,91 @@ "host": "localhost:8080", "basePath": "/", "paths": { + "/a/control/probes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Current live/ready/startup config + startup latch + uptime", + "produces": [ + "application/json" + ], + "summary": "Get probe control state", + "operationId": "getProbes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Update probe control state", + "operationId": "putProbes", + "parameters": [ + { + "description": "probe update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProbeUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/a/env": { "get": { "security": [ @@ -75,7 +160,7 @@ }, "/delay/{seconds}": { "get": { - "description": "Sleep N seconds (max 30) then return 200. Useful for timeout / slow upstream tests", + "description": "Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", "produces": [ "text/plain" ], @@ -223,75 +308,52 @@ } } }, - "/health": { + "/help": { "get": { - "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", + "description": "Quick map of useful routes", "produces": [ - "text/plain" - ], - "summary": "Get health", - "operationId": "health", - "parameters": [ - { - "type": "string", - "description": "set 0/false to force unhealthy", - "name": "ok", - "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "healthy", - "in": "query" - } + "application/json" ], + "summary": "Help", + "operationId": "help", "responses": { "200": { "description": "OK", "schema": { - "type": "string" - } - }, - "503": { - "description": "Unhealthy", - "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/healthz": { + "/livez": { "get": { - "description": "Liveness style check. Fail with env HEALTHY=false or ?ok=0", + "description": "Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health", "produces": [ "text/plain" ], - "summary": "Get healthz", - "operationId": "healthz", + "summary": "Liveness (livez)", + "operationId": "livez", "parameters": [ { "type": "string", - "description": "set 0/false to force unhealthy", + "description": "one-shot force fail with 0/false (curl only; kube does not send this)", "name": "ok", "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "healthy", - "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "ok", "schema": { "type": "string" } }, "503": { - "description": "Unhealthy", + "description": "unhealthy", "schema": { "type": "string" } @@ -299,27 +361,6 @@ } } }, - "/help": { - "get": { - "description": "Quick map of useful routes", - "produces": [ - "application/json" - ], - "summary": "Help", - "operationId": "help", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/metrics": { "get": { "description": "Prometheus scrape endpoint", @@ -340,7 +381,7 @@ }, "/ping": { "get": { - "description": "Simple alive check", + "description": "Simple alive check (not a kube probe — use /livez for that)", "produces": [ "text/plain" ], @@ -356,37 +397,31 @@ } } }, - "/ready": { + "/readyz": { "get": { - "description": "Readiness check. Fail with env READY=false or ?ok=0 so you can watch k8s/ECS kick the pod", + "description": "Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready", "produces": [ "text/plain" ], - "summary": "Get ready", - "operationId": "ready", + "summary": "Readiness (readyz)", + "operationId": "readyz", "parameters": [ { "type": "string", - "description": "set 0/false to force not ready", + "description": "one-shot force fail (curl only)", "name": "ok", "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "ready", - "in": "query" } ], "responses": { "200": { - "description": "Ready", + "description": "ready", "schema": { "type": "string" } }, "503": { - "description": "Not Ready", + "description": "not ready", "schema": { "type": "string" } @@ -394,37 +429,23 @@ } } }, - "/readyz": { + "/startupz": { "get": { - "description": "Readiness check. Fail with env READY=false or ?ok=0", + "description": "Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches.", "produces": [ "text/plain" ], - "summary": "Get readyz", - "operationId": "readyz", - "parameters": [ - { - "type": "string", - "description": "set 0/false to force not ready", - "name": "ok", - "in": "query" - }, - { - "type": "string", - "description": "same as ok", - "name": "ready", - "in": "query" - } - ], + "summary": "Startup (startupz)", + "operationId": "startupz", "responses": { "200": { - "description": "Ready", + "description": "started", "schema": { "type": "string" } }, "503": { - "description": "Not Ready", + "description": "starting", "schema": { "type": "string" } @@ -481,6 +502,67 @@ } } }, + "definitions": { + "handlers.ProbeConfig": { + "type": "object", + "properties": { + "bootDelaySeconds": { + "description": "Startup only: wall-clock seconds from process start before a success is allowed.\nSimulates real cold start — kube keeps hitting startup until this elapses (or control forces ok).", + "type": "number" + }, + "delaySeconds": { + "description": "sleep before answering (0–30)", + "type": "number" + }, + "mode": { + "description": "ok | fail | delay", + "type": "string" + } + } + }, + "handlers.ProbeSnapshot": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startupLatched": { + "type": "boolean" + }, + "startupWouldPass": { + "description": "True when startup would succeed *right now* (after boot delay / latch rules).", + "type": "boolean" + }, + "uptimeSeconds": { + "type": "number" + } + } + }, + "handlers.ProbeUpdate": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "resetStartupLatch": { + "description": "Clear the startup latch so the next checks behave like a fresh process again.", + "type": "boolean" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + } + } + } + }, "securityDefinitions": { "BearerAuth": { "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b481c9e..892e68e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,49 @@ basePath: / +definitions: + handlers.ProbeConfig: + properties: + bootDelaySeconds: + description: |- + Startup only: wall-clock seconds from process start before a success is allowed. + Simulates real cold start — kube keeps hitting startup until this elapses (or control forces ok). + type: number + delaySeconds: + description: sleep before answering (0–30) + type: number + mode: + description: ok | fail | delay + type: string + type: object + handlers.ProbeSnapshot: + properties: + live: + $ref: '#/definitions/handlers.ProbeConfig' + ready: + $ref: '#/definitions/handlers.ProbeConfig' + startup: + $ref: '#/definitions/handlers.ProbeConfig' + startupLatched: + type: boolean + startupWouldPass: + description: True when startup would succeed *right now* (after boot delay + / latch rules). + type: boolean + uptimeSeconds: + type: number + type: object + handlers.ProbeUpdate: + properties: + live: + $ref: '#/definitions/handlers.ProbeConfig' + ready: + $ref: '#/definitions/handlers.ProbeConfig' + resetStartupLatch: + description: Clear the startup latch so the next checks behave like a fresh + process again. + type: boolean + startup: + $ref: '#/definitions/handlers.ProbeConfig' + type: object host: localhost:8080 info: contact: {} @@ -7,6 +52,61 @@ info: title: Cluster Util API version: "2.0" paths: + /a/control/probes: + get: + description: Current live/ready/startup config + startup latch + uptime + operationId: getProbes + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProbeSnapshot' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get probe control state + put: + consumes: + - application/json + description: Partial update of live/ready/startup. Use resetStartupLatch to + re-run cold start without restarting the process. + operationId: putProbes + parameters: + - description: probe update + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProbeUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProbeSnapshot' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update probe control state /a/env: get: description: Env dump so you can check secrets/configmaps/task params actually @@ -52,8 +152,8 @@ paths: summary: Debug /delay/{seconds}: get: - description: Sleep N seconds (max 30) then return 200. Useful for timeout / - slow upstream tests + description: Sleep N seconds (max 30) then return 200. For probe timeouts prefer + LIVE/READY/STARTUP delaySeconds instead operationId: delay parameters: - description: seconds to sleep (max 30) @@ -154,70 +254,43 @@ paths: type: string type: object summary: Get headers - /health: + /help: get: - description: Liveness style check. Fail with env HEALTHY=false or ?ok=0 - operationId: health - parameters: - - description: set 0/false to force unhealthy - in: query - name: ok - type: string - - description: same as ok - in: query - name: healthy - type: string + description: Quick map of useful routes + operationId: help produces: - - text/plain + - application/json responses: "200": description: OK schema: - type: string - "503": - description: Unhealthy - schema: - type: string - summary: Get health - /healthz: + additionalProperties: + type: string + type: object + summary: Help + /livez: get: - description: Liveness style check. Fail with env HEALTHY=false or ?ok=0 - operationId: healthz + description: 'Kube liveness style. 200 = process fine; 503 = kube will restart. + Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health' + operationId: livez parameters: - - description: set 0/false to force unhealthy + - description: one-shot force fail with 0/false (curl only; kube does not send + this) in: query name: ok type: string - - description: same as ok - in: query - name: healthy - type: string produces: - text/plain responses: "200": - description: OK + description: ok schema: type: string "503": - description: Unhealthy + description: unhealthy schema: type: string - summary: Get healthz - /help: - get: - description: Quick map of useful routes - operationId: help - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - summary: Help + summary: Liveness (livez) /metrics: get: description: Prometheus scrape endpoint @@ -232,7 +305,7 @@ paths: summary: Prometheus metrics /ping: get: - description: Simple alive check + description: Simple alive check (not a kube probe — use /livez for that) operationId: ping produces: - text/plain @@ -242,57 +315,46 @@ paths: schema: type: string summary: Get ping - /ready: + /readyz: get: - description: Readiness check. Fail with env READY=false or ?ok=0 so you can - watch k8s/ECS kick the pod - operationId: ready + description: 'Kube readiness style. 200 = take traffic; 503 = drop from Service + endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready' + operationId: readyz parameters: - - description: set 0/false to force not ready + - description: one-shot force fail (curl only) in: query name: ok type: string - - description: same as ok - in: query - name: ready - type: string produces: - text/plain responses: "200": - description: Ready + description: ready schema: type: string "503": - description: Not Ready + description: not ready schema: type: string - summary: Get ready - /readyz: + summary: Readiness (readyz) + /startupz: get: - description: Readiness check. Fail with env READY=false or ?ok=0 - operationId: readyz - parameters: - - description: set 0/false to force not ready - in: query - name: ok - type: string - - description: same as ok - in: query - name: ready - type: string + description: 'Real startup semantics: fails until boot delay elapses, then latches + success until process restart or resetStartupLatch. After latch, always 200 + (fast) so kube stops startup probes. Mode=fail never latches.' + operationId: startupz produces: - text/plain responses: "200": - description: Ready + description: started schema: type: string "503": - description: Not Ready + description: starting schema: type: string - summary: Get readyz + summary: Startup (startupz) /status/{code}: get: description: Respond with whatever http status you pass (100-599). Great for diff --git a/handlers/probes.go b/handlers/probes.go index d76caa3..2002ac9 100644 --- a/handlers/probes.go +++ b/handlers/probes.go @@ -190,8 +190,6 @@ func sleepDelay(seconds float64) { // @Success 200 {string} string "ok" // @Failure 503 {string} string "unhealthy" // @Router /livez [get] -// @Router /healthz [get] -// @Router /health [get] func LiveHandler(c *gin.Context) { probes.mu.RLock() cfg := probes.live @@ -211,7 +209,6 @@ func HealthzHandler(c *gin.Context) { LiveHandler(c) } // @Success 200 {string} string "ready" // @Failure 503 {string} string "not ready" // @Router /readyz [get] -// @Router /ready [get] func ReadyHandler(c *gin.Context) { probes.mu.RLock() cfg := probes.ready @@ -228,7 +225,6 @@ func ReadyzHandler(c *gin.Context) { ReadyHandler(c) } // @Success 200 {string} string "started" // @Failure 503 {string} string "starting" // @Router /startupz [get] -// @Router /startup [get] func StartupHandler(c *gin.Context) { // snapshot under read lock, sleep without holding the lock probes.mu.RLock() From 471afeb29ad543a283078b8654506dafa56cf92e Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:39:05 +1000 Subject: [PATCH 04/10] fix: add flap mode for live/ready/startup probes mode=flap alternates ok/fail either on a wall-clock half-period (flapSeconds, default 5) or every Nth request (flapEvery). Control API reports current phase; README + k8s examples updated. --- README.md | 34 +++++- docs/docs.go | 35 ++++-- docs/swagger.json | 35 ++++-- docs/swagger.yaml | 50 ++++---- handlers/probes.go | 242 +++++++++++++++++++++++++++----------- k8s-cluster-util-apis.yml | 10 ++ main_test.go | 24 ++++ 7 files changed, 319 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index b80e583..bc24f06 100644 --- a/README.md +++ b/README.md @@ -102,20 +102,50 @@ That matches what kube expects: spam startup until it works, then stop and only | `ok` | 200 | | `fail` | 503 | | `delay` | 200 (same as ok — use with `delaySeconds` > probe `timeoutSeconds` to force **timeouts**) | +| `flap` | alternates **ok / fail** (see below) | `delaySeconds` is always applied on live/ready (capped at 30s). On startup it applies until latched; once latched answers are immediate. +### Flap mode + +For watching kube react to flapping readiness / restart thrash on liveness: + +| knob | meaning | +|------|---------| +| `flapSeconds` | wall-clock half-period: **ok for N sec, fail for N sec**, repeat (default **5** if unset) | +| `flapEvery` | if set (>0), **every Nth request fails** instead of using the clock (nice for scripts/tests) | + +```bash +# ready flaps every 2 requests +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"flap","flapEvery":2}}' localhost:8080/a/control/probes | jq + +# ready flaps on a 3s timer (3s ok, 3s fail, …) +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"flap","flapSeconds":3}}' localhost:8080/a/control/probes | jq + +# watch phase +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq '{ready, readyFlapPhase}' +``` + +On **startup**, flap only applies **before** the latch; an ok phase can still latch and then stay started (same as real init eventually finishing). + ### Seed from env (steady state at deploy) | env | default | notes | |-----|---------|--------| -| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `false`/`fail` → live 503 | +| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `false`/`fail`/`flap` | | `LIVE_DELAY` / `HEALTHY_DELAY` | 0 | seconds before live answers | +| `LIVE_FLAP_SECONDS` | 5 when mode=flap | half-period for time flap | +| `LIVE_FLAP_EVERY` | 0 | every Nth request fails if set | | `READY_MODE` / `READY` | ok | | | `READY_DELAY` | 0 | | +| `READY_FLAP_SECONDS` | 5 when mode=flap | | +| `READY_FLAP_EVERY` | 0 | | | `STARTUP_MODE` / `STARTUP` | ok | | | `STARTUP_DELAY` | 0 | per-request sleep while not latched | | `STARTUP_BOOT_DELAY` | 0 | wall clock from start before first success allowed | +| `STARTUP_FLAP_SECONDS` / `STARTUP_FLAP_EVERY` | | flap before latch only | ### Flip at runtime (no redeploy) @@ -128,7 +158,7 @@ curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq # write (partial update) curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ - "ready": {"mode":"fail","delaySeconds":0}, + "ready": {"mode":"flap","flapSeconds":5}, "live": {"mode":"ok","delaySeconds":0}, "startup": {"mode":"ok","delaySeconds":0,"bootDelaySeconds":5}, "resetStartupLatch": true diff --git a/docs/docs.go b/docs/docs.go index 4facc22..9d6e496 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,7 +22,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Current live/ready/startup config + startup latch + uptime", + "description": "Current live/ready/startup config + startup latch + flap phase + uptime", "produces": [ "application/json" ], @@ -52,7 +52,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process.", + "description": "Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process.", "consumes": [ "application/json" ], @@ -337,7 +337,7 @@ const docTemplate = `{ }, "/livez": { "get": { - "description": "Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health", + "description": "Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health", "produces": [ "text/plain" ], @@ -346,7 +346,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "one-shot force fail with 0/false (curl only; kube does not send this)", + "description": "one-shot force fail with 0/false (curl only)", "name": "ok", "in": "query" } @@ -405,7 +405,7 @@ const docTemplate = `{ }, "/readyz": { "get": { - "description": "Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready", + "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", "produces": [ "text/plain" ], @@ -437,7 +437,7 @@ const docTemplate = `{ }, "/startupz": { "get": { - "description": "Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches.", + "description": "Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases — use flap carefully here).", "produces": [ "text/plain" ], @@ -513,15 +513,22 @@ const docTemplate = `{ "type": "object", "properties": { "bootDelaySeconds": { - "description": "Startup only: wall-clock seconds from process start before a success is allowed.\nSimulates real cold start — kube keeps hitting startup until this elapses (or control forces ok).", + "description": "Startup only: wall-clock seconds from process start before a success is allowed.", "type": "number" }, "delaySeconds": { "description": "sleep before answering (0–30)", "type": "number" }, + "flapEvery": { + "type": "integer" + }, + "flapSeconds": { + "description": "Flap (mode=flap):\n flapSeconds — wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5).\n flapEvery — if \u003e 0, every Nth request fails instead of using the clock (handy for tests).", + "type": "number" + }, "mode": { - "description": "ok | fail | delay", + "description": "ok | fail | delay | flap", "type": "string" } } @@ -532,17 +539,26 @@ const docTemplate = `{ "live": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "liveFlapPhase": { + "description": "Which half of a time-based flap we are in right now (ok|fail|n/a).", + "type": "string" + }, "ready": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "readyFlapPhase": { + "type": "string" + }, "startup": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "startupFlapPhase": { + "type": "string" + }, "startupLatched": { "type": "boolean" }, "startupWouldPass": { - "description": "True when startup would succeed *right now* (after boot delay / latch rules).", "type": "boolean" }, "uptimeSeconds": { @@ -560,7 +576,6 @@ const docTemplate = `{ "$ref": "#/definitions/handlers.ProbeConfig" }, "resetStartupLatch": { - "description": "Clear the startup latch so the next checks behave like a fresh process again.", "type": "boolean" }, "startup": { diff --git a/docs/swagger.json b/docs/swagger.json index d689d8e..83249c9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -16,7 +16,7 @@ "BearerAuth": [] } ], - "description": "Current live/ready/startup config + startup latch + uptime", + "description": "Current live/ready/startup config + startup latch + flap phase + uptime", "produces": [ "application/json" ], @@ -46,7 +46,7 @@ "BearerAuth": [] } ], - "description": "Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process.", + "description": "Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process.", "consumes": [ "application/json" ], @@ -331,7 +331,7 @@ }, "/livez": { "get": { - "description": "Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health", + "description": "Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health", "produces": [ "text/plain" ], @@ -340,7 +340,7 @@ "parameters": [ { "type": "string", - "description": "one-shot force fail with 0/false (curl only; kube does not send this)", + "description": "one-shot force fail with 0/false (curl only)", "name": "ok", "in": "query" } @@ -399,7 +399,7 @@ }, "/readyz": { "get": { - "description": "Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready", + "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", "produces": [ "text/plain" ], @@ -431,7 +431,7 @@ }, "/startupz": { "get": { - "description": "Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches.", + "description": "Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases — use flap carefully here).", "produces": [ "text/plain" ], @@ -507,15 +507,22 @@ "type": "object", "properties": { "bootDelaySeconds": { - "description": "Startup only: wall-clock seconds from process start before a success is allowed.\nSimulates real cold start — kube keeps hitting startup until this elapses (or control forces ok).", + "description": "Startup only: wall-clock seconds from process start before a success is allowed.", "type": "number" }, "delaySeconds": { "description": "sleep before answering (0–30)", "type": "number" }, + "flapEvery": { + "type": "integer" + }, + "flapSeconds": { + "description": "Flap (mode=flap):\n flapSeconds — wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5).\n flapEvery — if \u003e 0, every Nth request fails instead of using the clock (handy for tests).", + "type": "number" + }, "mode": { - "description": "ok | fail | delay", + "description": "ok | fail | delay | flap", "type": "string" } } @@ -526,17 +533,26 @@ "live": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "liveFlapPhase": { + "description": "Which half of a time-based flap we are in right now (ok|fail|n/a).", + "type": "string" + }, "ready": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "readyFlapPhase": { + "type": "string" + }, "startup": { "$ref": "#/definitions/handlers.ProbeConfig" }, + "startupFlapPhase": { + "type": "string" + }, "startupLatched": { "type": "boolean" }, "startupWouldPass": { - "description": "True when startup would succeed *right now* (after boot delay / latch rules).", "type": "boolean" }, "uptimeSeconds": { @@ -554,7 +570,6 @@ "$ref": "#/definitions/handlers.ProbeConfig" }, "resetStartupLatch": { - "description": "Clear the startup latch so the next checks behave like a fresh process again.", "type": "boolean" }, "startup": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 892e68e..f965323 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3,30 +3,42 @@ definitions: handlers.ProbeConfig: properties: bootDelaySeconds: - description: |- - Startup only: wall-clock seconds from process start before a success is allowed. - Simulates real cold start — kube keeps hitting startup until this elapses (or control forces ok). + description: 'Startup only: wall-clock seconds from process start before a + success is allowed.' type: number delaySeconds: description: sleep before answering (0–30) type: number + flapEvery: + type: integer + flapSeconds: + description: |- + Flap (mode=flap): + flapSeconds — wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5). + flapEvery — if > 0, every Nth request fails instead of using the clock (handy for tests). + type: number mode: - description: ok | fail | delay + description: ok | fail | delay | flap type: string type: object handlers.ProbeSnapshot: properties: live: $ref: '#/definitions/handlers.ProbeConfig' + liveFlapPhase: + description: Which half of a time-based flap we are in right now (ok|fail|n/a). + type: string ready: $ref: '#/definitions/handlers.ProbeConfig' + readyFlapPhase: + type: string startup: $ref: '#/definitions/handlers.ProbeConfig' + startupFlapPhase: + type: string startupLatched: type: boolean startupWouldPass: - description: True when startup would succeed *right now* (after boot delay - / latch rules). type: boolean uptimeSeconds: type: number @@ -38,8 +50,6 @@ definitions: ready: $ref: '#/definitions/handlers.ProbeConfig' resetStartupLatch: - description: Clear the startup latch so the next checks behave like a fresh - process again. type: boolean startup: $ref: '#/definitions/handlers.ProbeConfig' @@ -54,7 +64,8 @@ info: paths: /a/control/probes: get: - description: Current live/ready/startup config + startup latch + uptime + description: Current live/ready/startup config + startup latch + flap phase + + uptime operationId: getProbes produces: - application/json @@ -75,8 +86,8 @@ paths: put: consumes: - application/json - description: Partial update of live/ready/startup. Use resetStartupLatch to - re-run cold start without restarting the process. + description: Partial update of live/ready/startup. resetStartupLatch re-runs + cold start without restarting the process. operationId: putProbes parameters: - description: probe update @@ -270,12 +281,11 @@ paths: summary: Help /livez: get: - description: 'Kube liveness style. 200 = process fine; 503 = kube will restart. - Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health' + description: 'Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. + LIVE_* env or /a/control/probes. Aliases: /healthz /health' operationId: livez parameters: - - description: one-shot force fail with 0/false (curl only; kube does not send - this) + - description: one-shot force fail with 0/false (curl only) in: query name: ok type: string @@ -317,8 +327,8 @@ paths: summary: Get ping /readyz: get: - description: 'Kube readiness style. 200 = take traffic; 503 = drop from Service - endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready' + description: 'Kube readiness. 200 = take traffic; 503 = leave Service endpoints. + Modes: ok|fail|delay|flap. Alias: /ready' operationId: readyz parameters: - description: one-shot force fail (curl only) @@ -339,9 +349,9 @@ paths: summary: Readiness (readyz) /startupz: get: - description: 'Real startup semantics: fails until boot delay elapses, then latches - success until process restart or resetStartupLatch. After latch, always 200 - (fast) so kube stops startup probes. Mode=fail never latches.' + description: 'Cold start latch: 503 until bootDelay, then sticky 200. mode=fail + never latches. mode=flap oscillates until a success latches (or forever if + you only hit fail phases — use flap carefully here).' operationId: startupz produces: - text/plain diff --git a/handlers/probes.go b/handlers/probes.go index 2002ac9..046f8e6 100644 --- a/handlers/probes.go +++ b/handlers/probes.go @@ -11,51 +11,65 @@ import ( "github.com/gin-gonic/gin" ) -// Probe modes — delay is orthogonal (always applied before the status decision). +// Probe modes — delaySeconds is orthogonal (applied before the status decision, except latched startup). const ( ProbeModeOK = "ok" ProbeModeFail = "fail" ProbeModeDelay = "delay" // same outcome as ok; name makes "timeout testing" configs obvious + ProbeModeFlap = "flap" // alternate ok/fail on a timer or every N requests ) -const maxProbeDelaySec = 30.0 +const ( + maxProbeDelaySec = 30.0 + defaultFlapSec = 5.0 +) // ProbeConfig is the knobs for one probe type (live / ready / startup). type ProbeConfig struct { - Mode string `json:"mode"` // ok | fail | delay - DelaySeconds float64 `json:"delaySeconds"` // sleep before answering (0–30) + Mode string `json:"mode"` // ok | fail | delay | flap + DelaySeconds float64 `json:"delaySeconds"` // sleep before answering (0–30) // Startup only: wall-clock seconds from process start before a success is allowed. - // Simulates real cold start — kube keeps hitting startup until this elapses (or control forces ok). BootDelaySeconds float64 `json:"bootDelaySeconds,omitempty"` + // Flap (mode=flap): + // flapSeconds — wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5). + // flapEvery — if > 0, every Nth request fails instead of using the clock (handy for tests). + FlapSeconds float64 `json:"flapSeconds,omitempty"` + FlapEvery int `json:"flapEvery,omitempty"` } // ProbeSnapshot is what /a/control/probes returns (includes runtime bits). type ProbeSnapshot struct { - Live ProbeConfig `json:"live"` - Ready ProbeConfig `json:"ready"` - Startup ProbeConfig `json:"startup"` - StartupLatched bool `json:"startupLatched"` - UptimeSeconds float64 `json:"uptimeSeconds"` - // True when startup would succeed *right now* (after boot delay / latch rules). - StartupWouldPass bool `json:"startupWouldPass"` + Live ProbeConfig `json:"live"` + Ready ProbeConfig `json:"ready"` + Startup ProbeConfig `json:"startup"` + StartupLatched bool `json:"startupLatched"` + UptimeSeconds float64 `json:"uptimeSeconds"` + StartupWouldPass bool `json:"startupWouldPass"` + // Which half of a time-based flap we are in right now (ok|fail|n/a). + LiveFlapPhase string `json:"liveFlapPhase,omitempty"` + ReadyFlapPhase string `json:"readyFlapPhase,omitempty"` + StartupFlapPhase string `json:"startupFlapPhase,omitempty"` } // ProbeUpdate is the body for PUT /a/control/probes (all fields optional). type ProbeUpdate struct { - Live *ProbeConfig `json:"live,omitempty"` - Ready *ProbeConfig `json:"ready,omitempty"` - Startup *ProbeConfig `json:"startup,omitempty"` - // Clear the startup latch so the next checks behave like a fresh process again. - ResetStartupLatch *bool `json:"resetStartupLatch,omitempty"` + Live *ProbeConfig `json:"live,omitempty"` + Ready *ProbeConfig `json:"ready,omitempty"` + Startup *ProbeConfig `json:"startup,omitempty"` + ResetStartupLatch *bool `json:"resetStartupLatch,omitempty"` } type probeState struct { - mu sync.RWMutex - live ProbeConfig - ready ProbeConfig - startup ProbeConfig - startupLatched bool - startedAt time.Time + mu sync.Mutex + live ProbeConfig + ready ProbeConfig + startup ProbeConfig + startupLatched bool + startedAt time.Time + // request counters for flapEvery + liveHits int + readyHits int + startupHits int } var probes = &probeState{ @@ -67,28 +81,37 @@ var probes = &probeState{ // InitProbesFromEnv seeds probe config from environment (call once at process start). // -// LIVE_MODE / HEALTHY_MODE / HEALTHY + LIVE_DELAY / HEALTHY_DELAY -// READY_MODE / READY + READY_DELAY -// STARTUP_MODE / STARTUP + STARTUP_DELAY + STARTUP_BOOT_DELAY +// LIVE_MODE / HEALTHY_MODE / HEALTHY + LIVE_DELAY + LIVE_FLAP_SECONDS / LIVE_FLAP_EVERY +// READY_MODE / READY + READY_DELAY + READY_FLAP_* +// STARTUP_MODE / STARTUP + STARTUP_DELAY + STARTUP_BOOT_DELAY + STARTUP_FLAP_* func InitProbesFromEnv() { probes.mu.Lock() defer probes.mu.Unlock() probes.startedAt = time.Now() probes.startupLatched = false + probes.liveHits = 0 + probes.readyHits = 0 + probes.startupHits = 0 probes.live = ProbeConfig{ Mode: envMode("LIVE_MODE", "HEALTHY_MODE", "HEALTHY"), DelaySeconds: envDelay("LIVE_DELAY", "HEALTHY_DELAY"), + FlapSeconds: envDelay("LIVE_FLAP_SECONDS"), + FlapEvery: envInt("LIVE_FLAP_EVERY"), } probes.ready = ProbeConfig{ Mode: envMode("READY_MODE", "READY"), DelaySeconds: envDelay("READY_DELAY"), + FlapSeconds: envDelay("READY_FLAP_SECONDS"), + FlapEvery: envInt("READY_FLAP_EVERY"), } probes.startup = ProbeConfig{ Mode: envMode("STARTUP_MODE", "STARTUP"), DelaySeconds: envDelay("STARTUP_DELAY"), BootDelaySeconds: envDelay("STARTUP_BOOT_DELAY"), + FlapSeconds: envDelay("STARTUP_FLAP_SECONDS"), + FlapEvery: envInt("STARTUP_FLAP_EVERY"), } } @@ -113,12 +136,26 @@ func envDelay(keys ...string) float64 { return 0 } +func envInt(keys ...string) int { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + n, err := strconv.Atoi(v) + if err == nil && n > 0 { + return n + } + } + } + return 0 +} + func normalizeMode(s string) string { switch strings.ToLower(strings.TrimSpace(s)) { case ProbeModeFail, "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": return ProbeModeFail case ProbeModeDelay, "slow": return ProbeModeDelay + case ProbeModeFlap, "flapping", "oscillate": + return ProbeModeFlap default: return ProbeModeOK } @@ -138,36 +175,101 @@ func (c ProbeConfig) normalized() ProbeConfig { c.Mode = normalizeMode(c.Mode) c.DelaySeconds = clampDelay(c.DelaySeconds) c.BootDelaySeconds = clampDelay(c.BootDelaySeconds) + if c.FlapSeconds < 0 { + c.FlapSeconds = 0 + } + if c.FlapSeconds > maxProbeDelaySec { + c.FlapSeconds = maxProbeDelaySec + } + if c.FlapEvery < 0 { + c.FlapEvery = 0 + } return c } -func (c ProbeConfig) shouldFail() bool { +func (c ProbeConfig) isHardFail() bool { return c.Mode == ProbeModeFail } +func (c ProbeConfig) isFlap() bool { + return c.Mode == ProbeModeFlap +} + +// flapFail decides if this request is on the fail half of a flap. +// hitCount should already include this request when using flapEvery. +func (c ProbeConfig) flapFail(startedAt time.Time, hitCount int) bool { + c = c.normalized() + if c.FlapEvery > 0 { + // every Nth request fails: hits N, 2N, 3N, ... + return hitCount > 0 && hitCount%c.FlapEvery == 0 + } + period := c.FlapSeconds + if period <= 0 { + period = defaultFlapSec + } + // even windows = ok, odd windows = fail + phase := int(time.Since(startedAt).Seconds()/period) % 2 + return phase == 1 +} + +func (c ProbeConfig) flapPhase(startedAt time.Time, hitCount int) string { + if !c.isFlap() { + return "" + } + if c.flapFail(startedAt, hitCount) { + return "fail" + } + return "ok" +} + // applyProbe runs delay + status for live/ready (not startup). -// Query overrides: ?ok=0 / ?ok=false force fail for this request only (handy for curl; kube never sends these). -func applyProbe(c *gin.Context, cfg ProbeConfig, queryKey, okBody, failBody string) { - cfg = cfg.normalized() +// name is "live" or "ready" for hit counters. +func applyProbe(c *gin.Context, name, queryKey, okBody, failBody string) { + // one-shot query overrides first (no counter bump needed for pure override... still bump so flapEvery stays predictable) + probes.mu.Lock() + var cfg ProbeConfig + var hits int + switch name { + case "live": + probes.liveHits++ + hits = probes.liveHits + cfg = probes.live.normalized() + default: + probes.readyHits++ + hits = probes.readyHits + cfg = probes.ready.normalized() + } + startedAt := probes.startedAt + probes.mu.Unlock() - // one-shot query overrides (debug / manual only) + // query overrides (curl only; kube never sends these) if q := c.Query("ok"); q != "" { + sleepDelay(cfg.DelaySeconds) if !isTruthy(q) { - sleepDelay(cfg.DelaySeconds) c.String(http.StatusServiceUnavailable, failBody) return } - } else if q := c.Query(queryKey); q != "" { + c.String(http.StatusOK, okBody) + return + } + if q := c.Query(queryKey); q != "" { + sleepDelay(cfg.DelaySeconds) if !isTruthy(q) { - sleepDelay(cfg.DelaySeconds) c.String(http.StatusServiceUnavailable, failBody) return } + c.String(http.StatusOK, okBody) + return } sleepDelay(cfg.DelaySeconds) - if cfg.shouldFail() { + fail := cfg.isHardFail() + if cfg.isFlap() { + fail = cfg.flapFail(startedAt, hits) + } + + if fail { c.String(http.StatusServiceUnavailable, failBody) return } @@ -183,26 +285,22 @@ func sleepDelay(seconds float64) { // --- HTTP handlers: live / ready / startup --- // @Summary Liveness (livez) -// @Description Kube liveness style. 200 = process fine; 503 = kube will restart. Config via LIVE_MODE/LIVE_DELAY or /a/control/probes. Aliases: /healthz /health +// @Description Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health // @ID livez // @Produce plain -// @Param ok query string false "one-shot force fail with 0/false (curl only; kube does not send this)" +// @Param ok query string false "one-shot force fail with 0/false (curl only)" // @Success 200 {string} string "ok" // @Failure 503 {string} string "unhealthy" // @Router /livez [get] func LiveHandler(c *gin.Context) { - probes.mu.RLock() - cfg := probes.live - probes.mu.RUnlock() - applyProbe(c, cfg, "healthy", "ok", "unhealthy") + applyProbe(c, "live", "healthy", "ok", "unhealthy") } -// HealthHandler kept as name for older tests/docs — same as live. func HealthHandler(c *gin.Context) { LiveHandler(c) } func HealthzHandler(c *gin.Context) { LiveHandler(c) } // @Summary Readiness (readyz) -// @Description Kube readiness style. 200 = take traffic; 503 = drop from Service endpoints (no restart). READY_MODE/READY_DELAY or control API. Alias: /ready +// @Description Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready // @ID readyz // @Produce plain // @Param ok query string false "one-shot force fail (curl only)" @@ -210,31 +308,30 @@ func HealthzHandler(c *gin.Context) { LiveHandler(c) } // @Failure 503 {string} string "not ready" // @Router /readyz [get] func ReadyHandler(c *gin.Context) { - probes.mu.RLock() - cfg := probes.ready - probes.mu.RUnlock() - applyProbe(c, cfg, "ready", "ready", "not ready") + applyProbe(c, "ready", "ready", "ready", "not ready") } func ReadyzHandler(c *gin.Context) { ReadyHandler(c) } // @Summary Startup (startupz) -// @Description Real startup semantics: fails until boot delay elapses, then latches success until process restart or resetStartupLatch. After latch, always 200 (fast) so kube stops startup probes. Mode=fail never latches. +// @Description Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases — use flap carefully here). // @ID startupz // @Produce plain // @Success 200 {string} string "started" // @Failure 503 {string} string "starting" // @Router /startupz [get] func StartupHandler(c *gin.Context) { - // snapshot under read lock, sleep without holding the lock - probes.mu.RLock() + probes.mu.Lock() cfg := probes.startup.normalized() latched := probes.startupLatched delay := cfg.DelaySeconds - if latched && !cfg.shouldFail() { - delay = 0 // finished init: answer immediately + if latched && !cfg.isHardFail() { + delay = 0 } - probes.mu.RUnlock() + probes.startupHits++ + hits := probes.startupHits + startedAt := probes.startedAt + probes.mu.Unlock() sleepDelay(delay) @@ -242,27 +339,29 @@ func StartupHandler(c *gin.Context) { defer probes.mu.Unlock() cfg = probes.startup.normalized() - // forced fail: never latch - if cfg.shouldFail() { + if cfg.isHardFail() { probes.startupLatched = false c.String(http.StatusServiceUnavailable, "startup failed") return } - // already started — sticky success (real startup endpoint behaviour) if probes.startupLatched { c.String(http.StatusOK, "started") return } - // still in cold-start window elapsed := time.Since(probes.startedAt).Seconds() if cfg.BootDelaySeconds > 0 && elapsed < cfg.BootDelaySeconds { c.String(http.StatusServiceUnavailable, "starting") return } - // first success → latch until reset / process death + // flap during pre-latch: fail phases keep returning starting; ok phase latches + if cfg.isFlap() && cfg.flapFail(startedAt, hits) { + c.String(http.StatusServiceUnavailable, "starting") + return + } + probes.startupLatched = true c.String(http.StatusOK, "started") } @@ -270,7 +369,7 @@ func StartupHandler(c *gin.Context) { // --- control API --- // @Summary Get probe control state -// @Description Current live/ready/startup config + startup latch + uptime +// @Description Current live/ready/startup config + startup latch + flap phase + uptime // @ID getProbes // @Produce json // @Security BearerAuth @@ -282,7 +381,7 @@ func GetProbesHandler(c *gin.Context) { } // @Summary Update probe control state -// @Description Partial update of live/ready/startup. Use resetStartupLatch to re-run cold start without restarting the process. +// @Description Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process. // @ID putProbes // @Accept json // @Produce json @@ -302,21 +401,22 @@ func PutProbesHandler(c *gin.Context) { probes.mu.Lock() if upd.Live != nil { probes.live = upd.Live.normalized() + probes.liveHits = 0 } if upd.Ready != nil { probes.ready = upd.Ready.normalized() + probes.readyHits = 0 } if upd.Startup != nil { - // preserve boot delay if client omitted it (0 is valid though — use pointer fields ideally; - // for simplicity: always take normalized startup update as full replacement of those fields) probes.startup = upd.Startup.normalized() + probes.startupHits = 0 } if upd.ResetStartupLatch != nil && *upd.ResetStartupLatch { probes.startupLatched = false - probes.startedAt = time.Now() // restart the boot clock for another cold-start demo + probes.startedAt = time.Now() + probes.startupHits = 0 } - // if startup forced to fail, drop latch - if probes.startup.shouldFail() { + if probes.startup.isHardFail() { probes.startupLatched = false } probes.mu.Unlock() @@ -325,20 +425,24 @@ func PutProbesHandler(c *gin.Context) { } func snapshotProbes() ProbeSnapshot { - probes.mu.RLock() - defer probes.mu.RUnlock() + probes.mu.Lock() + defer probes.mu.Unlock() uptime := time.Since(probes.startedAt).Seconds() cfg := probes.startup.normalized() wouldPass := probes.startupLatched || - (!cfg.shouldFail() && (cfg.BootDelaySeconds <= 0 || uptime >= cfg.BootDelaySeconds)) + (!cfg.isHardFail() && (cfg.BootDelaySeconds <= 0 || uptime >= cfg.BootDelaySeconds)) + // phase uses current hit counts (next request will increment) return ProbeSnapshot{ Live: probes.live.normalized(), Ready: probes.ready.normalized(), Startup: cfg, StartupLatched: probes.startupLatched, UptimeSeconds: uptime, - StartupWouldPass: wouldPass && !cfg.shouldFail(), + StartupWouldPass: wouldPass && !cfg.isHardFail(), + LiveFlapPhase: probes.live.normalized().flapPhase(probes.startedAt, probes.liveHits+1), + ReadyFlapPhase: probes.ready.normalized().flapPhase(probes.startedAt, probes.readyHits+1), + StartupFlapPhase: probes.startup.normalized().flapPhase(probes.startedAt, probes.startupHits+1), } } diff --git a/k8s-cluster-util-apis.yml b/k8s-cluster-util-apis.yml index 95fea8a..8609099 100644 --- a/k8s-cluster-util-apis.yml +++ b/k8s-cluster-util-apis.yml @@ -57,6 +57,11 @@ spec: # fail readiness (pod stays up, leaves Service endpoints) # - name: READY_MODE # value: "fail" + # flap readiness (in/out of endpoints) + # - name: READY_MODE + # value: "flap" + # - name: READY_FLAP_SECONDS + # value: "5" # slow readiness → probe timeout (timeoutSeconds: 1) # - name: READY_MODE # value: "delay" @@ -65,6 +70,11 @@ spec: # fail liveness → restarts # - name: LIVE_MODE # value: "fail" + # flap liveness (restart thrash — careful) + # - name: LIVE_MODE + # value: "flap" + # - name: LIVE_FLAP_SECONDS + # value: "10" # - name: AUTH_TOKEN # value: "fixed-token-for-tests" resources: diff --git a/main_test.go b/main_test.go index 9b692d3..ee19f5a 100644 --- a/main_test.go +++ b/main_test.go @@ -104,6 +104,30 @@ func TestReadyFailViaControl(t *testing.T) { assert.Equal(t, "not ready", rr2.Body.String()) } +func TestReadyFlapEvery(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + // every 2nd request fails + body := `{"ready":{"mode":"flap","delaySeconds":0,"flapEvery":2}}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + // hit 1 ok, hit 2 fail, hit 3 ok, hit 4 fail + codes := make([]int, 0, 4) + for i := 0; i < 4; i++ { + reqN := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rrN := httptest.NewRecorder() + r.ServeHTTP(rrN, reqN) + codes = append(codes, rrN.Code) + } + assert.Equal(t, []int{200, 503, 200, 503}, codes) +} + func TestStartupLatchAndBootDelay(t *testing.T) { token := "tok" r := setupTestRouter(token) From e2698aff9afcf08081c7136bd63933583d702cdc Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:41:10 +1000 Subject: [PATCH 05/10] docs: auth how-to plus debugging examples section Spell out how to get the bearer (AUTH_TOKEN, docker/kubectl logs, swagger) and add practical curl recipes for ingress, env dumps, probes, flap, timeouts, cold start, and in-cluster use. Startup logs are a bit more greppable for the token too. --- README.md | 313 ++++++++++++++++++++++++++++++++++++++---------------- main.go | 13 ++- 2 files changed, 233 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index bc24f06..f125344 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github Throw it into a namespace / ECS task / compose stack and use it to test: -- **probes** — real kube-style `/startupz`, `/livez`, `/readyz` with fail + delay + runtime control +- **probes** — real kube-style `/startupz`, `/livez`, `/readyz` with fail + delay + flap + runtime control - **routing & ingress** — hit it through a service, ingress, ALB, mesh; see what actually arrives - **headers & identity** — what the proxy rewrote, client IP, host, path (`/headers`, `/debug`, `/echo`) - **config / params in the env** — dump process env behind auth (`/a/env`) so you can check secrets, configmaps, task defs actually landed @@ -23,60 +23,223 @@ Default route dumps you into **swagger** so you can poke things from the browser | pair with: https://github.com/donkeyx/cluster-utils (shell / toolkit image) -## Usage +## Auth (how to get the token) -Most endpoints are open. Anything under `/a/` is authenticated — grab the bearer token from the container logs on startup (it rotates every restart unless you set `AUTH_TOKEN`). The app also logs ready made curls. +Most routes are open. Anything under **`/a/`** needs a bearer token: -Swagger UI: +```http +Authorization: Bearer +``` + +That covers: + +- `GET /a/env` — dump process environment +- `GET/PUT /a/control/probes` — read/flip probe modes without redeploying + +### Option 1 — fixed token (easiest for demos) + +```bash +docker run -d -p 8080:8080 -e AUTH_TOKEN=dev --name test-api donkeyx/cluster-utils-api:latest +export TOKEN=dev +``` + +Same idea in k8s — set `AUTH_TOKEN` on the container env. + +### Option 2 — random token from logs (default) + +If you **don’t** set `AUTH_TOKEN`, a random token is generated **every process start** and printed in the logs (JSON). + +Look for fields like `token` / `header`, or grep: + +```bash +# local docker +docker logs test-api 2>&1 | grep -E 'token|Bearer|example curl' | head + +# pull just the token value out of the json line (if jq + logs are one json object per line) +docker logs test-api 2>&1 | grep '"token"' | tail -1 | jq -r '.token' -- http://localhost:8080/ (redirects) -- http://localhost:8080/api-docs/index.html +# kubernetes +kubectl -n default logs deploy/cluster-utils-api --tail=50 | grep -E 'token|Bearer|example curl' +``` -Port defaults to `8080`, override with `PORT` if you need to. +On startup the app also logs **ready-made curls** (env dump + probe control) with the token already filled in — copy/paste those. -### Start container: +### Using it ```bash -docker run -d -p 8080:8080 --name test-api donkeyx/cluster-utils-api:latest -# fixed token for demos: -# docker run -d -p 8080:8080 -e AUTH_TOKEN=dev donkeyx/cluster-utils-api:latest +export TOKEN=dev # or whatever you pulled from logs + +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq ``` +### Swagger UI + +1. Open http://localhost:8080/ (or `/api-docs/index.html`) +2. Click **Authorize** +3. Enter `Bearer ` (word `Bearer`, space, then the token) — or whatever the UI label asks for; the header name is `Authorization` + +Without a token, `/a/*` returns **401**. + +--- + +## Quick start + ```bash +docker run -d -p 8080:8080 -e AUTH_TOKEN=dev --name test-api donkeyx/cluster-utils-api:latest +export TOKEN=dev + curl -sS localhost:8080/help | jq curl -sS localhost:8080/version | jq - -# kube-style probes -curl -sS localhost:8080/startupz curl -sS localhost:8080/livez -curl -sS localhost:8080/readyz +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq +``` + +Port defaults to `8080` (`PORT` to override). -# force ready fail without redeploy (token from logs) -TOKEN=... # or AUTH_TOKEN you set +--- + +## Examples (debugging recipes) + +Assume `TOKEN` is set (see **Auth** above) and the api is on `localhost:8080`. + +### 1. What did the ingress / mesh actually send me? + +```bash +# headers as the pod saw them (X-Forwarded-*, cookies, auth, host, …) +curl -sS -H 'X-Request-Id: demo-1' -H 'X-Forwarded-For: 1.2.3.4' \ + localhost:8080/headers | jq + +# fuller dump: hostname, client ip, uri, all headers +curl -sS localhost:8080/debug | jq + +# bounce method + body + query back (good for POST through a gateway) +curl -sS -X POST 'localhost:8080/echo?from=ingress' \ + -H 'Content-Type: application/json' \ + -d '{"hello":"cluster"}' | jq +``` + +### 2. Did my ConfigMap / Secret / task def actually land? + +```bash +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +# or one key: +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq '."MY_FEATURE_FLAG"' +``` + +### 3. Readiness: pull the pod out of the Service (no restart) + +```bash +# fail ready → kube should remove endpoints; process stays up curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"ready":{"mode":"fail"}}' localhost:8080/a/control/probes | jq -curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/readyz -# slow live so kube timeoutSeconds trips +curl -sS -o /dev/null -w 'readyz=%{http_code}\n' localhost:8080/readyz +# in cluster: kubectl get endpoints cluster-utils-api-svc -w + +# put it back +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"ok"}}' localhost:8080/a/control/probes | jq +``` + +### 4. Liveness: make kube restart the container + +```bash +# careful — this will restart once failureThreshold is hit +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"live":{"mode":"fail"}}' localhost:8080/a/control/probes | jq + +# watch +# kubectl get pod -l type=api -w +``` + +### 5. Probe timeouts (slow answers) + +Sample manifest uses `timeoutSeconds: 1`. Anything slower counts as a failed probe. + +```bash +# ready answers after 3s → timeouts with timeoutSeconds: 1 +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"delay","delaySeconds":3}}' localhost:8080/a/control/probes | jq + +time curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/readyz +``` + +### 6. Flapping readiness (in/out of endpoints) + +```bash +# time based: 5s ok, 5s fail, repeat +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"flap","flapSeconds":5}}' localhost:8080/a/control/probes | jq + +# or every 2nd request fails (handy from a loop) curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"live":{"mode":"delay","delaySeconds":3}}' localhost:8080/a/control/probes | jq + -d '{"ready":{"mode":"flap","flapEvery":2}}' localhost:8080/a/control/probes | jq + +for i in 1 2 3 4; do curl -sS -o /dev/null -w "$i %{http_code}\n" localhost:8080/readyz; done + +# see which half of the flap you're in +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes \ + | jq '{ready, readyFlapPhase, uptimeSeconds}' +``` + +### 7. Slow startup / cold start -# cold start again without restarting the process +```bash +# pretend the app needs 15s to init, then latch "started" curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"startup":{"mode":"ok","bootDelaySeconds":10},"resetStartupLatch":true}' \ + -d '{"startup":{"mode":"ok","bootDelaySeconds":15},"resetStartupLatch":true}' \ localhost:8080/a/control/probes | jq -curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/startupz -# status / delay / echo +curl -sS -o /dev/null -w 'startupz=%{http_code}\n' localhost:8080/startupz +# wait, then: +curl -sS -o /dev/null -w 'startupz=%{http_code}\n' localhost:8080/startupz +``` + +Deploy-time without control API: + +```bash +docker run -d -p 8080:8080 \ + -e AUTH_TOKEN=dev \ + -e STARTUP_BOOT_DELAY=20 \ + donkeyx/cluster-utils-api:latest +``` + +### 8. Upstream returns 502 / 503 / 418 + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/502 +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/503 curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/418 -curl -sS localhost:8080/delay/1 -curl -sS -X POST -d '{"hi":1}' localhost:8080/echo | jq +``` -curl -sS localhost:8080/debug | jq -curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +### 9. Which build is this pod? + +```bash +curl -sS localhost:8080/version | jq +# {"version":"...","gitHash":"...","hostname":"..."} +``` + +### 10. From inside the cluster (with cluster-utils shell) + +```bash +# port-forward +kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 + +# or exec into the toolkit image and curl the service DNS +kubectl exec -it deploy/cluster-utils -- \ + curl -sS http://cluster-utils-api-svc:8080/debug | jq + +# token from api pod logs +TOKEN=$(kubectl -n default logs deploy/cluster-utils-api --tail=100 \ + | grep '"token"' | tail -1 | jq -r '.token') +kubectl exec -it deploy/cluster-utils -- \ + curl -sS -H "Authorization: Bearer $TOKEN" http://cluster-utils-api-svc:8080/a/env | jq ``` -## Probes (the main event) +--- + +## Probes (reference) These follow the usual kube split. **Status codes matter more than bodies** — kube only cares 2xx vs not (and timeouts). @@ -93,81 +256,51 @@ These follow the usual kube split. **Status codes matter more than bodies** — 3. After latch → always **200** `started` (fast), until process restart or `resetStartupLatch` 4. `mode=fail` → **503** `startup failed` and **never** latches -That matches what kube expects: spam startup until it works, then stop and only run live/ready. - -### Modes + delay (live / ready / startup) +### Modes | mode | after optional delay | |------|----------------------| | `ok` | 200 | | `fail` | 503 | -| `delay` | 200 (same as ok — use with `delaySeconds` > probe `timeoutSeconds` to force **timeouts**) | -| `flap` | alternates **ok / fail** (see below) | - -`delaySeconds` is always applied on live/ready (capped at 30s). On startup it applies until latched; once latched answers are immediate. - -### Flap mode +| `delay` | 200 (use `delaySeconds` > probe `timeoutSeconds` for **timeouts**) | +| `flap` | alternates ok/fail — `flapSeconds` (time half-period, default 5) or `flapEvery` (every Nth request) | -For watching kube react to flapping readiness / restart thrash on liveness: - -| knob | meaning | -|------|---------| -| `flapSeconds` | wall-clock half-period: **ok for N sec, fail for N sec**, repeat (default **5** if unset) | -| `flapEvery` | if set (>0), **every Nth request fails** instead of using the clock (nice for scripts/tests) | - -```bash -# ready flaps every 2 requests -curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"ready":{"mode":"flap","flapEvery":2}}' localhost:8080/a/control/probes | jq - -# ready flaps on a 3s timer (3s ok, 3s fail, …) -curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"ready":{"mode":"flap","flapSeconds":3}}' localhost:8080/a/control/probes | jq - -# watch phase -curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq '{ready, readyFlapPhase}' -``` - -On **startup**, flap only applies **before** the latch; an ok phase can still latch and then stay started (same as real init eventually finishing). +On startup, flap only applies **before** the latch. ### Seed from env (steady state at deploy) | env | default | notes | |-----|---------|--------| -| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `false`/`fail`/`flap` | -| `LIVE_DELAY` / `HEALTHY_DELAY` | 0 | seconds before live answers | -| `LIVE_FLAP_SECONDS` | 5 when mode=flap | half-period for time flap | -| `LIVE_FLAP_EVERY` | 0 | every Nth request fails if set | +| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `fail` / `flap` / `delay` | +| `LIVE_DELAY` / `HEALTHY_DELAY` | 0 | | +| `LIVE_FLAP_SECONDS` / `LIVE_FLAP_EVERY` | | flap knobs | | `READY_MODE` / `READY` | ok | | | `READY_DELAY` | 0 | | -| `READY_FLAP_SECONDS` | 5 when mode=flap | | -| `READY_FLAP_EVERY` | 0 | | +| `READY_FLAP_SECONDS` / `READY_FLAP_EVERY` | | | | `STARTUP_MODE` / `STARTUP` | ok | | | `STARTUP_DELAY` | 0 | per-request sleep while not latched | -| `STARTUP_BOOT_DELAY` | 0 | wall clock from start before first success allowed | -| `STARTUP_FLAP_SECONDS` / `STARTUP_FLAP_EVERY` | | flap before latch only | +| `STARTUP_BOOT_DELAY` | 0 | wall clock before first success | +| `STARTUP_FLAP_SECONDS` / `STARTUP_FLAP_EVERY` | | flap before latch | -### Flip at runtime (no redeploy) +Query `?ok=0` still works on live/ready for quick curl hacks — **kube will never send that**, so use env or `/a/control/probes` for real demos. -Auth required (same bearer as `/a/env`): +### Control API (no redeploy) ```bash -# read curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq -# write (partial update) curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "ready": {"mode":"flap","flapSeconds":5}, - "live": {"mode":"ok","delaySeconds":0}, - "startup": {"mode":"ok","delaySeconds":0,"bootDelaySeconds":5}, + "live": {"mode":"ok"}, + "startup": {"mode":"ok","bootDelaySeconds":5}, "resetStartupLatch": true }' localhost:8080/a/control/probes | jq ``` -Query `?ok=0` still works on live/ready for quick curl hacks — **kube will never send that**, so use env or the control API for real probe demos. +--- -### Other endpoints +## Endpoints | path | notes | |------|--------| @@ -175,46 +308,48 @@ Query `?ok=0` still works on live/ready for quick curl hacks — **kube will nev | `GET /api-docs/*` | swagger ui | | `GET /help` | json list of routes | | `GET /version` | version + git hash + hostname | +| `GET /startupz` `/startup` | startup probe (latch) | +| `GET /livez` `/healthz` `/health` | liveness | +| `GET /readyz` `/ready` | readiness | | `GET /ping` | `PONG` (not a kube probe) | | `GET /headers` | request headers | | `GET /debug` | hostname / ip / headers / uri | | `GET /metrics` | prometheus | | `GET /status/:code` | respond with that http status (100-599) | -| `GET /delay/:seconds` | sleep then 200 (generic; prefer probe delays for kube) | +| `GET /delay/:seconds` | sleep then 200 | | `ANY /echo` | bounce method / query / headers / body | -| `GET /a/env` | env vars, **bearer auth** | -| `GET/PUT /a/control/probes` | probe state, **bearer auth** | +| `GET /a/env` | env vars — **auth** | +| `GET/PUT /a/control/probes` | probe state — **auth** | ### other config | env | default | what it does | |-----|---------|----------------| | `PORT` | `8080` | listen port | -| `AUTH_TOKEN` | random each start | fixed bearer token if set | +| `AUTH_TOKEN` | random each start | fixed bearer for `/a/*` if set | -### run image in k8 cluster: +--- -You can run the pod in your cluster with the commands below. This will start a deployment and service but limited to cluster ip. If you want to expose with type loadbalancer you can do it yourself, I don't want you to get a bill from this. +## Run on Kubernetes -```bash -kubectl -n default \ - apply -f https://raw.githubusercontent.com/donkeyx/cluster-utils-api/master/k8s-cluster-util-apis.yml -``` +Starts a deployment + ClusterIP service only. If you want a LoadBalancer, wire it yourself — I don't want you to get a bill from this. ```bash +kubectl -n default apply -f \ + https://raw.githubusercontent.com/donkeyx/cluster-utils-api/master/k8s-cluster-util-apis.yml + kubectl get pods,svc -n default -# service is cluster-utils-api-svc on 8080 +# service: cluster-utils-api-svc:8080 ``` -Sample manifest uses: +Sample manifest probes: - **startupProbe** → `/startupz` (timeout 1s, period 2s) - **livenessProbe** → `/livez` (timeout 1s) - **readinessProbe** → `/readyz` (timeout 1s) -Short timeouts make `delaySeconds: 3` an obvious timeout fail. Uncomment the env examples in the yaml to break things on purpose, or flip them live via `/a/control/probes`. +Uncomment the env examples in the yaml to break things on purpose, or flip live via `/a/control/probes` after you grab the token from pod logs (or set `AUTH_TOKEN`). ```bash kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 -curl -sS localhost:8080/readyz ``` diff --git a/main.go b/main.go index e215736..778987f 100644 --- a/main.go +++ b/main.go @@ -60,10 +60,15 @@ func main() { zap.String("version", Version), zap.String("gitHash", GitHash), ) - logger.Info("Security Token", zap.String("token", securityToken)) - logger.Info("Curl Command", zap.String("command", getCurlCommand(port, securityToken))) - logger.Info("Probe control", zap.String("command", - fmt.Sprintf("curl -sS -H 'Authorization: Bearer %s' http://localhost:%d/a/control/probes | jq", securityToken, port))) + // Greppable startup lines so people can find the bearer for /a/* endpoints. + logger.Info("auth token for /a/* endpoints (Authorization: Bearer )", + zap.String("token", securityToken), + zap.String("header", "Authorization: Bearer "+securityToken), + ) + logger.Info("example curl with auth", + zap.String("env", getCurlCommand(port, securityToken)), + zap.String("probes", fmt.Sprintf("curl -sS -H 'Authorization: Bearer %s' http://localhost:%d/a/control/probes | jq", securityToken, port)), + ) r.Run(fmt.Sprintf(":%d", port)) } From 89dd8289e7586c8c63e3b6624e6f167f26dcc3df Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:44:12 +1000 Subject: [PATCH 06/10] fix: swagger uses request host and keeps bearer auth Drop hardcoded localhost host so Try it out follows wherever you open the UI (docker/port-forward/ingress). Optional ?host=&scheme= override. PersistAuthorization so the Authorize token sticks in the browser. --- README.md | 24 +++++++++++++++++++++--- docs/docs.go | 6 +++--- docs/swagger.json | 5 ++--- docs/swagger.yaml | 9 +++++---- main.go | 5 ++--- routes/routes.go | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 78 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f125344..1b9562b 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,27 @@ curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq ### Swagger UI -1. Open http://localhost:8080/ (or `/api-docs/index.html`) -2. Click **Authorize** -3. Enter `Bearer ` (word `Bearer`, space, then the token) — or whatever the UI label asks for; the header name is `Authorization` +1. Open the UI **on the same host/port you want to call** (that way Try it out just works): + - local: http://localhost:8080/ or `/api-docs/index.html` + - port-forward: http://localhost:8080/api-docs/index.html +2. Click **Authorize** (lock icon) +3. Value: `Bearer ` — word **Bearer**, a space, then the token + Example: `Bearer dev` + Header name is `Authorization`. The UI remembers it in this browser (`PersistAuthorization`). +4. **Try it out** on any route — protected ones under `/a/` need Authorize first. + +**Host / port for Try it out** + +Swagger uses the host you opened the page on (so docker `:8080`, port-forward, or in-cluster ingress all line up without editing the spec). + +If you ever need to point Try it out somewhere else (UI on A, API on B): + +```text +http://localhost:8080/api-docs/index.html?host=cluster-utils-api-svc:8080&scheme=http +http://localhost:8080/api-docs/index.html?host=my-alb.example.com&scheme=https +``` + +`host` = `hostname` or `hostname:port` (no `http://`). `scheme` = `http` or `https`. Without a token, `/a/*` returns **401**. diff --git a/docs/docs.go b/docs/docs.go index 9d6e496..c2855ae 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -586,7 +586,7 @@ const docTemplate = `{ }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", + "description": "Paste: Bearer \u003ctoken\u003e (token from container logs, or AUTH_TOKEN env). Example: Bearer dev", "type": "apiKey", "name": "Authorization", "in": "header" @@ -597,11 +597,11 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "2.0", - Host: "localhost:8080", + Host: "", BasePath: "/", Schemes: []string{}, Title: "Cluster Util API", - Description: "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster", + Description: "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger \"Try it out\" uses the host you opened the UI on (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 83249c9..3be534b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,12 +1,11 @@ { "swagger": "2.0", "info": { - "description": "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster", + "description": "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger \"Try it out\" uses the host you opened the UI on (or override with ?host=host:port\u0026scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN.", "title": "Cluster Util API", "contact": {}, "version": "2.0" }, - "host": "localhost:8080", "basePath": "/", "paths": { "/a/control/probes": { @@ -580,7 +579,7 @@ }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env).", + "description": "Paste: Bearer \u003ctoken\u003e (token from container logs, or AUTH_TOKEN env). Example: Bearer dev", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f965323..f9bb424 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -54,11 +54,12 @@ definitions: startup: $ref: '#/definitions/handlers.ProbeConfig' type: object -host: localhost:8080 info: contact: {} description: Drop-in HTTP util for testing probes, routing, headers, env/params - and more in a cluster + and more in a cluster. Swagger "Try it out" uses the host you opened the UI on + (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize + with Bearer token from logs or AUTH_TOKEN. title: Cluster Util API version: "2.0" paths: @@ -400,8 +401,8 @@ paths: summary: Version / build info securityDefinitions: BearerAuth: - description: Type "Bearer" followed by a space and the token from the app logs - on startup (or AUTH_TOKEN env). + description: 'Paste: Bearer (token from container logs, or AUTH_TOKEN + env). Example: Bearer dev' in: header name: Authorization type: apiKey diff --git a/main.go b/main.go index 778987f..ec2b9dd 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,11 @@ // @title Cluster Util API // @version 2.0 -// @description Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster -// @host localhost:8080 +// @description Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger "Try it out" uses the host you opened the UI on (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN. // @BasePath / // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization -// @description Type "Bearer" followed by a space and the token from the app logs on startup (or AUTH_TOKEN env). +// @description Paste: Bearer (token from container logs, or AUTH_TOKEN env). Example: Bearer dev package main diff --git a/routes/routes.go b/routes/routes.go index b4ae450..d241764 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,19 +1,23 @@ package routes import ( + "cu-api/docs" "cu-api/handlers" "cu-api/middleware" "net/http" + "strings" + "sync" "github.com/gin-gonic/gin" "go.uber.org/zap" - _ "cu-api/docs" - swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) +// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes when serving the UI for different origins. +var swaggerInfoMu sync.Mutex + func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.Use(handlers.MetricsMiddleware()) @@ -22,7 +26,11 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { c.Redirect(http.StatusFound, "/api-docs/index.html") }) - r.GET("/api-docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // Swagger UI: persist Authorize token in the browser; host/scheme follow where you opened + // the page (so port-forward / docker / cluster DNS all work). Optional overrides: + // /api-docs/index.html?host=my-svc:8080&scheme=http + r.GET("/api-docs/*any", swaggerHandler()) + r.GET("/help", handlers.HelpHandler) r.GET("/version", handlers.VersionHandler) r.GET("/metrics", handlers.PrometheusMetricsHandler()) @@ -55,3 +63,37 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { authGroup.GET("/control/probes", handlers.GetProbesHandler) authGroup.PUT("/control/probes", handlers.PutProbesHandler) } + +func swaggerHandler() gin.HandlerFunc { + // Empty host in the generated spec would also work; we set Host from the request + // so the Swagger top bar shows a real target and Try it out hits the right place. + handler := ginSwagger.WrapHandler( + swaggerFiles.Handler, + ginSwagger.PersistAuthorization(true), + ginSwagger.DefaultModelsExpandDepth(-1), + ) + + return func(c *gin.Context) { + host := strings.TrimSpace(c.Query("host")) + if host == "" { + host = c.Request.Host + } + + scheme := strings.ToLower(strings.TrimSpace(c.Query("scheme"))) + if scheme != "http" && scheme != "https" { + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } else { + scheme = "http" + } + } + + // Serialize updates to the global SwaggerInfo used when doc.json is generated. + swaggerInfoMu.Lock() + docs.SwaggerInfo.Host = host + docs.SwaggerInfo.Schemes = []string{scheme} + docs.SwaggerInfo.BasePath = "/" + handler(c) + swaggerInfoMu.Unlock() + } +} From 8e153bd58e21b1526de003c539148fd9f71712e7 Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:51:31 +1000 Subject: [PATCH 07/10] fix: donkey branding, longer delays, and /proxy east-west hop Match utils vibe a bit with the donkey header. Raise delay cap (MAX_DELAY_SECONDS, default 120). Add GET/POST /proxy so north-south can trigger east-west calls with header passthrough for mesh testing. --- README.md | 87 +++++++++++++++-- docs/docs.go | 163 ++++++++++++++++++++++++++++++- docs/swagger.json | 163 ++++++++++++++++++++++++++++++- docs/swagger.yaml | 125 +++++++++++++++++++++++- handlers/handlers.go | 8 +- handlers/probes.go | 28 ++++-- handlers/proxy.go | 224 +++++++++++++++++++++++++++++++++++++++++++ main_test.go | 46 +++++++++ routes/routes.go | 3 + 9 files changed, 825 insertions(+), 22 deletions(-) create mode 100644 handlers/proxy.go diff --git a/README.md b/README.md index 1b9562b..91b05e9 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,31 @@ -# cluster-utils-api +# 🐴 DonkeyX's Cluster Utils API + +``` +╭────────────────────────────────────────╮ +| 🐴 DonkeyX's Cluster Utils API │ +╰────────────────────────────────────────╯ + + //\\ + (/oo\) .----. + (____) | API | + /||\ '----' + //||\\ 🔌 Probe Mode + ^^ ^^ ^^ + "Kick the tyres on the mesh!" +``` ## description -HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github.com/donkeyx/cluster-utils) is the shell box you exec into, this is the **service you drop into an environment** to exercise the platform around it. +HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github.com/donkeyx/cluster-utils) is the **shell box you exec into**, this is the **service you drop into an environment** and hit over HTTP — same donkey energy, different job. Throw it into a namespace / ECS task / compose stack and use it to test: - **probes** — real kube-style `/startupz`, `/livez`, `/readyz` with fail + delay + flap + runtime control - **routing & ingress** — hit it through a service, ingress, ALB, mesh; see what actually arrives +- **east-west hops** — north-south into this pod, then `/proxy` out to another svc (headers ride along) - **headers & identity** — what the proxy rewrote, client IP, host, path (`/headers`, `/debug`, `/echo`) - **config / params in the env** — dump process env behind auth (`/a/env`) so you can check secrets, configmaps, task defs actually landed -- **bad / slow upstreams** — force status codes and delays (`/status/503`, `/delay/5`) +- **bad / slow upstreams** — force status codes and long delays (`/status/503`, `/delay/90`) - **any entrypoint noise** — binary is also linked as `node` / `npm` so broken charts that call weird commands still come up and serve the api Default route dumps you into **swagger** so you can poke things from the browser without memorising paths. @@ -231,14 +246,72 @@ curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/503 curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/418 ``` -### 9. Which build is this pod? +### 9. Really slow request + +```bash +# sleep then 200 — default cap 120s (override with MAX_DELAY_SECONDS, hard max 600) +curl -sS localhost:8080/delay/90 +# delayed=90.000s requested=90.000s max=120s + +# or make a *probe* slow (so kube timeoutSeconds trips) +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"delay","delaySeconds":30}}' localhost:8080/a/control/probes | jq +``` + +### 10. East-west hop via north-south (`/proxy`) + +Pattern: **ingress → this api → another service** (mesh / NetworkPolicy / DNS / header propagation). + +Inbound headers are **forwarded by default** (minus hop-by-hop junk). Response is a JSON wrap with what we sent, what came back, and timing — unless `"raw": true`. + +```bash +# simple GET hop (query form) +curl -sS -H 'X-Request-Id: demo-ew-1' -H 'X-Trace: abc' \ + 'localhost:8080/proxy?url=http://other-api:8080/debug' | jq + +# POST form — full control +curl -sS -X POST localhost:8080/proxy \ + -H 'Content-Type: application/json' \ + -H 'X-Request-Id: demo-ew-2' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "url": "http://other-api:8080/echo", + "method": "POST", + "body": "{\"ping\":true}", + "headers": {"Content-Type": "application/json"}, + "timeoutSeconds": 15, + "forwardIncomingHeaders": true + }' | jq + +# chain: this api → other api's /debug (see if X-Request-Id survived) +curl -sS -H 'X-Request-Id: keep-me' \ + 'localhost:8080/proxy?url=http://other-api:8080/headers' | jq '.response.body' + +# hop to another cluster-utils-api that is deliberately slow +curl -sS -X POST localhost:8080/proxy -H 'Content-Type: application/json' -d '{ + "url": "http://other-api:8080/delay/5", + "timeoutSeconds": 30 +}' | jq '.meta' +``` + +In-cluster example (service DNS): + +```bash +# from laptop via port-forward to the *edge* api +curl -sS -H 'X-Request-Id: from-laptop' \ + "localhost:8080/proxy?url=http://cluster-utils-api-svc.other-ns.svc.cluster.local:8080/debug" | jq +``` + +You can also chain two apis: A `/proxy` → B `/proxy` → C `/debug` if you want multi-hop header paths. + +### 11. Which build is this pod? ```bash curl -sS localhost:8080/version | jq # {"version":"...","gitHash":"...","hostname":"..."} ``` -### 10. From inside the cluster (with cluster-utils shell) +### 12. From inside the cluster (with cluster-utils shell) ```bash # port-forward @@ -334,8 +407,9 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ | `GET /debug` | hostname / ip / headers / uri | | `GET /metrics` | prometheus | | `GET /status/:code` | respond with that http status (100-599) | -| `GET /delay/:seconds` | sleep then 200 | +| `GET /delay/:seconds` | sleep then 200 (cap `MAX_DELAY_SECONDS`, default 120) | | `ANY /echo` | bounce method / query / headers / body | +| `GET/POST /proxy` | east-west hop to another URL; forwards inbound headers | | `GET /a/env` | env vars — **auth** | | `GET/PUT /a/control/probes` | probe state — **auth** | @@ -345,6 +419,7 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ |-----|---------|----------------| | `PORT` | `8080` | listen port | | `AUTH_TOKEN` | random each start | fixed bearer for `/a/*` if set | +| `MAX_DELAY_SECONDS` | `120` (hard max 600) | cap for `/delay`, probe delays, proxy timeouts | --- diff --git a/docs/docs.go b/docs/docs.go index c2855ae..0fffd76 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -166,7 +166,7 @@ const docTemplate = `{ }, "/delay/{seconds}": { "get": { - "description": "Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", + "description": "Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", "produces": [ "text/plain" ], @@ -175,7 +175,7 @@ const docTemplate = `{ "parameters": [ { "type": "number", - "description": "seconds to sleep (max 30)", + "description": "seconds to sleep", "name": "seconds", "in": "path", "required": true @@ -403,6 +403,126 @@ const docTemplate = `{ } } }, + "/proxy": { + "get": { + "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/readyz": { "get": { "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", @@ -582,6 +702,45 @@ const docTemplate = `{ "$ref": "#/definitions/handlers.ProbeConfig" } } + }, + "handlers.ProxyRequest": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "body": { + "description": "Optional body (string; use for JSON text, form, etc.)", + "type": "string" + }, + "forwardIncomingHeaders": { + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Good for tracing / auth / x-request-id passthrough.", + "type": "boolean" + }, + "headers": { + "description": "Extra headers to set/override on the outbound request", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "method": { + "description": "HTTP method (default GET)", + "type": "string" + }, + "raw": { + "description": "When true, return the upstream body as raw response (status + headers from upstream).\nDefault false → JSON wrap with timing + what we sent/received (better for debugging).", + "type": "boolean" + }, + "timeoutSeconds": { + "description": "Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300)", + "type": "number" + }, + "url": { + "description": "Absolute URL to call, e.g. http://other-api:8080/debug", + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 3be534b..9c48ecb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -159,7 +159,7 @@ }, "/delay/{seconds}": { "get": { - "description": "Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", + "description": "Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", "produces": [ "text/plain" ], @@ -168,7 +168,7 @@ "parameters": [ { "type": "number", - "description": "seconds to sleep (max 30)", + "description": "seconds to sleep", "name": "seconds", "in": "path", "required": true @@ -396,6 +396,126 @@ } } }, + "/proxy": { + "get": { + "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/readyz": { "get": { "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", @@ -575,6 +695,45 @@ "$ref": "#/definitions/handlers.ProbeConfig" } } + }, + "handlers.ProxyRequest": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "body": { + "description": "Optional body (string; use for JSON text, form, etc.)", + "type": "string" + }, + "forwardIncomingHeaders": { + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Good for tracing / auth / x-request-id passthrough.", + "type": "boolean" + }, + "headers": { + "description": "Extra headers to set/override on the outbound request", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "method": { + "description": "HTTP method (default GET)", + "type": "string" + }, + "raw": { + "description": "When true, return the upstream body as raw response (status + headers from upstream).\nDefault false → JSON wrap with timing + what we sent/received (better for debugging).", + "type": "boolean" + }, + "timeoutSeconds": { + "description": "Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300)", + "type": "number" + }, + "url": { + "description": "Absolute URL to call, e.g. http://other-api:8080/debug", + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f9bb424..d519456 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -54,6 +54,39 @@ definitions: startup: $ref: '#/definitions/handlers.ProbeConfig' type: object + handlers.ProxyRequest: + properties: + body: + description: Optional body (string; use for JSON text, form, etc.) + type: string + forwardIncomingHeaders: + description: |- + When true (default), copy inbound request headers onto the outbound call + (minus hop-by-hop). Good for tracing / auth / x-request-id passthrough. + type: boolean + headers: + additionalProperties: + type: string + description: Extra headers to set/override on the outbound request + type: object + method: + description: HTTP method (default GET) + type: string + raw: + description: |- + When true, return the upstream body as raw response (status + headers from upstream). + Default false → JSON wrap with timing + what we sent/received (better for debugging). + type: boolean + timeoutSeconds: + description: Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS + / 300) + type: number + url: + description: Absolute URL to call, e.g. http://other-api:8080/debug + type: string + required: + - url + type: object info: contact: {} description: Drop-in HTTP util for testing probes, routing, headers, env/params @@ -164,11 +197,12 @@ paths: summary: Debug /delay/{seconds}: get: - description: Sleep N seconds (max 30) then return 200. For probe timeouts prefer - LIVE/READY/STARTUP delaySeconds instead + description: Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default + 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds + instead operationId: delay parameters: - - description: seconds to sleep (max 30) + - description: seconds to sleep in: path name: seconds required: true @@ -326,6 +360,91 @@ paths: schema: type: string summary: Get ping + /proxy: + get: + consumes: + - application/json + description: North→south hits this pod; this pod calls another URL east-west. + Forwards headers by default so you can test mesh/ingress propagation. Also + supports GET /proxy?url= + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + summary: Proxy / hop to another service + post: + consumes: + - application/json + description: North→south hits this pod; this pod calls another URL east-west. + Forwards headers by default so you can test mesh/ingress propagation. Also + supports GET /proxy?url= + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + summary: Proxy / hop to another service /readyz: get: description: 'Kube readiness. 200 = take traffic; 503 = leave Service endpoints. diff --git a/handlers/handlers.go b/handlers/handlers.go index 75bb7e6..fb45c69 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -92,6 +92,7 @@ func HelpHandler(c *gin.Context) { "/status/:code": "GET respond with that http status", "/delay/:seconds": "GET sleep then 200 (max 30s)", "/echo": "GET/POST echo method, headers, query, body", + "/proxy": "GET/POST hop to another url (east-west; forwards headers)", "/a/env": "GET env vars (bearer auth)", }) } @@ -183,10 +184,10 @@ func StatusHandler(c *gin.Context) { } // @Summary Delay then OK -// @Description Sleep N seconds (max 30) then return 200. For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead +// @Description Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead // @ID delay // @Produce plain -// @Param seconds path number true "seconds to sleep (max 30)" +// @Param seconds path number true "seconds to sleep" // @Success 200 {string} string "delayed" // @Router /delay/{seconds} [get] func DelayHandler(c *gin.Context) { @@ -196,9 +197,10 @@ func DelayHandler(c *gin.Context) { c.String(http.StatusBadRequest, "seconds must be a number >= 0") return } + requested := secs secs = clampDelay(secs) time.Sleep(time.Duration(secs * float64(time.Second))) - c.String(http.StatusOK, "delayed=%.3fs", secs) + c.String(http.StatusOK, "delayed=%.3fs requested=%.3fs max=%.0fs", secs, requested, maxDelaySeconds()) } // @Summary Echo request diff --git a/handlers/probes.go b/handlers/probes.go index 046f8e6..6dbe54f 100644 --- a/handlers/probes.go +++ b/handlers/probes.go @@ -20,10 +20,25 @@ const ( ) const ( - maxProbeDelaySec = 30.0 - defaultFlapSec = 5.0 + defaultMaxDelaySec = 120.0 // /delay, probe delaySeconds, proxy timeout + hardMaxDelaySec = 600.0 // absolute ceiling even if MAX_DELAY_SECONDS is wild + defaultFlapSec = 5.0 ) +// maxDelaySeconds is shared by probes, /delay, and /proxy timeouts. +// Override with env MAX_DELAY_SECONDS (default 120, hard cap 600). +func maxDelaySeconds() float64 { + if v, ok := os.LookupEnv("MAX_DELAY_SECONDS"); ok { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + if f > hardMaxDelaySec { + return hardMaxDelaySec + } + return f + } + } + return defaultMaxDelaySec +} + // ProbeConfig is the knobs for one probe type (live / ready / startup). type ProbeConfig struct { Mode string `json:"mode"` // ok | fail | delay | flap @@ -165,8 +180,9 @@ func clampDelay(d float64) float64 { if d < 0 { return 0 } - if d > maxProbeDelaySec { - return maxProbeDelaySec + max := maxDelaySeconds() + if d > max { + return max } return d } @@ -178,8 +194,8 @@ func (c ProbeConfig) normalized() ProbeConfig { if c.FlapSeconds < 0 { c.FlapSeconds = 0 } - if c.FlapSeconds > maxProbeDelaySec { - c.FlapSeconds = maxProbeDelaySec + if c.FlapSeconds > maxDelaySeconds() { + c.FlapSeconds = maxDelaySeconds() } if c.FlapEvery < 0 { c.FlapEvery = 0 diff --git a/handlers/proxy.go b/handlers/proxy.go new file mode 100644 index 0000000..4e4f536 --- /dev/null +++ b/handlers/proxy.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// hop-by-hop + sensitive-ish headers we don't blindly copy outbound +var skipForwardHeaders = map[string]bool{ + "connection": true, + "keep-alive": true, + "proxy-authenticate": true, + "proxy-authorization": true, + "te": true, + "trailers": true, + "transfer-encoding": true, + "upgrade": true, + // these belong to *this* hop, not the east-west one + "content-length": true, + "host": true, +} + +// ProxyRequest is the JSON body for POST /proxy. +// Use this to fire east-west traffic from a north-south entry (ingress → this pod → other svc). +type ProxyRequest struct { + // Absolute URL to call, e.g. http://other-api:8080/debug + URL string `json:"url" binding:"required"` + // HTTP method (default GET) + Method string `json:"method,omitempty"` + // Extra headers to set/override on the outbound request + Headers map[string]string `json:"headers,omitempty"` + // Optional body (string; use for JSON text, form, etc.) + Body string `json:"body,omitempty"` + // Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300) + TimeoutSeconds float64 `json:"timeoutSeconds,omitempty"` + // When true (default), copy inbound request headers onto the outbound call + // (minus hop-by-hop). Good for tracing / auth / x-request-id passthrough. + ForwardIncomingHeaders *bool `json:"forwardIncomingHeaders,omitempty"` + // When true, return the upstream body as raw response (status + headers from upstream). + // Default false → JSON wrap with timing + what we sent/received (better for debugging). + Raw bool `json:"raw,omitempty"` +} + +// @Summary Proxy / hop to another service +// @Description North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url= +// @ID proxy +// @Accept json +// @Produce json +// @Param body body ProxyRequest true "proxy request" +// @Param url query string false "absolute url (GET form)" +// @Param method query string false "HTTP method for GET form (default GET)" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 502 {object} map[string]interface{} +// @Router /proxy [post] +// @Router /proxy [get] +func ProxyHandler(c *gin.Context) { + var req ProxyRequest + + // GET convenience: /proxy?url=http://svc:8080/debug&method=GET + if c.Request.Method == http.MethodGet { + req.URL = c.Query("url") + req.Method = c.DefaultQuery("method", http.MethodGet) + if t := c.Query("timeoutSeconds"); t != "" { + if f, err := strconv.ParseFloat(t, 64); err == nil { + req.TimeoutSeconds = f + } + } + if c.Query("forwardIncomingHeaders") == "0" || c.Query("forwardIncomingHeaders") == "false" { + f := false + req.ForwardIncomingHeaders = &f + } + if c.Query("raw") == "1" || c.Query("raw") == "true" { + req.Raw = true + } + } else { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()}) + return + } + } + + if strings.TrimSpace(req.URL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + u, err := url.Parse(req.URL) + if err != nil || u.Scheme == "" || u.Host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url must be absolute, e.g. http://other-svc:8080/debug"}) + return + } + if u.Scheme != "http" && u.Scheme != "https" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url scheme must be http or https"}) + return + } + + method := strings.ToUpper(strings.TrimSpace(req.Method)) + if method == "" { + method = http.MethodGet + } + + timeout := req.TimeoutSeconds + if timeout <= 0 { + timeout = 10 + } + timeout = clampProxyTimeout(timeout) + + forward := true + if req.ForwardIncomingHeaders != nil { + forward = *req.ForwardIncomingHeaders + } + + var bodyReader io.Reader + if req.Body != "" { + bodyReader = strings.NewReader(req.Body) + } + + outReq, err := http.NewRequestWithContext(c.Request.Context(), method, req.URL, bodyReader) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 1) optional: copy north-south inbound headers → east-west outbound + if forward { + for k, vals := range c.Request.Header { + if skipForwardHeaders[strings.ToLower(k)] { + continue + } + for _, v := range vals { + outReq.Header.Add(k, v) + } + } + } + + // 2) explicit headers win + for k, v := range req.Headers { + outReq.Header.Set(k, v) + } + + // identify hop for debugging + if outReq.Header.Get("X-Forwarded-By") == "" { + outReq.Header.Set("X-Forwarded-By", "cluster-utils-api") + } + hostname, _ := os.Hostname() + outReq.Header.Add("X-Cu-Proxy-Hop", hostname) + + client := &http.Client{Timeout: time.Duration(timeout * float64(time.Second))} + start := time.Now() + resp, err := client.Do(outReq) + elapsed := time.Since(start) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{ + "error": err.Error(), + "url": req.URL, + "method": method, + "durationMs": elapsed.Milliseconds(), + "timeoutSeconds": timeout, + }) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 2MB cap in debug wrap + respHeaders := map[string][]string{} + for k, v := range resp.Header { + respHeaders[k] = v + } + + if req.Raw { + for k, vals := range resp.Header { + for _, v := range vals { + c.Writer.Header().Add(k, v) + } + } + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody) + return + } + + // what we actually sent (after merges) + sentHeaders := map[string][]string{} + for k, v := range outReq.Header { + sentHeaders[k] = v + } + + c.JSON(http.StatusOK, gin.H{ + "request": gin.H{ + "url": req.URL, + "method": method, + "headers": sentHeaders, + "body": req.Body, + }, + "response": gin.H{ + "status": resp.StatusCode, + "headers": respHeaders, + "body": string(respBody), + }, + "meta": gin.H{ + "durationMs": elapsed.Milliseconds(), + "timeoutSeconds": timeout, + "forwardIncomingHeaders": forward, + "proxyHostname": hostname, + "inboundClientIP": getClientIP(c.Request), + }, + }) +} + +func clampProxyTimeout(seconds float64) float64 { + max := maxDelaySeconds() + if seconds > max { + return max + } + if seconds < 0.1 { + return 0.1 + } + return seconds +} diff --git a/main_test.go b/main_test.go index ee19f5a..bd7c23e 100644 --- a/main_test.go +++ b/main_test.go @@ -289,3 +289,49 @@ func TestMetrics(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "http_requests_total") } + +func TestProxyHopToSelf(t *testing.T) { + // start a real listener via httptest won't work for outbound http client — + // use the test server pattern + token := "tok" + gin.SetMode(gin.TestMode) + handlers.SetBuildInfo("test-ver", "abc123") + handlers.InitProbesFromEnv() + resetProbesForTest() + r := gin.New() + logger := setupLogger() + routes.SetupRouter(logger, token, r) + + srv := httptest.NewServer(r) + defer srv.Close() + + // hop: GET proxy → same server's /debug, forward a marker header + proxyURL := srv.URL + "/proxy?url=" + srv.URL + "/debug" + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) + require.NoError(t, err) + req.Header.Set("X-Trace-Demo", "east-west-1") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var wrap map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&wrap)) + assert.Contains(t, wrap, "request") + assert.Contains(t, wrap, "response") + assert.Contains(t, wrap, "meta") + + // upstream /debug should have seen the forwarded header + respObj := wrap["response"].(map[string]interface{}) + body := respObj["body"].(string) + assert.Contains(t, body, "X-Trace-Demo") + assert.Contains(t, body, "east-west-1") +} + +func TestProxyRequiresAbsoluteURL(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/proxy?url=/debug", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/routes/routes.go b/routes/routes.go index d241764..62cd4e4 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -56,6 +56,9 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/status/:code", handlers.StatusHandler) r.GET("/delay/:seconds", handlers.DelayHandler) r.Any("/echo", handlers.EchoHandler) + // east-west hop: north-south hits us, we call another svc (headers forwarded by default) + r.GET("/proxy", handlers.ProxyHandler) + r.POST("/proxy", handlers.ProxyHandler) authGroup := r.Group("/a") authGroup.Use(middleware.AuthMiddleware(logger, st)) From 31d35138e4a1539c73991a69961902442c078edd Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:56:39 +1000 Subject: [PATCH 08/10] fix: lock /a/proxy behind auth and document security + proxy response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy is SSRF if public — moved under /a/ with bearer like /env. Do not auto-forward Authorization/Cookie east-west unless opted in. README spells out the JSON wrap (upstream status/headers/body) and which endpoints stay open for kube vs what must stay locked. --- README.md | 151 ++++++++++++++++++++++++++++++++++++------- handlers/handlers.go | 3 +- handlers/proxy.go | 69 +++++++++++++------- main_test.go | 26 ++++++-- routes/routes.go | 9 +-- 5 files changed, 198 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 91b05e9..b84f8d4 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,19 @@ Default route dumps you into **swagger** so you can poke things from the browser ## Auth (how to get the token) -Most routes are open. Anything under **`/a/`** needs a bearer token: +Most routes are open on purpose (probes, ingress debug). Anything under **`/a/`** needs a bearer token: ```http Authorization: Bearer ``` -That covers: +| path | why it's locked | +|------|------------------| +| `GET /a/env` | dumps **all env** — secrets, keys, tokens | +| `GET/PUT /a/control/probes` | can fail live (restarts) / ready (drop traffic) | +| `GET/POST /a/proxy` | **SSRF** if open — scan the cluster, hit metadata, pull internal APIs | -- `GET /a/env` — dump process environment -- `GET/PUT /a/control/probes` — read/flip probe modes without redeploying +See **Security** below for the full split. ### Option 1 — fixed token (easiest for demos) @@ -258,22 +261,88 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ -d '{"ready":{"mode":"delay","delaySeconds":30}}' localhost:8080/a/control/probes | jq ``` -### 10. East-west hop via north-south (`/proxy`) +### 10. East-west hop via north-south (`/a/proxy`) — **auth required** Pattern: **ingress → this api → another service** (mesh / NetworkPolicy / DNS / header propagation). -Inbound headers are **forwarded by default** (minus hop-by-hop junk). Response is a JSON wrap with what we sent, what came back, and timing — unless `"raw": true`. +Locked behind bearer on purpose: an open proxy is **SSRF** (anyone could make your pod call internal URLs). + +#### What you get back (default) + +Unless `"raw": true`, the HTTP response from *this* api is always **200 + JSON wrap** (even if upstream was 502). Inside that wrap you get the **full upstream response**: + +| field | what it is | +|-------|------------| +| `response.status` | status code from the other API | +| `response.headers` | **all response headers** from the other API | +| `response.body` | body as a string (capped ~2MB in the wrap) | +| `request.url` / `method` / `headers` / `body` | what we actually sent east-west | +| `meta.durationMs` | hop timing | +| `meta.forwardIncomingHeaders` | whether inbound headers were copied | +| `meta.forwardSensitiveHeaders` | whether Authorization/Cookie were copied | + +Example shape: + +```json +{ + "request": { + "url": "http://other-api:8080/debug", + "method": "GET", + "headers": { "X-Request-Id": ["demo"], "X-Cu-Proxy-Hop": ["pod-a"] }, + "body": "" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": ["application/json; charset=utf-8"] + }, + "body": "{\"Hostname\":\"other-pod\", ...}" + }, + "meta": { + "durationMs": 12, + "timeoutSeconds": 10, + "forwardIncomingHeaders": true, + "forwardSensitiveHeaders": false, + "proxyHostname": "edge-pod" + } +} +``` + +Handy jq: + +```bash +# just upstream status + headers + body +curl -sS -H "Authorization: Bearer $TOKEN" -H 'X-Request-Id: demo' \ + "$BASE/a/proxy?url=http://other-api:8080/debug" \ + | jq '{status: .response.status, headers: .response.headers, body: .response.body}' +``` + +`"raw": true` → no wrap; you get the upstream status/headers/body as the real HTTP response (harder to inspect the hop). + +#### Header forwarding + +| inbound headers | default | +|-----------------|---------| +| tracing / custom (`X-Request-Id`, etc.) | **forwarded** | +| hop-by-hop (`Host`, `Connection`, `Content-Length`, …) | stripped | +| **`Authorization` / `Cookie`** | **not** forwarded (so your `/a/*` bearer is not sent to the other svc by accident) | + +To forward credentials east-west on purpose: `"forwardSensitiveHeaders": true`, or set `headers.Authorization` in the JSON body. ```bash -# simple GET hop (query form) -curl -sS -H 'X-Request-Id: demo-ew-1' -H 'X-Trace: abc' \ - 'localhost:8080/proxy?url=http://other-api:8080/debug' | jq +export BASE=http://localhost:8080 +export TOKEN=dev + +# simple GET hop +curl -sS -H "Authorization: Bearer $TOKEN" \ + -H 'X-Request-Id: demo-ew-1' -H 'X-Trace: abc' \ + "$BASE/a/proxy?url=http://other-api:8080/debug" | jq # POST form — full control -curl -sS -X POST localhost:8080/proxy \ +curl -sS -X POST "$BASE/a/proxy" \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'X-Request-Id: demo-ew-2' \ - -H "Authorization: Bearer $TOKEN" \ -d '{ "url": "http://other-api:8080/echo", "method": "POST", @@ -283,26 +352,24 @@ curl -sS -X POST localhost:8080/proxy \ "forwardIncomingHeaders": true }' | jq -# chain: this api → other api's /debug (see if X-Request-Id survived) -curl -sS -H 'X-Request-Id: keep-me' \ - 'localhost:8080/proxy?url=http://other-api:8080/headers' | jq '.response.body' +# upstream headers only +curl -sS -H "Authorization: Bearer $TOKEN" \ + "$BASE/a/proxy?url=http://other-api:8080/headers" | jq '.response.headers' -# hop to another cluster-utils-api that is deliberately slow -curl -sS -X POST localhost:8080/proxy -H 'Content-Type: application/json' -d '{ - "url": "http://other-api:8080/delay/5", - "timeoutSeconds": 30 -}' | jq '.meta' +# slow peer +curl -sS -X POST "$BASE/a/proxy" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"url":"http://other-api:8080/delay/5","timeoutSeconds":30}' | jq '.meta' ``` -In-cluster example (service DNS): +In-cluster (service DNS) from a port-forwarded edge api: ```bash -# from laptop via port-forward to the *edge* api -curl -sS -H 'X-Request-Id: from-laptop' \ - "localhost:8080/proxy?url=http://cluster-utils-api-svc.other-ns.svc.cluster.local:8080/debug" | jq +curl -sS -H "Authorization: Bearer $TOKEN" -H 'X-Request-Id: from-laptop' \ + "$BASE/a/proxy?url=http://cluster-utils-api-svc.other-ns.svc.cluster.local:8080/debug" | jq ``` -You can also chain two apis: A `/proxy` → B `/proxy` → C `/debug` if you want multi-hop header paths. +Chain multi-hop if you want: A `/a/proxy` → B `/a/proxy` → C `/debug` (each hop needs a token for that api). ### 11. Which build is this pod? @@ -409,9 +476,9 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ | `GET /status/:code` | respond with that http status (100-599) | | `GET /delay/:seconds` | sleep then 200 (cap `MAX_DELAY_SECONDS`, default 120) | | `ANY /echo` | bounce method / query / headers / body | -| `GET/POST /proxy` | east-west hop to another URL; forwards inbound headers | | `GET /a/env` | env vars — **auth** | | `GET/PUT /a/control/probes` | probe state — **auth** | +| `GET/POST /a/proxy` | east-west hop; full upstream status/headers/body in wrap — **auth** | ### other config @@ -423,6 +490,40 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ --- +## Security + +This image is a **cluster debug tool**, not a public SaaS. Treat it like you treat `kubectl` access. + +### Behind bearer (`/a/*`) — keep it that way + +| endpoint | risk if open | +|----------|----------------| +| **`/a/env`** | Full process env — **secrets, API keys, cloud creds**. Correct to lock. | +| **`/a/control/probes`** | Fail liveness → restarts; fail readiness → blackhole traffic; flap → chaos. | +| **`/a/proxy`** | **SSRF**: call any http(s) URL the pod can reach (other namespaces, cloud metadata `169.254.169.254`, internal admin UIs). Response can **exfiltrate** internal data (status + headers + body) to whoever called you. Also why we do **not** auto-forward `Authorization`/`Cookie` east-west. | + +Same auth model as `/a/env` is the right call for `/a/proxy`. If it’s reachable from an ingress without network policy, auth is the main brake. + +### Open on purpose (kube + ingress testing) + +| endpoint | notes | +|----------|--------| +| `/startupz` `/livez` `/readyz` (+ aliases) | **Must** be unauthenticated — kube probes send no bearer | +| `/ping` `/version` `/help` | low sensitivity | +| `/headers` `/debug` `/echo` | can show request headers (including if a client *sent* a secret). Fine for a debug pod; don’t put internet-wide without a gateway auth layer | +| `/status/*` `/delay/*` | abuse = noisy DoS / long requests; cap delay; don’t expose to the open internet | +| `/metrics` | process metrics — usually ok inside the mesh | +| swagger `/api-docs` | documents everything including how to call `/a/*` | + +### Practical guidance + +- Prefer **ClusterIP** + port-forward / exec (as in the sample manifest) for day-to-day use +- If you put it on an ingress, put **auth at the edge** too — don’t rely only on the random token in logs +- Set **`AUTH_TOKEN`** to something you control when automating; rotate if logs are widely readable +- `/a/proxy` is powerful: only point `url` at targets you intend; assume the response body may contain sensitive data from the peer + +--- + ## Run on Kubernetes Starts a deployment + ClusterIP service only. If you want a LoadBalancer, wire it yourself — I don't want you to get a bill from this. diff --git a/handlers/handlers.go b/handlers/handlers.go index fb45c69..78b2097 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -92,8 +92,9 @@ func HelpHandler(c *gin.Context) { "/status/:code": "GET respond with that http status", "/delay/:seconds": "GET sleep then 200 (max 30s)", "/echo": "GET/POST echo method, headers, query, body", - "/proxy": "GET/POST hop to another url (east-west; forwards headers)", "/a/env": "GET env vars (bearer auth)", + "/a/control/probes": "GET/PUT probe state (bearer auth)", + "/a/proxy": "GET/POST east-west hop (bearer auth; SSRF-sensitive)", }) } diff --git a/handlers/proxy.go b/handlers/proxy.go index 4e4f536..0c26ba1 100644 --- a/handlers/proxy.go +++ b/handlers/proxy.go @@ -12,7 +12,7 @@ import ( "github.com/gin-gonic/gin" ) -// hop-by-hop + sensitive-ish headers we don't blindly copy outbound +// hop-by-hop headers we never copy outbound var skipForwardHeaders = map[string]bool{ "connection": true, "keep-alive": true, @@ -22,13 +22,20 @@ var skipForwardHeaders = map[string]bool{ "trailers": true, "transfer-encoding": true, "upgrade": true, - // these belong to *this* hop, not the east-west one - "content-length": true, - "host": true, + "content-length": true, + "host": true, } -// ProxyRequest is the JSON body for POST /proxy. -// Use this to fire east-west traffic from a north-south entry (ingress → this pod → other svc). +// credentials / session material — not forwarded unless forwardSensitiveHeaders=true +var sensitiveForwardHeaders = map[string]bool{ + "authorization": true, + "proxy-authorization": true, + "cookie": true, + "set-cookie": true, +} + +// ProxyRequest is the JSON body for POST /a/proxy. +// North-south hits this pod; we call another URL east-west (auth required — open proxy is SSRF). type ProxyRequest struct { // Absolute URL to call, e.g. http://other-api:8080/debug URL string `json:"url" binding:"required"` @@ -38,19 +45,23 @@ type ProxyRequest struct { Headers map[string]string `json:"headers,omitempty"` // Optional body (string; use for JSON text, form, etc.) Body string `json:"body,omitempty"` - // Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300) + // Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS) TimeoutSeconds float64 `json:"timeoutSeconds,omitempty"` // When true (default), copy inbound request headers onto the outbound call - // (minus hop-by-hop). Good for tracing / auth / x-request-id passthrough. + // (minus hop-by-hop). Tracing headers like X-Request-Id ride along. ForwardIncomingHeaders *bool `json:"forwardIncomingHeaders,omitempty"` - // When true, return the upstream body as raw response (status + headers from upstream). - // Default false → JSON wrap with timing + what we sent/received (better for debugging). + // When true, also forward Authorization / Cookie from the inbound request. + // Default false so your bearer token for *this* api is not sent east-west by accident. + ForwardSensitiveHeaders bool `json:"forwardSensitiveHeaders,omitempty"` + // When true, return the upstream body as the raw HTTP response (status + headers from upstream). + // Default false → JSON wrap with request/response/meta (includes upstream headers + body). Raw bool `json:"raw,omitempty"` } -// @Summary Proxy / hop to another service -// @Description North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url= +// @Summary Proxy / hop to another service (auth) +// @Description Auth required. North→south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF — keep behind bearer. // @ID proxy +// @Security BearerAuth // @Accept json // @Produce json // @Param body body ProxyRequest true "proxy request" @@ -58,13 +69,15 @@ type ProxyRequest struct { // @Param method query string false "HTTP method for GET form (default GET)" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string // @Failure 502 {object} map[string]interface{} -// @Router /proxy [post] -// @Router /proxy [get] +// @Router /a/proxy [post] +// @Router /a/proxy [get] +// @Param Authorization header string true "Bearer token" default(Bearer ) func ProxyHandler(c *gin.Context) { var req ProxyRequest - // GET convenience: /proxy?url=http://svc:8080/debug&method=GET + // GET convenience: /a/proxy?url=http://svc:8080/debug&method=GET if c.Request.Method == http.MethodGet { req.URL = c.Query("url") req.Method = c.DefaultQuery("method", http.MethodGet) @@ -77,6 +90,9 @@ func ProxyHandler(c *gin.Context) { f := false req.ForwardIncomingHeaders = &f } + if c.Query("forwardSensitiveHeaders") == "1" || c.Query("forwardSensitiveHeaders") == "true" { + req.ForwardSensitiveHeaders = true + } if c.Query("raw") == "1" || c.Query("raw") == "true" { req.Raw = true } @@ -131,7 +147,11 @@ func ProxyHandler(c *gin.Context) { // 1) optional: copy north-south inbound headers → east-west outbound if forward { for k, vals := range c.Request.Header { - if skipForwardHeaders[strings.ToLower(k)] { + lk := strings.ToLower(k) + if skipForwardHeaders[lk] { + continue + } + if sensitiveForwardHeaders[lk] && !req.ForwardSensitiveHeaders { continue } for _, v := range vals { @@ -140,12 +160,11 @@ func ProxyHandler(c *gin.Context) { } } - // 2) explicit headers win + // 2) explicit headers win (including Authorization if you set it here on purpose) for k, v := range req.Headers { outReq.Header.Set(k, v) } - // identify hop for debugging if outReq.Header.Get("X-Forwarded-By") == "" { outReq.Header.Set("X-Forwarded-By", "cluster-utils-api") } @@ -184,12 +203,12 @@ func ProxyHandler(c *gin.Context) { return } - // what we actually sent (after merges) sentHeaders := map[string][]string{} for k, v := range outReq.Header { sentHeaders[k] = v } + // Default wrap: full upstream response (status + headers + body) for mesh debugging c.JSON(http.StatusOK, gin.H{ "request": gin.H{ "url": req.URL, @@ -203,11 +222,13 @@ func ProxyHandler(c *gin.Context) { "body": string(respBody), }, "meta": gin.H{ - "durationMs": elapsed.Milliseconds(), - "timeoutSeconds": timeout, - "forwardIncomingHeaders": forward, - "proxyHostname": hostname, - "inboundClientIP": getClientIP(c.Request), + "durationMs": elapsed.Milliseconds(), + "timeoutSeconds": timeout, + "forwardIncomingHeaders": forward, + "forwardSensitiveHeaders": req.ForwardSensitiveHeaders, + "proxyHostname": hostname, + "inboundClientIP": getClientIP(c.Request), + "responseBodyTruncatedAtMB": 2, }, }) } diff --git a/main_test.go b/main_test.go index bd7c23e..494bf87 100644 --- a/main_test.go +++ b/main_test.go @@ -290,9 +290,15 @@ func TestMetrics(t *testing.T) { assert.Contains(t, rr.Body.String(), "http_requests_total") } +func TestProxyRequiresAuth(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/a/proxy?url=http://example.com/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + func TestProxyHopToSelf(t *testing.T) { - // start a real listener via httptest won't work for outbound http client — - // use the test server pattern token := "tok" gin.SetMode(gin.TestMode) handlers.SetBuildInfo("test-ver", "abc123") @@ -305,10 +311,10 @@ func TestProxyHopToSelf(t *testing.T) { srv := httptest.NewServer(r) defer srv.Close() - // hop: GET proxy → same server's /debug, forward a marker header - proxyURL := srv.URL + "/proxy?url=" + srv.URL + "/debug" + proxyURL := srv.URL + "/a/proxy?url=" + srv.URL + "/debug" req, err := http.NewRequest(http.MethodGet, proxyURL, nil) require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Trace-Demo", "east-west-1") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) @@ -321,16 +327,24 @@ func TestProxyHopToSelf(t *testing.T) { assert.Contains(t, wrap, "response") assert.Contains(t, wrap, "meta") - // upstream /debug should have seen the forwarded header + // wrap includes upstream headers map respObj := wrap["response"].(map[string]interface{}) + assert.Contains(t, respObj, "headers") + assert.Contains(t, respObj, "status") + assert.Contains(t, respObj, "body") + body := respObj["body"].(string) assert.Contains(t, body, "X-Trace-Demo") assert.Contains(t, body, "east-west-1") + + // our bearer for this api should NOT be auto-forwarded east-west + assert.NotContains(t, body, "Bearer "+token) } func TestProxyRequiresAbsoluteURL(t *testing.T) { r := setupTestRouter("tok") - req := httptest.NewRequest(http.MethodGet, "/proxy?url=/debug", nil) + req := httptest.NewRequest(http.MethodGet, "/a/proxy?url=/debug", nil) + req.Header.Set("Authorization", "Bearer tok") rr := httptest.NewRecorder() r.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) diff --git a/routes/routes.go b/routes/routes.go index 62cd4e4..52c0b9a 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -56,16 +56,17 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/status/:code", handlers.StatusHandler) r.GET("/delay/:seconds", handlers.DelayHandler) r.Any("/echo", handlers.EchoHandler) - // east-west hop: north-south hits us, we call another svc (headers forwarded by default) - r.GET("/proxy", handlers.ProxyHandler) - r.POST("/proxy", handlers.ProxyHandler) + // Sensitive / abusable — bearer auth required (see README security section) authGroup := r.Group("/a") authGroup.Use(middleware.AuthMiddleware(logger, st)) authGroup.GET("/env", handlers.EnvHandler) authGroup.GET("/control/probes", handlers.GetProbesHandler) authGroup.PUT("/control/probes", handlers.PutProbesHandler) -} + // open /proxy would be SSRF (scan cluster, hit metadata, etc.) + authGroup.GET("/proxy", handlers.ProxyHandler) + authGroup.POST("/proxy", handlers.ProxyHandler) + func swaggerHandler() gin.HandlerFunc { // Empty host in the generated spec would also work; we set Host from the request From 555c2b003a5453a8098618051bfd39aec6986503 Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 20:57:02 +1000 Subject: [PATCH 09/10] fix: repair routes brace and help map after proxy auth move --- docs/docs.go | 294 +++++++++++++++++++++++++------------------ docs/swagger.json | 294 +++++++++++++++++++++++++------------------ docs/swagger.yaml | 212 ++++++++++++++++++------------- handlers/handlers.go | 23 ++-- routes/routes.go | 2 +- 5 files changed, 476 insertions(+), 349 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 0fffd76..4ef20fa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -145,6 +145,170 @@ const docTemplate = `{ } } }, + "/a/proxy": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. North→south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF — keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. North→south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF — keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/debug": { "get": { "description": "Hostname, client ip, headers, uri — good for routing tests", @@ -403,126 +567,6 @@ const docTemplate = `{ } } }, - "/proxy": { - "get": { - "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "summary": "Proxy / hop to another service", - "operationId": "proxy", - "parameters": [ - { - "description": "proxy request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.ProxyRequest" - } - }, - { - "type": "string", - "description": "absolute url (GET form)", - "name": "url", - "in": "query" - }, - { - "type": "string", - "description": "HTTP method for GET form (default GET)", - "name": "method", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "summary": "Proxy / hop to another service", - "operationId": "proxy", - "parameters": [ - { - "description": "proxy request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.ProxyRequest" - } - }, - { - "type": "string", - "description": "absolute url (GET form)", - "name": "url", - "in": "query" - }, - { - "type": "string", - "description": "HTTP method for GET form (default GET)", - "name": "method", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, "/readyz": { "get": { "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", @@ -714,7 +758,11 @@ const docTemplate = `{ "type": "string" }, "forwardIncomingHeaders": { - "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Good for tracing / auth / x-request-id passthrough.", + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Tracing headers like X-Request-Id ride along.", + "type": "boolean" + }, + "forwardSensitiveHeaders": { + "description": "When true, also forward Authorization / Cookie from the inbound request.\nDefault false so your bearer token for *this* api is not sent east-west by accident.", "type": "boolean" }, "headers": { @@ -729,11 +777,11 @@ const docTemplate = `{ "type": "string" }, "raw": { - "description": "When true, return the upstream body as raw response (status + headers from upstream).\nDefault false → JSON wrap with timing + what we sent/received (better for debugging).", + "description": "When true, return the upstream body as the raw HTTP response (status + headers from upstream).\nDefault false → JSON wrap with request/response/meta (includes upstream headers + body).", "type": "boolean" }, "timeoutSeconds": { - "description": "Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300)", + "description": "Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS)", "type": "number" }, "url": { diff --git a/docs/swagger.json b/docs/swagger.json index 9c48ecb..44ab4e8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -138,6 +138,170 @@ } } }, + "/a/proxy": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. North→south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF — keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. North→south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF — keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/debug": { "get": { "description": "Hostname, client ip, headers, uri — good for routing tests", @@ -396,126 +560,6 @@ } } }, - "/proxy": { - "get": { - "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "summary": "Proxy / hop to another service", - "operationId": "proxy", - "parameters": [ - { - "description": "proxy request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.ProxyRequest" - } - }, - { - "type": "string", - "description": "absolute url (GET form)", - "name": "url", - "in": "query" - }, - { - "type": "string", - "description": "HTTP method for GET form (default GET)", - "name": "method", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "description": "North→south hits this pod; this pod calls another URL east-west. Forwards headers by default so you can test mesh/ingress propagation. Also supports GET /proxy?url=", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "summary": "Proxy / hop to another service", - "operationId": "proxy", - "parameters": [ - { - "description": "proxy request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.ProxyRequest" - } - }, - { - "type": "string", - "description": "absolute url (GET form)", - "name": "url", - "in": "query" - }, - { - "type": "string", - "description": "HTTP method for GET form (default GET)", - "name": "method", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, "/readyz": { "get": { "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", @@ -707,7 +751,11 @@ "type": "string" }, "forwardIncomingHeaders": { - "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Good for tracing / auth / x-request-id passthrough.", + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Tracing headers like X-Request-Id ride along.", + "type": "boolean" + }, + "forwardSensitiveHeaders": { + "description": "When true, also forward Authorization / Cookie from the inbound request.\nDefault false so your bearer token for *this* api is not sent east-west by accident.", "type": "boolean" }, "headers": { @@ -722,11 +770,11 @@ "type": "string" }, "raw": { - "description": "When true, return the upstream body as raw response (status + headers from upstream).\nDefault false → JSON wrap with timing + what we sent/received (better for debugging).", + "description": "When true, return the upstream body as the raw HTTP response (status + headers from upstream).\nDefault false → JSON wrap with request/response/meta (includes upstream headers + body).", "type": "boolean" }, "timeoutSeconds": { - "description": "Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS / 300)", + "description": "Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS)", "type": "number" }, "url": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d519456..27a5941 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -62,7 +62,12 @@ definitions: forwardIncomingHeaders: description: |- When true (default), copy inbound request headers onto the outbound call - (minus hop-by-hop). Good for tracing / auth / x-request-id passthrough. + (minus hop-by-hop). Tracing headers like X-Request-Id ride along. + type: boolean + forwardSensitiveHeaders: + description: |- + When true, also forward Authorization / Cookie from the inbound request. + Default false so your bearer token for *this* api is not sent east-west by accident. type: boolean headers: additionalProperties: @@ -74,12 +79,11 @@ definitions: type: string raw: description: |- - When true, return the upstream body as raw response (status + headers from upstream). - Default false → JSON wrap with timing + what we sent/received (better for debugging). + When true, return the upstream body as the raw HTTP response (status + headers from upstream). + Default false → JSON wrap with request/response/meta (includes upstream headers + body). type: boolean timeoutSeconds: - description: Timeout for the outbound call (default 10, max same as MAX_DELAY_SECONDS - / 300) + description: Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS) type: number url: description: Absolute URL to call, e.g. http://other-api:8080/debug @@ -182,6 +186,119 @@ paths: security: - BearerAuth: [] summary: Get environment variables + /a/proxy: + get: + consumes: + - application/json + description: Auth required. North→south hits this pod; this pod calls url east-west. + Default JSON wrap includes upstream status, headers, and body. Open proxy + would be SSRF — keep behind bearer. + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + - default: Bearer + description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Proxy / hop to another service (auth) + post: + consumes: + - application/json + description: Auth required. North→south hits this pod; this pod calls url east-west. + Default JSON wrap includes upstream status, headers, and body. Open proxy + would be SSRF — keep behind bearer. + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + - default: Bearer + description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Proxy / hop to another service (auth) /debug: get: description: Hostname, client ip, headers, uri — good for routing tests @@ -360,91 +477,6 @@ paths: schema: type: string summary: Get ping - /proxy: - get: - consumes: - - application/json - description: North→south hits this pod; this pod calls another URL east-west. - Forwards headers by default so you can test mesh/ingress propagation. Also - supports GET /proxy?url= - operationId: proxy - parameters: - - description: proxy request - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.ProxyRequest' - - description: absolute url (GET form) - in: query - name: url - type: string - - description: HTTP method for GET form (default GET) - in: query - name: method - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "502": - description: Bad Gateway - schema: - additionalProperties: true - type: object - summary: Proxy / hop to another service - post: - consumes: - - application/json - description: North→south hits this pod; this pod calls another URL east-west. - Forwards headers by default so you can test mesh/ingress propagation. Also - supports GET /proxy?url= - operationId: proxy - parameters: - - description: proxy request - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.ProxyRequest' - - description: absolute url (GET form) - in: query - name: url - type: string - - description: HTTP method for GET form (default GET) - in: query - name: method - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "502": - description: Bad Gateway - schema: - additionalProperties: true - type: object - summary: Proxy / hop to another service /readyz: get: description: 'Kube readiness. 200 = take traffic; 503 = leave Service endpoints. diff --git a/handlers/handlers.go b/handlers/handlers.go index 78b2097..3bcfb75 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -83,18 +83,17 @@ func HelpHandler(c *gin.Context) { "/version": "GET build version / git hash", "/livez|/healthz|/health": "GET liveness (LIVE_MODE / LIVE_DELAY)", "/readyz|/ready": "GET readiness (READY_MODE / READY_DELAY)", - "/startupz|/startup": "GET startup latch (STARTUP_* / boot delay)", - "/a/control/probes": "GET/PUT probe state (bearer auth)", - "/ping": "GET PONG", - "/headers": "GET request headers", - "/debug": "GET hostname / ip / headers / uri", - "/metrics": "GET prometheus metrics", - "/status/:code": "GET respond with that http status", - "/delay/:seconds": "GET sleep then 200 (max 30s)", - "/echo": "GET/POST echo method, headers, query, body", - "/a/env": "GET env vars (bearer auth)", - "/a/control/probes": "GET/PUT probe state (bearer auth)", - "/a/proxy": "GET/POST east-west hop (bearer auth; SSRF-sensitive)", + "/startupz|/startup": "GET startup latch (STARTUP_* / boot delay)", + "/ping": "GET PONG", + "/headers": "GET request headers", + "/debug": "GET hostname / ip / headers / uri", + "/metrics": "GET prometheus metrics", + "/status/:code": "GET respond with that http status", + "/delay/:seconds": "GET sleep then 200 (MAX_DELAY_SECONDS cap)", + "/echo": "GET/POST echo method, headers, query, body", + "/a/env": "GET env vars (bearer auth)", + "/a/control/probes": "GET/PUT probe state (bearer auth)", + "/a/proxy": "GET/POST east-west hop (bearer auth; SSRF-sensitive)", }) } diff --git a/routes/routes.go b/routes/routes.go index 52c0b9a..64a4579 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -66,7 +66,7 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { // open /proxy would be SSRF (scan cluster, hit metadata, etc.) authGroup.GET("/proxy", handlers.ProxyHandler) authGroup.POST("/proxy", handlers.ProxyHandler) - +} func swaggerHandler() gin.HandlerFunc { // Empty host in the generated spec would also work; we set Host from the request From 5b43d39f128f85ce9b9bd20f8231882940fd75c8 Mon Sep 17 00:00:00 2001 From: David Binney Date: Mon, 10 Aug 2026 21:07:21 +1000 Subject: [PATCH 10/10] chore: module path, dep upgrades, prom metrics, build hygiene - rename module to github.com/donkeyx/cluster-utils-api - bump gin, prometheus, zap, swag, testify and related to current latest - proper HTTP metrics middleware (count, duration, in-flight) + Go/process collectors on a dedicated registry (OpenMetrics scrape) - pin swag CLI, make swagger/update targets; Dockerfile mod download cache --- Dockerfile | 10 +- Makefile | 29 ++++- README.md | 2 +- docs/docs.go | 2 +- docs/swagger.json | 2 +- docs/swagger.yaml | 2 +- go.mod | 82 ++++++------- go.sum | 270 ++++++++++++++++--------------------------- handlers/handlers.go | 41 ------- handlers/metrics.go | 95 +++++++++++++++ main.go | 6 +- main_test.go | 9 +- routes/routes.go | 6 +- 13 files changed, 282 insertions(+), 274 deletions(-) create mode 100644 handlers/metrics.go diff --git a/Dockerfile b/Dockerfile index 2fee2f1..01a1a22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ FROM golang:1.26 AS builder WORKDIR /app -COPY . /app/ +# Cache module downloads when only source changes +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . LABEL org.opencontainers.image.source=https://github.com/donkeyx/cluster-utils-api LABEL maintainer="David Binney " -# Reproducible image build: use locked modules, do not go get -u -RUN make deps build +# Reproducible image build: locked modules only (no go get -u) +RUN make build # no longer using musl dns moved to debian FROM debian:stable-slim diff --git a/Makefile b/Makefile index b1f24f9..636f423 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,11 @@ GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_DATE := $(shell date) BUILD_FLAGS := -ldflags="-w -s -X main.Version=$(VERSION) -X main.GitHash=$(GIT_HASH)" +# Pin tool versions (reproducible local + CI) +SWAG_VERSION := v1.16.6 + # Phony targets -.PHONY: all build test clean deps tools update build-all +.PHONY: all build test clean deps tools swagger update build-all # Targets all: clean deps test build-all @@ -23,19 +26,33 @@ test: clean: go clean - find $(BINARY_PATH) -type f ! -name 'keep' -delete + find $(BINARY_PATH) -type f ! -name 'keep' -delete 2>/dev/null || true # Reproducible: download locked modules only (used by Docker) deps: go mod download -# Local tooling (swagger regen, etc.) +# Local tooling — pinned, not @latest tools: - go install github.com/swaggo/swag/cmd/swag@latest + go install github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) + +# Regen OpenAPI + gin docs from annotations +swagger: tools + swag init -g main.go -o docs -# Explicit dependency upgrades (local / deliberate only) +# Deliberate upgrades of *direct* deps only (never run naked go get -u ./... in Docker/CI) update: - go get -u ./... + go get github.com/gin-gonic/gin@latest + go get github.com/prometheus/client_golang@latest + go get github.com/stretchr/testify@latest + go get github.com/swaggo/files@latest + go get github.com/swaggo/gin-swagger@latest + go get github.com/swaggo/swag@latest + go get go.uber.org/zap@latest + go get golang.org/x/net@latest + go get golang.org/x/crypto@latest + go get golang.org/x/sys@latest + go get golang.org/x/text@latest go mod tidy build-all: diff --git a/README.md b/README.md index b84f8d4..b770315 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ | `GET /ping` | `PONG` (not a kube probe) | | `GET /headers` | request headers | | `GET /debug` | hostname / ip / headers / uri | -| `GET /metrics` | prometheus | +| `GET /metrics` | prometheus (OpenMetrics): request count/latency/in-flight + Go/process | | `GET /status/:code` | respond with that http status (100-599) | | `GET /delay/:seconds` | sleep then 200 (cap `MAX_DELAY_SECONDS`, default 120) | | `ANY /echo` | bounce method / query / headers / body | diff --git a/docs/docs.go b/docs/docs.go index 4ef20fa..b0efce2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -533,7 +533,7 @@ const docTemplate = `{ }, "/metrics": { "get": { - "description": "Prometheus scrape endpoint", + "description": "Prometheus scrape endpoint (Go + process + HTTP RED metrics)", "produces": [ "text/plain" ], diff --git a/docs/swagger.json b/docs/swagger.json index 44ab4e8..047f92f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -526,7 +526,7 @@ }, "/metrics": { "get": { - "description": "Prometheus scrape endpoint", + "description": "Prometheus scrape endpoint (Go + process + HTTP RED metrics)", "produces": [ "text/plain" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 27a5941..58e80d0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -455,7 +455,7 @@ paths: summary: Liveness (livez) /metrics: get: - description: Prometheus scrape endpoint + description: Prometheus scrape endpoint (Go + process + HTTP RED metrics) operationId: metrics produces: - text/plain diff --git a/go.mod b/go.mod index 5442bc8..14dd787 100644 --- a/go.mod +++ b/go.mod @@ -1,64 +1,68 @@ -module cu-api +module github.com/donkeyx/cluster-utils-api go 1.26 require ( - github.com/gin-gonic/gin v1.10.0 - github.com/prometheus/client_golang v1.19.1 - github.com/stretchr/testify v1.9.0 + github.com/gin-gonic/gin v1.12.0 + github.com/prometheus/client_golang v1.24.1 + github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 - github.com/swaggo/gin-swagger v1.6.0 - github.com/swaggo/swag v1.16.3 - go.uber.org/zap v1.27.0 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + go.uber.org/zap v1.28.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/sonic v1.11.9 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect - github.com/chenzhuoyu/iasm v0.9.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.4 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.22.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.23.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ab70b70..83d9ca8 100644 --- a/go.sum +++ b/go.sum @@ -1,130 +1,83 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 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/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= -github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc= -github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= -github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= -github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= -github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= -github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= -github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= -github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8= -github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs= -github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -133,134 +86,109 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= -github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +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.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= -github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= -github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04= -github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E= -github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= -github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= -go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= -golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -273,5 +201,3 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handlers/handlers.go b/handlers/handlers.go index 3bcfb75..1f7153f 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -10,8 +10,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" ) // Build info injected from main (ldflags). @@ -20,20 +18,6 @@ var ( AppGitHash = "unknown" ) -var ( - requestsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "http_requests_total", - Help: "Total number of HTTP requests.", - }, - []string{"method", "path", "status"}, - ) -) - -func init() { - prometheus.MustRegister(requestsTotal) -} - // SetBuildInfo lets main push version/git hash from ldflags. func SetBuildInfo(version, gitHash string) { if version != "" { @@ -44,31 +28,6 @@ func SetBuildInfo(version, gitHash string) { } } -// MetricsMiddleware counts requests for /metrics. -func MetricsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Next() - path := c.FullPath() - if path == "" { - path = c.Request.URL.Path - } - requestsTotal.WithLabelValues(c.Request.Method, path, strconv.Itoa(c.Writer.Status())).Inc() - } -} - -// @Summary Prometheus metrics -// @Description Prometheus scrape endpoint -// @ID metrics -// @Produce plain -// @Success 200 {string} string "metrics" -// @Router /metrics [get] -func PrometheusMetricsHandler() gin.HandlerFunc { - h := promhttp.Handler() - return func(c *gin.Context) { - h.ServeHTTP(c.Writer, c.Request) - } -} - // @Summary Help // @Description Quick map of useful routes // @ID help diff --git a/handlers/metrics.go b/handlers/metrics.go new file mode 100644 index 0000000..3ecffcc --- /dev/null +++ b/handlers/metrics.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Prometheus setup — client_golang is still the k8s scrape standard. +// We use a dedicated registry + standard RED-ish HTTP metrics + Go/process collectors. +// (promhttp.InstrumentHandler* is the other common pattern; middleware fits Gin better.) + +var ( + metricsRegistry = prometheus.NewRegistry() + + httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total HTTP requests processed.", + }, + []string{"method", "path", "status"}, + ) + + httpRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request latency in seconds.", + Buckets: prometheus.DefBuckets, // 5ms … 10s — fine for an API util + }, + []string{"method", "path", "status"}, + ) + + httpRequestsInFlight = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "http_requests_in_flight", + Help: "Number of HTTP requests currently being processed.", + }, + ) +) + +func init() { + metricsRegistry.MustRegister( + httpRequestsTotal, + httpRequestDuration, + httpRequestsInFlight, + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) +} + +// MetricsMiddleware records request count, in-flight gauge, and duration. +// Path label uses gin FullPath (route template) so cardinality stays bounded. +func MetricsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // don't instrument the scrape endpoint itself + if c.Request.URL.Path == "/metrics" { + c.Next() + return + } + + start := time.Now() + httpRequestsInFlight.Inc() + c.Next() + httpRequestsInFlight.Dec() + + path := c.FullPath() + if path == "" { + path = "unmatched" + } + status := strconv.Itoa(c.Writer.Status()) + method := c.Request.Method + + httpRequestsTotal.WithLabelValues(method, path, status).Inc() + httpRequestDuration.WithLabelValues(method, path, status).Observe(time.Since(start).Seconds()) + } +} + +// @Summary Prometheus metrics +// @Description Prometheus scrape endpoint (Go + process + HTTP RED metrics) +// @ID metrics +// @Produce plain +// @Success 200 {string} string "metrics" +// @Router /metrics [get] +func PrometheusMetricsHandler() gin.HandlerFunc { + h := promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{ + EnableOpenMetrics: true, + }) + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/main.go b/main.go index ec2b9dd..7533c53 100644 --- a/main.go +++ b/main.go @@ -15,9 +15,9 @@ import ( "os" "strconv" - "cu-api/handlers" - "cu-api/middleware" - "cu-api/routes" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/middleware" + "github.com/donkeyx/cluster-utils-api/routes" "github.com/gin-gonic/gin" "go.uber.org/zap" diff --git a/main_test.go b/main_test.go index 494bf87..3366824 100644 --- a/main_test.go +++ b/main_test.go @@ -2,8 +2,8 @@ package main import ( "bytes" - "cu-api/handlers" - "cu-api/routes" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/routes" "encoding/json" "net/http" "net/http/httptest" @@ -287,7 +287,10 @@ func TestMetrics(t *testing.T) { rr := httptest.NewRecorder() r.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "http_requests_total") + body := rr.Body.String() + assert.Contains(t, body, "http_requests_total") + assert.Contains(t, body, "http_request_duration_seconds") + assert.Contains(t, body, "go_goroutines") } func TestProxyRequiresAuth(t *testing.T) { diff --git a/routes/routes.go b/routes/routes.go index 64a4579..b3ad33d 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,9 +1,9 @@ package routes import ( - "cu-api/docs" - "cu-api/handlers" - "cu-api/middleware" + "github.com/donkeyx/cluster-utils-api/docs" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/middleware" "net/http" "strings" "sync"