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

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Small helpers for functional.sh (numpy + rasterio).
helpers.py ndvi OUT.tif write the 600x400 EPSG:4326 Float32 NDVI test raster
helpers.py rinfo FILE.tif print raster facts as JSON (driver, size, crs, dtype,
nodata, layout, compression, overviews)
helpers.py durations RUN.json print "total=<s> nodes=<min>..<max>ms" for a run
helpers.py elapsed DATASET.json print updated_at - created_at of a dataset, seconds
"""
import json
import sys
from datetime import datetime
def ndvi(out):
import numpy as np
import rasterio
from rasterio.transform import from_origin
w, h = 600, 400 # 0.01 deg pixels, bbox [68.5, 51.5, 74.5, 55.5]
y, x = np.mgrid[0:h, 0:w].astype(np.float32)
v = 0.45 + 0.35 * np.sin(x / 47.0) * np.cos(y / 31.0) + 0.1 * np.sin((x + y) / 13.0)
v = np.clip(v, -0.2, 1.0).astype(np.float32)
v[:5, :] = -9999
profile = dict(driver="GTiff", width=w, height=h, count=1, dtype="float32", crs="EPSG:4326",
transform=from_origin(68.5, 55.5, 0.01, 0.01), nodata=-9999)
with rasterio.open(out, "w", **profile) as dst:
dst.write(v, 1)
def rinfo(path):
import rasterio
with rasterio.open(path) as d:
st = d.tags(ns="IMAGE_STRUCTURE")
print(json.dumps({
"driver": d.driver, "width": d.width, "height": d.height,
"epsg": d.crs.to_epsg() if d.crs else None, "dtype": d.dtypes[0], "nodata": d.nodata,
"layout": st.get("LAYOUT"), "compression": st.get("COMPRESSION"),
"overviews": len(d.overviews(1)), "bounds": [round(b, 6) for b in d.bounds],
}))
def ts(s):
if not s:
return None
s = s.replace("Z", "+00:00")
if "." in s: # trim nanoseconds to microseconds for fromisoformat
head, rest = s.split(".", 1)
frac = "".join(ch for ch in rest if ch.isdigit())
tz = rest[len(frac):]
s = "%s.%s%s" % (head, frac[:6].ljust(6, "0"), tz)
return datetime.fromisoformat(s)
def durations(path):
with open(path, encoding="utf-8") as f:
run = json.load(f)
total = None
if run.get("started_at") and run.get("finished_at"):
total = (ts(run["finished_at"]) - ts(run["started_at"])).total_seconds()
nd = [(ts(n["finished_at"]) - ts(n["started_at"])).total_seconds() * 1000
for n in run.get("nodes", []) if n.get("started_at") and n.get("finished_at")]
out = "total=%.2fs" % total if total is not None else "total=?"
if nd:
out += " nodes=%d..%dms" % (min(nd), max(nd))
print(out)
def elapsed(path):
with open(path, encoding="utf-8") as f:
d = json.load(f)
print("%.2f" % (ts(d["updated_at"]) - ts(d["created_at"])).total_seconds())
if __name__ == "__main__":
cmd, arg = sys.argv[1], sys.argv[2]
{"ndvi": ndvi, "rinfo": rinfo, "durations": durations, "elapsed": elapsed}[cmd](arg)

View File

@@ -0,0 +1,11 @@
# Load generator image for load.sh: hey v0.1.5 (github.com/rakyll/hey) built
# from source on the same pinned Go/Alpine images as the application.
FROM golang:1.26.9-alpine3.24 AS build
RUN CGO_ENABLED=0 go install github.com/rakyll/hey@v0.1.5
FROM alpine:3.24
COPY --from=build /go/bin/hey /usr/local/bin/hey
RUN adduser -D -u 10001 hey
USER hey
# One-shot CLI container: nothing to health-check.
HEALTHCHECK NONE
ENTRYPOINT ["hey"]

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Aggregates the hey outputs of load.sh into the tables of Приложение 3.
load_analyze.py RESULTS_DIR
RESULTS_DIR/manifest.csv rows: scenario,mode,label,clients,file
mode par = the files of the scenario ran at the same time (duration = max)
seq = they ran one after another (duration = sum)
Each file is a default (summary) hey output. Per scenario the program reports
requests, clients, duration, RPS, success %, status codes, transport errors,
min/avg/P50/P90/P95/P99/max (ms). As in the protocol, percentiles of a
scenario made of several hey processes/runs are averages weighted by the
number of responses; min/max are exact.
Criteria (Приложение 3, табл. 8): K1 success >= 99 %, K2 no 5xx, K3 P95 <=
2000 ms; K4 (S6 vs S1): /readyz 200 and S6 P50 <= 2 x S1 P50 + 1 ms.
Writes summary.csv, summary.json, detail.csv into RESULTS_DIR and prints the
tables. Exit code 1 when a criterion fails.
"""
import csv
import json
import os
import re
import sys
PCTS = ["10", "25", "50", "75", "90", "95", "99"]
def parse_hey(path):
txt = open(path, encoding="utf-8", errors="replace").read()
def num(label):
m = re.search(r"^\s*%s:\s+([0-9.]+)" % re.escape(label), txt, re.M)
return float(m.group(1)) if m else None
r = {"total_s": num("Total"), "slowest_s": num("Slowest"), "fastest_s": num("Fastest"),
"average_s": num("Average"), "rps": num("Requests/sec"), "codes": {}, "errors": 0, "pct": {}}
for p, v in re.findall(r"^\s*(\d+)%+ in ([0-9.]+) secs", txt, re.M):
if p != "0": # hey 0.1.5 prints "%%" and a bogus "0%% in 0.0000" row for tiny samples
r["pct"][p] = float(v)
sec = txt.split("Status code distribution:", 1)
if len(sec) == 2:
for code, n in re.findall(r"^\s*\[(\d+)\]\s+(\d+) responses", sec[1].split("Error distribution:")[0], re.M):
r["codes"][code] = r["codes"].get(code, 0) + int(n)
sec = txt.split("Error distribution:", 1)
if len(sec) == 2:
r["errors"] = sum(int(n) for n in re.findall(r"^\s*\[(\d+)\]\s", sec[1], re.M))
r["responses"] = sum(r["codes"].values())
r["requests"] = r["responses"] + r["errors"]
return r
def aggregate(rows, mode):
parts = [x["hey"] for x in rows]
resp = sum(p["responses"] for p in parts)
req = sum(p["requests"] for p in parts)
durs = [p["total_s"] or 0 for p in parts]
dur = max(durs) if mode == "par" else sum(durs)
codes = {}
for p in parts:
for c, n in p["codes"].items():
codes[c] = codes.get(c, 0) + n
def wavg(get):
num = sum(get(p) * p["responses"] for p in parts if get(p) is not None and p["responses"])
den = sum(p["responses"] for p in parts if get(p) is not None and p["responses"])
return num / den if den else None
ms = lambda v: None if v is None else v * 1000.0
ok2xx = sum(n for c, n in codes.items() if c.startswith("2"))
fast = [p["fastest_s"] for p in parts if p["fastest_s"] is not None]
slow = [p["slowest_s"] for p in parts if p["slowest_s"] is not None]
return {
"requests": req, "responses": resp, "errors": sum(p["errors"] for p in parts),
"clients": sum(int(x["clients"]) for x in rows) if mode == "par" else max(int(x["clients"]) for x in rows),
"duration_s": dur, "rps": (resp / dur) if dur else None,
"success_pct": (100.0 * ok2xx / req) if req else 0.0, "codes": codes,
"n5xx": sum(n for c, n in codes.items() if c.startswith("5")),
"min_ms": ms(min(fast)) if fast else None, "avg_ms": ms(wavg(lambda p: p["average_s"])),
**{"p%s_ms" % k: ms(wavg(lambda p, k=k: p["pct"].get(k))) for k in ("50", "90", "95", "99")},
"max_ms": ms(max(slow)) if slow else None,
}
def f1(v, nd=1):
return "-" if v is None else ("%.*f" % (nd, v))
def codes_str(c):
return ", ".join("%s: %d" % (k, v) for k, v in sorted(c.items())) or "-"
def main():
d = sys.argv[1]
rows = []
with open(os.path.join(d, "manifest.csv"), encoding="utf-8") as f:
for x in csv.DictReader(f):
x["hey"] = parse_hey(os.path.join(d, "raw", x["file"]))
rows.append(x)
order, by = [], {}
for x in rows:
if x["scenario"] not in by:
order.append(x["scenario"])
by[x["scenario"]] = []
by[x["scenario"]].append(x)
summary = []
for s in order:
agg = aggregate(by[s], by[s][0]["mode"])
agg["scenario"] = s
agg["k1"] = agg["success_pct"] >= 99.0
agg["k2"] = agg["n5xx"] == 0
agg["k3"] = agg["p95_ms"] is not None and agg["p95_ms"] <= 2000.0
summary.append(agg)
extra = {}
xp = os.path.join(d, "extra.json")
if os.path.exists(xp):
extra = json.load(open(xp, encoding="utf-8"))
k4 = None
s1 = next((x for x in summary if x["scenario"] == "S1"), None)
s6 = next((x for x in summary if x["scenario"] == "S6"), None)
if s1 and s6:
k4 = (extra.get("readyz_after") == 200 and s6["p50_ms"] is not None and s1["p50_ms"] is not None
and s6["p50_ms"] <= 2 * s1["p50_ms"] + 1.0)
hdr = ["Сцен.", "Запр.", "Клиентов", "Длит.,с", "RPS", "Успех,%", "min", "avg", "P50", "P90", "P95", "P99", "max", "Коды", "Ошибки", "Итог"]
lines = []
for a in summary:
ok = a["k1"] and a["k2"] and a["k3"]
lines.append([a["scenario"], str(a["requests"]), str(a["clients"]), f1(a["duration_s"]), f1(a["rps"], 0),
f1(a["success_pct"], 2), f1(a["min_ms"]), f1(a["avg_ms"]), f1(a["p50_ms"]), f1(a["p90_ms"]),
f1(a["p95_ms"]), f1(a["p99_ms"]), f1(a["max_ms"]), codes_str(a["codes"]), str(a["errors"]),
"PASS" if ok else "FAIL"])
print_table("Сводные результаты (время — мс)", hdr, lines)
for s in order:
if len(by[s]) > 1 and s not in ("S1", "S6"):
dl = []
for x in by[s]:
a = aggregate([x], "par")
dl.append([x["label"], x["clients"], str(a["responses"]), f1(a["rps"], 1), f1(a["p50_ms"]),
f1(a["p95_ms"]), f1(a["p99_ms"]), f1(a["max_ms"]), codes_str(a["codes"]), str(a["errors"])])
print_table("%s по эндпоинтам / прогонам (время — мс)" % s,
["Эндпоинт / прогон", "Клиентов", "Ответов", "RPS", "P50", "P95", "P99", "max", "Коды", "Ошибки"], dl)
if extra.get("uploads"):
u = extra["uploads"]
print("S5 загрузки: %d запросов POST /datasets, коды %s, время ответа %s–%s мс; обработано до ready: %d/%d, макс. время обработки %s с"
% (u["count"], u["codes"], f1(u.get("min_ms")), f1(u.get("max_ms")), u.get("ready", 0), u["count"], f1(u.get("max_processing_s"), 2)))
if extra.get("stats_peak"):
print("\nПиковое потребление ресурсов (docker stats, сценарии S4/S5):")
for name, v in sorted(extra["stats_peak"].items()):
print(" %-34s CPU %6.1f %% память %8.1f МиБ" % (name, v["cpu"], v["mem_mib"]))
if k4 is not None:
print("\nK4 (после нагрузки): /readyz %s; S6 P50/P95 = %s/%s мс против S1 %s/%s мс -> %s" % (
extra.get("readyz_after"), f1(s6["p50_ms"]), f1(s6["p95_ms"]), f1(s1["p50_ms"]), f1(s1["p95_ms"]),
"PASS" if k4 else "FAIL"))
with open(os.path.join(d, "summary.csv"), "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["scenario", "requests", "clients", "duration_s", "rps", "success_pct", "min_ms", "avg_ms",
"p50_ms", "p90_ms", "p95_ms", "p99_ms", "max_ms", "codes", "errors", "result"])
for ln, a in zip(lines, summary):
w.writerow([a["scenario"], a["requests"], a["clients"], a["duration_s"], a["rps"], a["success_pct"],
a["min_ms"], a["avg_ms"], a["p50_ms"], a["p90_ms"], a["p95_ms"], a["p99_ms"], a["max_ms"],
codes_str(a["codes"]), a["errors"], ln[-1]])
with open(os.path.join(d, "detail.csv"), "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["scenario", "label", "clients", "requests", "responses", "errors", "duration_s", "rps",
"min_ms", "avg_ms", "p50_ms", "p90_ms", "p95_ms", "p99_ms", "max_ms", "codes", "file"])
for x in rows:
a = aggregate([x], "par")
w.writerow([x["scenario"], x["label"], x["clients"], a["requests"], a["responses"], a["errors"],
a["duration_s"], a["rps"], a["min_ms"], a["avg_ms"], a["p50_ms"], a["p90_ms"], a["p95_ms"],
a["p99_ms"], a["max_ms"], codes_str(a["codes"]), x["file"]])
with open(os.path.join(d, "summary.json"), "w", encoding="utf-8") as f:
json.dump({"scenarios": summary, "k4": k4, "extra": extra}, f, ensure_ascii=False, indent=2)
failed = [a["scenario"] for a in summary if not (a["k1"] and a["k2"] and a["k3"])]
if k4 is False:
failed.append("K4")
print("\nКритерии K1 (успех >= 99 %%), K2 (5xx = 0), K3 (P95 <= 2000 мс)%s: %s" % (
", K4" if k4 is not None else "", "все выполнены" if not failed else "НЕ выполнены: " + ", ".join(failed)))
print("Файлы: %s/{summary.csv,summary.json,detail.csv,raw/}" % d)
return 1 if failed else 0
def print_table(title, hdr, lines):
widths = [max(len(str(r[i])) for r in [hdr] + lines) for i in range(len(hdr))]
print("\n" + title)
print(" | ".join(h.ljust(w) for h, w in zip(hdr, widths)))
print("-+-".join("-" * w for w in widths))
for r in lines:
print(" | ".join(str(c).ljust(w) for c, w in zip(r, widths)))
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Regenerates the committed vector/SLD fixtures in test/acceptance/data/.
The fixtures are committed, so this script is only needed to rebuild them.
Requires the GDAL command-line tool ogr2ogr (GeoPackage conversion).
python3 test/acceptance/lib/make_fixtures.py
Values are deterministic: district KATO codes and names come from
stubs/districts.geojson (14 districts of North Kazakhstan region), the
indicator values follow a fixed formula, so re-running yields identical files.
"""
import json
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
DATA = os.path.abspath(os.path.join(HERE, "..", "data"))
def ring_bbox(coords):
xs, ys = [], []
def walk(c):
if isinstance(c[0], (int, float)):
xs.append(c[0])
ys.append(c[1])
else:
for x in c:
walk(x)
walk(coords)
return min(xs), min(ys), max(xs), max(ys)
def population(i, kato, year):
# Values quoted in the protocol (Приложение 7, F16/F17) are kept verbatim.
fixed = {
("591000000", 2022): 47567.6,
("591000000", 2023): 48311.2,
("591000000", 2024): 49120.5,
("595000000", 2022): 46862.7,
("595000000", 2023): 48268.6,
("595000000", 2024): 49205.8,
}
if (kato, year) in fixed:
return fixed[(kato, year)]
return round(20000 + 1750.3 * i + 812.4 * (year - 2022) + (i * 37 % 11) * 13.1, 1)
def livestock(i, year):
return int(30000 + 2311 * i + 977 * (year - 2022) + (i * 53 % 17) * 41)
def main():
with open(os.path.join(ROOT, "stubs", "districts.geojson"), encoding="utf-8") as f:
districts = json.load(f)["features"]
os.makedirs(DATA, exist_ok=True)
# 1. plain vector: 4 plot polygons spanning lon 69.0..71.7, lat 53.6..55.0
plots = []
boxes = [
(69.0, 53.6, 69.6, 54.0),
(69.9, 54.2, 70.5, 54.7),
(70.6, 53.7, 71.1, 54.1),
(71.2, 54.5, 71.7, 55.0),
]
for n, (x0, y0, x1, y1) in enumerate(boxes, 1):
plots.append({
"type": "Feature",
"properties": {"plot_id": n, "name": "Участок %d" % n, "area_ha": round((x1 - x0) * (y1 - y0) * 7000, 1)},
"geometry": {"type": "Polygon", "coordinates": [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]},
})
write_json("plots_nko.geojson", {"type": "FeatureCollection", "features": plots})
# 2. vector_with_kato (GeoJSON): population per district, Y2022..Y2024
pop = []
live = []
for i, d in enumerate(districts):
p = d["properties"]
kato = str(p["kato"])
x0, y0, x1, y1 = ring_bbox(d["geometry"]["coordinates"])
pt = {"type": "Point", "coordinates": [round((x0 + x1) / 2, 5), round((y0 + y1) / 2, 5)]}
props = {"kato": kato, "district": p["name_ru"]}
for y in (2022, 2023, 2024):
props["Y%d" % y] = population(i, kato, y)
pop.append({"type": "Feature", "properties": props, "geometry": pt})
lprops = {"KATO_CODE": kato, "district": p["name_ru"]}
for y in (2022, 2023, 2024):
lprops["H_%d" % y] = livestock(i, y)
live.append({"type": "Feature", "properties": lprops, "geometry": pt})
write_json("population_kato.geojson", {"type": "FeatureCollection", "features": pop})
# 3. vector_with_kato (GeoPackage): cattle per district, H_2022..H_2024
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "livestock.geojson")
with open(src, "w", encoding="utf-8") as f:
json.dump({"type": "FeatureCollection", "features": live}, f, ensure_ascii=False)
dst = os.path.join(DATA, "livestock_kato.gpkg")
if os.path.exists(dst):
os.remove(dst)
subprocess.run(["ogr2ogr", "-f", "GPKG", "-nln", "livestock", "-a_srs", "EPSG:4326", dst, src], check=True)
# 4. SLD styles
write_text("style.sld", sld("ndvi", "NDVI gradient", [("0.0", "#a50026"), ("0.5", "#ffffbf"), ("1.0", "#006837")]))
write_text("group_style.sld", sld("group", "Group fallback style", [("0", "#f7fbff"), ("100000", "#08306b")]))
write_text("style.txt", "not an SLD file\n")
print("fixtures written to", DATA)
def sld(name, title, stops):
entries = "\n".join(
' <sld:ColorMapEntry color="%s" quantity="%s"/>' % (c, q) for q, c in stops)
return """<?xml version="1.0" encoding="UTF-8"?>
<sld:StyledLayerDescriptor version="1.0.0" xmlns:sld="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc">
<sld:NamedLayer>
<sld:Name>%s</sld:Name>
<sld:UserStyle>
<sld:Title>%s</sld:Title>
<sld:FeatureTypeStyle>
<sld:Rule>
<sld:RasterSymbolizer>
<sld:ColorMap>
%s
</sld:ColorMap>
</sld:RasterSymbolizer>
</sld:Rule>
</sld:FeatureTypeStyle>
</sld:UserStyle>
</sld:NamedLayer>
</sld:StyledLayerDescriptor>
""" % (name, title, entries)
def write_json(name, obj):
with open(os.path.join(DATA, name), "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=1)
f.write("\n")
def write_text(name, text):
with open(os.path.join(DATA, name), "w", encoding="utf-8") as f:
f.write(text)
if __name__ == "__main__":
sys.exit(main())