Files
gis/test/acceptance/README.md
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

321 lines
26 KiB
Markdown
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.

# Пакет приёмочных испытаний (Этап 8)
Пакет позволяет Заказчику самостоятельно воспроизвести то, что заявлено в
документах Этапа 8, на изолированном испытательном стенде, развёрнутом из
исходного кода этой ветки:
| № | Что проверяется | Скрипт | Документ |
|---|---|---|---|
| 1 | 89 функциональных проверок испытательного стенда (F01–F38, N01–N38, L02–L05, L07–L15) | `functional.sh` | Приложение 7 «Протокол функционального тестирования», раздел 5 |
| 2 | Попиксельная сверка расчёта Ландшафтной программы (ЛП) с независимой эталонной реализацией | `lp_gen.py`, `lp_run.sh`, `lp_verify.py` | ЛП техописание (правила пикселя, граф узлов); Приложение 7, L07/L08/L12 |
| 3 | Нагрузочные испытания: 1000 одновременных клиентов, сценарии S1–S6 | `load.sh` (+ `seed.sh`) | Приложение 3 «Протокол нагрузочного тестирования» |
| 4 | Повторный анализ безопасности: 0 уязвимостей High/Critical | `scan.sh` | Приложение 4 «Отчет об устранении уязвимостей» |
Все скрипты — bash (macOS и Linux, `set -euo pipefail`, совместимы с bash 3.2),
curl, jq и Python 3. Результаты прогонов сохраняются в `test/acceptance/results/`
(каталог не версионируется).
> **Внимание.** `functional.sh`, `lp_run.sh`, `seed.sh` и сценарий S5 из
> `load.sh` **создают данные** (в том числе заполняют слоты ЛП). Их следует
> запускать только на изолированном стенде `gis-acceptance`. На рабочем стенде
> https://dssgis.dwh.kz допускается только `lp_verify.py` — он выполняет
> исключительно GET-запросы (`lp_run.sh` и `seed.sh` отказываются работать с
> этим адресом).
## Состав пакета
| Файл | Назначение |
|---|---|
| `compose.acceptance.yml` | Надстройка над `deployments/docker-compose.yml` для изолированного стенда (проект `gis-acceptance`) |
| `stand.sh` | Развёртывание (`up`: сборка, миграции, справочник районов, ожидание `/readyz`) и удаление (`down`) стенда |
| `functional.sh` | 89 функциональных проверок, построчный вывод `ID \| описание \| PASS/FAIL \| detail` |
| `lp_gen.py` | Генерация 13 синтетических входных растров ЛП и одного невыровненного растра |
| `lp_run.sh` | Загрузка входов в 13 слотов → прогон `{"from_stage":0}` → `lp_verify.py` |
| `lp_verify.py` | Независимая (numpy) реализация всех узлов ЛП и попиксельная сверка 15 выходов; только GET |
| `load.sh` | Сценарии нагрузки S1–S6 (hey 0.1.5), сводная таблица, CSV/JSON |
| `seed.sh` | Наполнение стенда данными для нагрузочных испытаний (`seed.sh clean` — удаление) |
| `scan.sh` | govulncheck (исходный код и бинарный файл), gosec, trivy fs, trivy image, golangci-lint |
| `data/` | Тестовые векторные данные (участки, население и поголовье КРС по 14 районам СКО с реальными кодами КАТО), SLD-стили |
| `lib/` | Вспомогательные скрипты: генерация NDVI, анализ вывода hey, Dockerfile генератора нагрузки, пересоздание `data/` |
| `requirements.txt` | Python-зависимости (`numpy`, `rasterio`) |
## Требования
* Docker Engine с Docker Compose v2.24+ (используется тег `!override` для портов).
* `curl`, `jq`, Python 3.10+ с `numpy` и `rasterio`:
```sh
python3 -m venv .venv && .venv/bin/pip install -r test/acceptance/requirements.txt
export PYTHON=$PWD/.venv/bin/python # скрипты используют $PYTHON (по умолчанию python3)
```
* Для `scan.sh`: Go (версия toolchain берётся из `go.mod`), `govulncheck`,
`gosec`, `trivy`; `golangci-lint` — по желанию.
## 0. Испытательный стенд
```sh
git clone https://dssgit.dwh.kz/infureal/gis.git && cd gis && git checkout stage8/security-fixes
test/acceptance/stand.sh up # сборка образа, запуск, migrate up, import-districts, ожидание /readyz
```
`stand.sh up` выполняет те же шаги, что и вручную:
```sh
C="docker compose -p gis-acceptance -f deployments/docker-compose.yml -f test/acceptance/compose.acceptance.yml"
$C up -d --build --wait postgres minio rabbitmq
$C run --rm --no-deps app migrate up # 11 миграций 00001…00011
$C run --rm --no-deps app import-districts /stubs/districts.geojson # 14 районов СКО
$C up -d app worker
curl http://localhost:18080/readyz # {"components":{"postgres":"ok","rabbitmq":"ok","s3":"ok"},"ready":true}
```
Порты стенда: API — 18080, PostgreSQL — 15432, MinIO — 19000 (API) / 19001
(консоль), RabbitMQ — 15672 (только веб-интерфейс управления). Сеть контейнеров —
`gis-acceptance_default`. Учётные данные — значения по умолчанию из
`deployments/docker-compose.yml` (стенд изолированный).
Переменные окружения стенда:
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `POSTGIS_PLATFORM` | `linux/amd64` | Образ `postgis/postgis:17-3.5` публикуется только для amd64. На Apple Silicon (arm64) он запускается в эмуляции, на amd64-хостах — нативно, поэтому значение по умолчанию работает на обеих платформах |
| `MINIO_IMAGE` | `minio/minio:latest` | Если образ `minio/minio` недоступен в реестре, подойдёт любой образ с сервером `minio` и клиентом `mc`, например `MINIO_IMAGE=cgr.dev/chainguard/minio:latest` |
| `APP_IMAGE` | `gis-acceptance-app:local` | Тег локально собранного образа приложения |
| `HTTP_MAX_UPLOAD_BYTES` | `2147483648` | Предельный размер тела запроса (2 ГиБ) |
| `LP_MAX_WORKERS` / `LP_MAX_NODES` | `8` / `2` | Параллелизм расчёта ЛП |
Удаление стенда вместе с томами: `test/acceptance/stand.sh down`
(= `docker compose -p gis-acceptance down -v`).
## 1. Функциональные проверки — `functional.sh`
```sh
test/acceptance/functional.sh # стенд http://localhost:18080
BASE_URL=http://host:18080 LOCAL_CHECKS=0 test/acceptance/functional.sh # удалённый испытательный стенд
```
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `BASE_URL` | `http://localhost:18080` | Адрес API |
| `LOCAL_CHECKS` | `auto` | `1` — подтверждение в PostGIS и MinIO через контейнеры проекта (`docker exec … psql`, `mc` в контейнере MinIO): F35 (часть MinIO), F36, F37, F38, ресурсы worker в L15. `0` — эти проверки пропускаются (`SKIP`). `auto` — `1`, если запущены контейнеры проекта `gis-acceptance` |
| `COMPOSE_PROJECT` | `gis-acceptance` | Проект Compose для `LOCAL_CHECKS` |
| `KEEP_DATA` | `0` | `1` — не удалять созданные данные |
| `WAIT_TIMEOUT` | `300` | Ожидание асинхронной обработки, с |
Скрипт создаёт собственные тестовые данные (все коды имеют уникальный префикс
`acc<время>-`), никогда не изменяет и не удаляет существовавшие объекты и в
конце удаляет всё созданное: наборы данных (в том числе входы и выходы ЛП своих
прогонов), группы, категории. Записи прогонов ЛП остаются — API не
предусматривает их удаления. Порядок и смысл проверок совпадают с таблицами
раздела 5 Приложения 7.
Ожидаемый вывод (фрагмент):
```
F01 | Создание корневой категории POST /categories | PASS | 201; acc1792084680-ecology «Экология», parent_id null
...
N38 | Загрузка 100 МБ мусора с сигнатурой TIFF | PASS | 201 (104 857 604 байт за 0.5 с) → status failed, parse_error от gdal_translate; /readyz 200, сервис доступен
...
L07 | Попиксельная сверка 15 выходов с эталонной реализацией | PASS | 15 выходов × 6 000 000 пикселей; «TOTAL mismatching pixels: 0 -> PASS» …
...
ИТОГО: 89 проверок — PASS 89, FAIL 0, SKIP 0
RESULT: 89/89 PASS
```
Код возврата ненулевой при любом `FAIL`. Журнал и вспомогательные выводы
(`L07_lp_verify.txt`, `L08_points.txt`, `L12_lp_verify.txt`, `F36.txt`) —
в `results/functional-<время>/`.
### Соответствие Приложению 7 и уточнения методики
| Проверки | Раздел Приложения 7 | Как проверяется в пакете |
|---|---|---|
| F01–F09 | 5.2 Категории | HTTP-код и поля ответа, контроль цикла, дубликат, удаление |
| F10–F34, N01–N13 | 5.3 Импорт/экспорт, SLD, группы, фильтры | Загрузка `data/plots_nko.geojson` (vector), `data/population_kato.geojson` и `data/livestock_kato.gpkg` (vector_with_kato), растра NDVI 600×400 EPSG:4326 Float32 (генерируется `lib/helpers.py`) |
| N14–N38, F35 | 5.4 Негативные проверки | 12 некорректных загрузок, 10 эндпоинтов с несуществующим id, 4 — с некорректным id, некорректные тела JSON, параметры ЛП, 100 МБ мусора |
| F36–F38 | 5.5 PostGIS и MinIO | SQL `ST_GeometryType/ST_SRID/ST_IsValid`, соединение наблюдений с `districts`, `mc stat` объектов |
| L02–L15 | 5.6 Ландшафтная программа | 13 растров `lp_gen.py`, прогоны, `lp_verify.py`, инкрементальный пересчёт, `from_stage`, параметры, невыровненная сетка |
Уточнения относительно протокола (смысл и ожидаемые результаты проверок не
изменены):
* **Коды объектов** имеют префикс прогона (`acc…-ecology` вместо `ecology`),
чтобы повторный запуск не конфликтовал с данными прошлых прогонов.
* **F33, F34** — выборки ограничены собственной категорией (`category_id`), чтобы
результат не зависел от других данных стенда; во втором фильтре F34
(`file_type + status`) добавлен `category_id`.
* **F12** — охват проверяется по координатам выгруженного GeoJSON (вместо
`ogrinfo`); **F22, L05** — формат COG проверяется через rasterio/GDAL
(`LAYOUT=COG`, сжатие, размер, EPSG, тип, NoData) вместо `gdalinfo`;
**L08** — 9 точек проверяются `lp_verify.py --points` вместо `gdallocationinfo`.
* **F38** — проверяется наличие всех 7 объектов, созданных скриптом (4 исходных
файла, COG, SLD набора, SLD группы), а не общее число объектов в бакете.
* **F16, F17** — эталонные значения берутся из `data/population_kato.geojson`
(значения, приведённые в протоколе, в файле сохранены).
* **L05** — перед чтением `/landscape/results` скрипт дожидается статуса `ready`
всех 15 выходов: конвертация выходов в COG выполняется worker'ом асинхронно
после завершения прогона, а `/landscape/results` возвращает только готовые слои.
* **N38 и ограничение размера тела запроса.** В этой ветке (исправления по
Приложению 4) тело запроса ограничено `HTTP_MAX_UPLOAD_BYTES` (по умолчанию
2 ГиБ); загрузка сверх предела отклоняется с **413** `request body exceeds
the … byte limit`. Файл N38 (100 МБ) меньше предела, поэтому ожидание N38
не изменилось: 201, затем `status = failed`. Проверить 413 можно вручную,
запустив стенд с `HTTP_MAX_UPLOAD_BYTES=1048576` и загрузив файл больше 1 МиБ.
* **`/readyz`** в этой ветке не раскрывает текст ошибок зависимостей: при сбое
компонент помечается `"unavailable"` (ранее — `"error: <текст>"`). На исправном
стенде ответ не изменился: `200 {"components":{"postgres":"ok","rabbitmq":"ok","s3":"ok"},"ready":true}`;
N38 проверяет `200` и `ready = true`.
* **L15** — PASS определяется успешным полным прогоном (202, `succeeded`, 9 узлов
`succeeded`); время CPU и `memory.peak` worker (cgroup v2) выводятся
справочно и включают COG-конвертацию выходов.
## 2. Ландшафтная программа: попиксельная сверка
### Входные данные — `lp_gen.py`
```sh
$PYTHON test/acceptance/lp_gen.py --out /tmp/lp-in [--width 3000 --height 2000]
```
13 растров `<код слота>.tif`: Int16, EPSG:32642, пиксель 100 м, 3000×2000,
классы 1…5 из гладких тригонометрических полей (у каждого слота своё),
рамка NoData −9999 шириной 40 пикселей. Особые случаи для правил пикселя:
в `lp-u-crop` — блок NoData 200×200 (U считается по water, soil и veg),
в `lp-u-veg` — блок NoData 100×100, наполовину перекрывающий его (там нет ни
crop, ни veg → U = NoData), в `lp-p-snow` — блоки со значениями 0 и 6
(не NoData, но вне 1…5 → недопустимы). Плюс `misaligned-lp-u-veg.tif`
(1500×1000, 200 м) для проверки L13. Генерация детерминирована.
### Прогон на стенде — `lp_run.sh`, режим (a)
```sh
test/acceptance/lp_run.sh # генерация → 13 слотов → {"from_stage":0} → lp_verify.py
```
Входы и выходы остаются на стенде, прогон можно перепроверить:
`$PYTHON test/acceptance/lp_verify.py --base-url http://localhost:18080 --run <run-id>`.
### Проверка любого стенда (только чтение) — `lp_verify.py`, режим (b)
```sh
$PYTHON test/acceptance/lp_verify.py --base-url https://dssgis.dwh.kz --run <run-id> [--cache DIR]
```
Скрипт выполняет только GET-запросы:
1. `GET /landscape/runs/{id}` — параметры прогона, для каждого узла `inputs` и
`outputs` (группа → набор данных);
2. `GET /datasets/{id}.cog` — 13 входов (наборы слотов, которые прогон
фактически использовал) и 15 выходов (`--inputs-from download` читает
входы через `/datasets/{id}/download` — исходный файл, который читал узел).
Ожидаемые значения рассчитываются по всей цепочке **только из 13 входов**
(E — из пересчитанных Z и U, C и V — из пересчитанного E), поэтому ошибка в
промежуточном слое не может скрыть ошибку в последующем. Дополнительно
проверяется согласованность цепочки самого прогона (узел 10 читал выход узла
00 этого прогона и т. д.) и совпадение сетки всех 28 растров. Растры
обрабатываются окнами 512×4096 пикселей (выровнены по тайлам COG 512), поэтому
расход памяти ограничен (~0,5 ГБ) и для растров рабочего стенда 22219×14977.
Правила пикселя (источник истины — `internal/landscape/kernels.go`, они же —
в ЛП техописании): значение допустимо при `1 ≤ v ≤ 5`; `classify` в float32
(`≤1.8→1, ≤2.6→2, ≤3.4→3, ≤4.2→4, ≤5.0→5`); z_integrated — среднее четырёх Z с
весом 0,25; u_integrated — water ∧ soil ∧ (crop ∨ veg), веса 0,25 только у
допустимых crop/veg; z_max/u_max — максимум; E = max(Z, U); C_j = min(P_j, E);
p_max/c_max — максимум по допустимым; func_zoning V1–V3 с порогами
`v1_min_s_e12`, `v1_min_s_e3`, `v2_min_s_e3`, `v2_min_s_e4` и `tie_priority`
из параметров прогона.
Вывод: по каждому из 15 выходов — число пикселей, допустимых (по эталону) и
**число несовпадающих пикселей** (с координатами первого расхождения), итоговая
строка `TOTAL mismatching pixels: N -> PASS/FAIL`. Код возврата: 0 — полное
совпадение, 1 — есть расхождения, 2 — ошибка данных (прогон не `succeeded`,
не найден вход/выход, разные сетки).
## 3. Нагрузочные испытания — `load.sh`
```sh
test/acceptance/load.sh # S1–S6, генератор внутри сети стенда
SCENARIOS="S1 S3 S6" test/acceptance/load.sh # часть сценариев
GEN=host TARGET_URL=http://localhost:18080 test/acceptance/load.sh # hey на хосте, через порт стенда
NETWORK=bridge TARGET_URL=https://host BASE_URL=https://host SCENARIOS="S1 S3" test/acceptance/load.sh
```
Генератор — hey 0.1.5, собирается из исходного кода в образ
`gis-acceptance-hey:0.1.5` (`lib/hey.Dockerfile`, на тех же закреплённых образах
Go/Alpine, что и приложение) и по умолчанию запускается **внутри сети стенда**
(`docker run --network gis-acceptance_default …`, цель `http://app:8080`).
Перед нагрузкой `seed.sh` (идемпотентно) наполняет стенд: 25 vector, 25
vector_with_kato (с маппингом КАТО, 14 районов × 3 года) и 50 raster в
категории `acc-load`; если прогонов ЛП ещё нет, выполняется `lp_run.sh`.
На удалённом стенде без `ALLOW_WRITES=1` данные не создаются: id для
эндпоинтов определяются GET-запросами, сценарий S5 пропускается.
Эндпоинты (табл. 5 Приложения 3): `/datasets?page_size=20`, `/datasets/{id}`,
`/datasets/{id}.geojson`, `/datasets/{id}/observations`, `/categories`,
`/dataset-groups/{id}/latest`, `/landscape/slots`, `/landscape/results`,
`/landscape/runs/{id}`, `/readyz`.
| Сценарий | Параметры hey |
|---|---|
| S1 | `-n 100 -c 1` последовательно по 10 эндпоинтам (1000 запросов) |
| S2 | 1000 запросов, 100 клиентов: 10 параллельных процессов `-n 100 -c 10` |
| S3.1 | `-n 1000 -c 1000 /datasets?page_size=20`, 3 прогона |
| S3.2 | `-n 1000 -c 1000 /landscape/results`, 3 прогона |
| S4 | `-z 60s -q 1`, 1000 клиентов в 8 процессах: 300 `/datasets?page_size=20`, 150 `/datasets/{id}`, по 100 `.geojson`, `/observations`, `/categories`, `/latest`, `/landscape/results`, 50 `/readyz` |
| S5 | `-z 30s -c 10` × 10 эндпоинтов + 20 параллельных `POST /datasets` (10 GeoJSON + 10 GeoTIFF), все загрузки должны дойти до `ready` (затем удаляются) |
| S6 | Повтор S1 и `GET /readyz` после нагрузки |
S3.3 протокола (1000 одновременных запросов через опубликованный порт)
воспроизводится запуском `GEN=host TARGET_URL=http://localhost:18080 SCENARIOS=S3`.
Вывод: сводная таблица (запросы, клиенты, длительность, RPS, доля успешных
ответов, min/avg/P50/P90/P95/P99/max в мс, коды ответов, транспортные ошибки,
итог по критериям), таблицы по эндпоинтам/прогонам для S2–S5, результаты
загрузок S5, пиковое потребление CPU/памяти контейнерами (docker stats во время
S4/S5) и критерий K4. Как и в протоколе, перцентили сценариев из нескольких
процессов/прогонов — средневзвешенные по числу ответов. Файлы:
`results/load-<время>/summary.csv`, `summary.json`, `detail.csv`, `raw/*.txt`
(исходные выводы hey). Критерии (табл. 8 Приложения 3): K1 — успешных ответов
≥ 99 %, K2 — ответов 5xx нет, K3 — P95 ≤ 2000 мс, K4 — после нагрузки `/readyz`
200 и P50 S6 не хуже P50 S1 (допуск ×2 + 1 мс). Код возврата 1 при
невыполнении любого критерия.
Генератор и стенд работают на одной машине и делят CPU, поэтому абсолютные
значения RPS зависят от оборудования (протокол фиксирует их как нижнюю оценку).
## 4. Анализ безопасности — `scan.sh`
```sh
test/acceptance/scan.sh
```
| Инструмент | Что проверяет | Что считается High/Critical |
|---|---|---|
| `govulncheck ./...` | Уязвимости Go-модулей и стандартной библиотеки, достижимые из кода | Каждая уязвимость с цепочкой вызовов из кода (у govulncheck нет шкалы severity) |
| `govulncheck -mode=binary` | Бинарный файл `/usr/local/bin/gis` из собранного образа | Каждая уязвимость с затронутыми символами в бинарнике |
| `gosec ./...` | Статический анализ кода | Severity `HIGH` |
| `trivy fs` | Зависимости (`go.mod`), секреты, конфигурации (Dockerfile, compose) | `HIGH`, `CRITICAL` |
| `trivy image` | Образ приложения (Alpine + GDAL + бинарник), собирается из `build/package/Dockerfile` | `HIGH`, `CRITICAL` |
| `golangci-lint` | Качество кода (если установлен) | Справочно: число замечаний (шкалы severity нет) |
Скрипт печатает таблицу «инструмент | цель | High | Critical | примечания» и
итог `RESULT: 0 High / 0 Critical -> PASS`; код возврата 1 при наличии
High/Critical. Полные отчёты (JSON) — `results/scan-<время>/`. Без Docker:
`SKIP_IMAGE=1` (бинарник собирается локально с cgo и GDAL, `trivy image`
пропускается).
## Порядок полного воспроизведения
```sh
test/acceptance/stand.sh up
test/acceptance/functional.sh | tee functional.txt # RESULT: 89/89 PASS
test/acceptance/lp_run.sh | tee lp_local.txt # TOTAL mismatching pixels: 0 -> PASS
$PYTHON test/acceptance/lp_verify.py --base-url https://dssgis.dwh.kz --run <run-id> | tee lp_prod.txt
test/acceptance/load.sh | tee load.txt # критерии K1–K4 выполнены
test/acceptance/scan.sh | tee scan.txt # RESULT: 0 High / 0 Critical -> PASS
test/acceptance/stand.sh down
```
Тестовые векторные данные `data/` пересоздаются командой
`python3 test/acceptance/lib/make_fixtures.py` (нужен `ogr2ogr`).