После комментариев на Хабре я ограничил чтение SQLite и перевёл Docker-модуль в WASI

—

от автора

Привет, Хабр.

В прошлой статье я рассказал про свой мониторинг Pulse: переход с JSONL на SQLite, разрешения модулей и квитанции для операций с неизвестным исходом. В комментариях мне задали вопросы, после которых пришлось снова открыть код.

Что будет с WAL, пока пользователь читает большую историю, а агенты продолжают писать? Что именно доказывает квитанция после перезапуска контейнера? И какой смысл в разрешениях, если исполняемый файл модуля всё равно работает с правами агента?

Я удивился, сколько можно узнать из комментариев к собственному проекту. Про зависший читатель SQLite я до этого не задумался. Про отсутствие песочницы написал сам, но после вопроса о чужих файлах и оставшихся процессах захотелось разобраться с этим уже в коде.

Получилось продолжение про ограничения, которые пришлось добавить, и про проверки, которыми я теперь подтверждаю результат. Изменения вошли в Monitor 0.7.0 и Agent 0.6.0. Docker-модуль с WASI пока имеет версию 3.0.0-alpha1. В Marketplace заодно появились темы с ограниченным форматом, а подключение Google обнаружило ошибку в обработке привязки аккаунтов. Про неё тоже расскажу: на сервере всё отклонялось правильно, но пользователь этого не видел.

Для тех, кто не читал первую часть: Pulse разворачивается на своих серверах. Агенты под Linux и Windows собирают показатели и отправляют их в Monitor, а пользователь смотрит данные в браузере. Модули добавляют сбор данных и действия. Например, Docker-модуль показывает контейнеры и позволяет управлять ими, если владелец выдал соответствующие права.

Главный экран Pulse Monitor

Главный экран Pulse Monitor

Долгий читатель и растущий WAL

В комментариях предложили конкретную проверку: открыть читающую транзакцию и продолжать запись из другого соединения. Сам по себе успешный INSERT ещё ничего не говорит о том, может ли checkpoint закончить работу.

У SQLite в режиме WAL читатель сохраняет снимок базы на время транзакции. Checkpoint переносит изменения из журнала в основной файл, но не может пройти дальше границы, которую удерживает активный читатель. Записи при этом могут продолжать поступать. Этот сценарий описан и в документации SQLite.

Для мониторинга это вполне прикладная ситуация: кто-то открыл историю логов, запрос занял много времени, а отчёты с серверов продолжают приходить.

Я добавил тест TestCheckpointObservesPinnedReaderAndRecovers. Он работает с настоящей SQLite во временном каталоге:

  1. Записывает исходную запись.

  2. Открывает читающую транзакцию и выполняет запрос, чтобы зафиксировать снимок.

  3. Через другое соединение добавляет ещё 20 записей отдельными транзакциями.

  4. Запускает пассивный checkpoint и проверяет, что в WAL осталось больше кадров, чем удалось перенести.

  5. Закрывает читателя и повторяет checkpoint. Теперь все доступные кадры должны быть перенесены.

Тест не требует, чтобы файл WAL сразу исчез или стал нулевого размера. SQLite может оставить его для повторного использования. Здесь проверяется отставание checkpoint, а не только число байтов на диске.

В запрос истории логов добавил собственный контекст с таймаутом в пять секунд:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)defer cancel()

Он создаётся до получения читающей транзакции. В бюджет попадает и ожидание свободного соединения. Для этого есть отдельный тест TestHistoryOwnDeadlineIncludesWaitingForReader: он занимает все четыре соединения читателей и проверяет, что запрос истории завершается по своему дедлайну, хотя внешний контекст разрешает ждать дольше.

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

Как я теперь смотрю на размер журнала

В фоновом обслуживании выполняется:

PRAGMA wal_checkpoint(PASSIVE);

Я сохраняю результат checkpoint и смотрю на размер файла -wal. Если журнал больше 64 MiB и число кадров превышает число перенесённых кадров, в служебный лог попадает предупреждение SQLite WAL checkpoint backlog. В нём есть wal_bytes, frames, checkpointed и busy.

Одного размера мало. Большой файл, все кадры которого уже перенесены, и большой файл с удерживаемым снимком требуют разной диагностики. Также я не полагаюсь только на busy: при пассивной проверке важна разница между количеством кадров и прогрессом checkpoint.

В настройках базы есть ещё:

PRAGMA journal_size_limit=16777216;

Здесь легко написать в документации лишнее. Эти 16 MiB не являются жёсткой квотой. Параметр помогает уменьшить оставленный журнал после завершённого checkpoint и возможности его переиспользовать. Он не позволяет отбросить страницы, которые нужны живому читателю. Подробности есть в описании journal_size_limit.

