Проект 4. Эталонное решение
Открывать после собственной реализации и прохождения CHECKLIST.md.
Итоговый проект курса. Решение — не единственно верное.
Что проверено на самом деле
| Что | Как | Результат |
|---|---|---|
| Скрипты конвейера | фактический запуск | работают |
| Три состояния сводки | три набора входных данных | коды 0, 1, 3 различимы |
| Отсутствие сканера | запуск с урезанным PATH | код 3, не 0 |
| Отказ развёртывания по тегу | запуск | отвергнуто |
| Отказ отката без записи | запуск | отвергнуто |
| Вычисление тега | временный репозиторий git | SHA, -dirty в трёх случаях |
| Тесты конвейера | pytest | 28 пройдено |
| Линтер | ruff | пройден (и один раз поймал настоящую претензию) |
| Сборка образа | не выполнялась | Docker отсутствует |
| Сканирование | не выполнялось | trivy отсутствует |
| Полный SBOM | не собирался | syft отсутствует |
| Публикация и развёртывание | не выполнялись | нет registry и Docker |
Прогон конвейера на машине курса даёт код 3: два этапа пройдено, четыре не выполнялось. Это правильный исход, а не неудача демонстрации — он показывает ровно то, чему проект учит.
Шаг 1. Общие определения
ci/lib.sh:
#!/usr/bin/env bash
# Общие определения для скриптов конвейера.
#
# Скрипты запускаются и локально, и в CI. Единственное, что даёт CI, —
# переменные окружения; вся логика здесь, поэтому цикл отладки
# измеряется секундами, а не минутами ожидания push.
set -euo pipefail
# Три кода возврата вместо двух. Отсутствующий инструмент не должен
# выглядеть как инструмент, не нашедший проблем.
readonly EXIT_OK=0
readonly EXIT_FINDINGS=1
readonly EXIT_NOT_CHECKED=3
: "${IMAGE_NAME:=ghcr.io/example/eventapi}"
: "${ARTIFACTS_DIR:=artifacts}"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
fail() { printf '[%s] ОШИБКА: %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; exit "$EXIT_FINDINGS"; }
# Отметить шаг невыполненным: инструмента нет.
skip() {
printf '[%s] НЕ ПРОВЕРЕНО: %s\n' "$(date -u +%H:%M:%S)" "$*" >&2
return "$EXIT_NOT_CHECKED"
}
have() { command -v "$1" >/dev/null 2>&1; }
artifacts() { mkdir -p "$ARTIFACTS_DIR"; printf '%s' "$ARTIFACTS_DIR"; }
# Неизменяемый тег: короткий SHA коммита плюс признак грязного дерева.
# Перезаписываемый тег вроде :prod делает откат невозможным —
# старого образа под ним уже нет.
image_tag() {
local sha dirty=""
sha="$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
# git status --porcelain, а не git diff: неотслеживаемый файл входит
# в контекст сборки и меняет образ, но git diff его не видит.
# Проверено: с одним лишь `git diff` тег не менялся при добавлении
# нового файла, и образ переставал соответствовать коммиту.
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
dirty="-dirty"
fi
printf 'g%s%s' "$sha" "$dirty"
}
image_ref() { printf '%s:%s' "$IMAGE_NAME" "$(image_tag)"; }
Два решения.
Три кода возврата объявлены в одном месте. Если каждый скрипт выбирает числа сам, рано или поздно один вернёт 2 вместо 3, и сводка отнесёт его к провалам. Общий файл — единственный способ удержать соглашение.
Функция skip печатает и возвращает 3. Соблазн был писать exit 0 с предупреждением: конвейер зелёный, никто не жалуется. Это и есть та ошибка, ради которой существует весь проект.
Найденная ошибка. Первая редакция image_tag использовала git diff для проверки чистоты дерева. Тест показал, что неотслеживаемый файл так не обнаруживается: git diff смотрит только на отслеживаемые. При этом такой файл входит в контекст сборки и меняет образ.
То есть образ переставал соответствовать коммиту, а тег об этом молчал — худший вид расхождения, потому что он не проявляется никак. Исправление — git status --porcelain, который видит и неотслеживаемое.
Шаг 2. Этапы
ci/lint.sh:
#!/usr/bin/env bash
# Быстрые проверки: идут первыми, потому что падают чаще всего
# и проверяются за секунды.
source "$(dirname "$0")/lib.sh"
failures=0
if have ruff; then
log "ruff"
ruff check . || failures=$((failures + 1))
else
log "ruff отсутствует, использую compileall"
python3 -m compileall -q app tests || failures=$((failures + 1))
fi
log "синтаксис скриптов конвейера"
for f in ci/*.sh; do bash -n "$f" || failures=$((failures + 1)); done
[ "$failures" -eq 0 ] || fail "быстрые проверки не прошли: $failures"
log "быстрые проверки пройдены"
ci/test.sh:
#!/usr/bin/env bash
# Тесты. Отсутствие базы в конвейере — отказ, а не пропуск.
source "$(dirname "$0")/lib.sh"
if ! have python3; then skip "python3 отсутствует" || exit $?; fi
if ! python3 -c "import pytest" 2>/dev/null; then
skip "pytest не установлен; тесты не выполнялись" || exit $?
fi
export EVENTAPI_REQUIRE_DB="${EVENTAPI_REQUIRE_DB:-1}"
python3 -m pytest -q || fail "тесты не прошли"
log "тесты пройдены"
EVENTAPI_REQUIRE_DB=1 по умолчанию: в конвейере отсутствие базы должно быть отказом, а не пропуском (проект 3).
ci/build.sh:
#!/usr/bin/env bash
# Сборка образа с кэшем, переносимым между запусками конвейера.
source "$(dirname "$0")/lib.sh"
if ! have docker; then
skip "docker недоступен; образ не собирался" || exit $?
fi
ref="$(image_ref)"
log "собираю $ref"
# mode=max, а не умолчание: mode=min экспортирует только последнюю
# стадию, и слой с зависимостями в кэш не попадает — самая частая
# причина «кэш настроен, а сборка медленная».
docker buildx build \
--target runtime \
--cache-from "type=registry,ref=${IMAGE_NAME}:buildcache" \
--cache-to "type=registry,ref=${IMAGE_NAME}:buildcache,mode=max" \
--tag "$ref" \
--load \
. || fail "сборка не удалась"
printf '%s\n' "$ref" > "$(artifacts)/image-ref.txt"
log "образ собран: $ref"
ci/verify-image.sh:
#!/usr/bin/env bash
# Проверки собранного образа — те же, что в уроке 15.5.
#
# Проверяется ОБРАЗ, а не исходники: между ними Dockerfile,
# и ошибки в нём исходниками не ловятся.
source "$(dirname "$0")/lib.sh"
IMAGE="${1:-$(image_ref)}"
failures=0
checked=0
skipped=0
check() {
local name="$1"; shift
checked=$((checked + 1))
if "$@" >/dev/null 2>&1; then
printf ' ✓ %s\n' "$name"
else
printf ' ✗ %s\n' "$name"
failures=$((failures + 1))
fi
}
if ! have docker; then
skipped=1
skip "docker недоступен; образ $IMAGE не проверялся" || true
printf 'проверок выполнено: 0, не выполнено: все\n'
exit "$EXIT_NOT_CHECKED"
fi
log "проверяю образ $IMAGE"
user="$(docker image inspect "$IMAGE" --format '{{.Config.User}}')"
check "пользователь задан" test -n "$user"
check "пользователь числовой" grep -qE '^[0-9]+(:[0-9]+)?$' <<<"$user"
check "пользователь не root" test "$user" != "0" -a "$user" != "0:0"
check "точка входа в exec-форме" docker image inspect "$IMAGE" \
--format '{{json .Config.Entrypoint}}{{json .Config.Cmd}}' \
| grep -qv 'sh","-c'
check "секретов в истории нет" bash -c \
"! docker history --no-trunc '$IMAGE' | grep -iqE 'password|secret|token|api_key'"
check "работает только для чтения" docker run --rm --read-only --tmpfs /tmp \
"$IMAGE" python -c 'print(1)'
check "нет оболочки" bash -c \
"! docker run --rm --entrypoint sh '$IMAGE' -c 'exit 0'"
printf 'проверок: %d, провалов: %d\n' "$checked" "$failures"
[ "$failures" -eq 0 ] || exit "$EXIT_FINDINGS"
Семь проверок применяются к собранному образу. Между исходниками и образом лежит Dockerfile, и его ошибки — лишний пакет, секрет в слое, забытый USER — исходниками не ловятся (урок 15.5).
ci/scan.sh:
#!/usr/bin/env bash
# Сканирование образа на уязвимости.
#
# Три кода возврата — главное решение скрипта:
# 0 просканировано, находок выше порога нет
# 1 находки есть
# 3 НЕ просканировано: инструмента нет
#
# Без третьего кода отсутствующий сканер неотличим от чистого образа,
# и конвейер становится зелёным ровно тогда, когда проверять перестали.
source "$(dirname "$0")/lib.sh"
SEVERITY="${SCAN_SEVERITY:-HIGH,CRITICAL}"
IMAGE="${1:-$(image_ref)}"
OUT="$(artifacts)/scan.json"
if ! have trivy; then
skip "trivy не установлен; образ $IMAGE не проверялся" || exit $?
fi
log "сканирую $IMAGE (пороги: $SEVERITY)"
if trivy image --severity "$SEVERITY" --format json --output "$OUT" \
--exit-code "$EXIT_FINDINGS" "$IMAGE"; then
log "находок уровня $SEVERITY нет"
exit "$EXIT_OK"
fi
count="$(python3 -c "
import json, sys
data = json.load(open('$OUT'))
n = sum(len(r.get('Vulnerabilities') or []) for r in data.get('Results', []))
print(n)" 2>/dev/null || echo '?')"
fail "найдено уязвимостей уровня $SEVERITY: $count (подробности в $OUT)"
ci/sbom.sh:
#!/usr/bin/env bash
# Состав образа (SBOM).
#
# Если syft недоступен, состав собирается из файла зависимостей.
# Это НЕ равноценная замена — она не видит системных пакетов базового
# образа, — поэтому результат помечается как частичный, а код возврата
# отличается от успешного.
source "$(dirname "$0")/lib.sh"
IMAGE="${1:-$(image_ref)}"
OUT="$(artifacts)/sbom.json"
if have syft; then
log "собираю SBOM образа $IMAGE"
syft "$IMAGE" -o spdx-json > "$OUT"
log "SBOM записан: $OUT"
exit "$EXIT_OK"
fi
log "syft не установлен; собираю ЧАСТИЧНЫЙ состав из requirements.txt"
python3 - "$OUT" <<'PY'
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
out = Path(sys.argv[1])
packages = []
for line in Path("requirements.txt").read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith(("#", "-")):
continue
m = re.match(r"^([A-Za-z0-9._-]+)(\[[^\]]+\])?==(.+)$", line)
if not m:
continue
packages.append({"name": m.group(1), "versionInfo": m.group(3),
"extras": (m.group(2) or "").strip("[]") or None})
out.write_text(json.dumps({
"spdxVersion": "SPDX-2.3",
"name": "частичный состав",
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"comment": ("ЧАСТИЧНЫЙ СОСТАВ: собран из requirements.txt, "
"системные пакеты базового образа не учтены"),
"complete": False,
"packages": packages,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"пакетов: {len(packages)}", file=sys.stderr)
PY
skip "полный SBOM не собран: syft отсутствует; записан частичный состав" || exit $?
Решение о частичном составе. Когда syft недоступен, состав собирается из requirements.txt — но помечается complete: false и возвращает код 3.
Соблазн был вернуть 0: файл ведь создан. Но такой файл не видит системных пакетов базового образа, а уязвимости чаще находят именно там. Файл, выглядящий как SBOM и не являющийся им, хуже отсутствующего: на него сошлются.
Шаг 3. Сводка
ci/summary.py:
"""Сводка по этапам конвейера.
Отдельный файл, а не heredoc внутри скрипта: в bash ширина поля
у printf считается в БАЙТАХ, и таблица с кириллицей разъезжается.
Первая редакция давала сдвиг на два символа в каждой строке.
Читает строки «имя<TAB>код» со стандартного ввода.
Код возврата: 0 всё пройдено, 1 есть провалы, 3 есть непроверенное.
"""
from __future__ import annotations
import sys
OK, FINDINGS, NOT_CHECKED = 0, 1, 3
VERDICT = {OK: "пройдено", NOT_CHECKED: "НЕ ПРОВЕРЕНО"}
def main() -> int:
rows: list[tuple[str, str, int]] = []
for line in sys.stdin.read().splitlines():
if not line.strip():
continue
name, _, raw = line.partition("\t")
code = int(raw)
rows.append((name, VERDICT.get(code, f"ПРОВАЛЕНО ({code})"), code))
if not rows:
print("этапов не выполнялось")
return NOT_CHECKED
width = max(len(n) for n, _, _ in rows)
print()
print(f"{'этап':<{width}} результат")
print("─" * (width + 16))
for name, verdict, _ in rows:
print(f"{name:<{width}} {verdict}")
ok = sum(1 for _, _, c in rows if c == OK)
unchecked = sum(1 for _, _, c in rows if c == NOT_CHECKED)
failed = len(rows) - ok - unchecked
print()
print(f"пройдено: {ok}, провалено: {failed}, не проверено: {unchecked}")
if failed:
print("ИТОГ: конвейер провален")
return FINDINGS
if unchecked:
print(f"ИТОГ: конвейер прошёл НЕ ПОЛНОСТЬЮ — "
f"{unchecked} этапов не выполнялось")
print("Это не успех. Для публикации образа требуется код 0.")
return NOT_CHECKED
print("ИТОГ: конвейер пройден полностью")
return OK
if __name__ == "__main__":
sys.exit(main())
Почему это Python, а не printf.
Первая редакция печатала таблицу средствами bash. Вывод разъехался:
этап результат
──────────────────────────────────────────
линтер пройдено
тесты ПРОВАЛЕНО (1)
сборка НЕ ПРОВЕРЕНО
проверка образа НЕ ПРОВЕРЕНО
Причина: в bash ширина поля у printf считается в байтах, а кириллица в UTF-8 занимает по два. Каждое русское слово сдвигало столбец.
Обходных путей в переносимом bash нет, поэтому сводка вынесена в отдельный файл на Python. Тест test_table_columns_line_up_with_cyrillic фиксирует выравнивание — иначе исправление отменилось бы при первой же правке.
ci/pipeline.sh:
#!/usr/bin/env bash
# Конвейер целиком. Порядок этапов не назначен, а выведен:
# сортировка по «время / вероятность отказа» ставит вперёд то,
# что падает часто и проверяется быстро.
#
# Итог различает ТРИ состояния, а не два: пройдено, провалено,
# не проверено. Конвейер, у которого «не проверено» сливается
# с «пройдено», становится зелёным ровно тогда, когда проверять
# перестали.
source "$(dirname "$0")/lib.sh"
RESULTS="$(mktemp)"
trap 'rm -f "$RESULTS"' EXIT
run_stage() {
local name="$1"; shift
local code=0
log "── этап: $name"
"$@" || code=$?
printf '%s\t%s\n' "$name" "$code" >> "$RESULTS"
}
run_stage "линтер" ci/lint.sh
run_stage "тесты" ci/test.sh
run_stage "сборка" ci/build.sh
run_stage "проверка образа" ci/verify-image.sh
run_stage "сканирование" ci/scan.sh
run_stage "состав (SBOM)" ci/sbom.sh
python3 ci/summary.py < "$RESULTS"
Порядок этапов выведен сортировкой по время / вероятность отказа (урок 16.1). Исключение — сканирование: оно стоит после сборки не по времени, а потому что проверяет образ.
Шаг 4. Развёртывание и откат
ci/deploy.sh:
#!/usr/bin/env bash
# Развёртывание по digest, а не по тегу.
#
# Тег — изменяемая ссылка. Между «собрали» и «запустили» он может
# указывать уже на другое, и это ровно тот случай, когда в эксплуатации
# работает не тот код, который ожидали.
source "$(dirname "$0")/lib.sh"
REF="${1:-}"
[ -n "$REF" ] || fail "использование: deploy.sh ОБРАЗ@sha256:... [ОКРУЖЕНИЕ]"
case "$REF" in
*@sha256:*) : ;;
*) fail "развёртывание по тегу запрещено: укажите digest ($REF)" ;;
esac
ENVIRONMENT="${2:-production}"
STATE="$(artifacts)/deployed-$ENVIRONMENT.txt"
previous="$(cat "$STATE" 2>/dev/null || echo "")"
log "окружение: $ENVIRONMENT"
log "было: ${previous:-<пусто>}"
log "будет: $REF"
if ! have docker; then
skip "docker недоступен; развёртывание не выполнялось" || exit $?
fi
docker pull "$REF" || fail "образ недоступен: $REF"
IMAGE_REF="$REF" docker compose up -d --wait || fail "развёртывание не удалось"
# Запись предыдущего состояния — то, что делает откат возможным.
[ -n "$previous" ] && printf '%s\n' "$previous" > "$(artifacts)/previous-$ENVIRONMENT.txt"
printf '%s\n' "$REF" > "$STATE"
log "развёрнуто: $REF"
ci/rollback.sh:
#!/usr/bin/env bash
# Откат на предыдущий digest.
#
# «Развернуть предыдущую версию» — не план отката, а пожелание.
# Планом он становится, когда предыдущий digest записан и команда,
# возвращающая его, выполнима без раздумий.
source "$(dirname "$0")/lib.sh"
ENVIRONMENT="${1:-production}"
STATE="$(artifacts)/deployed-$ENVIRONMENT.txt"
PREV="$(artifacts)/previous-$ENVIRONMENT.txt"
[ -f "$PREV" ] || fail "предыдущий digest не записан: откатываться некуда"
target="$(cat "$PREV")"
current="$(cat "$STATE" 2>/dev/null || echo "<неизвестно>")"
log "текущий: $current"
log "откат на: $target"
if ! have docker; then
skip "docker недоступен; откат не выполнялся" || exit $?
fi
docker pull "$target" || fail "образ отката недоступен: $target"
IMAGE_REF="$target" docker compose up -d --wait || fail "откат не удался"
printf '%s\n' "$current" > "$PREV"
printf '%s\n' "$target" > "$STATE"
log "откат выполнен: $target"
Два решения.
Развёртывание по тегу запрещено на уровне скрипта. Не предупреждение, а отказ. Тег — изменяемая ссылка, и между «собрали» и «запустили» он может указывать уже на другое (checkpoint 4).
Предыдущий digest записывается при каждом развёртывании. Это единственное, что превращает «откатимся на предыдущую версию» из пожелания в команду. Без записи откат требует выяснять, что было развёрнуто, — а выяснять это приходится в тот момент, когда всё горит.
Шаг 5. Тесты конвейера
tests/test_pipeline.py:
"""Проверки самого конвейера.
Скрипты конвейера — код, и он проверяется так же, как остальной.
Иначе единственный способ узнать об ошибке в нём — сломанная выкатка.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CI = ROOT / "ci"
OK, FINDINGS, NOT_CHECKED = 0, 1, 3
def run(args, **kw):
env = {**os.environ, **kw.pop("env", {})}
return subprocess.run(args, cwd=ROOT, env=env, capture_output=True,
text=True, **kw)
def summary(rows: str):
return subprocess.run([sys.executable, str(CI / "summary.py")],
input=rows, capture_output=True, text=True)
# ── Сводка: три состояния ────────────────────────────────────────────
def test_all_passed_gives_zero():
r = summary("линтер\t0\nтесты\t0\n")
assert r.returncode == OK
assert "пройден полностью" in r.stdout
def test_any_failure_gives_one():
r = summary("линтер\t0\nтесты\t1\n")
assert r.returncode == FINDINGS
assert "провален" in r.stdout
def test_unchecked_is_not_success():
"""Главное требование: непроверенное не равно пройденному."""
r = summary("линтер\t0\nсканирование\t3\n")
assert r.returncode == NOT_CHECKED
assert "НЕ ПОЛНОСТЬЮ" in r.stdout
assert "не успех" in r.stdout.lower()
def test_failure_wins_over_unchecked():
r = summary("тесты\t1\nсканирование\t3\n")
assert r.returncode == FINDINGS
def test_empty_input_is_not_success():
r = summary("")
assert r.returncode == NOT_CHECKED
def test_table_columns_line_up_with_cyrillic():
"""В bash printf ширина считается в байтах — таблица разъезжалась."""
r = summary("линтер\t0\nпроверка образа\t3\nсостав (SBOM)\t0\n")
rows = [line for line in r.stdout.splitlines()
if line.startswith(("линтер", "проверка образа", "состав"))]
assert len(rows) == 3
positions = {row.index("пройдено") if "пройдено" in row
else row.index("НЕ ПРОВЕРЕНО") for row in rows}
assert len(positions) == 1, f"столбцы не выровнены: {positions}"
# ── Скрипты этапов ───────────────────────────────────────────────────
@pytest.mark.parametrize("script", sorted(p.name for p in CI.glob("*.sh")))
def test_script_syntax_is_valid(script):
assert run(["bash", "-n", str(CI / script)]).returncode == 0
def test_missing_scanner_gives_not_checked():
"""Отсутствующий сканер обязан отличаться от чистого образа."""
r = run([str(CI / "scan.sh")], env={"PATH": "/usr/bin:/bin"})
assert r.returncode == NOT_CHECKED
assert "НЕ ПРОВЕРЕНО" in r.stderr
def test_missing_docker_gives_not_checked():
r = run([str(CI / "verify-image.sh")], env={"PATH": "/usr/bin:/bin"})
assert r.returncode == NOT_CHECKED
def test_partial_sbom_is_marked_incomplete():
"""Состав из requirements.txt не равноценен полному SBOM."""
import json
r = run([str(CI / "sbom.sh")], env={"PATH": "/usr/bin:/bin"})
assert r.returncode == NOT_CHECKED
data = json.loads((ROOT / "artifacts" / "sbom.json").read_text(encoding="utf-8"))
assert data["complete"] is False
assert "ЧАСТИЧНЫЙ" in data["comment"]
assert len(data["packages"]) > 0
# ── Теги ─────────────────────────────────────────────────────────────
def tag_in(cwd: Path) -> str:
"""Тег для указанного каталога.
Тесты тегов работают во ВРЕМЕННОМ репозитории, а не в этом:
первая редакция проверяла рабочее дерево проекта и падала,
стоило отредактировать сам файл теста. Тест, зависящий
от состояния репозитория, ломается от собственной правки.
"""
r = subprocess.run(["bash", "-c", f"source {CI}/lib.sh; image_tag"],
cwd=cwd, capture_output=True, text=True)
return r.stdout.strip()
@pytest.fixture
def repo(tmp_path: Path) -> Path:
for args in (["git", "init", "-q", "."],
["git", "config", "user.email", "t@example.com"],
["git", "config", "user.name", "Test"]):
subprocess.run(args, cwd=tmp_path, check=True, capture_output=True)
(tmp_path / "файл.txt").write_text("содержимое", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True,
capture_output=True)
subprocess.run(["git", "commit", "-qm", "первый"], cwd=tmp_path,
check=True, capture_output=True)
return tmp_path
def test_tag_is_commit_sha(repo: Path):
sha = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
cwd=repo, capture_output=True, text=True).stdout.strip()
assert tag_in(repo) == f"g{sha}"
def test_tag_marks_modified_file_as_dirty(repo: Path):
(repo / "файл.txt").write_text("другое", encoding="utf-8")
assert tag_in(repo).endswith("-dirty")
def test_tag_marks_untracked_file_as_dirty(repo: Path):
"""Неотслеживаемый файл входит в контекст сборки.
git diff его НЕ видит — первая редакция помечала дерево чистым,
и образ переставал соответствовать коммиту.
"""
assert not tag_in(repo).endswith("-dirty")
(repo / "новый.txt").write_text("x", encoding="utf-8")
assert tag_in(repo).endswith("-dirty")
(repo / "новый.txt").unlink()
assert not tag_in(repo).endswith("-dirty")
def test_tag_has_no_mutable_names(repo: Path):
"""Перезаписываемый тег делает откат невозможным."""
tag = tag_in(repo)
for mutable in ("latest", "prod", "stable", "main"):
assert mutable not in tag
# ── Развёртывание и откат ────────────────────────────────────────────
def test_deploy_refuses_tag():
"""Тег — изменяемая ссылка; развёртывание по нему запрещено."""
r = run([str(CI / "deploy.sh"), "ghcr.io/example/app:v1"])
assert r.returncode == FINDINGS
assert "по тегу запрещено" in r.stderr
def test_deploy_accepts_digest():
digest = "ghcr.io/example/app@sha256:" + "a" * 64
r = run([str(CI / "deploy.sh"), digest], env={"PATH": "/usr/bin:/bin"})
assert r.returncode == NOT_CHECKED, "docker нет — шаг не выполнен, а не провален"
assert digest in r.stderr
def test_deploy_requires_argument():
assert run([str(CI / "deploy.sh")]).returncode == FINDINGS
def test_rollback_without_previous_is_refused():
"""Откат «на предыдущую версию» без записанного digest невозможен."""
prev = ROOT / "artifacts" / "previous-production.txt"
if prev.exists():
prev.unlink()
r = run([str(CI / "rollback.sh")])
assert r.returncode == FINDINGS
assert "откатываться некуда" in r.stderr
def test_rollback_reads_recorded_digest():
art = ROOT / "artifacts"
art.mkdir(exist_ok=True)
digest = "ghcr.io/example/app@sha256:" + "b" * 64
(art / "previous-production.txt").write_text(digest + "\n", encoding="utf-8")
try:
r = run([str(CI / "rollback.sh")], env={"PATH": "/usr/bin:/bin"})
assert r.returncode == NOT_CHECKED
assert digest in r.stderr
finally:
(art / "previous-production.txt").unlink()
Ошибка в самих тестах, и она поучительнее прочих.
Первая редакция тестов тегов работала с репозиторием самого проекта. Тесты падали — и не потому, что код неверен, а потому, что я только что отредактировал файл теста, и дерево стало грязным.
Тест, зависящий от состояния репозитория, в котором лежит, ломается от собственной правки. Исправление — временный репозиторий в tmp_path, создаваемый приспособлением. После этого набор проходит и при грязном дереве, что отдельно проверено.
Ещё одна находка — от линтера. ruff отверг l как имя переменной (правило про неоднозначные имена: l, I, O неразличимы с цифрами в части шрифтов). Претензия справедливая, и поймал её первый этап конвейера — то, ради чего он стоит первым.
Шаг 6. Workflow и образ
.github/workflows/pipeline.yaml:
# Шаги вызывают скрипты, а не содержат логику.
# Из этого следует три вещи: конвейер отлаживается локально,
# перенос на другую платформу механический, и те же скрипты
# служат проверкой образа в тестах.
name: pipeline
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
id-token: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
EVENTAPI_REQUIRE_DB: "1"
jobs:
pipeline:
runs-on: ubuntu-latest
services:
db:
image: postgres:17-alpine
env:
POSTGRES_DB: appdb_test
POSTGRES_USER: app
POSTGRES_PASSWORD: ci-only-password
options: >-
--health-cmd "pg_isready -U app -d appdb_test"
--health-interval 5s --health-retries 10
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # нужен для tag по SHA
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: docker/setup-buildx-action@v3
- name: установить зависимости разработки
run: pip install -r requirements-dev.txt
# Быстрые проверки первыми: падают чаще, стоят секунды.
- name: линтер
run: ci/lint.sh
- name: тесты
run: ci/test.sh
env:
EVENTAPI_DATABASE_URL: postgresql://app:ci-only-password@localhost:5432/appdb_test
- name: вход в registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: сборка
run: ci/build.sh
- name: проверка образа
run: ci/verify-image.sh
# Сканирование ПОСЛЕ сборки: проверяется образ, а не исходники.
- name: сканирование
run: ci/scan.sh
- name: состав (SBOM)
run: ci/sbom.sh
- name: артефакты
if: always()
uses: actions/upload-artifact@v4
with:
name: artifacts-${{ github.sha }}
path: artifacts/
# Публикация только при полностью пройденном конвейере.
# Код 3 («не проверено») сюда не пускает — в этом весь смысл.
- name: публикация
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
ref="$(cat artifacts/image-ref.txt)"
docker push "$ref"
docker image inspect "$ref" --format '{{index .RepoDigests 0}}' \
| tee artifacts/published-digest.txt
Из тринадцати шагов шесть — вызовы ci/*.sh. Остальные: получение исходников, установка окружения, вход в registry, выгрузка артефактов, публикация. Ни одна проверка в YAML не живёт.
Публикация обусловлена не «зелёным» конвейером вообще, а его успешным завершением: код 3 сюда не пускает.
Dockerfile:
# syntax=docker/dockerfile:1
FROM python:3.13-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PATH=/opt/venv/bin:$PATH
WORKDIR /app
FROM base AS deps
RUN python -m venv /opt/venv
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
FROM deps AS test
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements-dev.txt
COPY . .
RUN python -m pytest -q
FROM base AS runtime
# Метки для прослеживаемости: по образу можно найти коммит,
# из которого он собран, не заглядывая в конвейер.
ARG VCS_REF=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.source="https://github.com/example/eventapi"
COPY --from=deps /opt/venv /opt/venv
COPY app/ ./app/
USER 10001:10001
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
Базовый образ закреплён по digest, а не по тегу: python:3.13-slim меняется, и сборка от этого перестаёт быть воспроизводимой. Метки org.opencontainers.image.* позволяют по образу найти коммит, не заглядывая в конвейер.
Чего решение не делает
Ни один образ не собран. Docker на машине курса отсутствует. Этапы сборка, проверка образа, сканирование и публикация не выполнялись ни разу — их код проверен только на том пути, где инструмента нет.
Это существенная оговорка: правильность семи проверок в verify-image.sh подтверждена чтением документации, а не запуском. Проверьте их у себя — в том числе на заведомо плохом образе.
Digest базового образа выдуман. Строка sha256:aaaa0000… в Dockerfile — заполнитель. Настоящий получают командой docker buildx imagetools inspect python:3.13-slim. Оставлять выдуманное значение в работе нельзя; здесь оно оставлено, потому что подставить настоящее нечем, а придумывать правдоподобное — тот самый подлог, против которого написан весь проект.
Кэш между запусками не измерен. Дополнительное задание 5 требует двух прогонов в CI.
Подпись образа не реализована. Дополнительное задание 1 требует cosign и ключей.
Воспроизводимость сборки не проверена. Дополнительное задание 2 требует двух сборок и сравнения digest.
Автоматический откат не реализован. Дополнительное задание 4 связывает конвейер с эксплуатацией, а эксплуатации здесь нет.
Тесты стека из проекта 3 в этот конвейер не подключены. Скрипт ci/test.sh запускает pytest в текущем каталоге; в настоящей работе он получил бы EVENTAPI_DATABASE_URL от сервиса db, объявленного в workflow. Объявление есть, проверка — нет.
Сравнение с вашей реализацией
Пять вопросов:
- Что делает ваш конвейер, если сканер не установился? Если становится зелёным — он врёт ровно тогда, когда важнее всего не врать.
- Можно ли запустить весь конвейер локально одной командой?
- Что произойдёт, если развернуть по тегу? Предупреждение — не защита.
- Где записан digest, на который вы откатитесь?
- Проходят ли ваши тесты при грязном рабочем дереве?
Что дальше
Курс закончен. Осталось:
Навигация
← Список проверок
← Техническое задание
Вернуться к проектам
Главное оглавление