Skip to content

Latest commit

 

History

History
191 lines (164 loc) · 21.4 KB

plugins.md

File metadata and controls

191 lines (164 loc) · 21.4 KB

Python-плагины: контракт parity-07

Это адаптация семантики, не исполнение OneScript и не полная совместимость всех внешних плагинов upstream. Загружаются только явно указанные доверенные Python-модули через --plugin package.module, с функцией register(host). Автопоиска/установки/запуска .os, opm, bindata, чужих Git hooks нет. Python-модуль — произвольный код с правами процесса, не sandbox: нельзя загружать недоверенные модули. Safety checks защищают операции ядра, но не могут запретить самому Python-коду писать файлы или запускать процессы.

def register(host):
    def change(ctx):
        ctx.message = '[export] ' + ctx.message
        ctx.author = 'Integration <integration@example.org>'
    host.subscribe('before_commit', change, priority=100, contextual=True)

Диспетчеризация

subscribe(event, handler, priority=0, contextual=False). Приоритет по убыванию, равные приоритеты — порядок регистрации. contextual=True: один изменяемый объект ctx передаётся всем подписчикам события; изменения видны следующему подписчику и затем ядру в перечисленных ниже изменяемых полях. Возвращаемое значение callback игнорируется. Legacy contextual=False получает именованные аргументы прежних событий; mutable списки работают как раньше. PluginHost(strict=True) по умолчанию: ошибки останавливают sync. strict=False — явный старый режим предупреждений только для legacy callbacks; ошибки contextual callbacks всегда fail closed.

Callbacks одного host сериализованы RLock. Export callbacks исполняются в worker threads, но одновременно два callback одного host не исполняются. Сам backend остаётся параллельным. Не ждать в callback другой callback/worker того же host: это может вызвать deadlock. Порядок между различными версиями export не обещается; коммиты строго последовательные. При retries export-события могут повторяться: требуются идемпотентность и отмена через cancel.

События и реально используемые изменения

Событие Контекст Эффект
before_sync work_dir После recovery под writer lock, до проверки чистоты/чтения marker. Подходит для явно выбранной remote-политики. Изменение поля пути не перенаправляет ядро.
before_history start, current_version, history=None, standard_processing=True Можно изменить start. False отменяет backend.fetch_history; необходимо предоставить history (список StorageVersion).
after_history history, current_version Можно заменить список и записи через dataclasses.replace; сортировка и проверка дублей выполняются после callback. Текущий marker этим полем не изменяется.
before_export version, destination, cancel, standard_processing=True False отменяет backend export; плагин обязан создать snapshot в выделенном destination. Переназначение destination/version не меняет выбранный ядром путь/номер.
after_export version, destination Можно преобразовать подготовленный snapshot. Затем обязательны подтверждение выгрузки и reserved/symlink validation.
before_cleanup version, work_dir, standard_processing=True False отменяет удаления отсутствующих в snapshot файлов (merge snapshot). Это изменение плана, не разрешение вручную чистить worktree. Перезапись пришедших файлов всё ещё выполняется транзакционно.
before_commit version, work_dir, author, дополнительно message, date для contextual API author/message/date реально передаются в Git. VERSION/номер, путь, records, expected HEAD, journal и index lock не подменяются. Прямые изменения файлов отклоняются checks транзакции.
after_commit version, work_dir, sha Только после durable commit. Ошибка → PostCommitError, ненулевой результат, commit и VERSION сохранены.
after_sync work_dir, result Только успешное завершение, в том числе no-op. Ошибка возвращается, уже сделанные commits не откатываются. Не является finally/cleanup callback.

История может запрашиваться повторно с start=1 для проверки усечения storage при no-op. Очистка ядра выполняется в защищённом finally после остановки export pool, в том числе при ошибке after_sync и при no-op. Удаляются только созданные этим run каталоги и ресурсы backend через его cleanup(), не общий temp parent. Ошибка after_sync сохраняется как исходный объект исключения; post_commit=True, если этот запуск подтвердил commits. Ошибки отдельных cleanup действий не прерывают остальные: при уже имеющейся ошибке они добавляются в __notes__ и журнал предупреждений, иначе становятся ошибкой результата. --keep-temp (cleanup_temp=False) явно сохраняет и run exports, и backend resources, включая ошибочные и no-op запуски: автоматический backend cleanup также отключён. Init генерирует AUTHORS обычным backend.fetch_history и подчиняется ТОМУ ЖЕ lifecycle: свой backend он отпускает в том же защищённом finally, в том числе при занятой цели. Отличие одно — у init нет объекта результата, поэтому сбой самой очистки остаётся предупреждением в журнале, а не ошибкой команды.

