# Пакет приёмочных испытаний (Этап 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 `. ### Проверка любого стенда (только чтение) — `lp_verify.py`, режим (b) ```sh $PYTHON test/acceptance/lp_verify.py --base-url https://dssgis.dwh.kz --run [--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 | 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`).