Files
gis/test/acceptance/load.sh
Bakhtiyar Issakhmetov ca87d6961b test(acceptance): reproducible Stage 8 acceptance package
Add test/acceptance/ so the customer's reviewers can re-run what the
Stage 8 documents claim on an isolated stand built from this branch:

- compose.acceptance.yml + stand.sh: compose project gis-acceptance
  (app 18080, postgres 15432, minio 19000/19001, rabbitmq mgmt 15672),
  POSTGIS_PLATFORM / MINIO_IMAGE knobs for arm64 and amd64 hosts.
- functional.sh: the 89 test-stand checks of Приложение 7 section 5
  (F01-F38, N01-N38, L02-L05, L07-L15), own prefixed test data,
  PostGIS/MinIO confirmation via the stand containers, full cleanup.
- lp_gen.py / lp_run.sh / lp_verify.py: synthetic ЛП inputs, a run from
  stage 0, and an independent numpy re-implementation of every node that
  compares all 15 outputs pixel by pixel; read-only (GET) mode works
  against any stand, in row/column windows for 22219x14977 rasters.
- load.sh + seed.sh: scenarios S1-S6 of Приложение 3 with hey 0.1.5
  running inside the stand network; CSV/JSON summaries, criteria K1-K4.
- scan.sh: govulncheck (source and binary), gosec, trivy fs/image,
  golangci-lint; fails on any High/Critical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:37:28 +05:00

