diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c0ac6f3 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,33 @@ +include: + - project: 'generic/common-ci' + file: '/common-build.yaml' + +stages: + - build + +variables: + RELEASE_NAME: iac-offline + DOCKERFILE_PATH: Dockerfile + IMAGE_NAME: ${CR}/${RELEASE_NAME}:${CI_COMMIT_TAG} + GIT_DEPTH: "1" + +build_offline_mirror: + extends: .base_docker_prod + rules: + - if: $CI_COMMIT_TAG =~ /^v?(?:\d+\.){2}\d+$/ + allow_failure: false + script: + - printf '%s' "$CI_JOB_TOKEN" > /tmp/gl_token + - mkdir -p /tmp/emptyctx + - docker build -f ${DOCKERFILE_PATH} /tmp/emptyctx + --secret id=gl_token,src=/tmp/gl_token + --build-arg PUSHER_IMAGE="${PUSHER_IMAGE}" + --build-arg GIT_TAG="${CI_COMMIT_TAG}" + --build-arg EXPECT_COMMIT="${CI_COMMIT_SHA}" + --build-arg BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + ${BUILD_ARGS} + -t ${IMAGE_NAME} + + - docker run --rm -e DRY_RUN=true ${IMAGE_NAME} + - docker push ${IMAGE_NAME} + timeout: 30m diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a73441d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,261 @@ +# syntax=docker/dockerfile:1 +# +# Самодостаточный образ переноса infra/iac в закрытый контур: несёт в себе +# и состояние репозитория на заданном теге, и логику push-а. Внешней базы +# нет — собирается от alpine, зависимостей от других репозиториев у сборки +# нет тоже. +# +# В образ попадают ровно два ref-а: +# refs/tags/ — сам тег +# refs/heads/ — ветка на коммите тега; её читает Flux +# Остальных веток и их истории нет — только объекты, достижимые от тега. +# +# Куда пушить, образ узнаёт при запуске (см. `docker run ... info`): +# GIT_URL обязательно, полный git-URL цели +# GIT_USERNAME логин +# GIT_PASSWORD_FILE файл с токеном (предпочтительно) … +# GIT_PASSWORD … либо значение +# PUSH_TAGS true пушить теги +# PUSH_PRUNE true снимать на сервере лишние ветки и теги +# PUSH_FORCE true форсированный push +# VERIFY_AFTER_PUSH true сверить refs после push-а и упасть при расхождении +# GIT_SSL_NO_VERIFY false самоподписанный сертификат цели +# +# Образ умеет ровно одно — push, в двух режимах: +# +# DRY_RUN=true показать подпись образа, refs и рефспеки; ничего +# не менять. GIT_URL при этом необязателен, так что этим +# же режимом проверяют образ сразу после сборки. +# DRY_RUN=false (по умолчанию) запушить и сверить результат: если на +# сервере оказалось не то, контейнер падает. +# +# Требует BuildKit. Локально: +# +# export GITLAB_TOKEN=... # read_repository на infra/iac +# printf '%s' "$GITLAB_TOKEN" > /tmp/gl_token +# DOCKER_BUILDKIT=1 docker build -f Dockerfile \ +# --secret id=gl_token,src=/tmp/gl_token \ +# --build-arg GIT_TAG=3.9.0 \ +# --build-arg BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ +# -t cr.yandex/crp3ccidau046kdj8g9q/iac-offline:3.9.0 . +# +# В CI это делает job build_offline_mirror, см. .gitlab-ci.yml. + +# ── этап 1: выкачать состояние тега ───────────────────────────────────── +FROM alpine/git:latest AS fetch + +ARG GIT_HOST=gitlab.sarex.io +ARG GIT_PATH=infra/iac +ARG GIT_USERNAME=gitlab-ci-token + +# Обязателен. Он же — ключ кэша: docker кэширует RUN ниже, и без +# меняющегося аргумента пересборка молча отдала бы старое состояние. +# Теги неизменяемы, поэтому пересборка того же тега берёт кэш намеренно. +# Если тег в GitLab передвинули — см. EXPECT_COMMIT. +ARG GIT_TAG + +# Ветка, которую увидит Flux в закрытом контуре. Создаётся на коммите тега. +ARG TARGET_BRANCH=master + +# Необязательно: ожидаемый SHA коммита тега. Не совпал — сборка падает. +# Страховка от передвинутого тега и от устаревшего кэша. +ARG EXPECT_COMMIT + +RUN --mount=type=secret,id=gl_token \ + set -eu; \ + : "${GIT_TAG:?не задан --build-arg GIT_TAG}"; \ + token="$(cat /run/secrets/gl_token)"; \ + \ + # Не clone --mirror: тот выкачивает все ветки и служебные + # refs/merge-requests/*, чтобы потом их выбросить. fetch одного тега + # тянет только объекты, достижимые от него. + git init --bare --quiet /repo; \ + git -C /repo fetch --quiet --no-tags \ + "https://${GIT_USERNAME}:${token}@${GIT_HOST}/${GIT_PATH}.git" \ + "refs/tags/${GIT_TAG}:refs/tags/${GIT_TAG}" \ + || { echo "не удалось выкачать тег ${GIT_TAG} из ${GIT_PATH}" >&2; exit 1; }; \ + \ + # URL с токеном нигде не сохраняется: remote не заводится, fetch + # получил его аргументом. В образ уезжают только объекты. + \ + commit="$(git -C /repo rev-parse "refs/tags/${GIT_TAG}^{commit}")"; \ + if [ -n "${EXPECT_COMMIT:-}" ] && [ "${commit}" != "${EXPECT_COMMIT}" ]; then \ + echo "тег ${GIT_TAG} указывает на ${commit}, ожидался ${EXPECT_COMMIT}" >&2; \ + exit 1; \ + fi; \ + \ + # Ветка на коммите тега. Без неё в образе нет ни одного refs/heads/*, + # а push с --prune снёс бы на сервере все ветки разом. + git -C /repo update-ref "refs/heads/${TARGET_BRANCH}" "${commit}"; \ + git -C /repo symbolic-ref HEAD "refs/heads/${TARGET_BRANCH}"; \ + git -C /repo gc --prune=now --quiet; \ + \ + # Коммит нужен следующему этапу как ENV, а ARG туда не пролезает. + echo "${commit}" > /repo-commit + +# ── этап 2: собственно образ ──────────────────────────────────────────── +FROM alpine:3.22 + +# Только то, что нужно для push по HTTP(S). Ни curl, ни jq: образ не ходит +# ни в какие API, он умеет ровно одно — git push. +RUN apk add --no-cache ca-certificates git tini \ + && addgroup -g 1000 mirror \ + && adduser -D -u 1000 -G mirror -h /home/mirror mirror \ + && mkdir -p /repo \ + && chown -R mirror:mirror /repo + +# Логика push-а. Специализирована под этот образ: внутри заведомо bare-репо +# с одной веткой и одним тегом, поэтому ни определения bare-vs-worktree, ни +# выбора ветки здесь нет. +COPY <<'PUSH_SH' /app/push.sh +#!/bin/sh +set -eu + +REPO_DIR=/repo +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +# Подпись образа: SOURCE_PATH, SOURCE_URL, GIT_TAG, COMMIT, BUILT_AT. +# Пишется при сборке; COMMIT вычисляется там же, через ARG в ENV он бы +# не пролез. +. /etc/mirror-meta + +PUSH_TAGS="${PUSH_TAGS:-true}" +PUSH_PRUNE="${PUSH_PRUNE:-true}" +PUSH_FORCE="${PUSH_FORCE:-true}" +VERIFY_AFTER_PUSH="${VERIFY_AFTER_PUSH:-true}" +DRY_RUN="${DRY_RUN:-false}" + +stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; } +log() { printf '%s [mirror] %s\n' "$(stamp)" "$*"; } +die() { printf '%s [mirror] ERROR %s\n' "$(stamp)" "$*" >&2; exit 1; } + +init_git() { + # Репозиторий в образе принадлежит сборщику, а не текущему uid. + # Конфиг — во временный каталог: корень образа read-only. + GIT_CONFIG_GLOBAL="${TMP_DIR}/gitconfig" + export GIT_CONFIG_GLOBAL + : > "${GIT_CONFIG_GLOBAL}" + git config --global --add safe.directory '*' + git config --global advice.detachedHead false +} + +# Пароль нигде не материализуется: ни в URL ремоута, ни в argv (виден +# в `ps`), ни в git-конфиге на диске — только в окружении процесса. +# Credential-хелпер, а не GIT_ASKPASS: askpass требует исполняемый файл во +# временном каталоге, а под Kubernetes тот может быть примонтирован noexec. +setup_auth() { + pass="${GIT_PASSWORD:-}" + if [ -z "${pass}" ] && [ -n "${GIT_PASSWORD_FILE:-}" ]; then + [ -r "${GIT_PASSWORD_FILE}" ] \ + || die "не читается GIT_PASSWORD_FILE=${GIT_PASSWORD_FILE}" + pass="$(tr -d '\r\n' < "${GIT_PASSWORD_FILE}")" + fi + + if [ -n "${pass}" ] || [ -n "${GIT_USERNAME:-}" ]; then + GIT_AUTH_USER="${GIT_USERNAME:-}" + GIT_AUTH_PASS="${pass}" + export GIT_AUTH_USER GIT_AUTH_PASS + git config --global credential.helper \ + '!f() { printf "username=%s\npassword=%s\n" "${GIT_AUTH_USER}" "${GIT_AUTH_PASS}"; }; f' + fi + + if [ "${GIT_SSL_NO_VERIFY:-false}" = "true" ]; then + export GIT_SSL_NO_VERIFY=true + fi +} + +local_refs() { + git -C "${REPO_DIR}" for-each-ref --format='%(objectname) %(refname)' \ + refs/heads refs/tags | sort +} + +remote_refs() { + git ls-remote "${GIT_URL}" 'refs/heads/*' 'refs/tags/*' \ + | grep -v '\^{}$' | awk '{print $1 " " $2}' | sort +} + +# Сверка делает Job самопроверяющимся: если что-то не доехало, контейнер +# падает, а не рапортует об успехе. +compare_refs() { + local_refs > "${TMP_DIR}/expected" + remote_refs > "${TMP_DIR}/actual" + if cmp -s "${TMP_DIR}/expected" "${TMP_DIR}/actual"; then + log "сверка: ok, refs $(wc -l < "${TMP_DIR}/expected" | tr -d ' ')" + return 0 + fi + log "сверка: РАСХОЖДЕНИЕ (< образ, > сервер)" + diff "${TMP_DIR}/expected" "${TMP_DIR}/actual" >&2 || true + die "состояние цели не совпало с образом" +} + +do_push() { + init_git + + prefix="" + if [ "${PUSH_FORCE}" = "true" ]; then prefix="+"; fi + + set -- "${prefix}refs/heads/*:refs/heads/*" + if [ "${PUSH_TAGS}" = "true" ]; then + set -- "$@" "${prefix}refs/tags/*:refs/tags/*" + fi + + log "${SOURCE_PATH} ${GIT_TAG} (${COMMIT}), собрано ${BUILT_AT}" + + # Режим сухого прогона: показать всё, что образ собирается сделать, и + # ничего не менять. Цель необязательна — так этим режимом проверяют + # сам образ сразу после сборки, ещё не зная, в какой контур он поедет. + if [ "${DRY_RUN}" = "true" ]; then + log "dry-run: цель ${GIT_URL:-<не задана>}, рефспеки: $*" + git -C "${REPO_DIR}" for-each-ref \ + --format=' %(objectname:short) %(refname)' refs/heads refs/tags + return 0 + fi + + [ -n "${GIT_URL:-}" ] || die "не задан GIT_URL" + setup_auth + + # --prune снимает на сервере ветки и теги, которых нет в образе. + # Без него удаления из источника в закрытый контур не доезжают. + if [ "${PUSH_PRUNE}" = "true" ]; then + git -C "${REPO_DIR}" push --quiet --prune "${GIT_URL}" "$@" + else + git -C "${REPO_DIR}" push --quiet "${GIT_URL}" "$@" + fi + log "запушено в ${GIT_URL}" + + if [ "${VERIFY_AFTER_PUSH}" = "true" ]; then compare_refs; fi +} + +do_push +PUSH_SH +RUN chmod 0755 /app/push.sh + +COPY --from=fetch --chown=1000:1000 /repo /repo + +# Подпись образа: видна в `docker run ... info` и в логе Job-а, так что по +# логу понятно, что именно уехало в контур. +ARG GIT_PATH=infra/iac +ARG GIT_TAG +ARG SOURCE_URL=https://gitlab.sarex.io/infra/iac +ARG BUILT_AT +COPY --from=fetch /repo-commit /tmp/repo-commit +RUN COMMIT="$(cat /tmp/repo-commit)" \ + && rm -f /tmp/repo-commit \ + && printf 'SOURCE_PATH=%s\nSOURCE_URL=%s\nGIT_TAG=%s\nCOMMIT=%s\nBUILT_AT=%s\n' \ + "${GIT_PATH}" "${SOURCE_URL}" "${GIT_TAG}" "${COMMIT}" "${BUILT_AT}" \ + > /etc/mirror-meta \ + && chmod 0444 /etc/mirror-meta + +ENV HOME=/home/mirror \ + GIT_TERMINAL_PROMPT=0 \ + TZ=UTC + +LABEL org.opencontainers.image.source="https://gitlab.sarex.io/infra/iac" \ + org.opencontainers.image.version="${GIT_TAG}" \ + org.opencontainers.image.created="${BUILT_AT}" + +USER mirror +WORKDIR / + +ENTRYPOINT ["/sbin/tini", "--", "/app/push.sh"]