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>
This commit is contained in:
2026-10-15 22:37:28 +05:00
parent c6229c46f7
commit ca87d6961b
22 changed files with 3136 additions and 0 deletions

227
test/acceptance/load.sh Executable file
View File

@@ -0,0 +1,227 @@
#!/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"