9 мин чтенияИнтеграции

Сокращатель URL для GitLab CI: короткие ссылки из каждого пайплайна

Используйте GitLab CI как сокращатель URL: создавайте короткие ссылки на review app для каждого merge request и перенаправляйте стабильную ссылку latest на тегах с помощью замаскированного защищённого ключа.

Marius Voß
DevRel · edge infra
Пайплайн сокращателя URL для GitLab CI, изображённый как пиксельные этапы: сборка, тестирование и развёртывание завершаются заданием link, которое записывает короткую ссылку для каждого merge request и перенаправляет ссылку latest на тегах

Да, GitLab CI может работать как ваш сокращатель URL. Задание с curl и одним API-ключом может создавать короткую ссылку для каждого merge request, направлять её на review app и переносить стабильную ссылку latest на новый релиз при отправке тега. Это около сорока строк YAML. Всё уже работает.

Обычно ошибаются не в HTTP-вызове, а в работе с ключом: где он хранится, какие пайплайны могут его читать и какой ущерб он способен причинить, если задание ветки утечёт. Поэтому в этом руководстве переменным и их области действия уделено не меньше внимания, чем самому .gitlab-ci.yml. Если вы предпочитаете декларативно управлять долгоживущими ссылками, лучше подойдёт подход к коротким ссылкам с Terraform; пайплайны удобны для ссылок, которые создаются и удаляются вместе с кодом.

Сразу уточню статус. Нативная интеграция Elido с GitLab готовится, но ещё не работает, и от неё не зависит ничего ниже. Хотите управляемую версию? На странице интеграции с GitLab есть лист ожидания.

Что делает задание сокращателя URL для GitLab CI

Сокращатель в пайплайне делает ровно три вещи. Он создаёт ссылку, если такого slug ещё нет, обновляет адрес назначения, если slug уже существует, и отключает ссылку, когда исчезает то, на что она указывала. Аналитика и QR-коды остаются на стороне Elido.

API здесь небольшой. Ссылки находятся по адресу /v1/workspaces/{workspace_id}/links: POST создаёт ссылку и требует domain_id и destination_url, PATCH /links/{link_id} меняет поля существующей ссылки, а GET /links?q= ищет по slug, адресу назначения или заголовку. Для аутентификации нужен один заголовок: Authorization: Bearer elido_.... Ключ берётся на странице API-ключей в дашборде.

Это весь контракт. В обзоре API и SDK перечислены остальные эндпоинты, но пайплайну редко нужно больше этих трёх.

Хранение ключа как замаскированной защищённой переменной

GitLab предоставляет здесь два важных переключателя, и работают они по-разному. Маскирование скрывает значение в логах задания. Защита определяет, какие пайплайны вообще получают это значение.

В Settings, CI/CD, Variables при создании переменной выберите Masked and hidden. Hidden (обычно доступно начиная с GitLab 17.6) означает, что позже никто не сможет раскрыть значение на странице настроек, что и нужно для учётных данных. В документации GitLab о переменных CI/CD перечислены требования к замаскированному значению: одна строка, без пробелов, не менее 8 символов. Ключи Elido состоят из elido_ и base32, поэтому подходят.

На той же странице прямо сказано об ограничении: masking "is not a guaranteed way to prevent malicious users from accessing variable values." Задание, которое кодирует переменную в base64 и выводит её, без труда обходит маскирование. Считайте маскирование гигиеной логов, а не контролем доступа.

Защита - это контроль доступа. Защищённая переменная попадает только в пайплайны защищённых веток или тегов, и здесь возникает проблема, с которой сталкивается каждая команда в первую неделю: ваш пайплайн merge request запускается из feature-ветки, поэтому защищённый ключ приходит пустой строкой, а задание завершается ошибкой 401, похожей на опечатку.

Я бы решил это двумя ключами, не ослабляя единственный. Я бы настроил их так:

VariableVisibilityProtectedRead by
ELIDO_PREVIEW_KEYMasked and hiddenNoMerge request pipelines
ELIDO_RELEASE_KEYMasked and hiddenYesTag pipelines on protected tags
ELIDO_PREVIEW_WS, ELIDO_RELEASE_WSVisibleNoAny job (IDs are not secrets)
ELIDO_DOMAIN_ID, SHORT_HOSTVisibleNoAny job

