iac/Dockerfile

262 lines
12 KiB
Docker
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# syntax=docker/dockerfile:1
#
# Самодостаточный образ переноса infra/iac в закрытый контур: несёт в себе
# и состояние репозитория на заданном теге, и логику push-а. Внешней базы
# нет — собирается от alpine, зависимостей от других репозиториев у сборки
# нет тоже.
#
# В образ попадают ровно два ref-а:
# refs/tags/<GIT_TAG> — сам тег
# refs/heads/<TARGET_BRANCH> — ветка на коммите тега; её читает 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"]