Каталог запуска нативного бэкенда (native-<uuid>) принадлежит запуску: бэкенд создаёт его сам при ПЕРВОМ обращении к конфигуратору (owns_temp_root=True, exist_ok=False — владение подтверждается созданием; первое создание из нескольких потоков сериализовано замком) и в cleanup() снимает целиком. Прогон, упавший до первого запуска конфигуратора, каталога не создаёт вовсе. Каталог, переданный бэкенду снаружи, не удаляется никогда — общий родитель временных файлов, внешние и постоянные данные вне области очистки, как и каталог, потерявший удостоверение. Retention диагностики отделён от одноразового состояния: временные ИБ снимаются всегда, а после ОТКАЗА каталог запуска остаётся с протоколами /Out, на путь которых ссылается текст ошибки. Успешный и отменённый запуски не оставляют ничего.

Граница удаления и модель угроз

Удаляется только то, что этот запуск создал сам, и принадлежность проверяется по УДОСТОВЕРЕНИЮ объекта файловой системы (устройство и inode), снятому в момент создания, а не по пути: путь к моменту очистки может вести куда угодно. Порядок существен — удостоверение каталога запуска сверяется ДО удаления чего-либо внутри него: рабочие каталоги лежат внутри, и подмена ПРЕДКА (символьная ссылка, соединение NTFS или просто другой настоящий каталог на том же пути) увела бы рекурсивное удаление в чужое дерево, хотя сам удаляемый каталог ссылкой при этом не является. Потеря удостоверения — отказ от очистки с предупреждением в журнале: оставленный каталог дешевле удалённых чужих данных.

Проверки мало — нужно ЗАКРЕПЛЕНИЕ. Проверка удостоверения непосредственно перед удалением по пути оставляет окно: между «проверили» и «удалили» путь можно подменить. Это окно закрыто тем, что удаление вообще не работает с путём — оно работает с объектом, открытым и удерживаемым на всё время операции.

Кого защищаем Чем подтверждено владение Чем закреплён объект
каталог запуска native-<uuid> создан нами (exist_ok=False) дескриптор/описатель каталога
рабочий каталог worker-<uuid> создан нами то же
каталог выгрузки run-<uuid> создан нами то же
каталог версии v<N>-<hex> лежит ВНУТРИ нашего run-<uuid> закреплён РОДИТЕЛЬ, удаление идёт относительно него

Каталог версии создаёт бэкенд или плагин (before_export вправе взять выгрузку на себя), поэтому своего удостоверения у него нет и быть не может; он снимается как запись внутри каталога выгрузки, удостоверение которого снято при создании.

Что защита даёт и чего не даёт:

  • POSIX. Каталог открывается с O_DIRECTORY | O_NOFOLLOW, удостоверение сверяется у ОТКРЫТОГО каталога (fstat), и содержимое снимается относительно дескриптора (dir_fd): дескриптор держит сам объект, поэтому подмена пути после проверки удаление наружу не уводит. Сам каталог снимается через дескриптор РОДИТЕЛЯ, причём запись с нашим именем сверяется по удостоверению непосредственно перед снятием.
  • Windows. Роль дескриптора играет описатель каталога, открытый без FILE_SHARE_DELETE. Пока он открыт, система запрещает переименовать и удалить как сам каталог, так и любого его ПРЕДКА (измерено из отдельного процесса: сам каталог — ERROR_SHARING_VIOLATION, предки — ERROR_ACCESS_DENIED), то есть подмена в этот момент невозможна не «потому что мы успели проверить», а потому что её отвергает ОС. Сам каталог снимается по описателю (FileDispositionInfoEx), и путь в удалении не участвует вовсе.
  • Остаточное окно есть — и только одно. В POSIX нет удаления каталога по дескриптору: unlinkat принимает имя, поэтому между сверкой записи и её снятием остаются микросекунды. Воспользоваться ими можно, лишь имея право удалить НАШУ запись из общего родителя (на каталогах со «липким» битом — от того же пользователя), и снять при этом удастся только ПУСТОЙ каталог: rmdir не трогает ни содержимое, ни ссылки. На Windows этого окна нет. Утверждение «подмена невозможна» относится к Windows; для POSIX утверждается ровно написанное выше.
  • Точка повторного разбора (символьная ссылка, соединение NTFS) внутри своего каталога снимается КАК ССЫЛКА: её цель не затрагивается ни на одной платформе.
  • Проверка не защищает от того, кто может писать ВНУТРЬ каталога запуска: этот каталог одноразовый и по построению принадлежит запуску целиком. Защищается граница «не выйти наружу», а не содержимое своего каталога. Подброшенный туда чужой каталог будет снят вместе с нашим — тот, кто пишет в наш одноразовый каталог, уже внутри доверия прогона.
  • Том, не отдающий inode (st_ino == 0), удостоверения не даёт: на таком томе очистка отказывается, а не удаляет наугад.
  • Это не блокировка: параллельное удаление того же каталога кем-то ещё приводит к штатному отказу очистки, а не к ошибке команды.
  • cleanup() освобождает ресурсы ВСЕГО экземпляра и вызывается после остановки пула. Одновременная работа другого потока в том же каталоге запуска — не поддерживаемый порядок; замок внутри гарантирует лишь, что учёт не разойдётся с диском (каталог не окажется снятым, но числящимся, или созданным, но не учтённым).

