Главная/Проекты/Решение
Эталонное решениеСначала выполните проект самостоятельно и используйте этот материал для сверки решений.

Проект 4. Эталонное решение

Открывать после собственной реализации и прохождения CHECKLIST.md.

Итоговый проект курса. Решение — не единственно верное.

Что проверено на самом деле

ЧтоКакРезультат
Скрипты конвейерафактический запускработают
Три состояния сводкитри набора входных данныхкоды 0, 1, 3 различимы
Отсутствие сканеразапуск с урезанным PATHкод 3, не 0
Отказ развёртывания по тегузапускотвергнуто
Отказ отката без записизапускотвергнуто
Вычисление тегавременный репозиторий gitSHA, -dirty в трёх случаях
Тесты конвейераpytest28 пройдено
Линтерruffпройден (и один раз поймал настоящую претензию)
Сборка образане выполняласьDocker отсутствует
Сканированиене выполнялосьtrivy отсутствует
Полный SBOMне собиралсяsyft отсутствует
Публикация и развёртываниене выполнялисьнет registry и Docker

Прогон конвейера на машине курса даёт код 3: два этапа пройдено, четыре не выполнялось. Это правильный исход, а не неудача демонстрации — он показывает ровно то, чему проект учит.


Шаг 1. Общие определения

ci/lib.sh:

bash
#!/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:

bash
#!/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:

bash
#!/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:

bash
#!/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:

bash
#!/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:

bash
#!/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:

bash
#!/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:

python
"""Сводка по этапам конвейера.

Отдельный файл, а не 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. Вывод разъехался:

text
этап               результат
──────────────────────────────────────────
линтер           пройдено
тесты             ПРОВАЛЕНО (1)
сборка           НЕ ПРОВЕРЕНО
проверка образа НЕ ПРОВЕРЕНО

Причина: в bash ширина поля у printf считается в байтах, а кириллица в UTF-8 занимает по два. Каждое русское слово сдвигало столбец.

Обходных путей в переносимом bash нет, поэтому сводка вынесена в отдельный файл на Python. Тест test_table_columns_line_up_with_cyrillic фиксирует выравнивание — иначе исправление отменилось бы при первой же правке.

ci/pipeline.sh:

bash
#!/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:

bash
#!/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:

bash
#!/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:

python
"""Проверки самого конвейера.

Скрипты конвейера — код, и он проверяется так же, как остальной.
Иначе единственный способ узнать об ошибке в нём — сломанная выкатка.
"""
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:

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:

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. Объявление есть, проверка — нет.


Сравнение с вашей реализацией

Пять вопросов:

  1. Что делает ваш конвейер, если сканер не установился? Если становится зелёным — он врёт ровно тогда, когда важнее всего не врать.
  2. Можно ли запустить весь конвейер локально одной командой?
  3. Что произойдёт, если развернуть по тегу? Предупреждение — не защита.
  4. Где записан digest, на который вы откатитесь?
  5. Проходят ли ваши тесты при грязном рабочем дереве?

Что дальше

Курс закончен. Осталось:


Навигация

← Список проверок
← Техническое задание
Вернуться к проектам
Главное оглавление

Markdown на GitHub ↗