Ключ preview должен находиться в отдельном рабочем пространстве, где хранятся только ссылки для review. Любой, кто может отправить ветку, в принципе может украсть незащищённую переменную, поэтому убедитесь, что в худшем случае он получит лишь набор одноразовых ссылок mr-142, тогда как ключ релиза находится в вашем настоящем рабочем пространстве и запускается только на защищённых вами тегах.

Назначьте обоим ключам роль Editor и срок действия; для ключа preview подойдут 90 дней. Editor - самая младшая предустановленная роль, позволяющая записывать ссылки, и она также может удалять их; API-ключи используют одну из предустановленных ролей, а мне хотелось бы иметь роль только для создания и обновления именно для такого случая, но её пока нет. Именно разделение рабочих пространств ограничивает радиус поражения.

Рабочее задание .gitlab-ci.yml для создания короткой ссылки

Вот общий фрагмент: операция upsert ищет slug, создаёт ссылку, если её нет, и в противном случае отправляет patch-запрос. Поместите его в скрытое задание и расширяйте его.

.elido_upsert:
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl jq
  script:
    - API="https://api.elido.app/v1/workspaces/${ELIDO_WS}"
    - AUTH="Authorization: Bearer ${ELIDO_KEY}"
    - |
      find_id() {
        curl -sS --fail-with-body -H "$AUTH" "$API/links?q=${SLUG}&limit=50" |
          jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" \
            '.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1
      }
      ID="$(find_id)"
      if [ -z "$ID" ]; then
        CODE=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/links" \
          -H "$AUTH" -H "Content-Type: application/json" \
          -H "Idempotency-Key: ${CI_PIPELINE_ID}-${SLUG}" \
          -d "$(jq -n --arg s "$SLUG" --arg u "$TARGET" --argjson d "$ELIDO_DOMAIN_ID" \
                '{domain_id: $d, slug: $s, destination_url: $u, tags: ["ci"]}')")
        case "$CODE" in
          201) ;;
          409) ID="$(find_id)" ;;   # another pipeline created it first
          *) cat resp.json; exit 1 ;;
        esac
      fi
      if [ -n "$ID" ]; then
        curl -sS --fail-with-body -X PATCH "$API/links/$ID" \
          -H "$AUTH" -H "Content-Type: application/json" \
          -d "$(jq -n --arg u "$TARGET" '{destination_url: $u, status: "active"}')"
      fi
    - echo "SHORT_URL=https://${SHORT_HOST}/${SLUG}" >> link.env
  artifacts:
    reports:
      dotenv: link.env

Поиск q выполняется по подстроке, поэтому фильтр jq сужает результат до точного slug на точном домене. Без него поиск web-mr-14 без проблем вернул бы web-mr-142. Один раз получите domain_id через GET /v1/workspaces/{id}/domains и сохраните его как обычную переменную; брендированный хост, настроенный через кастомные домены, лучше смотрится в merge request, чем общий.

Жизненный цикл короткой ссылки review app GitLab: пайплайн merge request создаёт или обновляет slug, записывает SHORT_URL в dotenv-отчёт, используемый как URL окружения, а задание остановки отключает ссылку при закрытии merge request

Короткие ссылки review app для каждого merge request

Review apps - так GitLab называет временное окружение для ветки или merge request, а документация review apps описывает их создание на основе динамических окружений. Их URL обычно неудобны: хеш, пространство имён, имя хоста облачного провайдера. Короткую ссылку вроде go.example.com/web-mr-142 можно произнести вслух на стендапе.

review_link:
  extends: .elido_upsert
  stage: deploy
  needs: [deploy_review]
  variables:
    ELIDO_KEY: $ELIDO_PREVIEW_KEY
    ELIDO_WS: $ELIDO_PREVIEW_WS
    SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
    TARGET: "https://${CI_ENVIRONMENT_SLUG}.review.example.com"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: $SHORT_URL
    on_stop: stop_review_link
    auto_stop_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Секрет здесь в dotenv-отчёте. Операция upsert записывает SHORT_URL в link.env, GitLab считывает его обратно, а environment:url становится короткой ссылкой, поэтому кнопка View app в merge request открывает web-mr-142, а не исходное имя хоста. Этот шаблон динамического URL описан в документации об окружениях.

