Simple docker image to allow testing within clusters or locally and provides endpoints for debugging
50K+
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
| ๐ด DonkeyX's Cluster Utils API โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
//\\
(/oo\) .----.
(____) | API |
/||\ '----'
//||\\ ๐ Probe Mode
^^ ^^ ^^
"Kick the tyres on the mesh!"
HTTP side of the cluster-utils toolkit. Where 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:
/startupz, /livez, /readyz with fail + delay + flap + runtime control/proxy out to another svc (headers ride along)/headers, /debug, /echo)/a/env) so you can check secrets, configmaps, task defs actually landed/status/503, /delay/90)node / npm so broken charts that call weird commands still come up and serve the apiDefault 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โ
| ghcr: ghcr.io/donkeyx/cluster-utils-api
| github: https://github.com/donkeyx/cluster-utils-apiโ
| pair with: https://github.com/donkeyx/cluster-utilsโ (shell / toolkit image)
Most routes are open on purpose (probes, ingress debug). Anything under /a/ needs a bearer token:
Authorization: Bearer <token>
| 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 |
See Security below for the full split.
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.
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:
# 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'
# kubernetes
kubectl -n default logs deploy/cluster-utils-api --tail=50 | grep -E 'token|Bearer|example curl'
On startup the app also logs ready-made curls (env dump + probe control) with the token already filled in โ copy/paste those.
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
Yes โ for /a/* you Authorize before Execute (same value as curl).
Local podman (AUTH_TOKEN=dev on :18080):
Open http://127.0.0.1:18080/โ (same host/port as the API)
Click the green Authorize lock (top right) โ not a random field on the op
Paste exactly:
Bearer dev
| Value | Result |
|---|---|
Bearer dev | 200 |
dev only | 401 |
bearer dev (lowercase) | 401 (we require exact Bearer ) |
Authorize โ Close
Operation โ Try it out โ Execute
If still 401: hard-refresh swagger (stale token), confirm
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer dev' http://127.0.0.1:18080/a/env
is 200, or check podman logs cu-api | grep '"token"'.
By default Swagger calls whatever host you opened the docs on.
If that is wrong (port-map, ingress, in-cluster Service, tunnel), pass the host on the docs URL โ you will do this a lot:
# point Try-it-out at a different host:port
/api-docs/index.html?host=127.0.0.1:18080&scheme=http
# in-cluster Service from a port-forwarded docs UI
/api-docs/index.html?host=cluster-utils-api.default.svc.cluster.local:8080&scheme=http
# public ingress
/api-docs/index.html?host=api.example.com&scheme=https
| Query | Example | What it does |
|---|---|---|
host | my-svc.ns.svc:8080 | Host:port Execute / Try-it-out uses |
scheme | http or https | Scheme for those calls |
theme | light | Stock bright Swagger (dark is default) |
Bookmark the full URL once; every open keeps that target.
| Env | Example | Purpose |
|---|---|---|
SWAGGER_HOST | api.example.com or cluster-utils-api.ns.svc:8080 | Default host:port for Try-it-out |
SWAGGER_SCHEME | https or http | Default scheme |
Priority: query ?host=&scheme= โ env SWAGGER_* โ request Host / X-Forwarded-Proto.
(PORT is only the process listen port; it does not set the public URL.)
Theme: dark by default (custom CSS skin โ stock Swagger has no real dark mode).
Light: ?theme=light.
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
curl -sS localhost:8080/livez
curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq
Port defaults to 8080 (PORT to override).
Assume TOKEN is set (see Auth above) and the api is on localhost:8080.
# 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
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"'
# 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 '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
# 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
Sample manifest uses timeoutSeconds: 1. Anything slower counts as a failed probe.
# 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
# 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 '{"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}'
# 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":15},"resetStartupLatch":true}' \
localhost:8080/a/control/probes | jq
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:
docker run -d -p 8080:8080 \
-e AUTH_TOKEN=dev \
-e STARTUP_BOOT_DELAY=20 \
donkeyx/cluster-utils-api:latest
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
# 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
/a/proxy) โ auth requiredPattern: ingress โ this api โ another service (mesh / NetworkPolicy / DNS / header propagation).
Locked behind bearer on purpose: an open proxy is SSRF (anyone could make your pod call internal URLs).
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:
{
"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:
# 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).
| 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.
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 "$BASE/a/proxy" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: demo-ew-2' \
-d '{
"url": "http://other-api:8080/echo",
"method": "POST",
"body": "{\"ping\":true}",
"headers": {"Content-Type": "application/json"},
"timeoutSeconds": 15,
"forwardIncomingHeaders": true
}' | jq
# upstream headers only
curl -sS -H "Authorization: Bearer $TOKEN" \
"$BASE/a/proxy?url=http://other-api:8080/headers" | jq '.response.headers'
# 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 (service DNS) from a port-forwarded edge api:
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
Chain multi-hop if you want: A /a/proxy โ B /a/proxy โ C /debug (each hop needs a token for that api).
curl -sS localhost:8080/version | jq
# {"version":"...","gitHash":"...","hostname":"..."}
# simulate gateway/mesh headers
curl -sS -D- \
-H 'X-Request-Id: istio-style-id-001' \
-H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
localhost:8080/debug -o /dev/null | grep -iE 'x-trace-id|x-request-id'
# with OTEL enabled, X-Trace-Id is the Tempo id; X-Request-Id echoes the mesh id
# logs: {"msg":"Request","trace_id":"...","request_id":"istio-style-id-001",...}
See Observability for push vs scrape and full header table.
# 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
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 |
bootDelaySeconds has not elapsed (from process start, or after a latch reset) โ 503 startingstarted (fast), until process restart or resetStartupLatchmode=fail โ 503 startup failed and never latches| mode | after optional delay |
|---|---|
ok | 200 |
fail | 503 |
delay | 200 (use delaySeconds > probe timeoutSeconds for timeouts) |
flap | alternates ok/fail โ flapSeconds (time half-period, default 5) or flapEvery (every Nth request) |
On startup, flap only applies before the latch.
| env | default | notes |
|---|---|---|
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 / READY_FLAP_EVERY | ||
STARTUP_MODE / STARTUP | ok | |
STARTUP_DELAY | 0 | per-request sleep while not latched |
STARTUP_BOOT_DELAY | 0 | wall clock before first success |
STARTUP_FLAP_SECONDS / STARTUP_FLAP_EVERY | flap before latch |
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.
curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"ready": {"mode":"flap","flapSeconds":5},
"live": {"mode":"ok"},
"startup": {"mode":"ok","bootDelaySeconds":5},
"resetStartupLatch": true
}' localhost:8080/a/control/probes | jq
| path | notes |
|---|---|
GET / | redirect to swagger |
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 scrape (OpenMetrics): request count/latency/in-flight + Go/process |
| OTEL traces | push OTLP to Alloy/collector (not scrape) โ see Observability |
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 /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 |
| env | default | what it does |
|---|---|---|
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 |
Two different pipelines โ don't mix them up:
| Signal | How it leaves the app | Endpoint / protocol | Typical sink |
|---|---|---|---|
| Metrics | Scrape (pull) | GET /metrics Prometheus/OpenMetrics | Alloy prometheus.scrape โ Mimir/Prometheus |
| Traces | Push | OTLP http/protobuf (default) or grpc | Alloy OTLP receiver โ Tempo |
| Logs | stdout JSON (zap) | not OTLP yet | Alloy/loki.source.kubernetes โ Loki |
Traces are not scraped. The app exports spans to a collector. Grafana Alloy is the usual middle hop: receive OTLP โ forward to Tempo.
Normal path in 2024โ26 stacks:
traceparent, orx-b3-traceid, โฆ) if the mesh is still on Zipkin-style configx-request-id (Envoy/Istio) is a separate correlation id used in access logs โ it is not the OTEL trace id. Join them by putting x-request-id on the span (we do) and echoing both on the response.| Header | What it is | We do |
|---|---|---|
traceparent / tracestate | W3C trace context (modern default) | extract + inject |
x-b3-* / b3 | Zipkin B3 (common with Istio) | extract + inject |
uber-trace-id | Jaeger | extract + inject |
x-request-id | Envoy request id (logs) | span attr http.request_id + response echo |
x-correlation-id | app/gateway variant | span attr + echo if present |
So: we match mesh traffic by speaking W3C + B3 + Jaeger, and we use Istioโs request id as an attribute / response header so you can jump from Envoy logs to Tempo (X-Trace-Id).
| Variable | Default here |
|---|---|
| export / SDK | disabled (no-op) until OTEL_EXPORTER_OTLP_ENDPOINT (or traces endpoint) is set |
OTEL_SERVICE_NAME | cluster-utils-api |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf (port 4318 on Alloy) |
OTEL_TRACE_SAMPLE_RATIO | 1.0 (all traces when enabled) |
OTEL_TRACE_PROBES | off (no spans for /livez /readyz /startupz /metrics /ping) |
| propagators | always tracecontext + baggage + b3 + jaeger |
OTEL_EXPORTER_OTLP_INSECURE | unset (exporter default); set "true" in-cluster without TLS |
On every startup we log a single line otel config (effective) with enabled flag, endpoints, protocol, sample ratio, probe tracing, and propagators โ grep pod logs for otel config.
env:
- name: OTEL_SERVICE_NAME
value: cluster-utils-api
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "alloy.observability.svc.cluster.local:4318" # http/protobuf default
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: http/protobuf # or grpc (often :4317)
- name: OTEL_EXPORTER_OTLP_INSECURE
value: "true" # TLS off inside the mesh
# optional:
# - name: OTEL_TRACE_SAMPLE_RATIO
# value: "1.0"
# - name: OTEL_SDK_DISABLED
# value: "true"
# - name: OTEL_TRACE_PROBES
# value: "true" # also span kube probes (noisy)
What gets instrumented:
otelgin) + mesh header attributes/a/proxy โ otelhttp client span + header inject east-westX-Trace-Id (OTEL) and X-Request-Id (if the mesh/client sent one)curl -sS -D- -H 'X-Request-Id: demo-from-gateway' localhost:8080/debug -o /dev/null | grep -iE 'x-trace-id|x-request-id'
Important: X-Request-Id (Istio/Envoy) โ X-Trace-Id (OpenTelemetry/Tempo).
They are both useful; we keep both. In Tempo, search by trace id, or by span attribute http.request_id when the mesh sent a request id. Pod logs include trace_id + request_id on each request line when present.
# 1) call through the mesh (or simulate Envoy's header)
curl -sS -D /tmp/hdrs -H 'X-Request-Id: 0a1b2c3d-demo' localhost:8080/debug -o /dev/null
grep -iE 'x-trace-id|x-request-id' /tmp/hdrs
# 2) app logs (same ids)
# kubectl logs deploy/cluster-utils-api | grep 0a1b2c3d-demo
# 3) Tempo: search TraceID = value of X-Trace-Id
# or attribute http.request_id = 0a1b2c3d-demo
On boot, always check:
kubectl logs deploy/cluster-utils-api | grep 'otel config'
# โ enabled, endpoint, protocol, sample_ratio, propagators, mesh_headers, โฆ
// metrics: scrape this app
prometheus.scrape "cu_api" {
targets = [{ __address__ = "cluster-utils-api-svc:8080" }]
metrics_path = "/metrics"
forward_to = [prometheus.remote_write.mimir.receiver]
}
// traces: receive OTLP *push* from the app
otelcol.receiver.otlp "default" {
http { endpoint = "0.0.0.0:4318" }
grpc { endpoint = "0.0.0.0:4317" }
output { traces = [otelcol.exporter.otlp.tempo.input] }
}
otelcol.exporter.otlp "tempo" {
client { endpoint = "tempo:4317" tls { insecure = true } }
}
Istio tip: prefer mesh config that emits W3C (or dual W3C+B3). If you only have B3 today, our B3 propagator still joins the chain.
Content type
Image
Digest
sha256:51cdb404dโฆ
Size
42.6 MB
Last updated
2 days ago
docker pull donkeyx/cluster-utils-api