Изоляция задач CI на облачном Mac с отдельным пользователем и launchctl

Изоляция задач CI на облачном Mac с отдельным пользователем и launchctl

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

Сначала определите границы изоляции

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

Цель изоляции не в том, чтобы задача «не видела всю машину», а в том, чтобы изменяемое состояние, созданное одной задачей, не могло без явного объявления повлиять на следующую.

Сначала сохраните исходное состояние, чтобы при последующей диагностике было с чем сравнивать:

id
umask
printf 'HOME=%s\nTMPDIR=%s\n' "$HOME" "$TMPDIR"
launchctl print "gui/$(id -u)" >/tmp/launchctl-baseline.txt 2>&1 || true
ps -axo user,pid,ppid,command

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

Создавайте отдельное рабочее пространство для каждой задачи

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

set -eu

case "${RUN_ID:-}" in
  ""|*[!A-Za-z0-9._-]*)
    echo "Invalid RUN_ID" >&2
    exit 64
    ;;
esac

RUN_ROOT="/Users/ci_runner/Jobs/$RUN_ID"
install -d -m 700 "$RUN_ROOT"
install -d -m 700 "$RUN_ROOT/src" "$RUN_ROOT/tmp" "$RUN_ROOT/cache"

export HOME="/Users/ci_runner"
export TMPDIR="$RUN_ROOT/tmp/"
export XDG_CACHE_HOME="$RUN_ROOT/cache"
umask 077

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

Изолируйте с помощью прав доступа, а не соглашений об именовании

Наличие номера задачи в имени каталога само по себе не обеспечивает безопасность. Командой stat -f '%Su %Sp %N' "$RUN_ROOT" проверьте владельца и права, а с помощью ls -lde — дополнительные ACL. Если обнаружены унаследованные правила, сначала установите их источник, а затем удалите ненужные разрешения через контролируемую процедуру инициализации. Не ослабляйте права рекурсивно непосредственно в скрипте сборки.

Разделите кэши по трём срокам жизни

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

| Тип | Примеры | Рекомендуемая граница | Обработка при завершении | |---|---|---|---| | На уровне задачи | Временные производные файлы, вложения тестов | Только текущий `RUN_ID` | Удалить после проверки | | На уровне ветки | Повторно загружаемые зависимости | Составной ключ репозитория и ветки | Ротация по истечении срока | | На уровне машины | Пакеты инструментов только для чтения, фиксированный индекс SDK | Каталог исходного кэша, обслуживаемый администратором | Задачи сборки не могут изменять |

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

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

Правильно учитывайте домены сеансов launchctl

Фоновые агенты macOS могут относиться к системному, пользовательскому домену или домену графического сеанса. То, что сборка выполняется пользователем ci_runner, ещё не гарантирует, что все запущенные ею агенты находятся в ожидаемом домене. Сначала проверьте идентификатор текущего пользователя, а затем нужный домен:

uid="$(id -u ci_runner)"
sudo -u ci_runner launchctl print "user/$uid" >/tmp/user-domain.txt

if launchctl print "gui/$uid" >/dev/null 2>&1; then
  sudo -u ci_runner launchctl print "gui/$uid" >/tmp/gui-domain.txt
fi

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

Выявляйте процессы, оставшиеся от других задач

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

Добавьте проверку завершения, которая может закончиться ошибкой

Этап очистки должен сохранять исходный код завершения задачи, но при этом делать ошибки очистки видимыми. Рекомендуется выполнять проверки в фиксированном порядке:

  1. Остановить дочерние процессы, запущенные задачей, и дождаться их завершения.
  2. Проверить рабочее пространство на наличие сокетов, точек монтирования и файлов с некорректными правами.
  3. Скопировать отчёты о тестировании в контролируемый архивный каталог за пределами рабочего пространства.
  4. Удалить временные учётные данные и переменные окружения задачи.
  5. Проверить префикс пути и удалить каталог задачи.
  6. Ещё раз сохранить состояние процессов и launchctl, а затем сравнить его с исходным.

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

cleanup() {
  case "$RUN_ROOT" in
    /Users/ci_runner/Jobs/*)
      rm -rf -- "$RUN_ROOT"
      ;;
    *)
      echo "Refusing unsafe cleanup path" >&2
      return 1
      ;;
  esac
}
trap cleanup EXIT HUP INT TERM

Скрипт проверки не должен подавлять все ошибки ради сохранения зелёного статуса. Ошибка архивирования, оставшиеся процессы или смена владельца каталога должны приводить к явному ненулевому коду завершения. Для непрерывно работающего CI на SoarMac сначала проверьте в консоли доступные конфигурации, а затем решите, требуется ли разделить runner с учётом нагрузки параллельных задач на память и диск. Независимо от выбранной конфигурации границы учётных записей и состояния должны оставаться одинаковыми.

Контрольный список перед запуском

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

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

Часто задаваемые вопросы

Нужен ли отдельный пользователь macOS для каждой задачи CI?

Обычно нет. Достаточно стандартного пользователя без прав администратора для runner и отдельного каталога с режимом 700 для каждой задачи. Разные учетные записи нужны для недоверяющих друг другу команд или разных уровней доступа.

Почему недостаточно удалить только рабочий каталог?

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

Запустите следующую задачу

Выберите облачный Mac под свою рабочую нагрузку

Проверьте чип, объём памяти, хранилище, срок аренды и регион, затем закажите выделенный физический сервер Apple Silicon.

Выбрать модель и заказать