nos_
ENRU

Гейты: как проверять изменения в nos

Самотесты при загрузке, smoke-загрузки на обеих архитектурах, сборки с проверками UB, гейты подсистем и фаззеры: что запускать после изменения и как судить.

12 мин чтенияОбновлено

В nos нет ни тестового раннера, ни отдельного тестового бинарника. Изменение проходит несколько уровней проверок, от самых частых к самым редким: самотесты, которые выполняет каждая загрузка; smoke-загрузки, которые CI гоняет на обеих архитектурах; сборки с проверками неопределённого поведения; гейт для каждой подсистемы — для того, чего smoke-загрузка заметить не может; и четыре фаззера, работающих на хосте. В итоге вы будете знать, что из этого запускать для своего изменения и как читать результат.

Понадобится:

  • дерево nos и Docker или нативный тулчейн (Сборка nos);
  • QEMU для обеих архитектур (qemu-system-x86_64 и qemu-system-aarch64) и Python 3;
  • для фаззеров: cargo на хосте для hv-fuzz.py, net-fuzz.py и fs-fuzz.py, а для cpp-fuzz.py — clang с санитайзерами и make.

Подробная версия этой страницы, где описано, что проверяет каждый гейт и ради какого сбоя он существует, — Tests and gates.

Главное правило: судите по коду возврата

Любая проверка здесь проходит или проваливается по коду возврата, а не по тому, что она напечатала. Поиск по выводу — ровно тот способ, которым провал принимают за успех. Сборка, остановившаяся на устаревшем файле зависимостей, пишет No rule to make target, а не error. А в логе загрузки, который так и не был записан, нет и PANIC:.

./scripts/smoke-test.sh --skip-build
echo "exit: $?"

Если вы пропускаете вывод проверки через конвейер, например через tail, статусом конвейера станет статус последней команды — если заранее не выполнить set -o pipefail.

1. Самотесты при каждой загрузке

Каждая загрузка, прежде чем идти дальше, выполняет самотесты ядра. Они лежат в src/cpp/kernel/test.cpp, и каждый — это функция Stdlib::Error TestXxx():

  • Когда они выполняются. Test::Test() вызывается рано при загрузке, из Main2 в main.cpp и из main_arm64.cpp; Test::TestMultiTasking() — позже. Проваленный тест возвращает Stdlib::Error, отличный от успеха.
  • Rust-часть запускается оттуда же, через rust_test. Среди её тестов — тест кадров, который пишет страницу через слот временного окна отображения каждого CPU, и тест lock-free-кольца.
  • TestModules загружает модуль modtest, встроенный в образ ядра, а затем скармливает загрузчику модулей его испорченные копии.
  • fstest=on в командной строке ядра запускает самотест файловой системы на / после монтирования и печатает fstest: passed или fstest: FAILED.

Чтобы добавить тест, напишите TestXxx() и зарегистрируйте его в диспетчере Test(). Чтобы запустить один тест, отредактируйте Test() так, чтобы он вызывал только эту функцию, пересоберите, загрузите систему (./scripts/qemu.sh пишет последовательную консоль в nos.log) и следите за nos.log.

2. Smoke-загрузки на обеих архитектурах

Любое изменение должно оставлять обе smoke-загрузки зелёными. CI запускает их на каждый push в master и на каждый pull request, а изменение общего кода, которое собирается только на x86, считается сломанным.

./scripts/smoke-test.sh
./scripts/smoke-arm64.sh
SMOKE_HVF=1 ./scripts/smoke-arm64.sh

