Как убедить гейм-дизайнера запустить тесты?
Но мы нашли выход — теперь нельзя закоммитить в репозиторий без тестов. По крайней мере незаметно и безнаказанно.
Система тестирования
Первое, что нам нужно — это система тестирования. Мы её уже описывали здесь. Напомним, что нужен один и тот же код и для запуска на сервере Ci, и локально, чтобы не было сложности в поддержке. Желательно, чтобы на проекте могли задавать разнообразные параметры для общих тестов, а ещё лучше — расширять собственными. Конечно, конфетка сразу не получилась.
Этап первый — запускать можно, но больно. Что делать с python-кодом ещё понятно, а вот со всевозможными утилитами вроде CppCheck, Bloaty, optipng, нашими внутренними костылями-велосипедами — нет. Для корректного запуска нужны исполняемые файлы для всех платформ, на которых работают наши коллеги (mac, windows и linux). На данном этапе все необходимые бинарные файлы находились в репозитории, а в настройках системы тестов указывался относительный путь к папке с бинарниками.
...
Это порождает несколько проблем:
- со стороны проекта нужно хранить в репозитории лишние файлы, так как они нужны на компьютере каждого разработчика. Естественно, репозиторий из-за этого больше.
- когда возникает проблема, сложно понять, какая именно версия стоит у проекта, нужная ли структура в папке.
- где брать нужные бинарные файлы? Компилировать самому, скачивать в интернете?
Этап второй — наводим порядок в утилитах. А что, если выписать все нужные утилиты и собрать их в одном хранилище? Идея в том, что на сервере находятся уже собранные утилиты для всех нужных платформ, которые ещё и версионируются. У нас уже использовался Nexus Sonatype, поэтому мы пошли в соседний отдел и договорились за файлики. В итоге получилась структура:
Для запуска нужен скрипт, который знает секретный адрес, где лежат бинарники, умеет их скачать, а также запустить в зависимости от платформы с переданными параметрами.
def get_tools_info(project_tools_xml, available_tools_xml):
# Parse available tools at first and feel up dictionary
root = etree.parse(available_tools_xml).getroot()
tools = {}
# Parse xml and find current installed version ...
return tools
def update_tool(tool_info: ToolInfo):
if tool_info.current_version == tool_info.needed_version:
return
if tool_info.needed_version not in tool_info.versions:
raise RuntimeError(f'Tool "{tool_info.tool_id}" has no version "{tool_info.needed_version}"')
if os.path.isdir(tool_info.output_folder):
shutil.rmtree(tool_info.output_folder)
g_server_interface.download(tool_id=tool_info.tool_id, version=tool_info.needed_version,
output_folder=tool_info.output_folder)
def run_tool(tool_info: ToolInfo, tool_args):
system_name = platform.system().lower()
tool_bin = tool_info.exe_infos[system_name].executable
full_path = os.path.join(tool_info.output_folder, tool_bin)
command = [full_path] + tool_args
try:
print(f'Run tool: "{tool_info.tool_id}" with commands: "{" ".join(tool_args)}"')
output = subprocess.check_output(command)
print(output)
except Exception as e:
print(f'Fail with: {e}')
return 1
return 0
def run(project_tools_xml, available_tools_xml, tool_id, tool_args):
tools = get_tools_info(project_tools_xml=project_tools_xml, available_tools_xml=available_tools_xml)
update_tool(tools[tool_id])
return run_tool(tool_info, tool_args)
На сервере мы добавили файл с описанием утилит. Адрес этого файла неизменный, поэтому первым делом идём туда и смотрим, что у нас есть в наличии. Опуская тонкости, это имена пакетов и путь к исполняемому файлу внутри пакета для каждой платформы.
А на проекте добавляем файл с описанием того, что нужно.
Чтобы было совсем хорошо, и не каждый раз перекачивать с сервера, можно заморочиться, сделав локальный кеш. Тогда переключение версий будет очень дешёвой операцией.
python -m utility_runner --available-source D:\Playrix\![habr]\gd_hooks\available_source.xml --project-tools D:\Playrix\![habr]\gd_hooks\project\project_tools.xml --run-tool CppCheck -- --version
После данных манипуляций процесс работы стал выглядеть предельно прозрачно:
- на проекте есть только один файл, где прописаны версии утилит, которые на данный момент актуальны
- стало просто распространять обновления, и главное, что понятна текущая версия на проекте. А это правда сильно упрощает поиск проблемы.
На сервере мы это относительно быстро подняли, но наша глобальная цель — запустить тесты у ГД.
А запускать-то как?
Вы когда-нибудь пробовали объяснить, как приготовить круассаны человеку, который никогда не готовил даже яичницы? Вот и нам сложно было объяснять гейм-дизайнерам, как запускать нужные скрипты. Если на сервере один раз настроил — и оно бодро выполняется, то с локальным запуском есть не так много вариантов. Точнее только один: хуки git.
По-простому, хук — это bash-скрипт, который запускается в определённые моменты при работе с git: перед pull или push, во время создания коммита, есть хуки даже на git-сервере.
Из всего разнообразия нас интересуют только три, которые запускаются во время создания коммита:
- pre-commit — он выполняется первым и стоит на страже данных. Если код выхода отличный от нуля, то создание коммита прерывается.
- prepare-commit-msg — он работает до вызова редактора сообщения коммита, но после создания стандартного сообщения. Нам он нужен, чтобы модифицировать сообщения коммитов слияния или rebase.
- commit-msg — в этом хуке можно проверить сообщение к коммиту. Например, что разработчик не забыл добавить ссылку на задачу. Если он вернёт не ноль, то создание коммита прерывается.
Чтобы хук начал действовать, его мало положить в репозиторий, как скрипт, его нужно скопировать в папку .git/hooks. Автоматически это сделать нельзя — эксплойт. Мы не выдумывали хитрых технологий, а сделали два командных файла (для Windows и Mac), которые копируют хуки из одной папки в другую и запускаются двойным кликом. Выполнить их нужно только один раз, и такое уже несложно объяснить человеку без технического образования.
Конечно, не всегда всё идеально. Иногда бывают сбои, которые в основном типичны и делятся на две группы.
Магия у пользователя. Непонятная на первый взгляд ошибка, но стандартная проблема вроде нестандартных символов в путях, отсутствия git-bash на Windows. Для этих случаев мы пишем FAQ.
Тонкости работы систем. Предусмотреть все возможные варианты и параметры мы не смогли, поэтому периодически вылавливаем разнообразные баги. Мы или подпираем их в скрипте, или добавляем пункт в FAQ. Например, папка .git/hooks не всегда находится в корне репозитория. Чтобы узнать точное расположение, можно использовать команду:
git rev-parse --git-path hooks
В зависимости от того, в каком типе репозитория запускается команда, она вернёт следующее:
Другой интересный случай — это переключение между ветками во время разработки. Мы не очищали папку .git/hooks, и там могли оставаться старые хуки. Они пытались запуститься и падали. Это довольно сильно расстраивало пользователей, поэтому мы добавили в скрипт очистку .git/hooks перед тем, как начать копирование новых хуков.
Всё сделать идеально с первого раза нельзя, поэтому просто необходима возможность внести правки в репозитории, и чтобы они каким-либо образом автоматически подхватились у всех локально. Это сильно спасает, когда мы находим серьезный просчёт и не бегаем всем в личные сообщения с просьбой опять вызвать наш убер-скрипт. Вся эта работа должна быть максимально скрыта для разработчика — это не его война. Единственная сложность в том, что в момент выполнения хука мы не можем обновить сам файл хука — запись в него заблокирована системой. Одно из решений:
- При вызове pre-commit обновить все файлы, кроме него самого. И создать pre-commit-tmp
- При вызове commit-msg заменить файл pre-commit на созданный в первом шаге pre-commit-tmp
Вот, теперь хорошо: один раз скопировали хуки, и они будут запускаться во время коммита и автоматически обновляться. Мы выдохнули, но пользователи прислали нам скриншот.
Но всё же, как запускать?
Сначала мы сделали многостраничную инструкцию о том, какие круассаны вкуснее, какой python нужно установить. Но мы помним про гейм-дизайнеров и яичницу? Она всегда была подгоревшей: то python не той битности, то 2.7 вместо 3.7. И всё это множится ещё и на две платформы, где работают пользователи: windows и mac. (Пользователи Linux у нас либо гуру и сами всё настраивали, тихо притопывая под звуки бубна, либо их миновали проблемы.)
Мы решили вопрос радикально — собрали python нужной версии и битности. А на вопрос «как нам его поставить и где хранить» ответили: Nexus! Единственная проблема: у нас ещё нет python, чтобы запустить python-скрипт, который мы сделали для запуска утилит из Nexus.
И тут на помощь приходит bash! Он не такой уж и страшный, а даже хороший, когда к нему привыкнешь. И работает везде: на unix уже всё хорошо, а на Windows он ставится вместе с git-bash (это наше единственное требование к локальной системе). Алгоритм установки очень простой:
- Скачать архив собранного python для нужной платформы. Проще всего это сделать через curl — он есть почти везде (даже в Windows).Скачиваем питон
mkdir -p "$PYTHON_PRIMARY_DIR" curl "$PYTHON_NEXUS_URL" --output "$PYTHON_PRIMARY_DIR/ci_python.zip" --insecure || exit 1
- Разархивировать его, создать виртуальное окружение, ссылающееся на скачанный бинарник. Не повторяйте наших ошибок: не забудьте прибить версию virtualenv.
echo "Unzip python..." unzip "$PYTHON_PRIMARY_DIR/ci_python.zip" -d "$PYTHON_PRIMARY_DIR" > "unzip.log" rm -f "$PYTHON_PRIMARY_DIR/ci_python.zip" echo "Create virtual environment..." "$PYTHON_EXECUTABLE" -m pip install virtualenv==16.7.9 --disable-pip-version-check --no-warn-script-location
- Если нужны какие-то библиотеки из lib/*, нужно их скопировать самому. virtualenv об этом не задумывается.
- Установить все нужные пакеты. Тут мы договорились с проектами, что у них будет файл ci/required.txt, в котором будут все зависимости в формате pip.
OUT_FILE="$VENV_DIR/pip_log.txt"
"$PYTHON_VENV_EXECUTABLE" -m pip install -r "$REQUIRED_FILE" >> "$OUT_FILE" 2>&1
result=$?
if [[ "$result" != "0" ]]; then
var2=$(grep ERROR "$OUT_FILE")
echo "$(tput setaf 3)" "$var2" "$(tput sgr 0)"
echo -e "\e[1;31m" "Error while installing requirements. More details in: $OUT_FILE" "\e[0m"
result=$ERR_PIP
fi
exit $result
pywin32==225;sys_platform == "win32"
cryptography==3.0.0
google-api-python-client==1.7.11
Когда обращаются с проблемой, то обычно прилагают скриншот консоли, где вывелись ошибки. Для облегчения себе работы мы не только храним вывод последнего запуска pip install, но и добавили красок в жизнь, выводя цветом ошибки из лога прямо в консоль. Да здравствует grep!
На первый взгляд может показаться, что нам не нужно виртуальное окружение. Ведь мы и так скачали отдельный бинарник, в отдельную директорию. Даже если есть несколько папок, где развёрнута наша система, то всё равно бинарники разные. Но! У virtualenv есть скрипт activate, который делает так, что можно вызывать python, как будто он в глобальном окружении. Это изолирует выполнение скриптов и упрощает запуск.
Представьте: вам нужно запустить командный файл, из которого запускается python-скрипт, из которого запустится другой python-скрипт. Пример не выдуманный — так выполняются post-build события при сборке приложения. Без virtualenv пришлось бы везде вычислять на лету нужные пути, а с activate везде просто используем python. Точнее vpython — мы добавили свою обёртку, чтобы удобней запускать и из консоли, и из скриптов. В оболочке мы проверяем, находимся ли уже в активированном окружении или нет, запускаемся ли на TeamCity (где своё виртуальное окружение), а заодно подготавливаем окружение.
set CUR_DIR=%~dp0
set "REPO_DIR=%CUR_DIR%\."
rem VIRTUAL_ENV is the variable from activate.bat and is set automatically
rem TEAMCITY - if we are running from agent we need no virtualenv activation
if "%VIRTUAL_ENV%"=="" IF "%TEAMCITY%"=="" (
set RETURN=if_state
goto prepare
:if_state
if %ERRORLEVEL% neq 0 (
echo [31m Error while prepare environment. Run ci\PrepareAll.cmd via command line [0m
exit /b 1
)
call "%REPO_DIR%\.venv\Scripts\activate.bat"
rem special variable to check if venv activated from this script
set VENV_FROM_CURRENT=true
)
rem Run simple python and forward args to it
python %*
SET result=%errorlevel%
if "%VENV_FROM_CURRENT%"=="true" (
call "%REPO_DIR%\.venv\Scripts\deactivate.bat"
set CI_VENV_RUN=
set VENV_FROM_CURRENT=
)
:eof
exit /b %result%
:prepare
setlocal
set RUN_FROM_SCRIPT=true
call "%REPO_DIR%\ci\PrepareEnvironment.cmd" > NUL
endlocal
goto %RETURN%
Танакан, или не надо забывать ставить тесты
Проблему забывчивости для запуска тестов мы решили, но даже один скрипт можно упустить из вида. Поэтому сделали таблетку от забывчивости. Она состоит из двух частей.
Когда наша система запустилась, она модифицирует комментарий к коммиту и ставит метку «одобрено». В качестве метки мы решили не мудрствовать и добавлять [+] или [-] в конце комментария к коммиту.
На сервере крутится скрипт, который парсит сообщения, и если он не находит заветный набор символов — создаёт задачу на автора.Это самое простое и элегантное решение. Непечатаемые символы — не очевидно. Чтобы запускать серверные хуки, нужен другой тарифный план на Гитхабе, а покупать премиум для одной фичи никто не будет. Пробежаться по истории коммитов, поискать символ и поставить задачу — очевидно и не так дорого.
Да, можно поставить символ и своими ручками, но вы уверены, что не сломаете сборку на сервере? А если сломаете… да-да, за вами уже едет лысый из Homescapes.
Каков итог
Отследить количество ошибок, которые нашли хуки, довольно сложно — они не попадают на сервер. Есть только субъективное мнение, что зелёных сборок стало гораздо больше. Однако есть и отрицательная сторона — коммит стал занимать довольно много времени. В некоторых случаях доходит до 10 минут, но это отдельная история про оптимизацию.