CI_MERGE_REQUEST_IID уникален в рамках проекта и не меняется в течение жизни merge request, поэтому каждый push в один и тот же MR попадает в тот же slug, а операция upsert обновляет ссылку, а не создаёт дубликат. Если нужен другой ключ, полный список есть в справочнике предопределённых переменных.

Очистка выполняется заданием с action: stop. Оно должно использовать те же rules, что и стартовое задание, иначе GitLab не сможет запустить его автоматически:

stop_review_link:
  image: alpine:3.20
  stage: deploy
  variables:
    GIT_STRATEGY: none
    SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
  script:
    - apk add --no-cache curl jq
    - API="https://api.elido.app/v1/workspaces/${ELIDO_PREVIEW_WS}"
    - ID=$(curl -sS -H "Authorization:
        Bearer ${ELIDO_PREVIEW_KEY}" "$API/links?q=${SLUG}" |
        jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" '.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1)
    - '[ -z "$ID" ] || curl -sS --fail-with-body -X PATCH "$API/links/$ID" -H "Authorization: Bearer ${ELIDO_PREVIEW_KEY}" -H "Content-Type: application/json" -d "{\"status\":\"disabled\"}"'
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Я отключаю ссылку, а не удаляю её. Отключённая ссылка сохраняет историю кликов, а если кто-то снова откроет MR, следующий пайплайн переведёт её обратно в active через ту же операцию upsert. GIT_STRATEGY: none нужен потому, что к этому моменту ветка может уже исчезнуть.

Если review apps у вас в десять раз больше релизов, здесь начинают сказываться лимиты тарифа. Проверьте допустимое число ссылок на странице цен, прежде чем подключать это к загруженному монорепозиторию, и заведите бесплатное рабочее пространство для preview на время тестирования.

Перенаправление стабильной ссылки latest в пайплайнах тегов

Второй шаблон запускается на тегах и делает противоположное ссылке review: использует один неизменный slug, адрес назначения которого обновляется с каждым релизом. В README можно навсегда указать go.example.com/cli-latest.

latest_link:
  extends: .elido_upsert
  stage: release
  variables:
    ELIDO_KEY: $ELIDO_RELEASE_KEY
    ELIDO_WS: $ELIDO_RELEASE_WS
    SLUG: "cli-latest"
    TARGET: "${CI_PROJECT_URL}/-/releases/${CI_COMMIT_TAG}"
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

Сочетайте это правило с шаблоном защищённых тегов вроде v*, чтобы создавать такие теги могли только сопровождающие; иначе защищённого ключа просто не будет, и задание завершится с ошибкой, что и требуется. Если нужна постоянная ссылка для каждой версии, запустите то же задание второй раз с SLUG: "cli-${CI_COMMIT_REF_SLUG}": тогда v1.4.0 превратится в cli-v1-4-0.

Не задавайте для ссылки latest redirect_status равным 301. Код 301 - постоянное обещание, которое браузеры могут кэшировать, а ссылка latest нарушает это обещание при каждом релизе. Elido использует 302 по умолчанию, если оставить поле пустым, а в нашем материале о редиректах 301 и 302 разобраны случаи, где этот выбор действительно важен.

Есть и честное ограничение: изменение адреса назначения может доходить до каждой edge-локации несколько минут, поэтому smoke-тест, который выполняет curl короткой ссылки уже через секунду, может по-прежнему увидеть предыдущий релиз. Проверяйте ответ API или подождите перед проверкой заголовка Location.

Принцип наименьших привилегий для сокращателя URL GitLab CI: незащищённый ключ preview ограничен рабочим пространством preview для пайплайнов merge request, а защищённый ключ релиза могут читать только пайплайны защищённых тегов для перенаправления ссылки latest

Идемпотентность, повторы и лимиты запросов

Пайплайны повторяют задания. Runner завершается посреди задания, кто-то нажимает Retry на красном задании, а два push происходят с разницей в тридцать секунд и начинают конкурировать. Приведённая выше операция upsert выдерживает все три случая, и полезно понимать почему.

Заголовок Idempotency-Key делает повторный POST безопасным: API кэширует успешный ответ на 24 часа и воспроизводит его для того же ключа, поэтому повтор того же пайплайна получает исходную ссылку, а не ошибку. Формирование ключа из CI_PIPELINE_ID и slug означает, что повторы внутри пайплайна воспроизводят результат, а новый пайплайн получает новую попытку. Ветка 409 обрабатывает гонку между двумя разными пайплайнами, а путь «поиск, затем patch» превращает второй запуск фактически в no-op.

Лимиты запросов действуют для каждого ключа поверх лимита рабочего пространства, а для совершенно новых рабочих пространств также установлен меньший дневной лимит создания ссылок, пока они набирают репутацию. Несколько merge request этого не заметят. А монорепозиторий, который одновременно запускает сорок review apps, может заметить, поэтому считайте 429 поводом для повтора с ключевым словом GitLab retry, а при 402 завершайте задание с явной ошибкой: это означает лимит тарифа, а не временную ошибку. В нашем подробном материале о лимитах запросов и идемпотентности для API сокращателей стратегия backoff разобрана подробнее, чем это нужно заданию CI.

Пропустите фильтр jq с точным совпадением, и пайплайн MR 14 незаметно обновит ссылку MR 142. Первым это обычно замечает озадаченный дизайнер. Не убирайте фильтр.

Если shell в YAML становится слишком громоздким, те же вызовы легко вынести в скрипт, который вы добавите в репозиторий, а руководство по CLI сокращателя URL показывает такую структуру.

Читайте основной материал → Короткие ссылки с Terraform: управление ссылками как кодом

Другие материалы в блоге

Частые вопросы

Может ли GitLab CI создавать короткие ссылки?

Да. Любое задание, в котором можно запустить curl, может вызвать REST API сокращателя URL, поэтому задание GitLab CI может создать короткую ссылку, изменить её адрес назначения или отключить её. API-ключ хранится в замаскированной переменной CI/CD, а задание передаёт его как Bearer-токен. Нативная интеграция с GitLab для этого не нужна.

Как безопасно хранить API-ключ в GitLab CI?

Добавьте его в Settings, CI/CD, Variables, установив видимость Masked and hidden, и включите Protect variable, если читать его должны только защищённые ветки или теги. Маскирование не даёт значению попасть в логи заданий, но собственная документация GitLab предупреждает, что это не гарантированная защита, поэтому ограничьте сам ключ минимально необходимыми правами.

Почему защищённая переменная пуста в пайплайне merge request?

Защищённые переменные передаются только пайплайнам, которые запускаются в защищённых ветках или с защищённых тегов. Пайплайн merge request из feature-ветки по умолчанию не подходит, поэтому переменная приходит пустой. Используйте отдельный незащищённый ключ с меньшими правами для заданий review или оставьте защищённый ключ только для пайплайнов тегов.

Как дать каждому review app GitLab короткую ссылку?

Запустите задание в пайплайнах merge request, которое создаёт или обновляет slug из имени проекта и CI_MERGE_REQUEST_IID и указывает на URL review app. Запишите полученный короткий URL в dotenv-отчёт и используйте его как environment:url, чтобы виджет merge request ссылался прямо на него. Задание остановки отключает ссылку, когда окружение останавливается.

Нужен ли для короткой ссылки на последний релиз редирект 301 или 302?

Используйте 302. Ссылка latest меняет адрес назначения при каждом релизе, а 301 сообщает браузерам и кэшам, что перенос постоянный, поэтому некоторые клиенты продолжат отправлять людей на старую версию. Elido по умолчанию создаёт новые ссылки с кодом 302, если не указать redirect_status, и здесь это правильный выбор.

Есть ли у Elido нативная интеграция с GitLab?

Пока нет. Нативная интеграция с GitLab готовится, и вы можете записаться в лист ожидания на странице интеграции с GitLab. Всё в этом руководстве уже работает через публичный REST API из задания пайплайна, и на стороне GitLab не нужно ничего устанавливать кроме переменной CI/CD.

Попробуйте Elido

Вставьте URL - получите короткую ссылку

Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.

Бесплатно, без регистрации · 2 в день

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф - без банковской карты.

Теги
gitlab ci url shortener
create short link gitlab pipeline
gitlab review app short link
gitlab ci masked variable api key
short link per merge request
latest release short link

Читать дальше