Кто вообще может писать в каталог запуска (stage-18, измерено)

Предыдущий пункт — «тот, кто пишет в наш одноразовый каталог, уже внутри доверия прогона» — верен, но он не ответ на вопрос, КТО этот «тот». Ответ определяется не кодом, а тем, ГДЕ лежит каталог запуска, и это замерено на стенде двумя штатными контейнерами с одним и тем же UID 10001:

где лежит каталог запуска видит ли его СОСЕДНИЙ контейнер что он с ним сделал
bind-mount каталога хоста да, и читает выгрузку rm -rf прошёл, каталог уничтожен
том Docker, именованный да, и читает выгрузку то же
том Docker, анонимный (--volumes-from) да, и читает выгрузку то же
собственный слой контейнера нет, не видит вовсе выгрузка цела

Отсюда договор контейнерной поставки: каталог запуска — /var/lib/gitsync/scratch, он часть образа и НЕ точка монтирования. Нарушение отвергается дважды: пусковым сценарием (examples/gitsync-run.sh, код 78) и самим заданием (UnsafePathError) — и во втором случае отказ происходит ДО создания каталога и ДО любого удаления.

Что этим не закрыто и закрытым не объявляется: враждебный процесс под тем же UID ВНУТРИ того же контейнера. В POSIX удаление адресует ИМЯ (rmdir/unlinkat привязки к inode не имеют), правами того же владельца сосед не выгораживается, а flock для него необязателен — измерения stage-17. Частный каталог запуска убирает соседний КОНТЕЙНЕР, а не соседний процесс внутри своего. Поэтому код внутри контейнера — включая плагины — обязан быть доверенным, и это условие поставки, а не следствие проверок.

Диагностика после отказа — это файл /Out в native-<uuid>/designer-out/: он остаётся, и на него ссылается текст ошибки. Остальные пути, которые сообщение повторяет за командной строкой конфигуратора (worker-*/ib, storage-report-*.mxl), одноразовые: к моменту чтения сообщения их уже нет, и это ожидаемое поведение, а не потеря диагностики. Пустой/missing override snapshot подчиняется тому же allows_empty_export контракту backend. Reserved-path validation повторяется перед построением транзакции; baseline проверяется после cleanup callback. Флаг standard_processing не отключает safety/recovery gates.

Соответствие источнику

МенеджерПодписок.os:775–799 — убывающие приоритеты; 802–840 — возврат mutable параметров. МенеджерСинхронизации.os:264–357 — lifecycle, 382–425 — export, 566–588 — стандартная очистка/override. Перенесены ключевые data-oriented stages; не перенесены все 30+ событий, native-конфигуратор/IB contexts, произвольная замена Git commit, низкоуровневых load/move, CLI subscription и установка/включение пакетов upstream. Полная drop-in plugin parity не заявляется.

Remote-политика: только opt-in

Без явно загруженного модуля sync не выполняет fetch/pull/push. clone --url — отдельная явная операция. Источник upstream sync-remote — внешний проект, его точный контракт в исследованном snapshot отсутствует; совместимость с ним не заявляется.

tests/test_parity07_remote.py исполняет настоящий Python-модуль с before_sync: clean → fetch → merge --ff-only и after_commit: push. Только собственный local bare fixture, без сети. Проверены remote advance, successful push, divergence refusal и pre-receive rejection. При отклонённом push локальный commit остаётся; в тесте оператор явно повторяет push после устранения отказа. Универсального установленного remote-плагина, автоматического retry push на no-op, rebase, force push, произвольной branch/auth политики в продукте нет.