Вступление
Привет, Хабр! Это мой первый пост на данной площадке, давно читаю, но писать все не решался, но, как говорится, когда-то все в жизни бывает в первый раз.
Коротко о том, что будет в статье
быстро настроим Gitlab для имитации CI-CD как у взрослых дядь (образно выражаясь)
напишем простое Hello world web-приложение (без излишеств, максимально быстро и просто)
превратим наше web-приложение в Android-приложение при помощи Cordova (как мне кажется, самый простой метод, для не вовлеченных в индустрию людей)
соберем тестовую версию .apk приложения и запустим на Android-телефоне
Дисклеймер
Web- и Android-разработка не являются моими прямыми должностными обязанностями, это мое хобби, на которое я предпочитаю тратить свободное от работы и прочих дел время, я понимаю, что в некоторых случаях могу показать антипаттерн в методах реализации демонстрационного проекта. Надеюсь, вы подсветите мне все нюансы в комментариях! Цель статьи — показать общую концепцию одного из вариантов реализации конвейера для быстрого выпуска простых Android-приложений
Глава 1. Gitlab CI-CD
Основная задача на данном этапе — получить локальный инстанс Gitlab-CE и раннеры для того, чтобы можно было хранить наш код, иметь систему контроля версий и быстро собирать продукт из исходников.
Воспользуемся контейнеризацией (Docker + Docker Compose) для того, чтобы упростить процесс деплоя Gitlab на локальную машину, нам понадобится собственно Gitlab-CE и несколько раннеров (в моем примере — 3), для того чтобы не только хранить код, но и собирать проект (таблица 1).
таблица 1 — Используемые образы
Наименование | Ссылка на Docker hub | Описание |
Gitlab-CE | gitlab-ce server 16.1.0-ce.0 | |
Gitlab Runner | gitlab build runner |
Для того чтобы иметь возможность тестировать код и собирать web- и android-приложения, нам понадобится модифицировать образ Gitlab Runner под свои нужды; так как мы взяли за основу версию Alpine, сделать это будет совсем просто, напишем простой Dockerfile, для того чтобы добавить все необходимое:
FROM gitlab/gitlab-runner:alpine3.18-v16.1.0 # set LANG env var ENV LANG=C.UTF-8 #add Glibc 2.34 for Alpine linux RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases/download" && \ ALPINE_GLIBC_PACKAGE_VERSION="2.34-r0" && \ ALPINE_GLIBC_BASE_PACKAGE_FILENAME="glibc-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_BIN_PACKAGE_FILENAME="glibc-bin-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_I18N_PACKAGE_FILENAME="glibc-i18n-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ apk add --no-cache --virtual=.build-dependencies wget ca-certificates && \ echo \ "-----BEGIN PUBLIC KEY-----\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZ2u1KJKUu/fW4A25y9m\ y70AGEa/J3Wi5ibNVGNn1gT1r0VfgeWd0pUybS4UmcHdiNzxJPgoWQhV2SSW1JYu\ tOqKZF5QSN6X937PTUpNBjUvLtTQ1ve1fp39uf/lEXPpFpOPL88LKnDBgbh7wkCp\ m2KzLVGChf83MS0ShL6G9EQIAUxLm99VpgRjwqTQ/KfzGtpke1wqws4au0Ab4qPY\ KXvMLSPLUp7cfulWvhmZSegr5AdhNw5KNizPqCJT8ZrGvgHypXyiFvvAH5YRtSsc\ Zvo9GI2e2MaZyo9/lvb+LbLEJZKEQckqRj4P26gmASrZEPStwc+yqy1ShHLA0j6m\ 1QIDAQAB\ -----END PUBLIC KEY-----" | sed 's/ */\n/g' > "/etc/apk/keys/sgerrand.rsa.pub" && \ wget \ "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ mv /etc/nsswitch.conf /etc/nsswitch.conf.bak && \ apk add --no-cache --force-overwrite \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ \ mv /etc/nsswitch.conf.bak /etc/nsswitch.conf && \ rm "/etc/apk/keys/sgerrand.rsa.pub" && \ (/usr/glibc-compat/bin/localedef --force --inputfile POSIX --charmap UTF-8 "$LANG" || true) && \ echo "export LANG=$LANG" > /etc/profile.d/locale.sh && \ \ apk del glibc-i18n && \ \ rm "/root/.wget-hsts" && \ apk del .build-dependencies && \ rm \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" # set env vars ENV ANDROID_SDK_ROOT "/opt/sdk" ENV ANDROID_HOME ${ANDROID_SDK_ROOT} ENV PATH $PATH:${ANDROID_SDK_ROOT}/gradle/6.5/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest_supported/bin:${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/extras/google/instantapps:${ANDROID_SDK_ROOT}/build-tools/34.0.0 # add core apps and libs (jdk8, jdk11, cordova10) RUN apk add --no-cache --update \ docker docker-compose openrc doas nodejs-current npm curl python3 python3-dev py3-pip gcc musl-dev openjdk8 openjdk11 curl unzip wget RUN python3 -m pip install --upgrade --no-cache semgrep && \ npm install -g cordova@10 RUN rc-update add docker boot RUN echo 'permit nopass gitlab-runner as root' > /etc/doas.d/doas.conf # add Gradle 6.5 RUN wget -q https://services.gradle.org/distributions/gradle-6.5-all.zip -O /tmp/gradle.zip && \ mkdir -p ${ANDROID_SDK_ROOT}/gradle && \ unzip -qq /tmp/gradle.zip -d ${ANDROID_SDK_ROOT}/gradle && \ mv ${ANDROID_SDK_ROOT}/gradle/* ${ANDROID_SDK_ROOT}/gradle/6.5 && \ rm -v /tmp/gradle.zip # add latest android command line tools RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O /tmp/tools.zip && \ mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \ unzip -qq /tmp/tools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \ mv ${ANDROID_SDK_ROOT}/cmdline-tools/* ${ANDROID_SDK_ROOT}/cmdline-tools/latest_supported && \ rm -v /tmp/tools.zip && \ mkdir -p ~/.android/ && touch ~/.android/repositories.cfg # add android platform tools, build tools 34.0.0 RUN yes | sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses && \ sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --install "platform-tools" "extras;google;instantapps" "build-tools;34.0.0" # remove jdk11 RUN apk del openjdk11 # fix build tools "dx" bug RUN mv /opt/sdk/build-tools/34.0.0/d8 /opt/sdk/build-tools/34.0.0/dx &&\ mv /opt/sdk/build-tools/34.0.0/lib/d8.jar /opt/sdk/build-tools/34.0.0/lib/dx.jar # set ownership of android sdk to gitlab-runner user RUN chown gitlab-runner -R /opt/sdk # set default jdk to jdk8 ENV JAVA_HOME "/usr/lib/jvm/java-1.8-openjdk" ENV PATH $PATH:${JAVA_HOME}/bin
Из необычного хотелось бы отметить несколько моментов:
добавление библиотеки Glibc (строка 6)
добавление jdk8 и jdk11 (строка 52)
удаление jdk11 (строка 79)
фикс нэйминга файлов Android SDK (строка 82)
Теперь по порядку. Glibc нам понадобится, чтобы поставить все компоненты, необходимые для сборки Android-приложения, при помощи Cordova (я не стал искать сложных путей и воспользовался уже готовым решением из Github). Jdk11 нам понадобится для того, чтобы иметь возможность использовать Android command line tools, впоследствии мы его удалим, так как ��спользовать будем Cordova 10, которая дружит с jdk8 (пожалуйста, не ругайте за легаси, да, я тоже слышал шутку Влада Тэна в подкасте, где он прерывает влажные фантазии о AI фразой в духе «Какой к черту AI, люди до сих пор 8 джавой пользуются!»). Фикс нэйминга файлов Android SDK нам понадобится для того, чтобы Gradle не падал с ошибкой на этапе сборки андроид-приложения.
Помимо вышеописанных действий мы также добавим в контейнер Nodejs, Python, Semgrep и Gradle и выполним ряд дополнительных манипуляций (в Dockerfile есть комментарии, не буду их дублировать)
Теперь у нас есть все для того, чтобы создать импровизированную версию конвейера в домашних условиях, давайте напишем docker-compose.yml
version: "3.9" networks: gitlab_ce: driver: bridge volumes: gitlab_conf: gitlab_data: gitlab_logs: build_runner_home: build_runner_conf: test_runner_home: test_runner_conf: deploy_runner_home: deploy_runner_conf: services: # gitlab-ce gitlab-ce: image: gitlab/gitlab-ce:16.1.0-ce.0 shm_size: '2gb' container_name: gitlab-ce hostname: gitlab-ce.local restart: always depends_on: - build-runner - test-runner - deploy-runner volumes: - gitlab_conf:/etc/gitlab - gitlab_data:/var/opt/gitlab - gitlab_logs:/var/log/gitlab ports: - "127.0.0.1:22:22" - "127.0.0.1:80:80" networks: - gitlab_ce deploy: resources: limits: cpus: "4" memory: 8G # gitlab-build-runner build-runner: build: ./ container_name: gitlab-build-runner hostname: gitlab-build-runner.local restart: always volumes: - build_runner_conf:/etc/gitlab-runner - build_runner_home:/home/gitlab-runner - /var/run/docker.sock:/var/run/docker.sock:rw networks: - gitlab_ce deploy: resources: limits: cpus: "4" memory: 4G # gitlab-test-runner test-runner: build: ./ container_name: gitlab-test-runner hostname: gitlab-test-runner.local restart: always volumes: - test_runner_conf:/etc/gitlab-runner - test_runner_home:/home/gitlab-runner - /var/run/docker.sock:/var/run/docker.sock:rw networks: - gitlab_ce deploy: resources: limits: cpus: "1" memory: 1G # gitlab-deploy-runner deploy-runner: build: ./ container_name: gitlab-deploy-runner hostname: gitlab-deploy-runner.local restart: always volumes: - deploy_runner_conf:/etc/gitlab-runner - deploy_runner_home:/home/gitlab-runner - /var/run/docker.sock:/var/run/docker.sock:rw networks: - gitlab_ce deploy: resources: limits: cpus: "1" memory: 1G
Стоит обратить внимание на секцию deploy в каждом сервисе, в ней можно указать, сколько системных ресурсов Вы хотите утилизировать под каждый сервис, также внимание стоит обратить на build: здесь указывается, из какой директории собирать образ для сервиса (где находится Dockerfile), не пропустите shm_size — это поможет заметно повысить производительность сервера Gitlab.
В данной конфигурации заложена избыточность по количеству раннеров (можно обойтись и одним, или же для каждой стадии (build, test, deploy) использовать отдельный образ, оптимизированный под определенную задачу) альтернативным методом может быть использование раннера c типом docker, однако в данном примере, несмотря на то что все раннеры являются Docker-контейнерами, я буду использовать их как shell-раннеры. Да, здесь используется импровизированный docker in docker (у сообщества есть вопросы к целесообразности использования такого метода не в последнюю очередь из-за проблем с безопасностью, но в целях эксперимента, думаю, можно попробовать и такую конфигурацию).
Теперь давайте автоматизируем процесс регистрации раннера (как вы знаете, недостаточно просто запустить раннер, его еще нужно зарегистрировать на сервере Gitlab)
#!/bin/sh RUNNER_CONTAINER_ID=$1 RUNNER_TAG=$2 GITLAB_RUNNER_REG_TOKEN=$3 GITLAB_SUBNET=$(docker network ls | grep gitlab_ce | cut -d " " -f 4) echo "Registering ..."; docker exec -it $RUNNER_CONTAINER_ID bash -c 'gitlab-runner register \ --non-interactive \ --executor "shell" \ --docker-image alpine:latest \ --url "http://gitlab-ce.local/" \ --registration-token "'$GITLAB_RUNNER_REG_TOKEN'" \ --description "docker-'$RUNNER_TAG'-runner" \ --maintenance-note "Docker runner for gitlab-ce" \ --tag-list "docker,'$RUNNER_TAG'" \ --run-untagged="true" \ --locked="false" \ --access-level="not_protected" > /dev/null 2>&1; \ echo " network_mode = \"'$GITLAB_SUBNET'\"" >> /etc/gitlab-runner/config.toml'; echo "Done"
Данный скрипт принимает id контейнера с раннером, тэг для него и регистрационный токен. В ходе выполнения скрипта в контейнер передается команда регистрации раннера, также вносится правка в его конфигурацию, для того чтобы указать сеть, из которой ему будет доступен сервер Gitlab.
Почти все готово для запуска своего инстанса Gitlab, финальный скрипт, связывающий все в единую картину и автоматизирующий рутину.
#!/bin/sh echo "################### Build & deploy Gitlab-CE ###################" docker compose -p gitlab-ce up -d --build --wait gitlab-ce echo "###############################################################" echo "..." echo "#################### Check Gitlab-CE state ####################" docker compose -p gitlab-ce ps echo "###############################################################" echo "..." echo "############## Gitlab runner registration token ###############" RUNNER_REG_TOKEN=$(docker exec -it gitlab-ce gitlab-rails runner -e production 'puts Gitlab::CurrentSettings.current_application_settings.runners_registration_token') echo "Token: $RUNNER_REG_TOKEN" echo "###############################################################" echo "..." echo "################ Gitlab initial root password #################" docker exec -it gitlab-ce bash -c 'grep 'Password:' /etc/gitlab/initial_root_password 2>/dev/null || echo "Custom root password already exists."' echo "###############################################################" echo "..." echo "################# Base runners registration ###################" echo "Registering gitlab-build-runner ..." ./reg_new_runner.sh gitlab-build-runner build $RUNNER_REG_TOKEN echo "Registering gitlab-test-runner ..." ./reg_new_runner.sh gitlab-test-runner test $RUNNER_REG_TOKEN echo "Registering gitlab-deploy-runner ..." ./reg_new_runner.sh gitlab-deploy-runner deploy $RUNNER_REG_TOKEN echo "###############################################################"
В итоге получаем готовый к тестам Gitlab-инстанс, который может собирать приложения и тестировать код.