Что делают скрипты:

  • Сначала собирают. Каждый собирает инкрементально в Docker-образе nos-builder: make rust && make nocheck для x86-64, make nocheck ARCH=aarch64 для arm64. С --skip-build загружается то, что уже собрано. make smoke запускает скрипт для x86-64.
  • Загружаются без экрана. x86-64 грузится под TCG с подключёнными virtio-blk (modern), virtio-scsi (legacy), NVMe, virtio-net и двумя устройствами virtio-rng, так что вместе с C++ под ними покрываются драйверы на Rust и путь MSI-X. arm64 грузит QEMU virt с virtio-mmio block, net и rng под TCG, чтобы работать где угодно; SMOKE_HVF=1 использует HVF на Apple Silicon, это примерно вчетверо быстрее. Обе загрузки монтируют корень ext2 с блоками по 1 КиБ и fstest=on.
  • Ждут маркеров. Успех — это все маркеры в последовательном логе. На x86-64 это After test, Preempt is now on, MountRootFs: mounted ext2 on /, fstest: passed, EntropySource registered: rng0 и rng1 и boot: complete. На arm64 — Self test passed, Cpus started, After test, MountRootFs: mounted ext2 on /, fstest: passed, EntropySource registered: rng0 и boot: complete.
  • Падают сразу. PANIC:, fstest: FAILED, источник энтропии, который зарегистрировался, но ничего не дал, досрочный выход QEMU или тайм-аут завершают прогон строкой SMOKE FAIL: <reason> (или SMOKE-ARM64 FAIL:) и последними 30 строками лога. Успех печатает smoke: OK (<seconds>s) или smoke-arm64: OK (<seconds>s).

SMOKE_TIMEOUT задаёт, сколько секунд ждать (по умолчанию 300), а SMOKE_LOG — куда писать последовательный лог.

3. Сборки с проверками неопределённого поведения

То же ядро можно собрать так, что обе его половины проверяют сами себя. Тогда первая операция с неопределённым поведением становится паникой с указанием места, а каждый гейт — проверкой UB в том коде, который он прогоняет:

make nocheck UBSAN=1
make nocheck RUSTUB=1
make nocheck ARCH=aarch64 UBSAN=1 RUSTUB=1
  • UBSAN=1 собирает C++ с санитайзером неопределённого поведения clang: сдвиги шире разрядности, знаковое переполнение, выход индекса за известную компилятору границу, обращения по нулевому или невыровненному указателю, недопустимые значения bool и enum, выход из функции без return. Её объектные файлы лежат отдельно, в out/$(ARCH)-ubsan/. Отчёт выглядит так: PANIC:Report():ubsan.cpp,267: UBSAN: shift exponent 64 is too large for 64-bit type 'unsigned long' at src/cpp/.... Загрузка с ubsan=warn собирает все отчёты вместо остановки на первом.
  • RUSTUB=1 включает проверки предусловий собственных unsafe-операций core (ptr::read по невыровненному или нулевому указателю, неверный slice::from_raw_parts, get_unchecked за концом и другие) и проверки переполнения — в Rust-части ядра и в каждом модуле. Отчёт начинается с RUST PANIC: panicked at и называет нарушенное предусловие. Ваш собственный unsafe он не анализирует: выход сырых указателей за объект, алиасинг и гонки данных не проверяются.
  • Флаги независимы и могут задаваться вместе. version тогда показывает +ubsan и +rustub.

Запускайте гейт на уже собранных образах, с --skip-build там, где скрипт собирает сам: шаг сборки в smoke-скриптах снова сделал бы обычное ядро. Гейт, который загружает модули, берёт их из того варианта сборки, который был собран. CI последним шагом загружает сборку UBSAN=1 RUSTUB=1 на обеих архитектурах.

4. Гейт для того, что вы изменили

То, чего smoke-загрузка заметить не может, проверяет отдельный тест в scripts/. Каждый существует ради сбоя, который выглядит как успех, и большинство написано на следующий день после встречи с таким сбоем. Все они требуют заранее собранного ядра, принимают --keep, чтобы оставить свой временный каталог, и возвращают 0, только если прошли все проверки. Часть из них работает только на arm64: они управляют шеллом по UDP, а включает его командная строка именно arm64-загрузки; проверяемый код на обеих архитектурах один и тот же.