Добавил и проверку свободного места: при остатке меньше 128 MiB Monitor отказывает в приёме новых отчётов с HTTP 503, вместо того чтобы подтверждать их сохранение. Это запас для работы системы, а не гарантия от переполнения диска: место могут одновременно занимать другие программы. Бесконечного буфера на стороне агента я тоже не обещаю.

Эти проверки закрывают разные случаи. Дедлайн ограничивает запрос истории, статистика показывает задержку checkpoint, а проверка места позволяет заранее отказать в записи. Заменять одно другим я не стал.

Квитанция после restart: что именно произошло

Второе замечание касалось управления контейнерами. Раньше в статье я много внимания уделил повторному выполнению: если ответ потерялся, нельзя вслепую послать restart ещё раз. Но рядом остался другой вопрос: на каком основании операция вообще получает успешный результат?

Docker может принять команду, после чего приложение внутри контейнера упадёт или надолго останется в состоянии запуска. Успешный выход исполняемого файла модуля этого не проверяет.

Теперь после изменяющего запроса агент опрашивает фактическое состояние выбранного контейнера через Docker API. Весь путь команды и проверки имеет бюджет 20 секунд.

Для stop нужно увидеть остановленный контейнер. Для start проверяется запущенное состояние. Если у контейнера настроен Docker healthcheck, требуется healthy. Состояния paused, restarting и dead не подходят.

У restart есть дополнительная проверка. До отправки команды агент читает StartedAt, а после неё требует более позднее время запуска. Иначе можно увидеть тот же работающий контейнер и принять его прежнее состояние за подтверждение перезапуска.

Команда

Что подтверждает агент

stop

Контейнер остановлен и не находится в промежуточном или аварийном состоянии

start

Контейнер работает; при наличии healthcheck он имеет статус healthy

restart

Контейнер работает, прошёл настроенный healthcheck, а StartedAt стал позже прежнего

Без настроенного healthcheck проверка подтверждает только состояние контейнера. Готовность базы принимать запросы или HTTP-сервиса обслуживать пользователей из одного Running=true не следует. Даже healthy полезен ровно настолько, насколько полезна проверка, которую автор контейнера туда положил.

Если Docker подтвердил команду, но ожидаемое состояние за отведённое время не удалось проверить, результат остаётся unknown. Такой же исход возможен при обрыве связи после отправки запроса.

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

Гарантии exactly-once здесь нет. Между внешним действием и сохранением его результата остаются точки отказа. Мне важнее показать неопределённость и оставить возможность проверить состояние, чем нарисовать зелёный ответ, которого система не может подтвердить.

Docker-модуль: список контейнеров

Docker-модуль: список контейнеров
Просмотр логов Docker-контейнера

Просмотр логов Docker-контейнера

Почему одних разрешений оказалось мало

С нативным модулем всё было неприятно просто. Панель могла проверить разрешение перед запуском, но запущенный процесс получал права агента. Если агент мог читать файл, потенциально его мог прочитать и модуль. Строка permissions в JSON не меняет правила доступа операционной системы.

Можно ограничивать такой процесс средствами ОС. Но мне нужна была работа и на Linux, и на Windows, а задача модулей пока достаточно узкая: получить разрешённые данные, обработать их и вернуть результат.

Я выбрал WebAssembly с WASI Preview 1 и встроил wazero в Go-приложения. Для этого пути используется интерпретатор. Гость собирается отдельно:

GOOS=wasip1 GOARCH=wasm go build

В пакете компонент указывает runtime: "wasip1", а артефакт хранится под ключом wasip1-wasm с SHA-256. Старые нативные варианты сохранены для совместимости и явно относятся к режиму полного доверия. Если WASI-компонент не удалось запустить, подменять его нативным процессом система не будет.

Гостю не монтируются каталоги хоста. Он не наследует переменные окружения, не получает сетевые сокеты и не может запустить обычный процесс ОС. Docker socket ему тоже не передаётся.

Так возникает следующий вопрос: как тогда читать Docker?

Docker socket остаётся у агента

Между гостем и привилегированными ресурсами теперь стоит посредник. Для Docker у него фиксированный набор методов:

docker.snapshotdocker.logsdocker.startdocker.restartdocker.stop

Гость может попросить действие через импорт pulse_host.request. Агент проверит разрешения и выполнит допустимый запрос через свой локальный Docker-клиент.

WASI-модуль    |    | docker.logs для выбранного контейнера    vПосредник агента    | проверяет разрешения, действие и container ID    vЛокальный Docker API

Посредник не принимает произвольный URL, HTTP-тело или путь к сокету. Для ручной операции цель привязана к заданию: модуль не может заменить выбранный контейнер другим. Используется полный 64-символьный ID. За одно такое выполнение допускается одна изменяющая команда.

