Гейты: как проверять изменения в nos
Самотесты при загрузке, smoke-загрузки на обеих архитектурах, сборки с проверками UB, гейты подсистем и фаззеры: что запускать после изменения и как судить.
В 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 и гейты аппаратных сетевых карт.
Прежде чем считать изменение готовым
make checkи сборки для обеих архитектур:make nocheckиmake nocheck ARCH=aarch64.- Обе smoke-загрузки.
- Гейт или гейты для того, что вы изменили.
- Те же smoke-загрузки и гейт на сборке
UBSAN=1 RUSTUB=1, с--skip-build. - Фаззер для этого кода, если код читает что-либо извне: пакет, диск, прошивку, гостя.
- Для C++ — повторное чтение диффа только ради неопределённого поведения: значения извне, использованные как индекс, длина или величина сдвига до проверки, переполнение, чтение неинициализированного, время жизни объектов, общее состояние без блокировки. Санитайзер ловит только то, что прогоняют гейты.
- Для 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 ..
Что дальше
- Tests and gates — что делает каждый гейт и ради какого сбоя он существует.
- Сборка nos и Запуск nos в QEMU.
- Загружаемый модуль ядра на Rust — и гейт для него.
- Linux-гость под гипервизором nos — то, что гейты гипервизора делают, вручную.