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:
227
test/acceptance/load.sh
Executable file
227
test/acceptance/load.sh
Executable 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"
|
||||
Reference in New Issue
Block a user