У чтения контейнеров, просмотра логов и управления разные разрешения. Для управления нужны docker.control и actions.execute. Проверки выполняются на стороне хоста; отключённая кнопка в браузере сама по себе доступ не ограничивает.

Результат внешнего действия тоже учитывает хост. Если посредник получил неопределённый исход, модуль не может спрятать его, вернув собственный status: "ok". Проверка состояния контейнера, о которой я писал выше, находится в доверенном коде агента.

Это сужает доступ, но оставляет опасные разрешённые действия. Модуль с выданным правом остановки может остановить разрешённый контейнер. Агент и Docker daemon остаются привилегированными компонентами, а ошибки в посреднике всё ещё имеют значение.

Разрешения изолированного WASI-модуля

Разрешения изолированного WASI-модуля

Что ограничено у гостя

Отсутствие файлов и сети не мешает программе бесконечно считать или печатать данные. Поэтому у выполнения есть отдельные пределы:

Ресурс

Предел

Артефакт

16 MiB

Вход

2 MiB

Линейная память гостя

128 MiB

Время запуска вместе с ожиданием свободного слота

30 секунд

Вывод и ответ посредника

512 KiB

Один запрос к посреднику

64 KiB

Вызовы посредника

Не больше 64 за запуск

Одновременные выполнения runtime

Не больше двух на процесс хоста

128 MiB относятся к линейной памяти WebAssembly. Это не ограничение всего RSS агента: память нужна ещё самому runtime, Go и обработчикам. Предел времени также не превращается в жёсткую квоту CPU.

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

Проверять пришлось и отказ

Для runtime появились тестовые WASI-гости, которые пытаются читать и записывать файлы хоста, получить секреты из окружения, подключиться к сети или запустить процесс. Отдельно проверяются выход за память, слишком большой вывод, дедлайн и превышение числа обращений к посреднику.

Есть тест на некорректные буферы ABI: обработчик не должен вызываться, если память для запроса или ответа не прошла проверку. Сценарий, где модуль формально получает отказ уже после внешнего действия, здесь был бы особенно плохим результатом.

Для Docker я проверил логи и команды на отдельном тестовом контейнере. Без разрешения stop должен отклоняться. После выдачи прав проверяются stop, start и restart с чтением фактического состояния. Рабочие контейнеры для такого прогона я не останавливал.

Проверки SQLite с удерживаемым читателем и ожиданием соединения остались отдельными регрессионными тестами. Их можно найти в storage_logs_test.go, а проверки изоляции в runtime_test.go.

Обновление не должно молча расширять доступ

При смене runtime или контракта разрешений компонент отключается, а выданные права очищаются. Владелец должен заново согласиться с тем, что будет выполняться на узле. Состояние WASI-компонента отделено от прежнего нативного состояния.

Старому агенту, который не объявляет поддержку WASI, такое задание не выдаётся. Обновление через Marketplace и ручной импорт JSON проходят через один установщик с одинаковыми проверками. Отключённый каталог не мешает владельцу обновить пакет вручную.

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

Тема оформления тоже приходит от чужого автора

В Marketplace появился каталог тем с предпросмотром. В Monitor уже был редактор оформления и импорт JSON, поэтому мне хотелось дать возможность обмениваться такими файлами. Исполняемый формат модулей для этого не нужен.

Для темы я оставил ограниченный JSON: 12 цветов в формате #RRGGBB, светлый или тёмный режим, скругление и настройки шрифтов из встроенного списка. Пакет не может содержать произвольный CSS, HTML, JavaScript, бинарники или ссылки на внешние ресурсы. Неизвестные поля и повторяющиеся ключи отклоняются. Размер ограничен 64 KiB.

Предпросмотр применяет значения внутри макета панели. Оформление самого Marketplace от чужой темы не меняется. Показатели в этом макете вымышленные; подключаться к серверам пользователя для выбора цвета фона незачем.

Обнаружение устроено через GitHub: автор добавляет topic pulse-theme, маркер в репозиторий и JSON-пакет в release. Автоматическая проверка отправляет конкретную версию на модерацию. Одобрение относится к сохранённым байтам пакета. Заменить их другими под тем же номером версии нельзя.

Есть небольшая деталь со скачиванием. В релизе лежит оболочка с именем, версией и вложенным оформлением. Monitor нужен сам объект оформления в прежнем формате импорта. Поэтому скачивание возвращает вложенный объект с каноническим порядком ключей, и его SHA-256 отличается от хеша исходного пакета. В API эти два значения разделены: sha256 относится к пакету, themeSha256 к скачиваемому файлу. Иначе было бы легко получить исправный файл и решить, что проверка целостности сломалась.

После импорта пользователь получает локальную копию. Новые версии автора автоматически её не меняют, агент темы вообще не загружает. Встроенные варианты в каталоге помечены как примеры оформления: выдавать их за публикации сообщества я не стал.