Глава 2. Простое web-приложение
Как вы уже, наверное, догадались, здесь мы сделаем очень простое web-приложение и соберем его при помощи Webpack, на эту тему есть очень много хороших материалов, официальная документация тоже на высоте.
От себя лишь добавлю пару скриншотов из своих pet-проектов для того, чтобы показать, как организована структура файлов у меня.


Не буду подробно останавливаться на этом пункте, единственное, на что обращу ваше внимание, задайте в качестве директории, куда будет собираться Ваш фронтенд, папку с названием www, это упростит понимание дальнейших процессов.

Глава 3. Hi Cordova, pls transform my website to android app
Подведем промежуточные итоги: у нас есть простое web-приложение и Gitlab, готовый собирать наш проект и тестировать его, давайте соберем все компоненты нашего пазла воедино.
Для начала добавим конфигурационный файл config.xml для нашего приложения (я положу его в папку Cordova).
<?xml version='1.0' encoding='utf-8'?> <widget id="com.moviefinder.main" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0" xmlns:android="http://schemas.android.com/apk/res/android"> <name>Hello-world-app</name> <description>My first app</description> <author email="username@domain.com" href="https://link-to-my.app"> Name Surname </author> <preference name="android-targetSdkVersion" value="34" /> <preference name="android-minSdkVersion" value="21" /> <preference name="AndroidPersistentFileLocation" value="Compatibility" /> <preference name="DisallowOverscroll" value="true" /> <allow-navigation href="*" /> <allow-intent href="*" /> <access origin="*" /> <edit-config file="app/src/main/AndroidManifest.xml" mode="merge" target="/manifest/application/activity"> <activity android:exported="true"/> </edit-config> </widget>
Обратите внимание на секцию edit-config: в ней задается параметр android:exported="true" — это необходимо для того, чтобы ваше приложение было доступно на последних версиях Android.
Так как наше андроид-приложение — это по факту еще и обычный веб-сайт, добавим сборку web-приложения в Nginx-контейнере, для того чтобы мы сразу могли видеть результат нашей работы (например, если у нас нет под рукой android-эмулятора или устройства, на которое можно поставить приложение), для этого создадим Dockerfile со следующим содержанием:
FROM nginx COPY ./www /usr/share/nginx/html
Таким образом мы получим на выходе веб-сервер с нашим web-приложением (ранее мы собирали наш web-проект в папку www)
Почти все готово, осталось только написать .gitlab-ci.yml конфигурацию для нашего пайплайна, давайте сделаем это!
stages: - build - test - deploy build-code-job: stage: build tags: - build - docker before_script: - curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20?%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20started" script: - PATH=$PATH:/opt/sdk/gradle/6.5/bin - mkdir -p ~/.android/ && touch ~/.android/repositories.cfg - npm i - npm run build - doas docker build -t $CI_PROJECT_NAME . - cordova create ${CI_PROJECT_NAME} - cd ${CI_PROJECT_NAME} - rm -Rf ./www/* - cp -r ../www/* ./www/ - cp ../cordova/* ./ - cordova platform add android - cordova build android cache: paths: - node_modules artifacts: paths: - ./${CI_PROJECT_NAME}/platforms/android expire_in: 1 week name: ${CI_PROJECT_NAME}_${CI_JOB_ID}_android after_script: - > if [ $CI_JOB_STATUS == 'success' ]; then curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20✅%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20completed" curl -F document=@"./${CI_PROJECT_NAME}/platforms/android/app/build/outputs/apk/debug/app-debug.apk" https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendDocument?chat_id=$TELEGRAM_CHAT_ID else curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20failed%20$CI_PIPELINE_URL" fi test-code-job: stage: test tags: - test - docker script: - doas docker image ls | grep "$CI_PROJECT_NAME" - semgrep scan src --config auto --output $CI_PROJECT_NAME-sast-report.json --json artifacts: paths: - $CI_PROJECT_NAME-sast-report.json expire_in: 4 week name: ${CI_PROJECT_NAME}_${CI_JOB_ID}_sast_report after_script: - > if [ $CI_JOB_STATUS == 'success' ]; then curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20✅%20%5B$CI_PROJECT_NAME%5D%20All%20tests%20passed%20successfuly" curl -F document=@"$CI_PROJECT_NAME-sast-report.json" https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendDocument?chat_id=$TELEGRAM_CHAT_ID else curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Tests%20failed%20$CI_PIPELINE_URL" fi deploy-code-job: stage: deploy tags: - deploy - docker script: - doas docker stop $CI_PROJECT_NAME || true && doas docker rm $CI_PROJECT_NAME || true - doas docker run -d --name $CI_PROJECT_NAME -p $HOST_PORT:80 --restart unless-stopped $CI_PROJECT_NAME after_script: - > if [ $CI_JOB_STATUS == 'success' ]; then curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20?%20%5B$CI_PROJECT_NAME%5D%20Deploy%20job%20completed%20$PROJECT_URL" else curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Deploy%20job%20failed%20$CI_PIPELINE_URL" fi
Данная конфигурация включает 3 шага:
build
test
deploy
Вы, конечно же, понимаете, что происходит на каждом этапе, но я поясню нюансы: помимо непосредственно сборки, тестирования и внедрения происходит также процесс информирования в Telegram-канал. Обратите внимание на условие в after_script — [ $CI_JOB_STATUS == 'success' ] — таким образом мы понимаем, выполнены ли все шаги успешно или же есть ошибки, и в зависимости от статуса конкретного этапа отправляем соответствующее сообщение.
Также обратите внимание на артефакты; на стадии сборки мы получаем в качестве артефакта папку с андроид-проектом — его потом можно открыть в Android Studio, а на стадии тестирования мы получаем отчет SAST-тестирования (вот, наконец, нам и пригодился Python и Semgrep)
Также в случае успешной сборки в Телеграм отправляется apk-файл с приложением и после прохождения тестирования SAST отчет также дублируется в мессенджер.

Полученный .apk файл можно запустить на телефоне и протестировать, а тот факт, что файл автоматически выгружается в мессенджер, поможет поделиться с вашей группой тестирования (если, конечно, она у вас есть)
И финальный штрих — определим переменные окружения для нашего проекта:

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

Спасибо за внимание, буду рад услышать любые советы и рекомендации от настоящих разработчиков, напоминаю: в этой области я не специалист, просто делюсь своим хобби с вами.
Небольшое отступление от темы: я веду любительский канал на youtube, где пытаюсь совершенствовать свой английский, рассказывая о своих любительских проектах, там наглядно показано,н как работает эта конфигурация Gitlab (заранее прошу прощения за свой английский, я только учусь).
