Привет, меня зовут Андрей Николаев и я занимаюсь автоматизацией тестирования в hh. Более 2/3 наших десктопных пользователей прямо сейчас используют последнюю версию Google Chrome, поэтому мы хотим, чтобы и в наших E2E-автотестах (Java+Selenium) версия браузера была максимально приближена к пользовательской. Но не всегда апгрейд версии в тестах проходит гладко (то работа с куками поменяется, то remote DevTools по умолчанию оказываются недоступны, то просто наши хитровыдуманные клики начинают кликать не туда, и т.д. и т.п.). Поэтому нельзя просто так взять и поднять версию Chrome в автотестах — нужна предварительная проверка, которая при ручном выполнении требовала множества телодвижений, поэтому в какой-то момент мы решили, что раз работа серверов стоит дешевле работы человека, то пусть они и проверяют.
Сразу оговорюсь, что это не универсальное решение (а я не мастер спорта по python), мы просто хотели подсветить наличие такого подхода, ведь, как говорил один мудрый человек, живший еще во времена с приставкой "до н.э.", порой суета сует не дает поднять головы, чтобы оценить ситуацию в целом и придумать новые способы автоматизации рутины.
Предусловия
Наши тесты — это свой фреймворк поверх Selenium на Java, запускаем их через Jenkins, браузеры берем в selenoid-гриде (именно docker-образы Chrome от aerokube мы и проверяем на совместимость), а браузеры ходят на тестовые стенды через прокси.
Итак, приступим
Для реализации задачи мы создали план в Bamboo, который запускает python-скрипт (лежащий в одном из репозиториев, которые предварительно клонируется при старте плана) каждое утро, пока большинство ресурсов свободно (т.к. никто еще не проснулся и не пользуется ими).
Первым делом проверяем, есть ли новый образ Chrome: если нет, то выходим, иначе вычленяем из тега образа в docker registry номер версии браузера (отфильтровывая тег latest, т.к. для нас это слово бесполезно)
код
def get_latest_tag_for_last_day():
try:
image_tags_url = 'https://registry.hub.docker.com/v2/repositories/selenoid/chrome/tags'
response = requests.get(image_tags_url)
except Exception as e:
logger.error('Failed to retrieve docker image tags: %s', e)
sys.exit(1)
if response.status_code != 200:
logger.error('Failed to retrieve docker image tags: %s', response.content)
sys.exit(1)
try:
datetime_pattern = '%Y-%m-%dT%H:%M:%S.%fZ'
results = [result for result in response.json()['results'] if result['name'] != 'latest']
latest_tag = max(results, key=lambda result: datetime.datetime.strptime(result['last_updated'], datetime_pattern))
last_updated = datetime.datetime.strptime(latest_tag['last_updated'], datetime_pattern)
if datetime.datetime.utcnow() - last_updated > datetime.timedelta(days=1):
logger.info('No new chrome images for last 24 hours.')
sys.exit(0)
latest_version = latest_tag['name'].split('.')[0]
except Exception as e:
logger.error('Failed to parse docker image tags: %s', e)
sys.exit(1)
logger.info('The latest version of chrome image is %s', latest_version)
return latest_version
Также в процессе предварительной подготовки мы получаем из строки запуска:
адрес selenoid-ноды, куда установим свежую версию Chrome. Выбрана она на глаз так, чтобы ее лимиты были чуть выше лимита тредов в запускаемых автотестах. Мы не стали делать оверинжиниринг с перебором конфигов нод и поиском в них минимального значения для лимита браузеров. Получение параметра сделано через стандартный argparse, никакого рокетсаенса.
опционально: версию Chrome для тестирования (на случай, если что-то пойдет не так и нужно будет перетестировать)
номер запуска плана в Bamboo, чтобы потом сослаться на него в оповещении и создать одноименные ветки в репозиториях
из окружения: данные для авторизации в Jenkins (задаем их в Bamboo, чтобы лишний раз не светились в строке запуска) — тоже ничего особенного:
os.environ.get('JENKINS_USER')
Далее мы резервируем тестовый стенд (как начинали создаваться наши стенды в текущем виде, можно почитать здесь), на котором будем запускать автотесты, чтобы никто другой им не пользовался и не влиял на результаты тестирования (это стандартная у нас практика). Делается это через ручку нашего самописного CI, так что полный код тут будет бесполезен (используйте API вашей любимой CI/CD-системы), но выглядит это довольно стандартно:result = requests.post(f'{CI_URL}/assign_stand', json=...)
Если что-то к этому моменту пошло не так, то скрипт просто падает, план в Bamboo краснеет, заинтересованные получают уведомление. Все остальные шаги мы оборачиваем в try-except-finally, чтобы в конце откатить внесенные в инфраструктуру изменения.
Деплой фермы браузеров у нас осуществляется через ansible-плейбуки, внутри которых ничего особенного: установка пакетов, копирование файлов и запуск нужных образов через ansible-модуль docker_container. Для наших целей нужно запустить два плейбука.
Выключаем ноду-жертву из балансировки: балансировку осуществляет аерокубовский же ggr — Go Grid Router — из его конфига мы и удаляем эту ноду целиком вот таким "элегантным" движением руки:
брюки превращаются...
def run_ggr_playbook(host, revert=False):
cmd = 'ansible-playbook ggr.yml -i inventory -t "chrome_image_update" ' \
+ (f'-e "excluded_host={host}"' if not revert else '')
logger.info('Starting ansible playbook: %s', cmd)
subprocess.run(cmd, shell=True, check=True, cwd=os.path.join(os.pardir, 'somedir/playbooks'))
сам шаблон конфига ggr: quota.xml выглядит так
...
<browser name="chrome" defaultVersion="{{ selenoid_default_browser_version }}">
<version number="{{ selenoid_default_browser_version }}">
<region name="grid farm">
{% for selenoidhost in groups['selenoid-user'] %}
{% if selenoidhost != excluded_host|default('') %}
<host name="{{ selenoidhost }}" port="4242" count="{{ hostvars[selenoidhost]['selenoid_browser_limit'] }}"/>
{% endif %}
{% endfor %}
</region>
</version>
</browser>
...
Примерно таким же образом меняем версию образа Chrome на нашей ноде (переменная selenoid_default_browser_version рендерится в конфиг selenoid и в аргумент модуля docker_image в плейбуке)
превращаются брюки...
def run_selenoid_playbook(host, version=None, revert=False):
cmd = f'ansible-playbook selenoid.yml -i inventory -l "{host}" -t "chrome_image_update" ' \
+ (f'-e "selenoid_default_browser_version={version}"' if not revert else '')
logger.info('Starting ansible playbook: %s', cmd)
subprocess.run(cmd, shell=True, check=True, cwd=os.path.join(os.pardir, 'somedir/playbooks'))
vars-файл для ansible
selenoid_chrome_image_version: "{{ selenoid_default_browser_version }}.0"
шаблон конфига selenoid - browsers.json
…
"chrome": {
"default": "{{ selenoid_default_browser_version }}",
"versions": {
"{{ selenoid_default_browser_version }}": {
"image": "selenoid/chrome:{{ selenoid_chrome_image_version }}"
часть плейбука selenoid
- name: pull chrome image
docker_image:
name: selenoid/chrome:{{ selenoid_chrome_image_version }}
source: pull
tags:
- chromedriver_update
Теперь всё готово к запуску тестов в Jenkins, делаем это при помощи пакета jenkinsapi. В случае упавших тестов (как говорится, какой же русский не любит быстрых прогонов с флаки-тестами) даем стенду время "подостыть" и перезапускаем упавшие тесты.
Версию Chrome в автотестах мы получаем из Java system properties и передаем её в browser capabilities/chrome options тестов, поэтому при запуске мы просто оверрайдим её:
код
def run_test_job(user, token, stand, retry=False, params=None, job_pattern='__TESTS'):
stand_name = stand.upper()...
jenkins = Jenkins("https://jenkins_url.org/", username=user, password=token)
job_name = stand_name + (job_pattern if not retry else '__RETRY_1')
job = jenkins[job_name]
logger.info('Starting jenkins job %s', job_name)
queue_item = job.invoke(block=True, build_params=params if params else {})
build = queue_item.get_build()
build_result = build.get_status()
logger.info('Job result is: %s', build_result)
return build_result, build.get_build_url()
...
jenkins_job_params = {
...,
'OPTIONAL_PARAMS': f'-Dselenoid.host=http://{grid_host}:4242 -Dchrome.image.version={chrome_image_version}'
}
jenkins_run_result, jenkins_run_url = run_test_job(jenkins_user, jenkins_token, test_stand, params=jenkins_job_params)
if jenkins_run_result == 'UNSTABLE':
time.sleep(30)
# retry jobs will disappear, so we keep link to original run
run_test_job(jenkins_user, jenkins_token, test_stand, retry=True)
Если полученную ранее версию Chrome удалось и раскатить, и запустить с ней тесты, то, кажется, она валидная, и мы можем для облегчения себе ручной части работы сделать соответствующие коммиты с поднятием версии. Конечно, для более сложных правок можно использовать xml.etree.ElementTree, yamlpath, ruamel.yaml и другие пакеты, но в данном случае это показалось излишним.
код
def set_new_version_in_configs_and_commit(repo_name, branch, file_type, version):
local_repo_path = os.path.join(..., repo_name)
repo = Repo(local_repo_path)
try:
new_branch = repo.create_head(branch)
new_branch.checkout()
if file_type == 'yaml':
local_file_path = 'path/to/ansible/var'
key_to_modify = 'selenoid_default_browser_version'
replacement_string = f'{key_to_modify}: {version}'
elif file_type == 'properties':
local_file_path = 'path/to/java/system.properties'
key_to_modify = 'chrome.image.version'
replacement_string = f'{key_to_modify}={version}'
else:
raise ValueError(f'invalid replacement file type {file_type}')
file_path = os.path.join(local_repo_path, local_file_path)
logger.info('Overriding file: %s', file_path)
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
content = re.sub(f'{key_to_modify}.*', replacement_string, content)
with open(file_path, 'w', encoding='utf-8') as file:
file.write(content)
logger.info('Committing file: %s', file_path)
repo.git.add(local_file_path)
repo.git.commit('-m', f'Update Chrome image for autotests to {version}')
except Exception as exception:
logger.error('Error while creating new configs: %s', exception)
raise exception
finally:
repo.git.checkout('master')
...
set_new_version_in_configs_and_commit('auto-tests-repo', options.branch, 'properties', chrome_image_version)
set_new_version_in_configs_and_commit('deploy-repo', options.branch, 'yaml', chrome_image_version)
Пушим ветки мы через соответствующие шаги в плане Bamboo ввиду некоторых ограничений в правах, но у себя в коде вы можете сделать repo.git.push('origin', branch)
Когда всё готово, и даже если нет, в finally-блоке мы прокатываем плейбуки (с параметром revert=True), освобождаем стенд (также POST-запросом на ручку нашего CI) и отправляем в командный чат сообщение с результатами тестирования, ссылками на созданные ветки в репозиториях и запуск плана в Bamboo.
Заключение
Описанный подход помог сэкономить время, которые мы потратили на более полезные вещи (например, написание этой статьи). Надеюсь, наш опыт будет вам полезен и вдохновит на свежие идеи по автоматизации рутины, буду рад ответить на вопросы в комментариях.