Каталог тем Pulse Marketplace с предпросмотром

Каталог тем Pulse Marketplace с предпросмотром

Сервер отказал правильно, а интерфейс потерял причину

После подключения входа через Google я столкнулся с ещё одной ошибкой. Зашёл через Google, открыл кабинет и нажал «Подключить GitHub». Страница как будто обновилась, ничего не объяснив.

К этому моменту GitHub уже был связан с прежним аккаунтом Pulse. Вход через Google создал отдельный аккаунт. Даже если у обоих провайдеров указана одна почта, Marketplace не объединяет их автоматически: привязка хранится по провайдеру и его идентификатору пользователя.

При попытке подключить занятый GitHub сервер находил конфликт и отклонял операцию. Дальше начиналась цепочка переадресаций:

Callback GitHub: привязка уже занята    -> /ru/login/?error=oauth    -> активная сессия уже есть    -> /ru/author/

До страницы, которая могла показать ошибку входа, пользователь фактически не добирался. Кабинет же о конфликте ничего не знал.

Теперь результат привязки возвращается в кабинет: успешное подключение, конфликт с другим аккаунтом или ошибка завершения. В интерфейсе есть сообщение и состояние ожидания у кнопки. Ошибка OAuth также сохраняется при переходе со страницы входа в уже открытую сессию.

При конфликте прежняя привязка остаётся на месте, текущая сессия сохраняется. Автоматического переноса аккаунта или его данных это исправление не добавляет. Для доступа к прежнему аккаунту можно выйти и войти через GitHub.

Регрессионная проверка воспроизвела именно потерянный результат. Потом я проверил успешную привязку свободной identity, отказ провайдера и потерю сессии во время OAuth. Последний случай важен: одного корректного OAuth state недостаточно, чтобы привязать способ входа к аккаунту, если исходная пользовательская сессия уже пропала.

Эта история оказалась близка к проблеме с квитанциями. Сервер знает результат операции, но до человека доходит только изменившийся экран. Для него успешная обработка и ошибка без сообщения выглядят одинаково.

Что осталось за пределами этих исправлений

Нативные модули продолжают работать с правами процесса агента. Для них есть очистка обычных дочерних процессов средствами Windows Job Objects и групп процессов Linux, но полноценной файловой песочницей это не стало. Если такому модулю нужен доступ к хосту, владелец должен учитывать его как доверенную программу.

У WASI доверенной частью остаются runtime и посредник. Разрешённые методы могут менять состояние сервера, а разрешённые для чтения данные доступны модулю. Эту границу тоже приходится проектировать: например, нельзя выдать гостю общий HTTP-клиент и считать, что сеть у него по-прежнему закрыта.

Долговременной очереди доставки и событийной диспетчеризации пока нет. Проверка Docker ограничена состоянием контейнера и настроенным healthcheck. Ограниченный запрос истории не доказывает, что любая будущая работа с базой будет короткой. Для новых путей чтения это нужно проверять отдельно.

После комментариев я стал внимательнее относиться к словам «успешно» и «ограничено» в собственном интерфейсе и документации. Теперь за ними есть конкретные условия: какой снимок удерживается, какое состояние проверено и какие ресурсы вообще доступны модулю. В коде эти условия закреплены тестами, в том числе на отказ.

Pulse попал в XERJ Corpus Hub

Ещё одна приятная для меня история: Pulse Monitor включили в XERJ Corpus Hub, и там появилась моя страница. В каталоге репозиторий доступен как источник примеров реализации, по которым ИИ-агенты могут искать код с привязкой к файлам и строкам.

В карточке выделен коммит 5f0978df61, fix(security): harden telemetry, storage and module execution. У него указаны 40 изменённых файлов и 1 131 строка изменений. Сам корпус охватывает весь репозиторий на закреплённом коммите.

Это более ранний снимок проекта. Новые изменения, в том числе описанный здесь WASI-runtime, не стоит искать в нём только потому, что они уже есть в основной ветке. Закреплённую версию можно воспроизвести, но она сама за разработкой не следует.

Мне приятно, что кто-то выбрал этот код для своей подборки. Статистики его использования у меня нет, и считать такую карточку аудитом безопасности я тоже не могу. Зато у разбора появился ещё один адрес, по которому можно посмотреть конкретную реализацию.

Страница проекта в XERJ

Страница проекта в XERJ

Исходники разбора: checkpoint SQLite, runtime WASI и проверка Docker после команды. Контракт и ограничения описаны в MODULES-WASI.md.

Для новых частей: валидатор пакета темы, описание формата тем и регрессионные проверки OAuth.

ссылка на оригинал статьи https://habr.com/ru/articles/1101028/