Всем привет! 

Меня зовут Алексей Халайджи, и недавно я присоединился к команде 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-хранилища; 

  • Brewfilebrew зависимости (такие как java, cookiecutter для быстрого и удобного создания новых модулей iOS-проекта или pyenv/rbenv/jenv);

  • Brewfile.lock.json последние успешные версии brew пакетов. В отличие от известного многим Gemfile.lock (по аналогии с которым и разработаны механизмы указания версий через Brewfile), не фиксирует версии установленных пакетов, поэтому они могут обновиться в процессе установки новых пакетов;

  • Gemfile – список Ruby гемов (внешних библиотек) с возможностью указания версий

  • Gemfile.lock – список зафиксированных версий всех установленных пакетов (с учётом разрешения зависимостей между пакетами). Позволяет сохранять идентичные версии гемов при установке на разных машинах в течение времени; 

  • requirements.txt – версии Python-пакетов, включая версию ansible. Да, несмотря на то, что для конфигурации удалённых машин ansible не требуется, он также устанавливается на них для возможности запуска скрипта настройки локальной инфраструктуры перед сборкой приложения на CI

  • hooksgit-хуки, на которые автоматически создаются символические ссылки (для старой версии хуков делается резервная копия в директории .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 позволяют значительно упростить интерфейс взаимодействия с ней до пары простых команд. 

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

До новых встреч в эфире!

Комментарии (0)