Всем привет!
Меня зовут Алексей Халайджи, и недавно я присоединился к команде Mobile Speed в AliExpress Россия. Мы занимаемся разработкой и поддержкой всей внутренней инфраструктуры наших мобильных приложений: от автоматизации сборки и тестирования до выстраивания и мониторинга процессов разработки.
Конкретно эта статья появилась благодаря одной из первых моих задач – настройки своего окружения и автоматизации этого процесса, чтобы инфраструктура для новых разработчиков и CI
-узлов происходила проще и быстрее. Я расскажу о проблемах, с которыми столкнулся, о том, как их удалось решить, и том, что получилось в итоге. И хоть в названии сказано, что речь идёт об iOS
-проектах, технологии, о которых я расскажу (а это ansible
, Hashicorp Vault
, *env
и др.), применимы и в веб-разработке.
Какие проблемы мы решали
В идеальном мире разработчик должен получить ноутбук, клонировать себе репозиторий и запустить волшебный скрипт настройки, который подготовит все инструменты за пару часов — а за это время как раз можно познакомиться с коллегами и просмотреть документацию. Когда несколько месяцев назад я пришёл в компанию, настройка необходимого для работы окружения могла занимать от нескольких часов (в лучшем случае) до нескольких дней. Сам процесс заключался в последовательном выполнении команд из confluence или README
-файла. Конкретно в моём случае, проблема усугубилась тем, что мне как новичку достался MacBook
с Apple M1
чипом, и оказалось, что многие из используемых библиотек и инструментов попросту не работают на нём так, как это было на Intel
-процессорах, или должны устанавливаться по-другому. Поэтому, решая возникающие проблемы, мы решили актуализировать инструкцию для первоначальной настройки оборудования под наш проект и максимально автоматизировать процесс.
С другой стороны, одна из наших задач — управление CI
-инфраструктурой: настройка новых узлов, их обновление, установка актуальных версий среды разработки (в нашем случае, это Xcode
) и т.д. Когда машин не так много (или используется решение, предоставляющее готовое предварительно настроенное окружение – например, Gitlab
предоставляет возможность использования SaaS macOS-раннеров), их настройка не занимает много времени, поэтому не имеет смысла полностью автоматизировать этот процесс. Однако, когда CI
-кластер обновляется регулярно, причём в некоторых случаях количество новых узлов может достигать десятков (и это ещё не включая возможные проблемы с крахом системы на старом оборудовании или, например, при обновлении, из-за чего часть рутинных операций необходимо повторять заново) – настройка и поддержание оборудования в актуальном состоянии могут занимать немало времени. В том числе, поэтому в крупных компаниях часто не спешат CI
переводить на новую версию Xcode
или MacOS
.
Проблема усугубляется тем, что многие разработчики участвуют в бета-программе Apple
и имеют намного более актуальные версии используемых инструментов, чтобы проверить работоспособность своих приложений на новых версиях SDK
и iOS
, но не могут пользоваться новыми возможностями из-за отсутствия их поддержки на CI
.
Решение
Решением может стать автоматизация развёртывания инфраструктуры и её поддержки в актуальном состоянии. Для этого необходимо в первую очередь сформулировать перечень требований и инструментов, которые необходимо настроить, определить их зависимости (например, для установки Python
может понадобиться brew
, а при установке через brew
может понадобиться git
, для работы которого на машине должны быть установлены Xcode Command-Line Tools
). Но на этом пути есть довольно много подводных камней.
Например, в мире iOS
-разработки часто используется Ruby
(самыми известными примерами его использования можно привести работу с CocoaPods и Fastlane). Обычно наMacOS
уже установлена версия Ruby
, которая называется системной, однако в этом и её основной недостаток. Первая проблема заключается в том, что многие операции по установкеRuby
-библиотек (они называются гемами
) необходимо выполнять через sudo
. Но — что ещё более неудобно – сложно «откатиться» или настроить локальное окружение, которое на уровне проекта может отличаться от системного, например, версией Ruby
или отдельных библиотек. Аналогичная ситуация, например, с Python
.
С другой стороны, автоматизация окружения – это не только про установку утилит на уровне операционной системы. Многие вещи настраиваются на уровне конкретного проекта. Например, для автоматизации контроля истории git
-коммитов или проверки нового исходного кода часто используются git
-хуки. Основная проблема их использования в том, что запускаемые хуки располагаются в директории .git/hooks
, которая у каждого разработчика своя и не синхронизируется через репозиторий. Обычно хуки добавляются в репозиторий в отдельную папку hooks
проекта, и сложность в синхронизации этой папки с директорией .git/hooks
. Может показаться, что это действие однократное (что не отменяет необходимости его автоматизации), однако по мере развития проекта могут добавляться новые хуки, и их также необходимо добавлять в .git
. По моему опыту, без автоматизации рано или поздно найдётся как минимум один человек в команде, у которого git
-хуки не настроены или неактуальны.
Наконец, важный фактор, который нужно учитывать при автоматизации — контроль совместимости инфраструктуры на CI
и машинах разработчиков. В идеале, она должна полностью совпадать или автоматически обновляться до общего совместимого состояния при сборке новых изменений.
Итого, вот какие проблемы мы решали:
ручной онбординг (процесс введения нового разработчика в команду) с использованием устаревших инструкций на
confluence
/README
-файлов в проекте;ручная настройка
CI
-узлов без обновления их инфраструктуры месяцами/годами;отсутствие автоматической поддержки актуальности
git
-хуков;отсутствие контроля совместимости инфраструктуры на
CI
и машинах разработчиков;работа с системными библиотеками через sudo (например, гемами
Ruby
);сложность внедрения новых технологий на
CI
(новая версияXcode
/библиотек и т.п.).
В то же время, нам хотелось иметь единую точку входа для автоматизации онбординга, включая настройку IDE
и окружения; начальной настройки CI
-узлов; обновления локальной инфраструктуры, включая обновление git
-хуков и IDE
, а также установку новых версий библиотек.
Технологии и подходы
Прежде всего, поскольку речь идёт о настройке инфраструктуры (а это установка системных пакетов, библиотек, сред разработки и мн. др.), что подразумевает большое количество выполняемых инструкций, следует использовать подход Infrastructure as a code
. Его основная идея заключается в формализации всего процесса настройки инфраструктуры в виде отдельных программ и конфигурационных файлов с их хранением в системе контроля версий. Любые изменения инфраструктуры должны осуществляться через модификацию установочного кода и/или конфигурационных файлов, чтобы с их помощью можно было воспроизвести состояние системы на другой идентичной машине.
При реализации подхода Infrastructure as a code
стоит использовать уже существующие инструменты, позволяющие упростить написание программ. В последнее время, всё чаще можно слышать о puppet, terraform, однако мы выбрали более простую, но эффективную технологию ansible. Основным преимуществом последнего является простота его развёртки – ansible
достаточно установить только на основную машину, а всё управление может вестись с управляющего узла, на котором располагаются ansible
скрипты. Далее в статье будет более подробно описано, как можно быстро начать работу сansible
.
Для решения проблемы использования системных версий языков программирования и др. инструментов на практике часто применяются менеджеры версий (rbenv для Ruby
и ему подобные – например, pyenv для Python
или jenv для Java
). Наконец, ещё один инструмент, который мы использовали — vault. Это инструмент для хранения секретной информации (пароли, access
-токены и т.п). Использование подобных инструментов позволяет избавиться от жёсткого хранения всей чувствительной информации в конфигурационных файлах или напрямую в коде.
Коротко об ansible
Ansible
– это инструмент для управления и конфигурации узлов. Для подключения к удалённым узлам обычно используется SSH
. Написан инструмент на Python и использует преимущественно формат YAML
для конфигурационных файлов и управляющих скриптов.
Для установки ansible
достаточно выполнить команду: $ pip3 install ansible
В ansible
используется множество понятий (про них можно почитать в официальной документации). Для понимания статьи и быстрого старта достаточно следующих понятий:
controller
– управляющая машина, запускающаяansible
-скрипты;host
– удалённый настраиваемый узел (не требует установкиansible
);inventory
– список узлов и их групп в форматеINI
/YAML
/…;task
– именованное действие, базовая единица работыansible
;playbook
– последовательностьansible
-задач/обработчиков/…;role
– структурированная коллекцияansible
-сущностей.
Большая часть работы с ansible ведётся через запуск команд из терминала. Общие опции используемых команд можно разместить в конфигурационном файле ansible.cfg в директории проекта, из которой запускаются ansible
-скрипты. Здесь можно сохранить путь до inventory-файла, конфигурации SSH
для подключения к узлам и аналогичной информации. Ниже пример конфигурационного файла:
[defaults]
inventory = ansible/inventory
[ssh_connection]
pipelining = True
ssh_args = -F ansible/ssh.cfg
В примере выше pipelining – это опция для замены множества SSH
соединений к удалённому узлу (для определения домашней директории, создания временной директории, отправки Python-файла со всеми инструкциями и его запуска) единственным SSH подключением с перенаправлением команд напрямую интерпретатору Python.
Чтобы сообщить ansible информацию о настраиваемых узлах, необходимо их указать в inventory-файле. Этот файл включает информацию об узлах/группах, параметрах подключения (имя пользователя, пароль и т.п.) и др.. Всю эту информацию использует ansible при своей работе, а также может использовать разработчик напрямую при написании собственных ansible-скриптов (если есть необходимость задать специфичные параметры для некоторых из хостов). Например:
[controller] # название группы
localhost ansible_connection=local # информация об узле
[runners]
m1-runner
intel-runner
test-intel ansible_host=1.2.3.4 ansible_user=testuser ansible_port=2222 ansible_become_password=P@ssw0rd
Информацию о соединении удобнее размещать в конфигурационном файле SSH
:
Host test-intel
HostName 1.2.3.4
User test_user
Port 2222
После указания информации об узлах, можно запустить первую команду через ansible
:
$ ansible test-intel,controller -m ping
При подключении будет установлено SSH
-соединение, и может понадобиться добавить информацию об узле в known_hosts
на управляющей машине, а для возможности подключаться без пароля к узлам – добавить свой публичный SSH-ключ в ~/.ssh/authorized_keys
на удалённой машине (или просто воспользоваться командой ssh-copy-id
).
Иногда нет необходимости автоматизировать сложный процесс через написание отдельного скрипта, и нужно только выполнение простой команды на удалённых узлах. Например, выполнение параллельного git pull
на кластере или получение информации с узлов (публичные SSH
-ключи и т.д.). Для этого стоит использовать ad hoc ansible-команды. Общий синтаксис таких команд следующий:
$ ansible [pattern] -m [module] -a "[module arguments]"
,
где pattern
– подмножество узлов/групп из inventory
-файла,
module
– название используемого ansible
-модуля.
Примеры: $ ansible all -m shell -a "cd ~/my_service && git pull"
$ ansible runners -m command -a "cat ~/.ssh/id_rsa.pub"
Независимо от сложности ansible
-скрипта или ad hoc
-команды, в любом случае нужно использовать стандартные ansible
-модули в качестве базовой инфраструктуры для интерпретации команд – как минимум, модули ansible.builtin. Ниже перечислены часто используемые модули:
command – выполняет простую команду (не обрабатывает
ENV
-переменные, перенаправления потоков, конвейеры и пр.);copy – копирует файл с управляющего или удалённого узла на удалённый узел по указанному пути;
debug – печатает отладочные сообщения и стандартные или определённые пользователем переменные;
env – читает значение
ENV
-переменной на управляющей машине (пример:lookup("env", "VAR_NAME")
);file – гарантирует указанное состояние файла (например, существование определённых директорий или ссылок);
get_url – скачивает содержимое по
URL
в файл на удалённом узле;ping – пытается подсоединиться к удалённому хосту, возвращает
pong
в случае успеха;set_fact – устанавливает значение переменной, которое может использовать результаты уже выполненных задач;
shell – более гибкий аналог модуля
command
, запускает скрипт через исполняемую среду (по умолчанию,/bin/sh
);stat – получает информацию о системной сущности, такую как существование файла/директории, их размер и т.п.
После завершения работы ansible playbook
показывается краткое резюме с общей статистикой по статусам выполненных задач по каждому узлу. Выглядит это так:
PLAY RECAP
*************************************************************************
localhost : ok=57 changed=2 unreachable=0 failed=0 skipped=8 rescued=0 ignored=0
Статусы задач могут быть следующими:
ok
– задача была запущена и успешно завершена;changed
– задача была запущена и успешна завершена (ok
), и были зарегистрированы некоторые изменения;unreachable
– не удалось подключиться к узлу (например, если узел выключился/пропало сетевое соединение);failed
– задача выполнилась и завершилась с ошибкой;skipped
– задача была пропущена поwhen
условию;rescued
– произошла ошибка, но она была поймана и не привела к падению всегоplaybook
(rescue
секция блока задач);ignored
– произошла ошибка, но она была проигнорирована (опцияignore_errors
).
Правильно построенный ansible playbook
должен обладать свойством идемпотентности
. Под этим понимается, в частности, то, что повторный запуск ansible playbook
на только что сконфигурированной машине не должен приводить к каким-либо изменениям. Для этого имеет смысл управлять вручную условиями changed_when
, чтобы безобидные операции (как вывод чего-то в консоль с целью проверки значения ENV
-переменной) не приводили к регистрации изменений. В том числе, в некоторых случаях уместно явно отключить регистрацию изменений для какой-то задачи (changed_when: False
), если они регистрируются централизованно в одной из следующих задач.
По аналогии с управлением регистрацией изменений, можно контролировать регистрацию падения команды. Типовым примером, когда имеет смысл явно выставлять флаг failed_when
, можно привести использование фильтрации с помощью команды grep. Так, в некоторых случаях необходимо выполнить операцию, если в выводе команды отсутствует определённый паттерн (например, настроить pyenv
-окружение, если используется версия Python
не из ~/.pyenv
). Ниже пример того, как можно выполнить подобную проверку с ключом failed_when
:
- name: Example
command: "{{ shell_executable }} -lc \"which python3 | grep '.pyenv'\""
register: python_env_check
changed_when: False
failed_when: False
Ниже пример простого ansible playbook
, в котором выполняется проверка существования файла .zprofile
в домашней директории пользователя, под которым подключается ansible
-клиент с помощью одного из стандартных модулей ansible.builtin.stat:
$ cat ansible/playbooks/example.yml
---
- name: Example
hosts: all
tasks:
- name: Check profile exists
stat:
path: "${HOME}/.zprofile"
register: profile_check
$ ansible-playbook -v # подробный вывод результатов команд
-–limit localhost, # висячая запятая в конце нужна!
ansible/playbooks/example.yml
Using /Users/test_user/test_project/ansible.cfg as config file
PLAY [Example]
*************************************************************************
TASK [Gathering Facts]
*************************************************************************
ok: [localhost]
TASK [Check profile exists]
*************************************************************************
ok: [localhost] => {"changed": false, "stat": {"atime": 1644410438.1459398,
"attr_flags": "", "attributes": [], "birthtime": 1644410402.6912248,
"block_size": 4096, "blocks": 8, "charset": "us ascii",
"checksum": "05812059c6c4b3c16332ffe9034370f0291230f2",
"ctime": 1644410438.1456242, "dev": 16777233, "device_type": 0,
"executable": false, "exists": true, "flags": 0, "generation": 0,
"gid": 20, "gr_name": "staff", "inode": 51993012, "isblk": false,
"ischr": false, "isdir": false, "isfifo": false, "isgid": false,
"islnk": false, "isreg": true, "issock": false, "isuid": false,
"mimetype": "text/plain", "mode": "0644", "mtime": 1644410438.1456242,
"nlink": 1, "path": "/Users/test_user/.zprofile", "pw_name": "test_user",
"readable": true, "rgrp": true, "roth": true, "rusr": true, "size": 478,
"uid": 502, "version": null, "wgrp": false, "woth": false, "writeable": true,
"wusr": true, "xgrp": false, "xoth": false, "xusr": false}}
PLAY RECAP
*************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
После выполнения задачи Check profile exists
в рассмотренном примере результат сохраняется в переменную profile_check
с помощью ключа register
. Для уменьшения числа лишних копирований стоит использовать переменные. С помощью модуля set_fact
можно задавать новые, а также менять значения уже определённых ранее переменных. Для получения значения переменной используется следующий синтаксис: {{ VAR_NAME }}
.
Ниже пример, в котором сначала определяется в блоке vars
переменная shell_executable
, затем проверяется с помощью модуля command
, выставлена ли ENV
-переменная ${CI}
, и в зависимости от этого определяется и инициализируется переменная is_ci
(поскольку модуль command
не чувствителен к ENV
, запуск команды выполняется явно через zsh shell
). Наконец, показано, как можно объединять несколько команд в единый блок, для которого можно указать условие выполнения через ключ when
.
---
- name: Variables example
hosts: all
vars:
- shell_executable: '/bin/zsh'
tasks:
- name: Several tasks in one block example
# when: (block can have when clause)
block:
- name: Check is on CI
command: "{{ shell_executable }} -lc 'echo ${CI}'"
register: is_ci_check
changed_when: False
- name: Set CI flag
set_fact:
is_ci: "{{ is_ci_check.stdout != '' }}"
Запуск такого playbook приводит к следующему результату:
Using /Users/test_user/project/ansible.cfg as config file
PLAY [Variables example]
*************************************************************************
TASK [Gathering Facts]
*************************************************************************
ok: [localhost]
TASK [Check is on CI]
*************************************************************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/zsh", "-lc",
"echo ${CI}"], "delta": "0:00:00.426706", "end": "2022-02-09 21:31:09.454311",
"msg": "", "rc": 0, "start": "2022-02-09 21:31:09.027605", "stderr": "",
"stderr_lines": [], "stdout": "", "stdout_lines": []}
TASK [Set CI flag]
*************************************************************************
ok: [localhost] => {"ansible_facts": {"is_ci": false}, "changed": false}
PLAY RECAP
*************************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Иногда нужно выполнить задачу, только если зарегистрировано определённое изменение (например, перезапустить сервис при изменении его конфигурационных файлов). Такое поведение можно реализовать так:
пусть есть некоторая (нотифицирующая) задача, для которой может быть зарегистрировано изменение;
можно сохранить результат работы этой задачи через ключ
register
в переменнуюnotify_task_result
;в ожидающей задаче (обработчике) можно поместить следующее условие запуска:
when: notify_task_result.changed
.
Это и есть основной сценарий, где на первый план выходят ansible handlers. По умолчанию, они запускаются после завершения всех задач. Ниже представлен пример обновления git
-хуков, в котором при регистрации изменений добавляется уведомление пользователю о том, что некоторые из git
-хуков были обновлены, по окончании работыansible playbook
:
---
- name: Git hooks update with notification example
hosts: all
vars:
- resources_location: "../resources"
- hooks: "hooks"
- hooks_location: "{{ resources_location }}/{{ hooks }}"
- git_hooks: "git-hooks"
- git_hooks_location: "{{ resources_location }}/{{ git_hooks }}"
- shell_executable: '/bin/zsh'
tasks:
- name: Prepare environment
block:
- name: Check is on CI
command: "{{ shell_executable }} -lc 'echo ${CI}'"
register: is_ci_check
changed_when: False
- name: Set CI flag
set_fact:
is_ci: "{{ is_ci_check.stdout != '' }}"
- name: Git setup
when: not is_ci and inventory_hostname == 'localhost'
block:
- name: Get current timestamp
command: date "+%Y%m%d-%H%M%S"
register: timestamp_command
changed_when: False
- name: Set hooks backup directory name
set_fact:
git_hooks_backup: "hooks.bak.{{ timestamp_command.stdout }}"
- name: Set hooks backup directory location
set_fact:
git_hooks_backup_location: "{{ git_hooks_location }}/../{{ git_hooks_backup }}"
- name: Check hooks directory exists
stat:
path: "{{ hooks_location }}"
follow: true
register: hooks_exist_check
- name: Update Git hooks
when: hooks_exist_check.stat.exists
block:
- name: Check git-hooks dir existence
stat:
path: "{{ git_hooks_location }}"
follow: true
register: existing_git_hooks_check
- name: Create git-hooks dir
file: path={{ git_hooks_location }} state=directory mode=0755
when: not existing_git_hooks_check.stat.exists
- name: Backup git hooks
copy:
src: "{{ git_hooks_location }}/"
remote_src: true
local_follow: true
dest: "{{ git_hooks_backup_location }}/"
follow: true
mode: '0755'
changed_when: False
when: existing_git_hooks_check.stat.exists
- name: Update git hooks
file: src="{{ item }}"
dest="{{ git_hooks_location }}/{{ item | basename }}"
state=link force=true
with_fileglob:
- "{{ hooks_location }}/*"
register: git_update_hooks
notify:
- On git hooks updated
- name: Remove git hooks backup
file:
path: "{{ git_hooks_backup_location }}"
state: absent
changed_when: False
when: existing_git_hooks_check.stat.exists and not(git_update_hooks.changed)
handlers:
- name: On git hooks updated
debug:
msg: "Git hooks were updated. Old hooks are saved at .git/{{ git_hooks_backup }}"
changed_when: True
when: existing_git_hooks_check.stat.exists
Результат работы такого ansible playbook выглядит так:
Using /Users/test_user/project/ansible.cfg as config file
PLAY [Git hooks update with notification example]
*************************************************************************
TASK [Gathering Facts]
*************************************************************************
ok: [localhost]
TASK [Check is on CI]
*************************************************************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/zsh", "-lc",
"echo ${CI}"], "delta": "0:00:00.700564", "end": "2022-02-21 20:45:00.528926",
"msg": "", "rc": 0, "start": "2022-02-21 20:44:59.828362", "stderr": "",
"stderr_lines": [], "stdout": "", "stdout_lines": []}
TASK [Set CI flag]
*************************************************************************
ok: [localhost] => {"ansible_facts": {"is_ci": false}, "changed": false}
TASK [Get current timestamp]
*************************************************************************
ok: [localhost] => {"changed": false, "cmd": ["date", "+%Y%m%d-%H%M%S"],
"delta": "0:00:00.005179", "end": "2022-02-21 20:45:00.719959", "msg": "",
"rc": 0, "start": "2022-02-21 20:45:00.714780", "stderr": "",
"stderr_lines": [], "stdout": "20220221-204500", "stdout_lines": ["20220221-204500"]}
TASK [Set hooks backup directory name]
*************************************************************************
ok: [localhost] => {"ansible_facts": {"git_hooks_backup":
"hooks.bak.20220221-204500"}, "changed": false}
TASK [Set hooks backup directory location]
*************************************************************************
ok: [localhost] => {"ansible_facts": {"git_hooks_backup_location":
"../resources/git-hooks/../hooks.bak.20220221-204500"}, "changed": false}
TASK [Check hooks directory exists]
*************************************************************************
ok: [localhost] => {"changed": false, "stat": {"atime": 1644444198.4608057,
"attr_flags": "", "attributes": [], "birthtime": 1644443485.9654312,
"block_size": 4096, "blocks": 0, "charset": "binary",
"ctime": 1644444198.3459952, "dev": 16777230, "device_type": 0,
"executable": true, "exists": true, "flags": 0, "generation": 0,
"gid": 20, "gr_name": "staff", "inode": 52221014, "isblk": false,
"ischr": false, "isdir": true, "isfifo": false, "isgid": false,
"islnk": false, "isreg": false, "issock": false, "isuid": false,
"mimetype": "inode/directory", "mode": "0755", "mtime": 1644444198.3459952,
"nlink": 5, "path": "../resources/hooks", "pw_name": "test_user",
"readable": true, "rgrp": true, "roth": true, "rusr": true, "size": 160,
"uid": 502, "version": null, "wgrp": false, "woth": false, "writeable": true,
"wusr": true, "xgrp": true, "xoth": true, "xusr": true}}
TASK [Check git-hooks dir existence]
*************************************************************************
ok: [localhost] => {"changed": false, "stat": {"atime": 1641831932.4657955,
"attr_flags": "", "attributes": [], "birthtime": 1641831855.6257997,
"block_size": 4096, "blocks": 0, "charset": "binary",
"ctime": 1645465491.4455118, "dev": 16777230, "device_type": 0,
"executable": true, "exists": true, "flags": 0, "generation": 0,
"gid": 20, "gr_name": "staff", "inode": 59268589, "isblk": false,
"ischr": false, "isdir": true, "isfifo": false, "isgid": false,
"islnk": false, "isreg": false, "issock": false, "isuid": false,
"mimetype": "inode/directory", "mode": "0755", "mtime": 1645465491.4455118,
"nlink": 16, "path": "../resources/git-hooks", "pw_name": "test_user",
"readable": true, "rgrp": true, "roth": true, "rusr": true, "size": 512,
"uid": 502, "version": null, "wgrp": false, "woth": false, "writeable": true,
"wusr": true, "xgrp": true, "xoth": true, "xusr": true}}
TASK [Create git-hooks dir]
*************************************************************************
skipping: [localhost] => {"changed": false, "skip_reason":
"Conditional result was False"}
TASK [Backup git hooks]
*************************************************************************
ok: [localhost] => {"changed": false, "checksum": null, "dest":
"../resources/git-hooks/../hooks.bak.20220221-204500/", "gid": 20,
"group": "staff", "md5sum": null, "mode": "0755", "owner": "test_user",
"size": 512, "src": "../resources/git-hooks/", "state": "directory", "uid": 502}
TASK [Update git hooks]
*************************************************************************
changed: [localhost] =>
(item=/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit-swiftlint) =>
{"ansible_loop_var": "item", "changed": true,
"dest": "../resources/git-hooks/pre-commit-swiftlint", "gid": 20,
"group": "staff",
"item": "/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit-swiftlint",
"mode": "0755", "owner": "test_user", "size": 115,
"src": "/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit-swiftlint",
"state": "link", "uid": 502}
changed: [localhost] =>
(item=/Users/test_user/project/ansible/playbooks/../resources/hooks/prepare-commit-msg) =>
{"ansible_loop_var": "item", "changed": true,
"dest": "../resources/git-hooks/prepare-commit-msg", "gid": 20, "group": "staff",
"item": "/Users/test_user/project/ansible/playbooks/../resources/hooks/prepare-commit-msg",
"mode": "0755", "owner": "test_user", "size": 113,
"src": "/Users/test_user/project/ansible/playbooks/../resources/hooks/prepare-commit-msg",
"state": "link", "uid": 502}
changed: [localhost] =>
(item=/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit) =>
{"ansible_loop_var": "item", "changed": true,
"dest": "../resources/git-hooks/pre-commit", "gid": 20, "group": "staff",
"item": "/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit",
"mode": "0755", "owner": "test_user", "size": 105,
"src": "/Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit",
"state": "link", "uid": 502}
TASK [Remove git hooks backup]
*************************************************************************
skipping: [localhost] => {"changed": false,
"skip_reason": "Conditional result was False"}
RUNNING HANDLER [On git hooks updated]
*************************************************************************
changed: [localhost] => {
"msg": "Git hooks were updated. Old hooks are saved at .git/hooks.bak.20220221-204500"
}
PLAY RECAP
*************************************************************************
localhost : ok=11 changed=2 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
Помимо зарегистрированного обработчика события об изменении хуков, в этом примере интересно использование конструкция with_fileglob
– одной из циклических конструкций, доступных в ansible
. В подобных конструкциях для обращения к элементу на каждой итерации используется ключевое слово item
, над которым можно выполнять различные преобразования (например, в примере выше выполняется получение базового имени файла из списке всех сущностей внутри директории hooks
).
Другая его особенно — структура файлов проекта, которая позволяет обособить логику ansible
-скрипта от их реального расположения в системе. Для этого выделена директория resources, в которой все используемые файлы задаются символическими ссылками на необходимые директории и файлы проекта, а внутри ansible
используются файлы только из директории ресурсов. Для этого примера файловая структура проекта выглядит так:
project/
├─.git/
| ├─hooks/
| | ├─pre-commit –> /Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit
| | ├─pre-commit-swiftlint –> /Users/test_user/project/ansible/playbooks/../resources/hooks/pre-commit-swiftlint
| | └─prepare-commit-msg –> /Users/test_user/project/ansible/playbooks/../resources/hooks/prepare-commit-msg
| └─hooks.bak.20220221-204500/
| ├─applypatch-msg.sample
| ├─commit-msg.sample
| ├─fsmonitor-watchman.sample
| ├─post-update.sample
| ├─pre-applypatch.sample
| ├─pre-commit
| ├─pre-commit.sample
| ├─pre-merge-commit.sample
| ├─pre-push.sample
| ├─pre-rebase.sample
| ├─pre-receive.sample
| ├─prepare-commit-msg.sample
| ├─push-to-checkout.sample
| └─update.sample
├─ansible/
| ├─inventory
| ├─playbooks/
| | └─example.yml
| ├─resources/
| | ├─git-hooks –> ../../.git/hooks
| | └─hooks –> ../../hooks
| └─ssh.cfg
├─ansible.cfg
└─hooks/
├─pre-commit
├─pre-commit-swiftlint
└─prepare-commit-msg
Среди разработчиков ansible
-скрипты распространяются в виде ansible
ролей, задающих типовую структуру проекта, через систему ansible-galaxy. Например, для установки ansible
-роли установки Xcode Command-Line Tools используется команда:
$ ansible-galaxy install elliotweiser.osx-command-line-tools
Для подключения ansible
-роли в ansible playbook
, достаточно добавить секцию:
roles:
- { role: elliotweiser.osx-command-line-tools }
Типовая структура роли выглядит так:
role/
├─tasks/ – коллекция задач/плейбуков;
├─handlers/ – обработчики, которые можно использовать как внутри, так и вне роли;
├─library/ – модули, которые могут использоваться внутри роли;
├─files/ – файлы, которые роль отправляет на удалённые ресурсы;
├─templates/ – шаблоны отправляемых файлов (содержат {{ плейсхолдеры }});
├─defaults/ – значения по умолчанию для переменных роли (наименьший приоритет);
├─vars/ – значения других переменных роли (перекрывают по приоритету defaults);
└─meta/ – метаинформация о роли, включая список зависимостей.
Как мы организовали развёртывание инфраструктуры в iOS-проекте
Вот как сейчас выглядит интерфейс для разработчиков, позволяющий автоматизировать основные сценарии развёртки локальной и удалённой инфраструктуры:
онбординг – открывает страницу с документацией для онбординга (по процессам в команде/компании и пр.) и запускает скрипт настройки локальной инфраструктуры:
$ ./Scripts/onboarding.sh && source ~/.zprofile
настройка локальной инфраструктуры – проверяет установку всех необходимых компонентов, устанавливает и конфигурирует их при отсутствии. Используется как для первичной настройки уже как-то ранее настроенных машин, а также для изменения инфраструктуры при обнаружении обновления конфигурационных файлов (например, после массового переезда на новую версию
Xcode
):$ ./Scripts/setup_local_environment.sh && source ~/.zprofile
обновление локальной инфраструктуры – отличается от предыдущего тем, что помимо установки компонентов проверяет наличие обновлений и устанавливает их. Вынесен в отдельный скрипт, чтобы не происходило ситуации, при которой локально всё работает, а при отправке на
CI
всё сломалось из-за того, что какой-то пакет не смог обновиться/в новой версии пакета поведение поменялось:$ ./Scripts/update_local_environment.sh && source ~/.zprofile
настройка удалённой инфраструктуры – используется для параллельного запуска скрипта настройки локальной инфраструктуры для удалённых узлов (например, новых CI-узлов):
$ ./Scripts/setup_remote_environment.sh # на всём кластере
$ ./Scripts/setup_remote_environment.sh runners # идентично предыдущей
$ ./Scripts/setup_remote_environment.sh m1,new # указаны группы/узлы
Все эти примеры команд вызова этих интерфейсных скриптов приведены в README.md
-файле проекта, поэтому разработчик сразу может запустить настройку окружения после клонирования репозитория. Каждый из этих скриптов запускает 2 вспомогательных скрипта:
# устанавливает всё необходимое для запуска ansible и внешних ролей
$ source ./Scripts/prepare_local_environment.sh
# основной скрипт для развёртки инфраструктуры
$ ansible-playbook ansible/playbooks/setup.yml
Унифицированный интерфейс реализован благодаря инкапсуляции различий по логике настройки в зависимости от того, выполняется ли скрипт на CI
, и выполняется ли настройка локального или удалённого узла. Реализация подобного условного поведения была показана выше в примере с обновлением git
-хуков.
Итоговое состояние, устанавливаемое инфраструктурными скриптами, задаётся несколькими конфигурационными файлами. Таким образом, типовые действия в жизни iOS
разработчика (как, например, обновление версии Xcode
) вырождаются в изменение номера версии в одном из таких файлов и запуск скрипта настройки локальной инфраструктуры, который автоматически обнаружит те компоненты, версии которых отличаются от указанных в конфигурационных файлах, и установит недостающие. Такие конфигурационные файлы:
.java-version
– версияjava
, которая устанавливается в виде пакетаAdoptOpenJDK
(1.8 или 9+) через brew cask или обычного пакетаbrew
(последняя версияjava
, на момент написания статьи – 17), и выбирается с помощью менеджера версий jenv;.python-version
– версияPython
, которая устанавливается и выбирается через менеджер версий pyenv;.ruby-version
– версияRuby
, которая устанавливается и выбирается через менеджер версий rbenv;.xcode-version
– версияXcode
, установщик которой (xip
-файл) при необходимости скачивается с внутреннего S3-хранилища;Brewfile –
brew
зависимости (такие какjava
, cookiecutter для быстрого и удобного создания новых модулейiOS
-проекта илиpyenv
/rbenv
/jenv
);Brewfile.lock.json
– последние успешные версииbrew
пакетов. В отличие от известного многимGemfile.lock
(по аналогии с которым и разработаны механизмы указания версий черезBrewfile
), не фиксирует версии установленных пакетов, поэтому они могут обновиться в процессе установки новых пакетов;Gemfile – список
Ruby
гемов (внешних библиотек) с возможностью указания версий;Gemfile.lock
– список зафиксированных версий всех установленных пакетов (с учётом разрешения зависимостей между пакетами). Позволяет сохранять идентичные версии гемов при установке на разных машинах в течение времени;requirements.txt – версии
Python
-пакетов, включая версиюansible
. Да, несмотря на то, что для конфигурации удалённых машинansible
не требуется, он также устанавливается на них для возможности запуска скрипта настройки локальной инфраструктуры перед сборкой приложения наCI
;hooks –
git
-хуки, на которые автоматически создаются символические ссылки (для старой версии хуков делается резервная копия в директории.git
).
На все эти файлы (и некоторые другие) созданы символические ссылки из директории ansible
/resources
(по аналогии с тем, как было показано в примере с обновлением git
хуков), поэтому ansible
-скрипты не зависят от реального расположения этих файлов в дереве проекта, но используют актуальные версии конфигурационных файлов. Перед сборкой на CI
(мы используем Gitlab CI) в качестве before_script
выполняется скрипт настройки локальной инфраструктуры.
Из интересного – мы обнаружили, что Gitlab CI
иногда падает при маскировании вывода информации о Python
-пакетах и другой информации, которую выводит ansible
в процессе своей работы, поэтому весь вывод ansible
-скриптов перенаправляется в отдельный лог-файл, прикладываемый в качестве артефактов Gitlab CI job
.
Сама конфигурация Gitlab CI
осуществляется через .gitlab-ci.yml
файл, располагаемый в корне проекта. Чтобы не дублировать логику запуска скрипта настройки, можно воспользоваться возможностью расширения уже описанных конфигураций. Поэтому наш .gitlab-ci.yml
-файл организован так:
.Generic ios:
tags:
- ios
before_script:
- ./Scripts/setup_local_environment.sh >setup.log 2>&1
artifacts:
when: always
paths:
- ./*.log
Build for Test:
extends:
- .Generic ios
# дальнейшее описание job
Если при локальной настройке (или обновлении) на машине разработчика версии зависимостей обновятся (например, пакетов brew
), то соответствующие изменения применятся к исходным конфигурационным файлам автоматически, и через git diff
можно будет их отследить. Таким образом, довольно удобно централизованно контролировать изменения в инфраструктуре через запуск скрипта обновления.
Помимо установки разных утилит, скрипт настройки также конфигурирует доступ к секретной информации, включая пароль для подписи приложений через Fastlane
или токены для доступа к S3
-хранилищу. В нашей инфраструктуре подобная информация распространяется двумя путями:
из хранилища Hashicorp Vault, администрируемого на уровне компании;
(актуально только при выполнении на
CI
) изENV
-переменныхGitlab CI
.
У нас в компании для всех разработчиков есть портал, позволяющий каждой команде в пару кликов заводить сервисы по шаблону или выделять ресурсы в виде хранилищ S3
, Vault
и др. Для получения доступа к ним используется единая учётная запись сотрудника, что позволяет использовать OpenID Connect для подключения внешними утилитами через автоматическую авторизацию в браузере. Таким образом, разработчику достаточно связать свою основную рабочую учётную запись с записью на портале разработчиков, запросить права доступа к определённой команде или сервису у тимлида, после чего такие утилиты, как vault, смогут проходить авторизацию и получать секретную информацию.
Преимущества такой схемы: отсутствие дополнительных учётных записей, возможность защищённого доступа к секретной информации под внутренней авторизацией, а также отсутствие необходимости разработчику что-либо дополнительно настраивать перед запуском скриптов развёртки инфраструктуры – они автоматически выполнят авторизацию через открытие страницы в браузере. Более того, консольная утилита vault
удобна тем, что она полностью автономно управляет токенами, сохраняя их на устройстве и пересоздавая их автоматически, проходя повторную процедуру авторизации.
В общем случае, можно было бы использовать Vault
-хранилище и на CI
, если использовать сервисные учётные записи, однако в целях упрощения на текущем этапе развития инфраструктуры сделано так, как описано выше. У текущего решения в качестве достоинств можно выделить возможность разделять особенно чувствительные ресурсы для CI
-окружения и разработчика (например, read-write
и read-only
доступ к S3
– подробнее про политики доступа можно почитать, например, тут), а также использования временных значений без изменения значений в хранилище и скриптов для отладки каких-то специфичных ошибок при сборке. Мы используем S3
для хранения установочных (xip
) файлов актуальных версий Xcode
. Дело в том, что несмотря на возможность получения всех версий через официальный портал Apple
(ссылки на все версии можно найти, например, на этом ресурсе), его использование имеет ряд ограничений:
необходима авторизация на портале Apple под учётной записью разработчика;
скорость скачивания файлов с официального сайта Apple довольно низкая.
Первое ограничение усугубляется тем, что периодически учётные записи блокируются по разным причинам (например, подозрительной активности, если происходят частые авторизации из разных мест, что свойственно для запуска на CI
-кластере). Ещё одной преградой является двухфакторная аутентификация, которая требует привязки номера телефона (причём один телефон можно привязать лишь к нескольким учётным записям), что вызывает дополнительные проблемы в том случае, если, например, номер телефона принадлежит сотруднику, который сейчас в отпуске или вообще больше не работает в компании.
Со вторым ограничением многие и так сталкивались, поэтому обычно для ускорения развёртки новых версий Xcode
на кластере, предварительно один xip
файл размещается на одном из CI
-узлов, а дальше – через scp или подобные инструменты, но уже по внутренней проводной сети быстро передаётся на другие узлы кластера. Разработчики тоже могут получить эту версию с узла кластера (при наличии доступа к нему) или попросить коллегу скинуть ему этот файл через AirDrop
. Тут стоит уточнить, что многим (особенно, начинающим) разработчикам может показаться проблема надуманной, т.к. у них всегда установлена единственная версия Xcode
из Mac AppStore
, однако обычно наличие нескольких версий необходимо для тестирования новых версий SDK
(в том числе, бета-версий) или воспроизведения старых багов.
Несмотря на распространённость описанного выше подхода с размещением xip
-файла на одном из узлов кластера, он имеет большое количество ограничений, связанных с тем, что:
способ доставки
xip
-файла на этот узел необходимо регламентировать отдельно;пропускная способность сети на уровне этого узла ограничена;
узел может в любой момент стать недоступным (например, из-за того, что упало сетевое соединение или выключилось электричество в датацентре), что потребует поиска
xip
-файла ещё где-то;каждый
xip
-файл занимает около 10 гигабайт памяти, поэтому их хранение отнимает свободное место на узле, которое могло быть использовано с большей пользой для сборок;необходимо отдельно контролировать время жизни
xip
-файла на машине
Использование выделенного S3
-хранилища для хранения xip
-файла отчасти решает многие из описанных проблем (в первую очередь, с доступом на портал Apple
и скоростью скачивания файлов на кластере). Процесс взаимодействия с ним выглядит примерно так:
при первом запуске скриптов настройки инфраструктуры, автоматически на машине разработчика настраивается доступ к
S3
-хранилищу (через получение секретов изVault
);разработчику ставится задача исследовать сборку на новой версии
Xcode
(как правило, такая задача всегда ставится, т.к. в больших проектах поддержка новой версииXcode
– не всегда тривиальная задача, особенно при обновлении мажорной версии);разработчик один раз скачивает с портала
Apple
xip
-файл;с помощью консольной утилиты minio-mc загружает xip-файл в бакет
xcode
ресурсаmobile_infra
корпоративногоS3
-хранилища с помощью команды вида:$ minio-mc cp Xcode_13.1.xip mobile_infra/xcode/Xcode_13.1.xip
обновляет версию
Xcode
в.xcode-version
;теперь каждый разработчик, запустив скрипт настройки инфраструктуры, автоматически поставит себе новый
Xcode
версии, указанной в.xcode-version
– скачивание версииXcode
будет выполнятьсяansible
-скриптом с помощью следующей команды:$ minio-mc cp mobile_infra/xcode/Xcode_13.1.xip Xcode_13.1.xip
Последнее, что осталось сказать про распространение секретов, это то, как они попадают на удалённые машины при их настройке через ansible
(а не при сборке на CI
). В этом случае, доступа к ENV
-переменным от Gitlab CI
нет, поэтому при отсутствии сервисной учётной записи остаётся каким-то образом задать необходимые ENV
-переменные другим способом. К счастью, это можно очень просто сделать через стандартный ansible
-модуль env
. Таким образом, разработчик на своей машине может обратиться к Vault
со своей учётной записи, получить все необходимые данные и оформить их в виде ENV
-переменных для команды запуска ansible playbook
, а в самом playbook
– с помощью ключа environment
и модуля env
пробросить значения этих переменных на настраиваемые машины. Ниже представлен пример, позволяющий разместить SSH
-ключи на настраиваемые машины описанным выше способом:
$ cat ./Scripts/setup_remote_environment.sh
#!/bin/zsh
set -e
TARGET="${@:-runners}"
SCRIPTS_DIR=$(dirname -- $0)
if [[ -z "${SUDO_PASSWORD}" && -z "${CI}" ]]; then
echo -n "Enter sudo password: "
read -s SUDO_PASSWORD
echo
echo "${SUDO_PASSWORD}" | sudo -k -S -u root whoami >/dev/null 2>&1 || (echo "Incorrect password!" && exit 1)
fi
SUDO_PASSWORD=${SUDO_PASSWORD} source "${SCRIPTS_DIR}/prepare_local_environment.sh"
ID_RSA_PRIVATE_KEY=$(vault read -field=ID_RSA_PRIVATE_KEY mobile_infra/gitlab-ssh)
ID_RSA_PUBLIC_KEY=$(vault read -field=ID_RSA_PUBLIC_KEY mobile_infra/gitlab-ssh)
echo 'Configuring local infrastucture via ansible...'
if ! ID_RSA_PRIVATE_KEY=${ID_RSA_PRIVATE_KEY} ID_RSA_PUBLIC_KEY=${ID_RSA_PUBLIC_KEY} ansible-playbook -l "${TARGET}" -v --extra-vars ansible_become_password="" ansible/playbooks/setup.yml; then
echo 'Ansible failed :('
exit 1
fi
В самом же ansible/playbooks/setup.yml
:
---
- name: SSH remote nodes setup example
hosts: all
vars:
- ssh_dir: "${HOME}/.ssh"
- ssh_known_hosts_file: "{{ ssh_dir }}/known_hosts"
- ssh_key_identifier: 'id_rsa'
- gitlab_host: 'corp.gitlab.host,1.2.3.4'
- gitlab_host_identity: "{{ gitlab_host }} ecdsa-sha2-nistp256 aHR0cHM6Ly95b3V0dS5iZS9kUXc0dzlXZ1hjUQ==\n"
- name: SSH setup
when: is_ci or inventory_hostname != 'localhost'
block:
- name: Check SSH dir
stat:
path: "{{ ssh_dir }}"
register: ssh_dir_check
- name: Create SSH dir
file: path={{ item.path }} state={{ item.state }} mode={{ item.mode }}
with_items:
- { path: "{{ ssh_dir }}", state: "directory", mode: '0700' }
- { path: "{{ ssh_known_hosts_file }}", state: "touch", mode: '0644' }
when: not ssh_dir_check.stat.exists
- name: Check SSH key
stat:
path: "{{ ssh_dir }}/{{ ssh_key_identifier }}"
register: ssh_check
- name: Configure SSH
shell: |
echo `echo "${ID_RSA_PRIVATE_KEY}" | base64 --decode` > "{{ ssh_dir }}/{{ ssh_key_identifier }}"
echo `echo "${ID_RSA_PUBLIC_KEY}" | base64 --decode` > "{{ ssh_dir }}/{{ ssh_key_identifier }}.pub"
chmod 644 "{{ ssh_dir }}/{{ ssh_key_identifier }}.pub"
chmod 600 "{{ ssh_dir }}/{{ ssh_key_identifier }}"
args:
executable: "{{ shell_executable }}"
when: not ssh_check.stat.exists
environment:
- ID_RSA_PRIVATE_KEY: "{{ lookup('env', 'ID_RSA_PRIVATE_KEY') }}"
- ID_RSA_PUBLIC_KEY: "{{ lookup('env', 'ID_RSA_PUBLIC_KEY') }}"
- name: Check SSH known hosts
shell: |
grep -E "{{ gitlab_host }}" "{{ ssh_known_hosts_file }}"
args:
executable: "{{ shell_executable }}"
register: grep_known_hosts
changed_when: False
failed_when: False
- name: Configure SSH known hosts
shell: |
"{{ shell_executable }}" -lc "echo '{{ gitlab_host_identity }}' >> {{ ssh_known_hosts_file }}"
args:
executable: "{{ shell_executable }}"
when: grep_known_hosts.stdout | length == 0
В примере выше показано, что представляет из себя один из интерфейсных скриптов ./Scripts/setup_remote_environment.sh
. Выполнение команды source ~/.zprofile
необходимо для того, чтобы можно было применить все изменения в текущей терминальной сессии, поскольку именно этот файл содержит все скрипты инициализации установленного окружения, а также определения экспортируемых ENV
-переменных. Остальные скрипты мало чем отличаются от представленного – ./Scripts/onboarding.sh
просто открывает корневую страницу на документацию по онбордингу, а затем передаёт управление скрипту./Scripts/setup_local_environment.sh
. В свою очередь, последний делает примерно то же, что и рассмотренный выше, однако содержит несколько отличий:
для
CI
устанавливается повышенный уровень логирования, а также перенаправление вывода командыansible-playbook
в отдельный лог-файл (в связи с упомянутой выше проблемой маскирования вGitlab CI
);в команде запуска
ansible playbook
явно передаётся имя пользователя, текущий хост, а также пароль (через ранее рассмотренныйansible
-модульenv
), введённый при запуске интерфейсного скрипта:OUTPUT_REDIRECTION="" VERBOSITY="-v" if [[ ! -z "${CI}" ]]; then VERBOSITY="-vvv" OUTPUT_REDIRECTION=">ansible_setup.log 2>&1" fi eval "SUDO_PASSWORD=${SUDO_PASSWORD} ansible-playbook -u $(whoami) -l localhost, --connection=local ${VERBOSITY} ansible/playbooks/setup.yml --extra-vars ansible_become_password='{{ lookup(\"env\", \"SUDO_PASSWORD\") }}' ${OUTPUT_REDIRECTION}"
Передача пароля выполняется только при запуске локальной настройке, т.к. на удалённых CI
-машинах у нас выставлена опция NOPASSWD
, позволяющая не запрашивать пароль при выполнении sudo
-команд (подробнее об этом можно почитать тут). Наконец, скрипт обновления ./Scripts/update_local_environment.sh
просто запускает скрипт настройки ./Scripts/setup_local_environment.sh
с выставленной ENV
-переменнойSHOULD_UPDATE=1
.
Вспомогательные скрипты
Итак, мы рассмотрели интерфейсную часть скриптов, а также то, как скрипты взаимодействуют с другими сервисами. Осталось самое главное – разобраться в том, как работают вспомогательные скрипты, в которых содержится основная логика по развёртке инфраструктуры.
Начнём со скрипта ./Scripts/prepare_local_environment.sh
. Подробно они описываться не будут в силу специфики нашего проекта, однако я постараюсь описать основные шаги, которые представляют наибольший интерес. Основная задача этого скрипта – подготовить минимальное окружение на управляющей машине, необходимое для запуска главного ansible playbook
. При этом предполагается, что исходное состояние машины может быть «сразу после распаковки», т.е. без Xcode Command-Line Tools
и даже brew
.
В первую очередь, обеспечивается наличие файла ~/.zprofile – в нём будут находиться все скрипты инициализации окружения и экспортируемые ENV
-переменные. Скрипты разработаны таким образом, чтобы наносить минимальный вред системе, т.е. стараться ничего лишнего не удалять, и добавлять все новые изменения поверх уже существующих (что актуально для тех разработчиков, которые уже ранее что-то настраивали вручную). Применительно к файлу ~/.zprofile
, это делать удобно, добавляя необходимые инструкции в конец (такие как добавление пути в начало переменной ${PATH}
). Тогда всё новое окружение будет в приоритете по сравнению с тем, которое было перед настройкой.
Затем проверяется актуальность и целостность Xcode Command-Line Tools
(CLT
). Делается это аналогично тому, как это реализовано в соответствующей ansible-роли, с некоторыми дополнительными проверками. Так, проверяется, что путь до активных CLT, который выдаёт команда $(xcode-select -p)
, существует, системные CLT
(стандартное расположение на системе – /Library/Developer/CommandLineTools
), необходимые, например, для работы предустановленной утилиты git
, существуют, а также, с помощью утилиты pkgutil
проверяется, что пакет com.apple.pkg.CLTools_Executables
установлен (например, эта проверка упала на одной из машин после обновления с MacOS Catalina
до MacOS Monterey
). Если хотя бы одна из этих проверок не проходит, то создаётся файл /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
, который сообщает стандартной утилите обновления MacOS
(softwareupdate
) о наличии доступных обновлений Xcode CLT
. Без этого файла, при наличии уже установленных Xcode CLT
, утилита обновлений возможности установить их не даст. После успешной установки стоит этот файл удалять, чтобы лишний раз не переустанавливать существующие Xcode CLT
. Среди доступных обновлений выбирается последняя доступная версия, которая устанавливается на систему, после чего выбирается в качестве основных Xcode CLT
с помощью той же команды xcode-select
, если выбранные до этого были некорректными или отсутствовали):
$ sudo xcode-select --reset
Начинающему разработчику может показаться всё это излишним, ведь достаточно выполнить команду xcode-select --install
(а в некоторых случаях, MacOS
сам её вызовет неявно – например, при попытке обратиться к предустановленной версии git
), однако основной её недостаток в том, что она предполагает взаимодействие с пользователем через графический интерфейс (в частности, для принятия лицензионного соглашения), что, конечно, можно пытаться сделать через AppleScript
, но более неудобно. Бывает, что описанный выше способ не гарантированно ставит Xcode CLT
– например, если центр обновлений Apple
вернул ошибку, или при скачивании установочного файла произошла сетевая ошибка. Поэтому в нашем скрипте выполняется несколько попыток установки CLT
, и в случае нескольких неудач пользователю предлагается запустить этот скрипт и попробовать установить их вручную. Тем не менее, в подавляющем большинстве случаев это не требуется, и всё работает полностью автоматически.
После установки Xcode CLT
, скрипт проверяет наличие brew
(да, на «чистой системе» даже он может отсутствовать). Если brew
не находится в системе, то он устанавливается скриптом. Здесь есть одна тонкость, которая заключается в том, что стандартный способ установки через запуск .sh
-скрипта, получаемого через curl
, проверяет наличие прав суперпользователя (которые могут и не пригодиться в итоге), поэтому запрашивает пароль, причём без возможности явного задания его заранее через стандартный поток ввода. Поэтому для максимальной автоматизации этой процедуры используется альтернативный способ, приведённый на соответствующей странице официальной документации – распаковка версии с github
и распаковка в требуемой директории на системе. По умолчанию, на M1
используется директория /opt/homebrew
, а на Intel Macbook
: /usr/local/Homebrew
. После скачивания и распаковки необходимо рекурсивно поменять права в этой директории для текущего пользователя, из-под которого работает ansible
-скрипт (для этого снова пригождается пароль, запрошенный в интерфейсном скрипте).
При локальной настройке на машине разработчика следующим этапом осуществляется установка утилиты vault
(при её отсутствии, через brew
или из официального репозитория) и получение всех необходимых секретов из Vault
-хранилища, в том числе пароль для подписи приложений через Fastlane
и S3
-токены. При этом, все полученные переменные добавляются как экспортируемые в файл ~/.zprofile
. На CI
и настройке удалённых машин этот этап пропускается, т.к. все секреты передаются либо явно через ansible
, либо доступны из ENV
-переменных Gitlab CI
.
После того, как установлены Xcode CLT
, brew
, а также все необходимые секреты, осталось установить ansible
, для чего нужен Python
. В самом начале статьи уже описывалось, почему плохо использовать системную версию Python
(не говоря уже о том, что на разных системах могут быть разные версии), поэтому для установки Python
следует установить менеджер версий pyenv
. Работа с pyenv
максимально проста и заключается в выполнении всего нескольких команд:
инициализация окружения (уместно располагать в
~/.zprofile
):if ! which python3 | grep '.pyenv' >/dev/null 2>&1; then export PYENV_ROOT="${HOME}/.pyenv"; export PATH="${PYENV_ROOT}/shims:${PATH}"; eval "$(pyenv init -)"; fi
pyenv versions
– получить все установленные версии;pyenv install -s "${PYTHON_VERSION}"
- установка требуемой версииPython
, если ещё не установлена;pyenv global "${PYTHON_VERSION}"
– выбор версииPython
на уровне системы (на уровне проекта версию задаёт файл.python-version
, что позволяет сосуществовать нескольким версиямPython
в разных проектах).
После установки Python
нужной версии скрипт проверяет установку менеджера пакетов pip, устанавливает его, если он отсутствует (но как показывает практика, pyenv
устанавливает pip
автоматически), и после этого устанавливает ansible
и все используемые в setup.yml
роли (на текущий момент – это роль проверки и установки Xcode CLT
). Наконец, если выставлена ENV-переменная SHOULD_UPDATE=1
(например, скриптом ./Scripts/update_local_environment.sh
) то на каждом этапе осуществляется попытка обновления (начиная от Xcode CLT
, заканчивая пакетами brew
и pip
).
Для полного понимания устройства наших скриптов развёртки инфраструктуры осталось рассмотреть основной ansible playbook
. Ранее в статье уже приводились примеры из него. Стоит отметить, что для простоты, вся работа с ansible
у нас организована в виде единственного playbook
и директории resources
. Если требуется что-то более содержательное, уместно использовать ansible
-роли, о которых было рассказано ранее в статье. Весь ansible playbook
разбит на блоки для простоты ориентации – каждый блок представляет собой завершённый этап настройки. Далее кратко описаны основные этапы работы этого playbook
:
секция инициализации переменных – её можно было заметить в примерах ранее;
запуск роли
Xcode CLT
, которая проверяет их актуальность и целостность, а также пытается их переустановить, если обнаружены какие-то неполадки;блок подготовки – определяет переменные-факты, влияющие на логику дальнейшей работы
playbook
(запуск наCI
/архитектура ОС для определения корректных путей установки или версий утилит) и извлекает требуемые версии Python/Ruby/Java и Xcode из конфигурационных файлов;блок настройки
git
-хуков – он был приведён ранее в статье. Обновляет хуки следующим образом: делает резервную копию директории.git/hooks
, заменяет существующие хуки символическими ссылками на хуки из директорииhooks
проекта и отправляет пользователю нотификацию, если зарегистрированы изменения, и удаляет копию, если хуки устанавливаются впервые или никаких изменений зарегистрировано не было;(только на
CI
или на удалённом хосте) блок настройки SSH – также была показана ранее в статье. В рамках этого блока конфигурируется директория~/.ssh
(в том числе ключи и файлknown_hosts
);блок установки
brew
– устанавливаетbrew
(при отсутствии), копируетBrewfile
иBrewfile.lock.json
, устанавливает пакеты с помощью команды brew bundle, после чего удаляет конфигурационные файлы с удалённой машины. При локальной настройке перед удалением выполняется проверка наличия изменений, и при их обнаружении исходные конфигурационные файлы проекта обновляются в соответствии с итоговым состоянием этих файлов после установки;блок установки
Python
– проверяется корректность конфигурации окруженияpyenv
, выбирается версияPython
согласно файлу.python-version
, и устанавливаются пакеты из файлаrequirements.txt
(сам файл сначала копируется перед установкой, а после установки – удаляется с удалённой машины);блок установки
Ruby
– проверяется корректность конфигурации окружения rbenv, выбирается версияRuby
согласно файлу.ruby-version
, устанавливаются гемы тех версий, которые зафиксированы вGemfile.lock
(который вместе сGemfile
сначала также копируется, а в конце – удаляется с удалённой машины). Тонкостью является расширение файлаGemfile
(дописыванием в конец) зависимостямиFastlane
, описываемыми в файлеPluginfile
;блок установки
Java
– обеспечивает правильную конфигурацию jenv, наличие системной версииJava
(устанавливаемой как символическая ссылка на версию пакета, предоставляемую черезbrew
), при необходимости, устанавливает требуемую версиюAdoptOpenJDK
с помощьюbrew cask
, добавляет информацию о всех установленных версияхJava
вjenv
и выбирает необходимую версию, указанную в файле .java-version;блок установки Xcode – обеспечивает, что в результате выбрана та версия Xcode, которая указана в
.xcode-version
. Если версия не установлена на машине, то выполняется её автоматическое скачивание изS3
-хранилища, еслиxip
локально не обнаружено (в соответствии с токенами, инициализированными либо командойsource ./Scripts/prepare_local_environment.sh
, либо черезGitlab CI
ENV
-переменные – более подробно уже было описано ранее в статье), после чего устанавливаетсяXcode
, распаковываяxip
-файл, автоматически принимаются все соглашения и выполняется первый тестовый запускXcode
. Если на каком-то из этапов возникла ошибка, то новыйXcode
не выбирается, если же всё прошло успешно – выполняется выбор новой версииXcode
. Последнее правило очень важно, т.к. в случае ошибочного выбораXcode
(например, неподдерживаемой версии 13.1 наMacOS Catalina
)ansible
не сможет больше подключиться к машине при попытке выполнения следующих команд, и необходимо будет это исправлять ручным подключением поSSH
к машине и восстановлением предыдущей версииXcode CLT
. Установка осуществляется через утилитуxcversion
, устанавливаемую через один изRuby
-гемов xcode-install. Помимо установки и выбора Xcode на этом же этапе самXcode
и симулятор добавляются в исключения фаервола (тем не менее, это не исключает необходимости добавлять своиiOS
приложения перед запуском на симуляторе).
Стоит отметить, что работа с pyenv
практически ничем не отличается от работы с другими менеджерами версий *env
(например, rbenv
или jenv
) – всё отличие заключается в командах инициализации, и в jenv
чуть более сложно выполняется установка версий Java
. Ниже показана организация установки требуемой версии Java через ansible playbook
:
# ранее в блоке установки brew устанавливаются пакеты и tap из Brewfile:
brew "java"
brew "jenv"
tap "AdoptOpenJDK/openjdk"
# организация установки Java в ansible playbook:
vars:
- resources_location: '../resources'
- install_timeout: 1200
- openjdk_system_location: '/Library/Java/JavaVirtualMachines'
- shell_executable: '/bin/zsh'
- shell_profile_location: "${HOME}/.zprofile"
# ...
tasks:
- name: Prepare environment
block:
- name: Set update flag
set_fact:
should_update: "{{ lookup('env', 'SHOULD_UPDATE') == '1' }}"
- name: Set facts
# delegate_to – запустить на управляющей машине
delegate_to: localhost
block:
- name: Java
block:
- name: Get Java version
command: cat "{{ resources_location }}"/.java-version
register: java_version_command
changed_when: False
- name: Set Java version
set_fact:
java_version: "{{ java_version_command.stdout }}"
- name: Set installing Java version
set_fact:
install_java_version: "{{ java_version_command.stdout }}"
when: java_version != '1.8'
- name: Setup Java 8 version
set_fact:
install_java_version: '8'
when: java_version == '1.8'
# ...
- name: Brew install
block:
# ...
- name: Allow brew upgrades
set_fact:
brew_additional_flags: ""
when: should_update
- name: Deny brew upgrades
set_fact:
brew_additional_flags: "--no-upgrade"
when: not should_update
- name: Install Brew dependencies
command: "{{ shell_executable }} -lc \"brew bundle {{ brew_additional_flags }}\""
register: brew_install_check
# async+poll используются для борьбы с таймаутом SSH-соединения
async: "{{ install_timeout }}"
poll: 1
changed_when: '"Installing" in brew_install_check.stdout or "Upgrading" in brew_install_check.stdout'
# ...
- name: Java install
block:
- name: Check system Java directory exists
stat:
path: "{{ openjdk_system_location }}"
register: system_java_directory_check
changed_when: False
- name: Ensure system Java directory exists
become: true
file: path={{ openjdk_system_location }} state="directory" mode=0755
when: not system_java_directory_check.stat.exists
- name: Check openJDK packet installed
command: "{{ shell_executable }} -lc \"brew info java | grep -E 'sudo ln -sfn' | sed 's/sudo ln -sfn //' | awk '{ print $1 }'\""
register: openjdk_installed_path_check
failed_when: openjdk_installed_path_check.stdout | length == 0
changed_when: False
- name: Check system openJDK installed
stat:
path: "{{ openjdk_system_location }}/{{ openjdk_installed_path_check.stdout | basename }}"
register: system_openjdk_check
- name: Configure system openJDK
become: true
file:
src: "{{ openjdk_installed_path_check.stdout }}"
dest: "{{ openjdk_system_location }}/{{ openjdk_installed_path_check.stdout | basename }}"
state: link
when: not system_openjdk_check.stat.exists
- name: Check Java environment configuration
command: "{{ shell_executable }} -lc \"which java | grep '.jenv'\""
register: java_env_check
changed_when: False
failed_when: False
- name: Configure Jenv Init
shell: echo "if ! which java | grep '.jenv' >/dev/null 2>&1; then eval \"\$(jenv init -)\"; fi" >> "{{ shell_profile_location }}"
args:
executable: "{{ shell_executable }}"
when: java_env_check.stdout | length == 0
- name: Register system Java version in jenv
command: "{{ shell_executable }} -lc \"jenv add {{ openjdk_system_location }}/{{ openjdk_installed_path_check.stdout | basename }}/Contents/Home\""
register: system_java_register_result
changed_when: "'already' not in system_java_register_result.stdout"
- name: Check Java
shell: |
"{{ shell_executable }}" -lc "jenv versions 2>/dev/null | grep -E '(?:^|\s+){{ java_version }}(?:\s+|$)'"
register: java_check
changed_when: False
failed_when: "'command not found: jenv' in java_check.stderr"
- name: Java cask installation
when: java_check.stdout | length == 0
block:
- name: Install required Java cask
homebrew_cask:
name: "adoptopenjdk{{ install_java_version }}"
state: present
sudo_password: "{{ ansible_become_password }}"
- name: Get installed openJDK list
find:
paths: "{{ openjdk_system_location }}"
file_type: directory
register: installed_opendjk_list
- name: Register Java cask versions
block:
- name: Allow install apps from unknown developers
command: "spctl --master-disable"
become: true
changed_when: False
- name: Register Java cask version in jenv
command: "{{ shell_executable }} -lc \"jenv add {{ item.path }}/Contents/Home\""
loop: "{{ installed_opendjk_list.files }}"
register: cask_java_register_result
changed_when: "'already' not in cask_java_register_result.stdout"
# внутри block блок always выполняется даже при падении задач
always:
- name: Deny install apps from unknown developers
command: "spctl --master-enable"
become: true
changed_when: False
- name: Current Java Check
shell: |
"{{ shell_executable }}" -lc "jenv global | grep -E '(?:^|\s+){{ java_version }}(?:\s+|$)'"
register: current_java_check
changed_when: False
failed_when: False
- name: Select Java
command: "{{ shell_executable }} -lc \"jenv global {{ java_version }}\""
when: current_java_check.stdout | length == 0
При запуске скрипта обновления (./Scripts/update_local_environment.sh
) устанавливается факт should_update
, в зависимости от чего добавляются команды обновления Python
/Ruby
-зависимостей и т.п. аналогично тому, как это показано для brew в последнем примере.
Эпилог
После работы описанных выше скриптов, можно начинать полноценно работать над iOS
-проектом. У нас для этого используются CocoaPods и Fastlane, которые через довольно простой интерфейс инкапсулируют в себе содержательную инфраструктурную логику сборки приложений и запуска UI
-тестов. Одной из зависимостей, устанавливаемых с помощью CocoaPods
, является Kotlin Multiplatform-компонент, позволяющий строить унифицированные решения на разных мобильных платформах. Так, для запуска сборки достаточно запустить команду:
$ bundle exec fastlane canary
Последняя команда, в свою очередь, неявно запустит сборку подов (зависимостей iOS
проекта), вызвав в before_all
блоке другую fastlane
-задачу install_pods
, которая уже, в свою очередь, вызовет установку командой:
$ bundle exec pod install --repo-update
Как видно из всего доклада (который получился довольно большим), инфраструктура современного iOS
-проекта – вещь нетривиальная, особенно если используется большое количество технологических решений. Тем не менее, использование таких инструментов, как Ansible
и Fastlane
позволяют значительно упростить интерфейс взаимодействия с ней до пары простых команд.
Надеюсь, что наш опыт был вам полезен, и вы сможете повторить наш успех уже на своих проектах. А если вам было ещё и интересно – подписывайтесь на наш блог, в котором вы сможете почитать и про другие интересные разработки нашей команды.
До новых встреч в эфире!