228 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Load test of the GIS API — scenarios S1–S6 of «Протокол нагрузочного
# тестирования» (Приложение 3), generator hey 0.1.5:
# S1 baseline: 10 endpoints one after another, hey -n 100 -c 1 each
# S2 mixed: 1000 requests, 100 concurrent clients over the 10 endpoints
# (10 parallel hey processes, -n 100 -c 10 each)
# S3.1 1000 simultaneous requests: hey -n 1000 -c 1000 /datasets?page_size=20, 3 runs
# S3.2 1000 simultaneous requests: hey -n 1000 -c 1000 /landscape/results, 3 runs
# S4 sustained ~1000 req/s for 60 s: 1000 clients at -q 1 split over 8
# endpoints (300/150/100/100/100/100/100/50), 8 parallel hey processes
# S5 30 s of reads (10 endpoints x 10 clients, -z 30s) while 20 uploads
# (10 GeoJSON + 10 GeoTIFF) run in parallel; uploads must reach ready
# S6 recovery: S1 again + /readyz
#
# By default the generator runs INSIDE the stand's docker network
# (docker run --network gis-acceptance_default gis-acceptance-hey:0.1.5,
# target http://app:8080), so host port forwarding is not measured.
#
# Environment:
# BASE_URL API address for setup calls (default http://localhost:18080)
# TARGET_URL address the generator loads (default http://app:8080 inside
# the network; any URL with GEN=host or NETWORK=bridge)
# GEN docker (default) | host (use a locally installed hey)
# NETWORK docker network of the generator (default gis-acceptance_default)
# SCENARIOS subset to run (default "S1 S2 S3 S4 S5 S6")
# S4_DURATION / S5_DURATION default 60s / 30s
# PYTHON python3 with numpy + rasterio (S5 raster uploads, seeding)
# ALLOW_WRITES 1 = allow seeding and S5 uploads on a non-local BASE_URL
#
# Output: summary table on stdout; test/acceptance/results/load-<timestamp>/
# {summary.csv, summary.json, detail.csv, raw/*.txt}. Exit code 1 when a
# criterion K1–K4 fails.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
BASE_URL="${BASE_URL:-http://localhost:18080}"; BASE_URL="${BASE_URL%/}"
GEN="${GEN:-docker}"
NETWORK="${NETWORK:-gis-acceptance_default}"
HEY_IMAGE="${HEY_IMAGE:-gis-acceptance-hey:0.1.5}"
SCENARIOS="${SCENARIOS:-S1 S2 S3 S4 S5 S6}"
S4_DURATION="${S4_DURATION:-60s}"
S5_DURATION="${S5_DURATION:-30s}"
PYTHON="${PYTHON:-python3}"
ALLOW_WRITES="${ALLOW_WRITES:-0}"
if [ -z "${TARGET_URL:-}" ]; then
if [ "$GEN" = docker ] && [ "$NETWORK" = gis-acceptance_default ]; then TARGET_URL=http://app:8080; else TARGET_URL="$BASE_URL"; fi
fi
TARGET_URL="${TARGET_URL%/}"
LOCAL=0
case "$BASE_URL" in http://localhost:*|http://127.0.0.1:*) LOCAL=1;; esac
case "$BASE_URL" in *dssgis.dwh.kz*) ALLOW_WRITES=0;; esac
for bin in curl jq python3; do command -v "$bin" >/dev/null || { echo "missing $bin" >&2; exit 2; }; done
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="$HERE/results/load-$STAMP"; RAW="$OUT/raw"; mkdir -p "$RAW"
echo "scenario,mode,label,clients,file" > "$OUT/manifest.csv"
EXTRA="$OUT/extra.json"; echo '{}' > "$EXTRA"
log() { echo "[$(date +%H:%M:%S)] $*"; }
extra_set() { local t; t="$(mktemp)"; jq "$1" "$EXTRA" > "$t" && mv "$t" "$EXTRA"; }
# ---------------------------------------------------------------- generator
if [ "$GEN" = docker ]; then
if ! docker image inspect "$HEY_IMAGE" >/dev/null 2>&1; then
log "building $HEY_IMAGE"
docker build -q -t "$HEY_IMAGE" -f "$HERE/lib/hey.Dockerfile" "$HERE/lib" >/dev/null
fi
NETARG="--network $NETWORK"; [ "$NETWORK" = bridge ] && NETARG=""
fi
# run_batch SCEN MODE "label|clients|file|hey args and path" ...
# MODE par: all hey processes start together; seq: one after another.
run_batch() {
local scen="$1" mode="$2"; shift 2
local script="" spec label clients file args sep=" & "
[ "$mode" = seq ] && sep=" ; "
for spec in "$@"; do
IFS='|' read -r label clients file args <<< "$spec"
echo "$scen,$mode,\"$label\",$clients,$file" >> "$OUT/manifest.csv"
script="${script}hey $args > /out/$file 2>&1${sep}"
done
script="${script} wait"
if [ "$GEN" = docker ]; then
# shellcheck disable=SC2086
docker run --rm $NETARG --ulimit nofile=65536:65536 -v "$RAW:/out" --entrypoint sh "$HEY_IMAGE" -c "$script"
else
( cd "$RAW" && ulimit -n 65536 2>/dev/null || true; sh -c "${script//\/out\//$RAW/}" )
fi
}
# --------------------------------------------------------------- endpoints
if [ "$LOCAL" = 1 ] || [ "$ALLOW_WRITES" = 1 ]; then
log "seed (idempotent): $BASE_URL"
BASE_URL="$BASE_URL" PYTHON="$PYTHON" "$HERE/seed.sh" | tail -n 4
# shellcheck disable=SC1091
. "$HERE/results/seed.env"
else
log "read-only discovery of endpoint ids on $BASE_URL"
VWK_ID="$(curl -fsS "$BASE_URL/datasets?file_type=vector_with_kato&status=ready&page_size=1" | jq -r '.data[0].id')"
GROUP_ID="$(curl -fsS "$BASE_URL/datasets/$VWK_ID" | jq -r '.dg_id // empty')"
CAT_ID=""
fi
RUN_ID="$(curl -fsS "$BASE_URL/landscape/runs?page_size=1" | jq -r '.data[0].id // empty')"
if [ -z "$RUN_ID" ] && { [ "$LOCAL" = 1 ] || [ "$ALLOW_WRITES" = 1 ]; }; then
log "no ЛП run yet: running lp_run.sh"
BASE_URL="$BASE_URL" PYTHON="$PYTHON" "$HERE/lp_run.sh" > "$OUT/lp_run.txt" 2>&1
RUN_ID="$(curl -fsS "$BASE_URL/landscape/runs?page_size=1" | jq -r '.data[0].id')"
fi
for v in VWK_ID GROUP_ID RUN_ID; do
eval "val=\${$v:-}"
# shellcheck disable=SC2154
[ -n "$val" ] && [ "$val" != null ] || { echo "cannot resolve $v on $BASE_URL" >&2; exit 2; }
done
EP_PATHS="/datasets?page_size=20 /datasets/$VWK_ID /datasets/$VWK_ID.geojson /datasets/$VWK_ID/observations /categories /dataset-groups/$GROUP_ID/latest /landscape/slots /landscape/results /landscape/runs/$RUN_ID /readyz"
label_of() { # path -> protocol label
echo "$1" | sed -e "s#$VWK_ID#{id}#g" -e "s#$GROUP_ID#{id}#g" -e "s#$RUN_ID#{id}#g"
}
fname() { echo "$1" | sed -e 's#^/##' -e 's#[/?=.{}]#_#g'; }
for p in $EP_PATHS; do # every endpoint must answer 200 before loading it
c="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE_URL$p")"
[ "$c" = 200 ] || { echo "endpoint $p answers $c, fix the stand first" >&2; exit 2; }
done
log "target $TARGET_URL (generator: $GEN${NETARG:+, $NETARG}); results $OUT"
curl -fsS "$BASE_URL/datasets?status=ready&page_size=1" | jq -r '"ready datasets on the stand: \(.total)"'
stats_start() { # docker stats sampler for the compose project (local stand only)
[ "$LOCAL" = 1 ] || return 0
( while :; do docker stats --no-stream --format '{{.Name}},{{.CPUPerc}},{{.MemUsage}}' \
$(docker ps -q --filter label=com.docker.compose.project=gis-acceptance) >> "$OUT/stats.csv" 2>/dev/null || true
sleep 3; done ) &
STATS_PID=$!
}
stats_stop() { [ -n "${STATS_PID:-}" ] && kill "$STATS_PID" 2>/dev/null || true; STATS_PID=""; }
trap 'stats_stop' EXIT
baseline() { # S1 / S6
local scen="$1" specs=() p
for p in $EP_PATHS; do specs+=("$(label_of "$p")|1|$scen-$(fname "$p").txt|-n 100 -c 1 '$TARGET_URL$p'"); done
log "$scen: 10 endpoints x hey -n 100 -c 1 (sequential)"
run_batch "$scen" seq "${specs[@]}"
}
for S in $SCENARIOS; do
case "$S" in
S1) baseline S1 ;;
S2)
specs=()
for p in $EP_PATHS; do specs+=("$(label_of "$p")|10|S2-$(fname "$p").txt|-n 100 -c 10 '$TARGET_URL$p'"); done
log "S2: 1000 requests, 100 clients over 10 endpoints"
run_batch S2 par "${specs[@]}" ;;
S3)
for i in 1 2 3; do
log "S3.1 run $i: hey -n 1000 -c 1000 /datasets?page_size=20"
run_batch S3.1 seq "/datasets?page_size=20 №$i|1000|S3.1-run$i.txt|-n 1000 -c 1000 '$TARGET_URL/datasets?page_size=20'"
done
for i in 1 2 3; do
log "S3.2 run $i: hey -n 1000 -c 1000 /landscape/results"
run_batch S3.2 seq "/landscape/results №$i|1000|S3.2-run$i.txt|-n 1000 -c 1000 '$TARGET_URL/landscape/results'"
done ;;
S4)
specs=()
for pair in "/datasets?page_size=20:300" "/datasets/$VWK_ID:150" "/datasets/$VWK_ID.geojson:100" "/datasets/$VWK_ID/observations:100" \
"/categories:100" "/dataset-groups/$GROUP_ID/latest:100" "/landscape/results:100" "/readyz:50"; do
p="${pair%:*}"; c="${pair##*:}"
specs+=("$(label_of "$p")|$c|S4-$(fname "$p").txt|-z $S4_DURATION -q 1 -c $c '$TARGET_URL$p'")
done
log "S4: 1000 clients at 1 req/s each for $S4_DURATION (8 hey processes)"
stats_start; run_batch S4 par "${specs[@]}"; stats_stop ;;
S5)
if [ "$LOCAL" != 1 ] && [ "$ALLOW_WRITES" != 1 ]; then log "S5 skipped: uploads need a local stand or ALLOW_WRITES=1"; continue; fi
TMP5="$(mktemp -d "${TMPDIR:-/tmp}/gis-load.XXXXXX")"
"$PYTHON" "$HERE/lib/helpers.py" ndvi "$TMP5/ndvi_s5.tif"
specs=()
for p in $EP_PATHS; do specs+=("$(label_of "$p")|10|S5-$(fname "$p").txt|-z $S5_DURATION -c 10 '$TARGET_URL$p'"); done
log "S5: reads 10 x 10 clients for $S5_DURATION + 20 parallel uploads"
stats_start
run_batch S5 par "${specs[@]}" &
READ_PID=$!
sleep 5
for i in $(seq 1 20); do
if [ "$i" -le 10 ]; then f="$HERE/data/plots_nko.geojson"; t=vector; else f="$TMP5/ndvi_s5.tif"; t=raster; fi
( curl -sS -o "$TMP5/up$i.json" -w '%{http_code} %{time_total}\n' -X POST "$BASE_URL/datasets" \
-F "file=@$f" -F "file_type=$t" -F "category_id=$CAT_ID" -F "name=S5 upload $i" > "$TMP5/up$i.code"; date +%s > "$TMP5/up$i.t" ) &
done
for i in $(seq 1 20); do while [ ! -s "$TMP5/up$i.t" ]; do sleep 0.2; done; done
wait "$READ_PID" || true
stats_stop
# processing: every upload must reach ready
codes=""; ready=0; maxproc=0; mins=999999; maxs=0
for i in $(seq 1 20); do
read -r code tt < "$TMP5/up$i.code"; codes="$codes $code"
ms="$(awk -v t="$tt" 'BEGIN{printf "%.1f", t*1000}')"
mins="$(awk -v a="$mins" -v b="$ms" 'BEGIN{print (b<a)?b:a}')"; maxs="$(awk -v a="$maxs" -v b="$ms" 'BEGIN{print (b>a)?b:a}')"
id="$(jq -r '.id // empty' "$TMP5/up$i.json")"; [ -n "$id" ] || continue
for _ in $(seq 1 300); do
st="$(curl -fsS "$BASE_URL/datasets/$id" | jq -r .status)"
[ "$st" = ready ] || [ "$st" = failed ] && break; sleep 1
done
if [ "$st" = ready ]; then
ready=$((ready + 1))
# server-side processing time: updated_at (ready) - created_at (upload)
curl -fsS "$BASE_URL/datasets/$id" -o "$TMP5/ds$i.json"
proc="$(python3 "$HERE/lib/helpers.py" elapsed "$TMP5/ds$i.json" 2>/dev/null || echo 0)"
maxproc="$(awk -v a="$maxproc" -v b="$proc" 'BEGIN{print (b>a)?b:a}')"
fi
curl -sS -o /dev/null -X DELETE "$BASE_URL/datasets/$id"
done
cs="$(echo $codes | tr ' ' '\n' | sort | uniq -c | awk '{printf "%s%s: %s", (NR>1?", ":""), $2, $1}')"
extra_set ".uploads = {count: 20, codes: \"$cs\", min_ms: $mins, max_ms: $maxs, ready: $ready, max_processing_s: $maxproc}"
log "S5 uploads: $cs; ready $ready/20 (max processing ${maxproc}s = updated_at - created_at); uploads deleted"
rm -rf "$TMP5" ;;
S6)
baseline S6
rz="$(curl -sS -o "$OUT/readyz_after.json" -w '%{http_code}' "$BASE_URL/readyz")"
extra_set ".readyz_after = $rz | .readyz_body = $(cat "$OUT/readyz_after.json")"
log "S6: /readyz $rz $(cat "$OUT/readyz_after.json")" ;;
*) echo "unknown scenario $S" >&2; exit 2 ;;
esac
done
if [ -s "$OUT/stats.csv" ]; then # peak CPU % / memory MiB per container
peaks="$(awk -F, '{gsub("%","",$2); split($3,m," / "); v=m[1]; mult=1;
if (v ~ /GiB$/) {mult=1024} else if (v ~ /KiB$/) {mult=1/1024} else if (v ~ /kB$/) {mult=1/1024} else if (v ~ /MB$/) {mult=1};
gsub(/[A-Za-z]+$/,"",v); mem=v*mult; if ($2>cpu[$1]) cpu[$1]=$2; if (mem>mm[$1]) mm[$1]=mem }
END { printf "{"; n=0; for (k in cpu) { printf "%s\"%s\":{\"cpu\":%s,\"mem_mib\":%.1f}", (n++?",":""), k, cpu[k], mm[k] } printf "}" }' "$OUT/stats.csv")"
extra_set ".stats_peak = $peaks"
fi
python3 "$HERE/lib/load_analyze.py" "$OUT"