Модуль 2. Git Smart HTTP в Cloud Function
Цель модуля: Реализовать Git Smart HTTP протокол в Cloud Function: принять POST от git-клиента, распарсить CGI-заголовки, стримить packfile через io.Copy в git-http-backend, и защитить репозиторий от Directory Traversal. Понять, почему io.ReadAll убьёт функцию на 32 MB push, и как тестировать exec.Command через интерфейсы.
Стадия роста проекта: MVP / первые юзеры. Окружение развёрнуто (Модуль 1), health-check работает. Теперь Вася делает свой первый git push origin main — и Git-клиент ожидает Smart HTTP ответ. Первый вопрос: «Как Cloud Function обрабатывает Git-протокол, если у неё 32 MB лимит на тело запроса?»
Перед началом: Модуль 1 (Setup), git (понимание packfile, refs, Smart HTTP)
Инструменты: Go 1.22+, Cloud Functions, git-http-backend, os/exec, io.Copy
Трудоёмкость: ~6 ч (теория 1.5 ч · практика 4 ч · тестирование 0.5 ч)
🔥 Инцидент: push на 40 MB убил функцию
Вася делает git push origin main. В репозитории — 40 MB packfile (большой бинарный файл). Cloud Function получает POST с телом 40 MB. Код написан «как обычно»:
gobody, err := io.ReadAll(r.Body) // ❌ Читает ВСЁ в память
Функция падает с OOM: лимит памяти 512 MB, но Yandex Cloud убивает функцию при превышении 32 MB на тело запроса. Push не прошёл. Вася видит 502 Bad Gateway.
Вторая проблема: другой разработчик делает push одновременно. Оба запроса пишут в один bare-репозиторий на NFS. Без блокировки refs обновляются конкурентно — один push «перезаписывает» другой.
Третья проблема: кто-то отправляет запрос на /git/../../etc/passwd.git/info/refs. Без проверки пути git-http-backend получает доступ к файлам вне /var/git.
Три задачи этого модуля:
- Стриминг —
io.Copyвместоio.ReadAll: не читаем всё в память, а стримим в stdin git-http-backend. - Безопасность —
assertSafeRepo: проверяем, что путь не выходит за пределы/var/git. - Тестируемость —
exec.Commandчерез интерфейс, чтобы мокать в тестах.
Сквозной User Story: «Вася делает git push origin main. В этом модуле мы смотрим, что происходит на этапе Git Gateway: как Cloud Function принимает POST на /repo.git/git-receive-pack, парсит CGI-заголовки, стримит packfile в git-http-backend, и отвечает статусом. В Модуле 3 — как метаданные push сохраняются в YDB. В Модуле 4 — как событие RepositoryPushed доставляется в CI/CD.»
📖 Теория: Git Smart HTTP протокол
Глоссарий модуля:
Smart HTTP — протокол Git поверх HTTP: клиент отправляет POST с packfile, сервер отвечает ACK/NAK. Два эндпоинта: git-upload-pack (clone/fetch) и git-receive-pack (push).
CGI (Common Gateway Interface) — стандарт: HTTP-сервер передаёт запрос внешней программе через переменные окружения и stdin/stdout. Git использует CGI для git-http-backend.
git-http-backend — CGI-программа из поставки Git: принимает HTTP-запрос, обрабатывает Smart HTTP протокол, пишет в bare-репозиторий.
Packfile — бинарный формат Git: сжатые объекты (blobs, trees, commits) в одном файле. Push отправляет packfile.
io.Copy vs io.ReadAll — io.Copy стримит данные (O(1) память), io.ReadAll читает всё в память (O(n) память). Для 32 MB push — только io.Copy.
exec.Command — запуск внешнего процесса в Go. Требует context.Context для таймаута и cmd.Wait() для освобождения ресурсов.
assertSafeRepo — проверка, что путь к репозиторию не выходит за пределы базовой директории (защита от Directory Traversal).
2.1. Как работает Git Smart HTTP
Git-клиент общается с сервером через два эндпоинта:
| Операция | Эндпоинт | Метод | Что передаётся |
|---|---|---|---|
git clone / git fetch | /repo.git/info/refs?service=git-upload-pack | GET | Список refs |
git clone / git fetch | /repo.git/git-upload-pack | POST | Want/Have → packfile |
git push | /repo.git/info/refs?service=git-receive-pack | GET | Список refs |
git push | /repo.git/git-receive-pack | POST | Packfile + команды обновления refs |
Ключевой момент: Cloud Function не обрабатывает Git-протокол сама. Она передаёт запрос в git-http-backend через CGI-переменные окружения и stdin/stdout. Функция — «клей» между HTTP и CGI.
2.2. Мини-гайд по os/exec в Cloud Functions
Для новичков в Go: exec.Command — это запуск внешнего процесса. Три критических правила:
go// ❌ Антипаттерн: нет контекста, нет Wait cmd := exec.Command("git", "http-backend") cmd.Start() // Процесс запущен, но никто не ждёт завершения // ✅ Правильно: контекст + Wait func runGitBackend(ctx context.Context, env []string, stdin io.Reader, stdout io.Writer) error { cmd := exec.CommandContext(ctx, "git", "http-backend") cmd.Env = env cmd.Stdin = stdin // io.Copy стримит сюда cmd.Stdout = stdout // Ответ git-http-backend cmd.Stderr = os.Stderr // Логи ошибок if err := cmd.Start(); err != nil { return fmt.Errorf("start git-http-backend: %w", err) } // Обязательно: ждём завершения процесса // Без Wait() — zombie-процессы, утечка файловых дескрипторов if err := cmd.Wait(); err != nil { return fmt.Errorf("git-http-backend exited: %w", err) } return nil }
Правила:
- Всегда
exec.CommandContext(ctx, ...)— если Cloud Function убивают по таймауту, процесс тоже умрёт. - Всегда
cmd.Wait()— иначе zombie-процессы. В Cloud Function каждый zombie = утечка файловых дескрипторов. cmd.Stdin=io.Reader,cmd.Stdout=io.Writer— не читаем в память, стримим.
2.3. Стриминг: io.Copy вместо io.ReadAll
Лимит Cloud Function: 32 MB на тело запроса, 1 GB RAM. Push на 30 MB — это нормально. Но io.ReadAll читает всё в память:
go// ❌ Антипаттерн: читает 30 MB в память body, err := io.ReadAll(r.Body) cmd.Stdin = bytes.NewReader(body)
Правильно — стримить напрямую:
go// ✅ Правильно: стримим из HTTP body в stdin git-http-backend func (h *GitHandler) handleReceivePack(w http.ResponseWriter, r *http.Request) { // r.Body — io.ReadCloser, стримит из сети // cmd.Stdin — io.Reader, git-http-backend читает из него // io.Copy копирует байт за байтом, O(1) память ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) // Таймаут Cloud Function defer cancel() cmd := exec.CommandContext(ctx, "git", "http-backend") cmd.Env = h.buildCGIEnv(r) cmd.Stdin = r.Body // Стриминг: не читаем в память cmd.Stdout = w // Ответ git-http-backend → HTTP response cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { http.Error(w, "failed to start git-http-backend", 500) return } // git-http-backend сам читает из stdin и пишет в stdout // io.Copy не нужен здесь — git-http-backend делает это сам if err := cmd.Wait(); err != nil { http.Error(w, "git-http-backend failed", 500) return } }
Почему это работает: r.Body — это io.ReadCloser, который стримит данные из сети. cmd.Stdin = r.Body передаёт этот стрим напрямую в процесс git-http-backend. Данные не задерживаются в памяти функции.
2.4. CGI-переменные окружения
git-http-backend ожидает CGI-переменные. Строим их из HTTP-запроса:
gofunc (h *GitHandler) buildCGIEnv(r *http.Request) []string { repoPath := h.extractRepoPath(r.URL.Path) // /repo.git return []string{ "GATEWAY_INTERFACE=CGI/1.1", "SERVER_PROTOCOL=HTTP/1.1", "SERVER_SOFTWARE=CloudFunction", "REQUEST_METHOD=" + r.Method, "QUERY_STRING=" + r.URL.RawQuery, "CONTENT_TYPE=" + r.Header.Get("Content-Type"), "CONTENT_LENGTH=" + r.Header.Get("Content-Length"), "PATH_INFO=" + repoPath, "GIT_PROJECT_ROOT=/var/git", // Базовая директория NFS "GIT_HTTP_EXPORT_ALL=1", // Разрешить все репозитории "REMOTE_USER=" + r.Header.Get("X-User-Id"), // Из JWT (Модуль 7) } }
Критично: PATH_INFO должен содержать только путь к репозиторию, без ... Это проверяет assertSafeRepo.
2.5. Защита от Directory Traversal: assertSafeRepo
Атакующий отправляет запрос на /git/../../etc/passwd.git/info/refs. Без проверки PATH_INFO содержит ../../etc/passwd.git, и git-http-backend получает доступ к /etc/passwd.
go// assertSafeRepo проверяет, что путь не выходит за пределы baseDir func assertSafeRepo(baseDir, repoPath string) error { // 1. filepath.Clean нормализует путь (убирает .., .) cleaned := filepath.Clean(repoPath) // 2. filepath.Join строит полный путь fullPath := filepath.Join(baseDir, cleaned) // 3. filepath.Rel вычисляет относительный путь от baseDir rel, err := filepath.Rel(baseDir, fullPath) if err != nil { return fmt.Errorf("invalid repo path: %w", err) } // 4. Если rel начинается с ".." — путь выходит за пределы baseDir if strings.HasPrefix(rel, "..") { return fmt.Errorf("directory traversal detected: %s", repoPath) } return nil }
Примеры:
/repo.git→filepath.Rel("/var/git", "/var/git/repo.git")=repo.git→ OK/../../etc/passwd.git→filepath.Rel("/var/git", "/etc/passwd.git")=../../etc/passwd.git→ REJECT
2.6. Тестирование: моки для os/exec
Как тестировать exec.Command без реального git? Через интерфейс:
go// Интерфейс для мокирования type CommandRunner interface { Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error } // Реальная реализация type RealRunner struct{} func (r *RealRunner) Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error { cmd := exec.CommandContext(ctx, name, args...) cmd.Stdin = stdin cmd.Stdout = stdout cmd.Env = env return cmd.Run() } // Мок для тестов type MockRunner struct { RunFunc func(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error } func (m *MockRunner) Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error { return m.RunFunc(ctx, name, args, stdin, stdout, env) }
Тест parseCGIHeaders:
gofunc TestParseCGIHeaders(t *testing.T) { // CGI-ответ git-http-backend: заголовки + пустая строка + тело input := "Status: 200 OK\r\nContent-Type: application/x-git-receive-pack-result\r\n\r\n000eunpack ok\n" headers, body, err := parseCGIHeaders(strings.NewReader(input)) if err != nil { t.Fatal(err) } if headers["Status"] != "200 OK" { t.Errorf("expected Status 200 OK, got %s", headers["Status"]) } if body != "000eunpack ok\n" { t.Errorf("expected body '000eunpack ok\\n', got %q", body) } } // Критично: пустая строка между заголовками и телом func TestParseCGIHeaders_EmptyLineSeparator(t *testing.T) { // ❌ Без пустой строки — парсер не найдёт тело input := "Status: 200 OK\r\nContent-Type: text/plain\r\nbody" _, _, err := parseCGIHeaders(strings.NewReader(input)) if err == nil { t.Error("expected error: no empty line separator") } }
Тест assertSafeRepo:
gofunc TestAssertSafeRepo(t *testing.T) { tests := []struct { path string wantErr bool }{ {"/repo.git", false}, {"/repo.git/info/refs", false}, {"/../../etc/passwd.git", true}, {"/repo.git/../../../etc/shadow", true}, {"/repo.git/./info/refs", false}, } for _, tt := range tests { err := assertSafeRepo("/var/git", tt.path) if (err != nil) != tt.wantErr { t.Errorf("assertSafeRepo(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr) } } }
2.7. Этот паттерн в архетипах
Smart HTTP проявляется по-разному в четырёх архетипах проектов:
| Архетип | Протокол | Стриминг | Почему критично |
|---|---|---|---|
| Финтех | gRPC (protobuf) | Нет (малые payload) | Strong consistency, нет больших бинарных данных |
| Git-хостинг | Smart HTTP (CGI) | io.Copy, packfile до 32 MB | Push большого репо = стриминг обязателен |
| Мессенджер | WebSocket | Long-lived соединения | Real-time доставка, Serverless не подходит |
| Лента | REST + CDN | Нет (JSON < 1 MB) | Чтение, не запись; кэш снимает нагрузку |
Общий принцип: стриминг нужен там, где payload > RAM. Для Git push — packfile может быть 30 MB, а RAM функции 512 MB. io.ReadAll на 30 MB — это 6% RAM, но при 10 одновременных push — 60%. io.Copy — O(1) память.
2.8. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| io.Copy (стриминг) | O(1) память; push до 32 MB | Сложнее отлаживать (нет полного тела в логах) | Всегда для Smart HTTP |
| io.ReadAll | Просто; тело в памяти для логов | OOM при push > 32 MB | Антипаттерн для Git |
| exec.Command + git-http-backend | Не реализуем Git-протокол сами | Зависимость от git в образе | Всегда (не пишем свой Smart HTTP) |
| assertSafeRepo | Защита от Directory Traversal | Доп. проверка на каждый запрос | Всегда для путей из URL |
| Моки для exec.Command | Тестируемость без git | Доп. абстракция (интерфейс) | Всегда для CI/CD |
Вывод: для Git Smart HTTP в Cloud Function — только io.Copy + exec.Command + assertSafeRepo. См. ADR-SV2.
🛠 Практические задания
Сценарий: Вася делает git push origin main с packfile 15 MB. Цель: push проходит за < 30 сек, функция не падает, репозиторий обновлён.
Задача 1: Git Smart HTTP handler
Реализовать handler для /git/{repo}.git/{service}:
- Извлечь
repoPathиз URL - Проверить
assertSafeRepo - Построить CGI-переменные
- Запустить
git-http-backendчерезexec.CommandContext - Стримить stdin/stdout
✅ Готово, когда:
[ ] GET /repo.git/info/refs?service=git-upload-pack возвращает список refs
[ ] POST /repo.git/git-upload-pack стримит packfile (clone/fetch)
[ ] POST /repo.git/git-receive-pack стримит packfile (push)
[ ] assertSafeRepo отклоняет ../../etc/passwd.git
[ ] exec.CommandContext с таймаутом 10 мин
[ ] cmd.Wait() вызывается всегда
Задача 2: Тестирование
Написать тесты:
TestParseCGIHeaders— парсинг CGI-ответа (заголовки + пустая строка + тело)TestAssertSafeRepo— табличные тесты для Directory TraversalTestGitHandler— интеграционный тест с MockRunner
✅ Готово, когда:
[ ] TestParseCGIHeaders проверяет пустую строку-разделитель
[ ] TestAssertSafeRepo покрывает минимум 5 кейсов (нормальный путь, .., ./, вложенный)
[ ] TestGitHandler использует MockRunner (не реальный git)
[ ] go test ./... -race проходит без ошибок
Задача 3: Локальная проверка
Поднять local-ydb и NFS-эмулятор (Модуль 1). Создать bare-репозиторий. Сделать git push через curl:
bash# Создаём bare-репозиторий git init --bare /var/git/test-repo.git # Проверяем info/refs curl -v "http://localhost:8080/git/test-repo.git/info/refs?service=git-receive-pack" # Делаем push (упрощённо, через git) cd /tmp && git init test && cd test echo "hello" > file.txt && git add . && git commit -m "init" git remote add origin http://localhost:8080/git/test-repo.git git push origin main
✅ Готово, когда:
[ ] git push проходит успешно
[ ] В bare-репозитории появился коммит
[ ] Функция не падает при push 15 MB
⚠️ Антипаттерн для разбора
go// Go Cloud Function (Антипаттерн) func (h *GitHandler) Push(w http.ResponseWriter, r *http.Request) { // ❌ 1. Читаем всё в память body, _ := io.ReadAll(r.Body) // ❌ 2. Нет проверки пути repoPath := r.URL.Path // Может содержать ../../etc/passwd // ❌ 3. Нет контекста, нет Wait cmd := exec.Command("git", "http-backend") cmd.Stdin = bytes.NewReader(body) cmd.Start() // cmd.Wait() не вызывается → zombie-процесс w.WriteHeader(200) }
Вопросы студенту:
- Что произойдёт при push на 40 MB? (OOM, функция упадёт)
- Что произойдёт, если отправить запрос на
/../../etc/passwd.git? (Directory Traversal) - Что произойдёт, если
cmd.Wait()не вызывается? (Zombie-процессы, утечка FD)
🧩 Проверь себя
Q: Почему io.ReadAll — антипаттерн для Git push в Cloud Function?
- io.ReadAll медленнее io.Copy
- Лимит 32 MB на тело запроса; io.ReadAll читает всё в память → OOM*
- io.ReadAll не поддерживает стриминг
Q: Как защитить git-http-backend от Directory Traversal?
- Проверить Content-Type
- assertSafeRepo: filepath.Clean + filepath.Rel, отклонить если путь начинается с ".."*
- Использовать HTTPS
Q: Зачем cmd.Wait() после exec.Command?
- Для логирования
- Освобождает ресурсы процесса; без Wait() — zombie-процессы и утечка FD*
- Это не обязательно
Q: Что передаётся в cmd.Stdin для git-http-backend?
- Полное тело запроса ([]byte)
- r.Body (io.ReadCloser) — стриминг из сети*
- JSON с метаданными
Q: Как тестировать exec.Command без реального git?
- Не тестировать
- Интерфейс CommandRunner + MockRunner*
- Поднять реальный git в тесте
Q: Пустая строка в CGI-ответе — зачем?
- Для красоты
- Разделяет заголовки и тело; без неё парсер не найдёт тело*
- Это требование HTTP/2
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 2
Привет! Я начинаю Модуль 2 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud». Выступи в роли Наставника-Архитектора.
Сначала проверь понимание: дай короткий ответ (2–3 предложения) — почему io.ReadAll убьёт Cloud Function при push на 30 MB, и как io.Copy решает эту проблему.
Затем выдай задание:
- Реализовать Git Smart HTTP handler для Cloud Function (GET info/refs + POST git-receive-pack).
- Написать assertSafeRepo для защиты от Directory Traversal.
- Написать тесты: parseCGIHeaders (с пустой строкой-разделителем), assertSafeRepo (5+ кейсов), MockRunner для exec.Command.
Покажи мне код Антипаттерна (io.ReadAll, нет assertSafeRepo, нет cmd.Wait()) и попроси объяснить, почему каждый из трёх пунктов убьёт функцию.
НЕ ПИШИ за меня handler и тесты. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «нет таймаута на exec.CommandContext», «assertSafeRepo не покрывает все кейсы», «тесты не проверяют пустую строку в CGI»).
📝 Чек-лист ментора
[ ] Стриминг: io.Copy или cmd.Stdin = r.Body — нет io.ReadAll.
[ ] exec.Command: exec.CommandContext(ctx, ...) с таймаутом 10 мин.
[ ] cmd.Wait(): вызывается всегда, даже при ошибке.
[ ] assertSafeRepo: filepath.Clean + filepath.Rel, отклоняет ...
[ ] CGI-переменные: PATH_INFO, GIT_PROJECT_ROOT, REQUEST_METHOD, CONTENT_TYPE.
[ ] Тесты: parseCGIHeaders проверяет пустую строку-разделитель.
[ ] Тесты: assertSafeRepo покрывает минимум 5 кейсов.
[ ] Тесты: MockRunner для exec.Command (не реальный git в CI).
[ ] Интеграционный тест: git push через curl или реальный git.
[ ] ADR-SV2: «Smart HTTP в Cloud Function: стриминг vs io.ReadAll».
🔗 Мостик к Модулю 3 (YDB Serverless)
В конце ревью скажи студенту:
«Отлично, push прошёл: packfile записан в bare-репозиторий на NFS, refs обновлены. Но как мы узнаем, кто сделал push, когда, и в какой репозиторий? Метаданные (репозитории, пользователи, refs) нужно хранить в БД. В Модуле 3 мы подключим YDB Serverless: разберём YQL (не SQL!), оптимистичные транзакции (нет FOR UPDATE!), и как уложиться в 10 RU/с free-tier. А ещё — как EXPLAIN показывает, что запрос идёт по индексу.»
Сквозной User Story: «Вася сделал git push origin main. Packfile записан на NFS. В Модуле 3 мы посмотрим, как метаданные push (кто, когда, какой репозиторий, какие refs обновлены) сохраняются в YDB. В Модуле 4 — как событие RepositoryPushed доставляется в CI/CD через Outbox.»