После изменений в Запускайте Где
пути отображения памяти wx-test.sh [--arch x86_64|aarch64] обе
пути паники, panic=N, сбросе panic-test.sh [--arch x86_64|aarch64] обе
блочном слое, таблицах разделов parttest.py [--arch aarch64|x86_64] обе, в CI
асинхронном пути блочного слоя, C ABI, через который до него добираются модули netblk-test.py [--arch x86_64|aarch64] обе
ext2 ext2-test.py (образ судит e2fsck) arm64, в CI
nanofs nanofs-test.py arm64, в CI
журнале на диске disklog-test.py arm64
пути трассировки, сетевом устройстве, netconsole netconsole-test.py arm64, в CI
TCP, HTTP-клиенте tcp-test.py arm64, в CI
слушающей стороне TCP, загрузчике модулей, объявлениях ffi, ssh, modules/sshd sshd-test.py [--arch aarch64|x86_64] обе
пути приёма, пуле кадров, modules/netload netload-test.py [--arch aarch64|x86_64] (на arm64 около семи минут, так задумано) обе; arm64 в CI
drivers/igb, стыке сетевых драйверов tcp-test.py --nic igb, netload-test.py --nic igb (QEMU 8.0 или новее) вручную
drivers/usb/ usb-test.py x86-64
гипервизоре: hv, hvarch, modules/hv hv-test.py [--arch x86_64|aarch64] обе
декодере MMIO гипервизора и обходе страниц гостя insn-test.py хост, в CI
всём, до чего гость дотягивается в hv: устройствах, APIC, MMIO, загрузчике, ACPI, политике CPUID и MSR, цикле выполнения, коммутаторе гостей и NAT hv-fuzz.py хост, в CI
Linux-госте: загрузчике, политике, устройствах, нескольких CPU, ACPI, сети hv-linux-test.py --bzimage <bzImage> --initrd <cpio> с --disk, --net, --cpus N, --acpi, --attach по необходимости x86-64, вручную
том, с чем сталкивается ядро дистрибутива hv-distro-test.py --iso <alpine-virt.iso> [--debian <raw>] x86-64, вручную
всём, что ядру отдаёт сеть: стеке, DHCP, DNS, HTTP и TLS, UDP-шелле, netconsole, ssh, sshd net-fuzz.py хост, в CI
всём, что ядру отдаёт диск: таблицах разделов, ext2, nanofs, VFS, root= fs-fuzz.py хост, в CI
том, что прошивка, загрузчик или диск отдают C++-части, а также таблицах страниц и куче cpp-fuzz.py хост, в CI
системном образе, nos-install, root=UUID= image-test.py x86-64, в CI
примитиве ожидания, выборе планировщиком задачи idle idle-wait-test.py (нужны 12 или больше CPU и KVM) x86-64
drivers/r8168, drivers/r8125 их ничто не эмулирует: компилятор, потом настоящая машина —

Эта таблица — сводка. Авторитетная таблица, с точным списком файлов для каждой строки, лежит в CLAUDE.md проекта, а Tests and gates описывает каждый гейт. Гейтам гипервизора, которые загружают настоящий Linux, нужны ядро гостя и initramfs, собранные, как описано в руководстве Linux-гость под гипервизором nos.

5. Фаззеры на хосте

Четыре программы собирают собственные исходники ядра для хоста и гоняют их на случайных входных данных, сверяя каждую с моделью того, что код должен делать. Паника, нарушенный инвариант, нарушенное правило блокировок, зацикливание и зависание — это находки, и о каждой сообщается вместе с seed и номером итерации, которые её воспроизводят.

Скрипт Что фаззит Нужно
hv-fuzz.py всё, до чего гость дотягивается в гипервизоре: устройства, локальный APIC и IO-APIC, MMIO, загрузчик Linux и таблицы ACPI, политику CPUID и MSR, цикл выполнения на каждом CPU гостя, коммутатор гостей, DHCP и NAT, а также размещение гостей cargo
net-fuzz.py всё, что ядру отдаёт сеть: кадры любого вида, TCP, HTTP и HTTPS, DNS, DHCP, ICMP и ARP, UDP-шелл, netconsole, SSH cargo
fs-fuzz.py всё, что ядру отдаёт диск: MBR и GPT, журнал на диске, исправные и повреждённые образы ext2 и nanofs, VFS, команды работы с хранилищем, root= cargo
cpp-fuzz.py C++-часть под ASan и UBSan: дерево устройств, теги Multiboot2, таблицы ACPI, карту памяти, командную строку, блок окружения GRUB, загрузчик модулей, printf, таблицы страниц и кучу clang с санитайзерами, make

Без аргументов фаззер работает как гейт: фиксированные входные данные из фиксированного seed, одни и те же при каждом прогоне, — от заметно меньше минуты (hv-fuzz.py) до пары минут (net-fuzz.py, fs-fuzz.py). Кампания даёт каждой цели заданное число секунд и собственный seed:

python3 scripts/hv-fuzz.py
python3 scripts/net-fuzz.py --seed 7 --seconds 300
python3 scripts/fs-fuzz.py --target ext2 --target nanofs
python3 scripts/net-fuzz.py --replay tcp out/net-fuzz/findings/X.hex --trace
  • Находки сохраняются в out/<fuzzer>/findings/, а --replay прогоняет одну из них снова. --trace есть у net-fuzz.py, fs-fuzz.py и cpp-fuzz.py.
  • Коды возврата: 0 — находок нет, 1 — есть находка, 3 — зависание.
  • Статистика: HV_FUZZ_STATS=1, NET_FUZZ_STATS=1, FS_FUZZ_STATS=1 и CPP_FUZZ_STATS=1 добавляют статистику фаззера: самые медленные входные данные и то, как часто он доходил до учитываемых состояний.
  • Новый код: добавляя разбор недоверенных данных, новое устройство или новый протокол, дайте соответствующему фаззеру новую цель.

Что запускает CI

.github/workflows/ci.yml срабатывает на каждый push в master и на каждый pull request, по одному заданию на архитектуру:

  • На обеих: make check (cppcheck, который падает на любой находке), make nocheck ARCH=<arch>, smoke-загрузка, parttest.py --arch <arch> --tcg и последним шагом сборка UBSAN=1 RUSTUB=1 со своей smoke-загрузкой.
  • Задание x86-64 добавляет make image с image-test.py, insn-test.py, hv-fuzz.py и fs-fuzz.py.
  • Задание aarch64 добавляет net-fuzz.py, cpp-fuzz.py, а также ext2-test.py, nanofs-test.py, netconsole-test.py, tcp-test.py и netload-test.py, каждый с --tcg.

Все гейты из таблицы, которые CI не запускает, вы запускаете сами, когда ваше изменение их затрагивает. Среди них гейты гипервизора с Linux и дистрибутивами (bzImage весит мегабайты, а образы дистрибутивов нужно скачивать), hv-test.py, idle-wait-test.py и гейты аппаратных сетевых карт.

Прежде чем считать изменение готовым

  1. make check и сборки для обеих архитектур: make nocheck и make nocheck ARCH=aarch64.
  2. Обе smoke-загрузки.
  3. Гейт или гейты для того, что вы изменили.
  4. Те же smoke-загрузки и гейт на сборке UBSAN=1 RUSTUB=1, с --skip-build.
  5. Фаззер для этого кода, если код читает что-либо извне: пакет, диск, прошивку, гостя.
  6. Для C++ — повторное чтение диффа только ради неопределённого поведения: значения извне, использованные как индекс, длина или величина сдвига до проверки, переполнение, чтение неинициализированного, время жизни объектов, общее состояние без блокировки. Санитайзер ловит только то, что прогоняют гейты.
  7. Для Rust — scripts/unsafe-count.py по изменённым крейтам. Выросшее число требует объяснения.

Если по дороге вам встретился сбой, который выглядел как успех, напишите гейт, который бы его поймал.

Если что-то пошло не так

No rule to make target ... — и больше ничего. После переноса, переименования или удаления заголовка файлы .d в out/ всё ещё ссылаются на старый путь. Соберите с нуля или удалите эти файлы .d.

Smoke-загрузка под TCG не укладывается в тайм-аут на медленной машине. Увеличьте SMOKE_TIMEOUT. На Apple Silicon SMOKE_HVF=1 ускоряет загрузку arm64 примерно вчетверо.

hv-test.py или hv-linux-test.py --cpus отказываются работать под TCG. Им нужен QEMU 9.2 или новее, в котором TCG пропускает обращения гостя с выключенной страничной адресацией через вложенную таблицу страниц.

Привязанная ко времени проверка hv-linux-test.py --cpus 2 падает на загруженной машине. Таковы шесть сбросов перезапускаемого гостя за минуту и поток строк лога в пределах --vm-secs. Прежде чем считать провал виной своего изменения, повторите гейт на свободной машине.

Сборка вдруг хочет в сеть. Сборки работают офлайн: зависимости Rust лежат в репозитории, а тулчейн зафиксирован в src/rust/rust-toolchain.toml. Значит, либо зависимость так и не была положена в vendor — выполните scripts/vendor.sh и закоммитьте результат, — либо образ nos-builder старше фиксации тулчейна: пересоберите его командой docker build --platform linux/amd64 -t nos-builder ..

Что дальше