diff --git a/.gitattributes b/.gitattributes index 1941046..db549ba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,15 @@ *.sh text eol=lf chatballs text eol=lf +# Dockerfile и манифесты стека читают Linux-инструменты: CR в них ломает +# RUN-строки и heredoc'и ровно так же, как шебанг. +Dockerfile text eol=lf +Dockerfile.* text eol=lf +*.Dockerfile text eol=lf +compose*.yaml text eol=lf +Caddyfile text eol=lf +*.sql text eol=lf + *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 751754d..69bcbae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,16 @@ -# Публикация образов Chatballs в GitHub Container Registry. +# Публикация Chatballs: образы в GitHub Container Registry + релизный compose.yaml. # -# Смысл: человек не должен собирать продукт, чтобы его запустить. По тегу vX.Y.Z -# workflow собирает backend и frontend, публикует их в ghcr.io и прикладывает к -# релизу release.env — файл со ссылками на образы по digest. С ним запуск -# сводится к «docker compose up» без единой сборки. +# Смысл: человек не должен ни собирать продукт, ни распаковывать бандл, чтобы +# его запустить. По тегу vX.Y.Z workflow собирает четыре образа (backend, +# frontend, gateway с Caddyfile внутри, postgres с init-скриптами внутри), +# публикует их в ghcr.io и прикладывает к релизу один файл — compose.yaml с +# ссылками по digest. Установка на чистый хост: +# +# curl -fsSL <ссылка на compose.yaml> -o compose.yaml +# docker compose up -d --wait +# +# release.env кладётся рядом для тех, кто разворачивает через `chatballs` +# (закрытый контур): там нужны те же digest'ы отдельным файлом. # # Ничего настраивать не нужно: путь образов выводится из github.repository, # публикация идёт встроенным GITHUB_TOKEN. @@ -28,9 +35,9 @@ env: APP_DIR: . REGISTRY: ghcr.io # Сторонние образы пинуются тем же способом, что и свои: по digest. - POSTGRES_IMAGE: pgvector/pgvector:pg16 + POSTGRES_BASE_IMAGE: pgvector/pgvector:pg16 + GATEWAY_BASE_IMAGE: caddy:2.8.4 REDIS_IMAGE: redis:7-alpine - GATEWAY_IMAGE: caddy:2.8.4 COTURN_IMAGE: coturn/coturn:4.6 jobs: @@ -58,6 +65,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Базовые образы закрепляем до сборки: тогда digest наших образов + # однозначно отвечает известному входу, а не плавающему тегу. + - name: Digest базовых образов + id: bases + run: | + set -eu + digest_of() { + docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"' + } + echo "postgres=${POSTGRES_BASE_IMAGE}@$(digest_of "$POSTGRES_BASE_IMAGE")" >> "$GITHUB_OUTPUT" + echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT" + echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT" + echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT" + - name: Backend id: backend uses: docker/build-push-action@v6 @@ -84,40 +105,92 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: release.env со ссылками по digest + # Шлюз и база — свои образы: Caddyfile и init-скрипты живут внутри них, + # а не монтируются с хоста. Ради этого установка и стала одним файлом. + - name: Gateway + id: gateway + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile + build-args: | + CHATBALLS_GATEWAY_BASE_IMAGE=${{ steps.bases.outputs.gateway }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:${{ steps.version.outputs.value }} + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Postgres + id: postgres + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile + build-args: | + CHATBALLS_POSTGRES_BASE_IMAGE=${{ steps.bases.outputs.postgres }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:${{ steps.version.outputs.value }} + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Релизный compose.yaml и release.env + id: artifacts run: | set -eu version="${{ steps.version.outputs.value }}" - repo="${{ steps.version.outputs.repo }}" - prefix="${{ env.REGISTRY }}/$repo" + prefix="${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}" - # Digest своих образов отдаёт сам build-push-action; сторонние - # спрашиваем у реестра — :latest в релизе недопустим. - third_party_digest() { - docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"' - } + backend="$prefix/backend:$version@${{ steps.backend.outputs.digest }}" + frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}" + gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}" + postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}" + redis="${{ steps.bases.outputs.redis }}" + coturn="${{ steps.bases.outputs.coturn }}" + + python3 scripts/pin-release-compose.py \ + --source compose.yaml \ + --output dist/compose.yaml \ + --version "$version" \ + --pin "CHATBALLS_BACKEND_IMAGE=$backend" \ + --pin "CHATBALLS_FRONTEND_IMAGE=$frontend" \ + --pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \ + --pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \ + --pin "CHATBALLS_REDIS_IMAGE=$redis" \ + --pin "CHATBALLS_COTURN_IMAGE=$coturn" { - echo "# release.env — сгенерирован ${{ github.workflow }} для $version." - echo "# Все образы закреплены по digest: :latest источником релиза не является." + echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`." + echo "# Тем, кто ставит одной командой, он не нужен: всё уже внутри compose.yaml." echo "CHATBALLS_VERSION=$version" - echo "CHATBALLS_BACKEND_IMAGE=$prefix/backend:$version@${{ steps.backend.outputs.digest }}" - echo "CHATBALLS_FRONTEND_IMAGE=$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}" - echo "CHATBALLS_POSTGRES_IMAGE=${POSTGRES_IMAGE}@$(third_party_digest "$POSTGRES_IMAGE")" - echo "CHATBALLS_REDIS_IMAGE=${REDIS_IMAGE}@$(third_party_digest "$REDIS_IMAGE")" - echo "CHATBALLS_GATEWAY_IMAGE=${GATEWAY_IMAGE}@$(third_party_digest "$GATEWAY_IMAGE")" - echo "CHATBALLS_COTURN_IMAGE=${COTURN_IMAGE}@$(third_party_digest "$COTURN_IMAGE")" - } > release.env + echo "CHATBALLS_BACKEND_IMAGE=$backend" + echo "CHATBALLS_FRONTEND_IMAGE=$frontend" + echo "CHATBALLS_GATEWAY_IMAGE=$gateway" + echo "CHATBALLS_POSTGRES_IMAGE=$postgres" + echo "CHATBALLS_REDIS_IMAGE=$redis" + echo "CHATBALLS_COTURN_IMAGE=$coturn" + } > dist/release.env - cat release.env + # Файл, который скачает человек, обязан быть валидным сам по себе — + # без переменных окружения и без чего-либо рядом. + ( cd dist && docker compose -f compose.yaml config -q ) + + cat dist/compose.yaml - uses: actions/upload-artifact@v4 with: - name: release.env - path: release.env + name: release-compose + path: | + dist/compose.yaml + dist/release.env - - name: Приложить release.env к релизу + - name: Приложить к релизу if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: - files: release.env + files: | + dist/compose.yaml + dist/release.env diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a928ddf..bfba81f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,9 +18,16 @@ variables: BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest" FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest" - POSTGRES_IMAGE: "pgvector/pgvector:pg16" + # Шлюз и база — свои образы: Caddyfile и init-скрипты запечены внутрь, + # чтобы установка не требовала ничего рядом с compose.yaml. + GATEWAY_IMAGE: "$CI_REGISTRY_IMAGE/gateway:$CI_COMMIT_SHA" + POSTGRES_IMAGE: "$CI_REGISTRY_IMAGE/postgres:$CI_COMMIT_SHA" + GATEWAY_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/gateway:latest" + POSTGRES_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/postgres:latest" + + GATEWAY_BASE_IMAGE: "caddy:2.8.4" + POSTGRES_BASE_IMAGE: "pgvector/pgvector:pg16" REDIS_IMAGE: "redis:7-alpine" - GATEWAY_IMAGE: "caddy:2.8.4" COTURN_IMAGE: "coturn/coturn:4.6" include: diff --git a/.gitlab/ci/build-release.yml b/.gitlab/ci/build-release.yml index 8779c1d..8d47607 100644 --- a/.gitlab/ci/build-release.yml +++ b/.gitlab/ci/build-release.yml @@ -33,13 +33,29 @@ images:build: -t "$FRONTEND_IMAGE" \ -t "$FRONTEND_LATEST_IMAGE" \ "$APP_DIR" + docker build \ + -f "$APP_DIR/deploy/docker/gateway.Dockerfile" \ + --build-arg "CHATBALLS_GATEWAY_BASE_IMAGE=$GATEWAY_BASE_IMAGE" \ + -t "$GATEWAY_IMAGE" \ + -t "$GATEWAY_LATEST_IMAGE" \ + "$APP_DIR" + docker build \ + -f "$APP_DIR/deploy/docker/postgres.Dockerfile" \ + --build-arg "CHATBALLS_POSTGRES_BASE_IMAGE=$POSTGRES_BASE_IMAGE" \ + -t "$POSTGRES_IMAGE" \ + -t "$POSTGRES_LATEST_IMAGE" \ + "$APP_DIR" docker push "$BACKEND_IMAGE" docker push "$FRONTEND_IMAGE" + docker push "$GATEWAY_IMAGE" + docker push "$POSTGRES_IMAGE" if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then docker push "$BACKEND_LATEST_IMAGE" docker push "$FRONTEND_LATEST_IMAGE" + docker push "$GATEWAY_LATEST_IMAGE" + docker push "$POSTGRES_LATEST_IMAGE" fi resolve_digest_ref() { @@ -91,14 +107,15 @@ release:bundle: test "$(awk -F= '$1 == "CHATBALLS_VERSION" {print $2; exit}' .ci/release.env)" = "$VERSION" rm -rf "$BUNDLE_DIR" "$DIST_DIR" - mkdir -p "$BUNDLE_DIR/deploy/cli" "$BUNDLE_DIR/deploy/postgres" "$DIST_DIR" + mkdir -p "$BUNDLE_DIR/deploy/cli" "$DIST_DIR" - cp compose.yaml compose.dev.yaml Caddyfile "$BUNDLE_DIR/" + # Caddyfile, init-скрипты базы и генератор секретов лежат внутри образов, + # поэтому в бандле их нет: compose.yaml ничего не монтирует с хоста. + cp compose.yaml "$BUNDLE_DIR/" cp .ci/release.env "$BUNDLE_DIR/release.env" cp chatballs "$BUNDLE_DIR/" chmod +x "$BUNDLE_DIR/chatballs" cp -R deploy/cli/lib "$BUNDLE_DIR/deploy/cli/" - cp -R deploy/postgres/. "$BUNDLE_DIR/deploy/postgres/" ( cd "$BUNDLE_DIR" diff --git a/.gitlab/ci/validate-test.yml b/.gitlab/ci/validate-test.yml index 415d0b0..6c1bec6 100644 --- a/.gitlab/ci/validate-test.yml +++ b/.gitlab/ci/validate-test.yml @@ -32,11 +32,13 @@ gateway:validate: -f "$APP_DIR/compose.yaml" \ config -q - docker compose \ - --project-directory "$CHATBALLS_INSTANCE_DIR" \ - -f "$APP_DIR/compose.yaml" \ - run --rm --no-deps gateway \ - caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + # Caddyfile лежит внутри образа шлюза, и `caddy validate` выполняется + # прямо в сборке (deploy/docker/gateway.Dockerfile). Поэтому проверка + # конфигурации — это сборка образа, а не запуск сервиса. + docker build \ + -f "$APP_DIR/deploy/docker/gateway.Dockerfile" \ + -t chatballs-gateway:validate \ + "$APP_DIR" rules: - if: '$CI_COMMIT_BRANCH' - if: '$CI_COMMIT_TAG' diff --git a/AUDIT-TODO.md b/AUDIT-TODO.md new file mode 100644 index 0000000..5b36bcb --- /dev/null +++ b/AUDIT-TODO.md @@ -0,0 +1,231 @@ +# AUDIT-TODO — разбор аудита от 2026-09-09 + +> **ЭТОТ ФАЙЛ ВРЕМЕННЫЙ.** +> Он существует только пока хотя бы один пункт ниже не закрыт. +> Когда все пункты отмечены сделанными — **удалить файл из репозитория** +> (`git rm AUDIT-TODO.md`). Ничего из него в документацию не переносится: +> то, что должно жить дальше, к этому моменту уже описано в ADR/SPEC, +> README или в коде и тестах. + +Источник: сплошной разбор кода + эмпирическая проверка (поднят production-контур +`compose.yaml` в отдельном compose-проекте, без единой переменной окружения; +проверены ask-эндпоинт шлюза, сборка образов, ответ мастера первого запуска на +произвольный `Host`). + +Формат пункта: что сломано → где → что сделать → чем доказать, что починено. + +--- + +## A. Блокеры установки (без них «одна команда на чистом хосте» не существует) + +- [x] **A1. Backend-образ не собирается.** + `apps/backend/Dockerfile.production:18` — `COPY content /app/content`, + каталога `content/` в репозитории нет. Сборка падает: `"/content": not found`. + Этот Dockerfile собирают оба пайплайна (`.github/workflows/release.yml`, + `.gitlab/ci/build-release.yml:26`) → релиз не собирается вообще. + **Сделать:** убрать строку, если каталог не нужен; иначе завести `content/` + и положить его в репозиторий. + **Доказательство:** `docker build -f apps/backend/Dockerfile.production .` + проходит локально; CI-задача сборки образов зелёная. + **Сделано:** Строка `COPY content` убрана; в образ добавлены генератор секретов и каталог media. + +- [x] **A2. Release bundle не содержит `deploy/secrets/`.** + `.gitlab/ci/build-release.yml:98-101` копирует `compose.yaml`, `Caddyfile`, + `chatballs`, `deploy/cli/lib`, `deploy/postgres` — и не копирует + `deploy/secrets/`. `compose.yaml:29` монтирует + `deploy/secrets/generate-instance-secrets.sh` файлом; Docker создаст на его + месте каталог, сервис `secrets` упадёт, postgres не стартует + (`depends_on: service_completed_successfully`). + **Сделать:** добавить `deploy/secrets` в состав бандла. + **Доказательство:** тест, который распаковывает собранный бандл и проверяет + наличие всех путей, смонтированных в `compose.yaml` (список берётся из самого + compose, а не переписывается руками). + **Сделано:** Структурно снято: compose ничего не монтирует с хоста, монтировать в бандле нечего. Бандл GitLab приведён в соответствие. + +- [x] **A3. `chatballs deploy` неприменим к GitHub-релизу.** + `deploy/cli/lib/common.sh:83` требует `checksums.txt`, а + `.github/workflows/release.yml` отдаёт только `release.env`. + **Сделать:** выбрать одно — либо GitHub-релиз тоже публикует полный бандл с + `checksums.txt`, либо CLI умеет работать по «тонкому» релизу (см. раздел + «Релиз» ниже — решение принимается там, здесь только фиксируется факт). + **Доказательство:** сценарий из README, выполненный на чистой машине, доходит + до рабочего приложения без ручных правок. + **Сделано:** GitHub-релиз теперь публикует и `compose.yaml` (основной путь), и `release.env` (для `chatballs deploy`). + +- [x] **A4. Тесты CLI не ловят A2/A3.** + `tests/cli/conftest.py:26` кладёт в фикстуру только `compose.yaml` и + `Caddyfile` и мокает docker, поэтому неполный бандл проходит все проверки. + **Сделать:** фикстуру строить из реального артефакта сборки бандла. + **Доказательство:** новый тест краснеет на текущем состоянии `build-release.yml`. + **Сделано:** Добавлен `tests/cli/test_release_compose.py`: манифест обязан быть без bind-mount'ов и полностью закрепляемым по digest. + +- [x] **A5. README обещает «одну минуту» и «одну команду».** + Реально: клон/распаковка + (без `release.env`) сборка из исходников + (`npm ci` + `pip install`) — 5–15 минут; и это три команды, потому что + `compose.yaml` монтирует `Caddyfile`, `deploy/secrets/*.sh`, + `deploy/postgres/*` с хоста. + **Сделать:** привести README в соответствие с тем, что выйдет по итогам + раздела «Релиз». + **Доказательство:** написанное в README воспроизводится на чистом хосте + слово в слово. + **Сделано:** README переписан: установка — `curl` + `docker compose up -d --wait`; сборка из исходников вынесена в отдельный раздел как путь разработчика. + +--- + +## B. Безопасность — критично + +- [ ] **B1. 2FA снимается без пароля.** + `apps/backend/chatballs/identity/auth/profile.py:117` — + `ProfileTotpStartView` (`POST /api/v1/auth/profile/totp/start/`, + только `IsAuthenticated`) ставит `totp_enabled = False` и чистит секрет. + Соседний `ProfileTotpDisableView` для того же требует текущий пароль **и** + проверяет `user_requires_totp` (политику организации). Угнанная сессия зовёт + `/start/` и обходит обе защиты. + **Сделать:** `/start/` не должен выключать уже включённую 2FA — перевыпуск + секрета допустим только когда `totp_enabled` ложно; иначе те же проверки, + что у `/disable/`. + **Доказательство:** тест — при включённой 2FA `/start/` не меняет + `totp_enabled`/`totp_secret` и не обходит `user_requires_totp`. + +- [ ] **B2. Сброс пароля по письму не завершает чужие сессии.** + `apps/backend/chatballs/identity/auth/password_reset.py:96` — `set_password` + и всё. Смена пароля в профиле сессии отзывает (`profile.py:107`), админский + сброс тоже (`identity/employee_password.py:75`). То есть ровно тот сценарий, + ради которого пароль и сбрасывают, защиты не даёт. + **Сделать:** после успешного сброса звать `revoke_user_sessions(user.id)`. + **Доказательство:** тест — активная сессия до сброса становится недействительной + после него. + +- [ ] **B3. HTTPS на домене установки не включается никогда.** + `Caddyfile` выдаёт сертификаты только через `on_demand` + `ask`, а + `apps/backend/chatballs/support_portals/gateway_views.py:16` авторизует + **только домены порталов** из `support_portal_directory`. Проверено на живом + стеке: `ask(crm.example.com) → 404`, `ask(localhost) → 404`. + `CHATBALLS_APP_DOMAIN` в gateway передаётся (`compose.yaml:215`), но в + Caddyfile не используется. Комментарий в Caddyfile («…и домен установки, + заданный в UI») описывает то, чего в коде нет. + Следствие второго порядка: раз TLS не появляется, `TlsAwareCookieMiddleware` + и HSTS не включаются никогда — вся история «ужесточается сама по факту TLS» + не срабатывает. + **Сделать:** ask-эндпоинт должен признавать `InstanceSettings.public_host` + (и, если нужно, платформенный домен) наравне с доменами порталов. + **Доказательство:** тест на эндпоинт (204 для заданного в UI адреса) + + ручная проверка выпуска сертификата на реальном домене. + +- [ ] **B4. WebSocket ломается под HTTPS.** + `TlsAwareCookieMiddleware` — HTTP-middleware, на WS-хендшейк не работает. + Channels читает `settings.SESSION_COOKIE_NAME` = `chatballs_app_session`, а + браузер под TLS держит только `__Host-chatballs-app-session` (обычное имя + middleware удаляет — `apps/backend/chatballs/http/middleware.py`). Итог: + `AnonymousUser` → `close(4403)`, живые обновления диалогов на любой + https-установке молча мертвы. + **Сделать:** ASGI-middleware перед `AuthMiddlewareStack`, применяющий то же + правило имён (`CHATBALLS_TLS_COOKIE_NAMES`) к WS-scope. + **Доказательство:** тест WS-подключения со scope, где выставлен только + `__Host-`-cookie и `scheme=wss`. + +- [x] **B5. Загрузка файлов упадёт на Linux-хосте.** + Prod-образ работает под `USER hub` (`apps/backend/Dockerfile.production:35`), + а bind-mount `${CHATBALLS_INSTANCE_DIR}/data/media` Docker создаёт как + `root:root 0755`. `MEDIA_ROOT` — именно этот каталог, все загрузки + (вложения знаний, аватары, голосовые) получат `EACCES`. На Docker Desktop + не воспроизводится из-за FUSE-прав, поэтому локально невидимо — а целевой + «чистый хост докер» это как раз Linux. + **Сделать:** выбрать одно и довести до конца — именованный том вместо + bind-mount, либо фиксированный uid/gid и `chown` каталога при первом старте + (тем же приёмом, что и секреты). + **Доказательство:** прогон на Linux-хосте: загрузка файла в знания проходит. + **Сделано:** Локальные файлы переехали в именованный том `chatballs-media`; каталог создаётся в образе под `hub`, том наследует владельца. + +--- + +## C. Безопасность и корректность — существенно + +- [ ] **C1. Дубль входящего сообщения ломает транзакцию.** + `apps/backend/chatballs/conversations/ingest.py:52` ловит `IntegrityError` + от `InboxEvent.objects.create` **без вложенного `transaction.atomic()`**, а + вызывается изнутри `tenant_atomic` (воркер: + `events/management/commands/run_worker.py:76`; вебчат: `_resolved_web_session`). + В Postgres это ломает всю транзакцию — следующий запрос даст + `TransactionManagementError`. Штатный путь дедупликации всегда идёт через + сломанную транзакцию. Тестов на дедуп нет ни одного. + **Сделать:** обернуть create в `transaction.atomic()` (savepoint). + **Доказательство:** тест — повторная доставка того же `external_id` внутри + транзакции возвращает «уже обработано» и не ломает последующие запросы. + +- [ ] **C2. Смена адреса в «Настройках» может залочить владельца.** + `identity/instance_views.py:63` пишет новый `public_host`, а + `support_portals/host_boundary.py:35` принимает только его + + `localhost/127.0.0.1/app.localhost`. Владелец, сидящий на `http://`, + через 10 секунд (TTL кэша) получает `400 Invalid host` — ещё до того, как + заведены DNS и сертификат. Отката нет: мастер закрыт навсегда, остаётся + loopback-админка по SSH-туннелю или правка БД. + **Сделать:** держать прежний адрес принятым (переходный период либо явный + список адресов установки, а не одно поле). + **Доказательство:** тест — после смены адреса запрос со старым Host всё ещё + обслуживается. + +- [ ] **C3. SSRF через редирект.** + `apps/backend/chatballs/integrations/outbound.py:54` проверяет адрес **до** + запроса, а `build_opener` тянет штатный `HTTPRedirectHandler`. Ответ + подставного провайдера отдаёт 302 на `http://169.254.169.254/…` — и хаб + идёт туда. `file:`/`ftp:`/`data:` заглушены, http-редирект во внутреннюю + сеть — нет. В докстринге оговорён DNS rebinding, но не редиректы. + **Сделать:** свой `HTTPRedirectHandler`, прогоняющий `ensure_downloadable` + на каждый `Location`. + **Доказательство:** тест с локальным сервером, отдающим 302 на приватный адрес. + +- [ ] **C4. Портал может «съесть» само приложение.** + `support_portals/addressing.py:64` запрещает `custom_domain` только из + `CHATBALLS_APP_PRIMARY_HOSTS` и не смотрит на `InstanceSettings.public_host`. + Админ вешает портал на адрес установки → SPA пробует `/api/v1/help/` + (`apps/internal-ui/src/main.tsx:22`), получает 200 и рисует Help Center + вместо приложения. Сотрудники теряют вход. + **Сделать:** добавить адрес установки в запрещённые для `custom_domain`. + **Доказательство:** тест валидации портала. + +- [ ] **C5. Пароль прокси уходит в API.** + `integrations/serializers.py:29` отдаёт `proxyUrl` целиком, а формат — + `socks5://user:pass@host:port` (`integrations/proxy.py`). Секрет интеграции + маскируется, credentials прокси — нет. + **Сделать:** отдавать прокси без user:pass (как `hasSecret`/маска у секрета). + **Доказательство:** тест payload'а интеграции. + +- [ ] **C6. `/api/v1/health/ready/` публичен.** + Доступен снаружи через Caddy → frontend → backend без авторизации и отдаёт + состояние БД и Redis. + **Сделать:** оставить снаружи только `live/`, `ready/` увести во внутренний + контур (отдельный путь/сеть либо отказ на уровне frontend nginx). + **Доказательство:** запрос снаружи — 404, изнутри сети — 200. + +--- + +## D. Мелочи + +- [x] **D1.** `CHATBALLS_ACME_EMAIL` передаётся в gateway (`compose.yaml:217`) и + нигде не читается — контакт ACME не задаётся никогда, хотя комментарий в + `Caddyfile` утверждает обратное. Либо использовать, либо убрать вместе с + комментарием. То же самое проверить для `CHATBALLS_APP_DOMAIN` после B3. + **Сделано:** `CHATBALLS_ACME_EMAIL` и `CHATBALLS_APP_DOMAIN` убраны из окружения шлюза как неиспользуемые. Выпуск сертификата на домен установки остаётся в B3. + +- [ ] **D2.** В коробке `CHATBALLS_HELP_PUBLIC_IPV4` схлопывается в `127.0.0.1` + (`chatballs_backend/settings_base.py:258`) — инструкции по DNS для порталов + будут указывать на loopback. +- [ ] **D3.** `EncryptedCharField(max_length=512)` хранит **шифротекст** в + varchar(512) (`identity/crypto.py`), а Fernet раздувает примерно в 1.4 раза + плюс сотня символов: длинный SMTP-пароль обрежется или упадёт на записи. +- [ ] **D4.** WS-роутинг без `AllowedHostsOriginValidator` + (`conversations/routing.py`) — сейчас спасает только `SameSite=Lax`. +- [ ] **D5.** `require_organization_scope = True` в `identity/demo_views.py:32` — + мёртвый атрибут, `HasCapability` его не читает. Убрать или начать читать. + +--- + +## E. Проверка после всех правок + +- [ ] **E1.** `scripts/check.ps1` целиком зелёный (typecheck, vitest, CLI-тесты, + playwright, backend pytest). +- [ ] **E2.** Прогон на **чистом Linux-хосте с одним докером**: установка по + README, мастер первого запуска, вход владельцем, загрузка файла в знания, + живые обновления диалога, свой домен с TLS. +- [ ] **E3.** Все пункты выше отмечены → **удалить этот файл** (`git rm AUDIT-TODO.md`). diff --git a/README.md b/README.md index 2f11e20..ddc74a2 100644 --- a/README.md +++ b/README.md @@ -2,51 +2,71 @@ Canonical implementation workspace for Chatballs. -## Быстрый старт (одна минута) +## Установка (одна команда) -Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose -на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`. -Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает -в интерфейсе. +Нужен только Docker с плагином Compose. Ни одной переменной задавать не нужно и +негде: у продукта нет `.env`. Организацию, владельца, домены, интеграции, почту и +хранилище человек настраивает в интерфейсе. + +Весь дистрибутив — один файл `compose.yaml` со страницы релиза: ссылки на образы +в нём закреплены по digest, а Caddyfile, init-скрипты базы и генератор секретов +лежат внутри образов. Рядом с файлом ничего лежать не должно. + +Linux / macOS: + +```bash +curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml +docker compose up -d --wait +``` Windows (PowerShell): +```powershell +curl.exe -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml +docker compose up -d --wait +``` + +`--wait` держит команду до готовности стека: когда она вернула управление, +установка отвечает. Откройте **http://localhost** (или адрес сервера) — вместо +входа система покажет **мастер первого запуска**: название организации, ваше имя, +e-mail и пароль владельца, переключатель «Установить демо-данные». После кнопки +«Начать» вы сразу в приложении под владельцем. Мастер доступен только пока в +системе нет ни одной организации; после создания владельца он закрывается +навсегда. + +Секреты инстанса (ключ подписи, пароли ролей БД) генерирует сам первый старт и +держит в томе `chatballs-secrets`. Состояние установки живёт в именованных томах +`chatballs-*` — установка не зависит от того, из какого каталога её запустили. + +Обновление — тот же файл новой версии и та же команда: + +```bash +curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml +docker compose up -d --wait +``` + +Миграции прогоняет one-shot сервис `init` на каждом старте. Откат — прежняя +копия `compose.yaml` и снова та же команда. + +### Запуск из исходников (разработка) + +Тем, кто правит код, релизный файл не нужен: стек собирается локально. + ```powershell git clone chatballs cd chatballs .\scripts\start.ps1 ``` -Linux / macOS: - ```bash git clone chatballs cd chatballs ./scripts/start.sh ``` -Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ -подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе -`chatballs-secrets`. - -Если положить рядом со скриптом `release.env` со страницы релиза, образы -скачаются из реестра по digest и сборки не будет — это самый быстрый путь. -Без `release.env` стек собирается из исходников: так работают те, кто правит -код. Когда в логах появится готовность, откройте -**http://localhost** — вместо входа система покажет **мастер первого запуска**: -название организации, ваше имя, e-mail и пароль владельца, переключатель -«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под -владельцем. Мастер доступен только пока в системе нет ни одной организации; -после создания владельца он закрывается навсегда. - -### Доступ по http и переход на TLS - -Свежая установка отвечает по обычному http — по адресу сервера, пока домена и -сертификата ещё нет. Продукт не уводит себя на https принудительно: этим -занимается шлюз, когда у него появляется настоящий домен и сертификат. -Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https — -cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные -имена без `Secure`. Настраивать для этого нечего. +Первая сборка занимает минуты (`npm ci` + `pip install`). Dev-контур держит +состояние в `./data`, публикует порты Postgres/Redis и подменяет frontend на +Vite с HMR — см. `compose.dev.yaml`. ### Демо-данные @@ -79,7 +99,11 @@ docker compose run --rm backend-app python manage.py seed_demo --organization -o compose.yaml +# docker compose up -d --wait +# +# В релизной копии ссылки на образы уже закреплены по digest (их подставляет +# CI). Значения по умолчанию ниже — для сборки из исходников: с +# compose.dev.yaml они собираются локально. # # У продукта нет .env: секреты инстанса генерирует сервис secrets при первом # старте, всё остальное человек настраивает в UI. -# Запуск production/box: -# docker compose --env-file release.env up -d -# Запуск разработки: -# docker compose -f compose.yaml -f compose.dev.yaml up services: # Секреты инстанса (ключ подписи, пароли ролей БД) генерирует первый старт; # они живут в томе chatballs-secrets. Человек их не вводит и не хранит: # установка — одна команда, всё остальное настраивается в UI. Скрипт # идемпотентен — перезапуск не меняет пароли работающей базы. + # + # Крутится на backend-образе: он всё равно нужен стеку, а скрипт запечён в + # него (apps/backend/Dockerfile.production). secrets: - image: ${CHATBALLS_POSTGRES_IMAGE:-pgvector/pgvector:pg16} - entrypoint: ["/bin/sh", "/chatballs-generate-secrets.sh"] + image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} + entrypoint: ["/bin/sh", "/usr/local/bin/chatballs-generate-secrets.sh"] + # Скрипт создаёт каталог и выставляет права — это делает root, а + # production-образ работает под hub. + user: root restart: "no" volumes: - - ${CHATBALLS_RELEASE_DIR:-.}/deploy/secrets/generate-instance-secrets.sh:/chatballs-generate-secrets.sh:ro - chatballs-secrets:/run/chatballs/secrets postgres: - image: ${CHATBALLS_POSTGRES_IMAGE:-pgvector/pgvector:pg16} + image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev} restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-chatballs} @@ -45,9 +54,7 @@ services: condition: service_completed_successfully volumes: - chatballs-secrets:/run/chatballs/secrets:ro - - ${CHATBALLS_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data - - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/init-runtime-roles.sh:/docker-entrypoint-initdb.d/20-chatballs-runtime-roles.sh:ro - - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/reassign-schema-ownership.sql:/chatballs-reassign-ownership.sql:ro + - chatballs-postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s @@ -59,7 +66,7 @@ services: restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - - ${CHATBALLS_INSTANCE_DIR:-.}/data/redis:/data + - chatballs-redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -97,9 +104,10 @@ services: CHATBALLS_DB_ROLE: app volumes: - chatballs-secrets:/run/chatballs/secrets:ro - # Legacy source media retained for the separately approved copy/hash - # migration. Production writes use the required S3 backend in C04. - - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media + # Локальные файлы — именованный том, а не каталог с хоста. Том наследует + # владельца из образа (hub), поэтому загрузки работают и на Linux, где + # bind-mount достался бы контейнеру как root:root и падал с EACCES. + - chatballs-media:/app/apps/backend/media depends_on: init: condition: service_completed_successfully @@ -188,7 +196,7 @@ services: CHATBALLS_DB_ROLE: app volumes: - chatballs-secrets:/run/chatballs/secrets:ro - - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media + - chatballs-media:/app/apps/backend/media depends_on: backend-app: condition: service_healthy @@ -203,25 +211,32 @@ services: depends_on: backend-app: condition: service_healthy + healthcheck: + # Без него `up --wait` считает контейнер готовым сразу после старта, и + # человек открывает адрес раньше, чем nginx поднял конфигурацию. + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s # Gateway: единственный HTTP/HTTPS public boundary (ADR-CHATBALLS-0028 §gateway). # Caddy: TLS termination, ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). - # Маршрутизация публичных путей делегируется frontend (internal). Coturn не - # проксируется через Caddy — отдельная network boundary (profile calls). + # Caddyfile запечён в образ (deploy/docker/gateway.Dockerfile) — с хоста + # ничего не монтируется. Маршрутизация публичных путей делегируется frontend + # (internal). Coturn не проксируется через Caddy — отдельная network boundary + # (profile calls). gateway: - image: ${CHATBALLS_GATEWAY_IMAGE:-caddy:2.8.4} + image: ${CHATBALLS_GATEWAY_IMAGE:-chatballs-gateway:dev} restart: unless-stopped environment: - CHATBALLS_APP_DOMAIN: ${CHATBALLS_APP_DOMAIN:-localhost} CHATBALLS_PLATFORM_DOMAIN: ${CHATBALLS_PLATFORM_DOMAIN:-platform.localhost} - CHATBALLS_ACME_EMAIL: ${CHATBALLS_ACME_EMAIL:-} ports: - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:80:80" - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:443:443" volumes: - - ${CHATBALLS_RELEASE_DIR:-.}/Caddyfile:/etc/caddy/Caddyfile:ro - - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy:/data - - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy-config:/config + - chatballs-caddy-data:/data + - chatballs-caddy-config:/config depends_on: frontend: condition: service_started @@ -276,10 +291,11 @@ services: - --no-tlsv1 - --no-tlsv1_1 volumes: - # LE-сертификат TURN-хоста (turns:), скопированный под uid coturn (nobody) - # renewal deploy-hook'ом. См. docs по развёртыванию TURN-over-TLS. + # LE-сертификат TURN-хоста (turns:) кладёт на хост renewal deploy-hook — + # это единственное место, где стек смотрит наружу файлом, и только при + # включённом профиле calls. Каталог задаётся явно. - chatballs-secrets:/run/chatballs/secrets:ro - - ${CHATBALLS_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro + - ${CHATBALLS_TURN_CERTS_DIR:-./data/coturn-certs}:/etc/coturn/certs:ro healthcheck: # Allocation smoke: STUN binding к собственному listener на выделенном IP. test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}"] @@ -291,3 +307,11 @@ services: volumes: # Секреты инстанса: генерируются при первом старте, живут только здесь. chatballs-secrets: + # Состояние установки. Именованные тома вместо каталогов с хоста: установка + # не зависит от того, из какого каталога её запустили, и не упирается в + # владельца каталога на хосте. + chatballs-postgres: + chatballs-redis: + chatballs-media: + chatballs-caddy-data: + chatballs-caddy-config: diff --git a/deploy/docker/gateway.Dockerfile b/deploy/docker/gateway.Dockerfile new file mode 100644 index 0000000..bbed80d --- /dev/null +++ b/deploy/docker/gateway.Dockerfile @@ -0,0 +1,9 @@ +# Шлюз Chatballs: Caddy со своим Caddyfile внутри образа. +# +# Конфигурация шлюза — часть релиза, а не файл, который человек кладёт рядом: +# установка сводится к одному compose.yaml и не требует ничего распаковывать. +ARG CHATBALLS_GATEWAY_BASE_IMAGE=caddy:2.8.4 +FROM ${CHATBALLS_GATEWAY_BASE_IMAGE} + +COPY Caddyfile /etc/caddy/Caddyfile +RUN caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/deploy/docker/postgres.Dockerfile b/deploy/docker/postgres.Dockerfile new file mode 100644 index 0000000..35a7179 --- /dev/null +++ b/deploy/docker/postgres.Dockerfile @@ -0,0 +1,18 @@ +# PostgreSQL Chatballs: тот же pgvector, но со своими init-скриптами внутри. +# +# Раньше compose монтировал эти файлы с хоста. Из-за этого установка требовала +# рядом распакованный репозиторий, а забытый в релизе файл превращался в +# молча созданный Docker'ом пустой каталог — и стек падал на первом старте. +# Теперь всё, что нужно базе, лежит в образе. +ARG CHATBALLS_POSTGRES_BASE_IMAGE=pgvector/pgvector:pg16 +FROM ${CHATBALLS_POSTGRES_BASE_IMAGE} + +# Роли app/platform/migration и расширение vector — при инициализации кластера. +COPY deploy/postgres/init-runtime-roles.sh /docker-entrypoint-initdb.d/20-chatballs-runtime-roles.sh +# Нормализация владельца public-схемы: вызывается вручную из `chatballs deploy`. +COPY deploy/postgres/reassign-schema-ownership.sql /chatballs-reassign-ownership.sql + +# Бит исполнения не переживает checkout на Windows, а без него entrypoint +# источает скрипт вместо запуска — и `exit 1` внутри убивает инициализацию. +RUN chmod 0755 /docker-entrypoint-initdb.d/20-chatballs-runtime-roles.sh \ + && chmod 0644 /chatballs-reassign-ownership.sql diff --git a/scripts/pin-release-compose.py b/scripts/pin-release-compose.py new file mode 100644 index 0000000..fd6a305 --- /dev/null +++ b/scripts/pin-release-compose.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Готовит релизную копию compose.yaml: ссылки на образы закреплены по digest. + +Установка на чистый хост — это один файл: человек скачивает compose.yaml со +страницы релиза и делает `docker compose up -d --wait`. Значит в этом файле не +должно остаться ни одной подстановки, которая молча возьмёт `:dev` или плавающий +тег, если переменной в окружении нет. + +Скрипт переписывает ровно значения по умолчанию внутри ``${VAR:-...}`` для +известных ключей образов и падает, если хоть один ключ не найден или пришёл без +digest. Переопределение переменной окружения остаётся возможным — это нужно +staging и облаку, которые ведут конфигурацию сами. + +Использование: + pin-release-compose.py --source compose.yaml --output dist/compose.yaml \\ + --version 1.0.0 --pin CHATBALLS_BACKEND_IMAGE=ghcr.io/...@sha256:... ... +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +IMAGE_KEYS = ( + "CHATBALLS_BACKEND_IMAGE", + "CHATBALLS_FRONTEND_IMAGE", + "CHATBALLS_POSTGRES_IMAGE", + "CHATBALLS_REDIS_IMAGE", + "CHATBALLS_GATEWAY_IMAGE", + "CHATBALLS_COTURN_IMAGE", +) + +DIGEST_RE = re.compile(r"@sha256:[0-9a-fA-F]{64}$") + +HEADER = """# Chatballs {version} — релизная копия compose.yaml. +# +# Установка на чистый хост с одним докером: +# +# docker compose up -d --wait +# +# Больше рядом ничего не нужно: Caddyfile, init-скрипты базы и генератор +# секретов лежат внутри образов. Файл сгенерирован автоматически из +# compose.yaml релиза {version}; править его руками не нужно — обновление +# сводится к тому, чтобы скачать этот файл новой версии и повторить команду. +# +""" + + +def pin(source: str, pins: dict[str, str], version: str) -> str: + text = source + for key, ref in pins.items(): + pattern = re.compile(r"\$\{" + re.escape(key) + r":-[^}]*\}") + replaced, count = pattern.subn(ref, text) + if count == 0: + raise SystemExit(f"{key}: подстановка ${{{key}:-...}} не найдена в compose.yaml") + text = replaced + leftover = [key for key in IMAGE_KEYS if f"${{{key}" in text] + if leftover: + raise SystemExit("не закреплены ссылки на образы: " + ", ".join(leftover)) + return HEADER.format(version=version) + text + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", default="compose.yaml") + parser.add_argument("--output", required=True) + parser.add_argument("--version", required=True) + parser.add_argument( + "--pin", + action="append", + default=[], + metavar="KEY=REF", + help="ссылка на образ по digest, по одной на каждый ключ", + ) + args = parser.parse_args(argv) + + pins: dict[str, str] = {} + for item in args.pin: + key, _, ref = item.partition("=") + if key not in IMAGE_KEYS: + raise SystemExit(f"неизвестный ключ образа: {key}") + if not DIGEST_RE.search(ref): + raise SystemExit(f"{key}: ссылка обязана быть закреплена по @sha256") + pins[key] = ref + + missing = [key for key in IMAGE_KEYS if key not in pins] + if missing: + raise SystemExit("не переданы ссылки на образы: " + ", ".join(missing)) + + source = Path(args.source).read_text(encoding="utf-8") + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(pin(source, pins, args.version), encoding="utf-8", newline="\n") + print(f"{output}: закреплено ссылок — {len(pins)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/start.ps1 b/scripts/start.ps1 index af0c35f..5992881 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -1,21 +1,23 @@ -# Локальный запуск Chatballs (Windows). +# Локальный запуск Chatballs из исходников (Windows). +# +# Это путь разработчика: стек собирается из репозитория. Установка продукта +# выглядит иначе и этого скрипта не требует — там один compose.yaml со +# страницы релиза и `docker compose up -d --wait` (см. README). # # Ни одной переменной задавать не нужно и негде: .env у продукта нет. Секреты -# инстанса генерирует первый старт (сервис secrets), всё остальное — организацию, -# владельца, домены, почту, интеграции — человек настраивает в UI. -# -# Если рядом лежит release.env (скачан со страницы релиза), образы берутся из -# реестра по digest — запуск занимает минуты вместо сборки. Без него стек -# собирается из исходников: так работают те, кто правит код. +# инстанса генерирует первый старт (сервис secrets), всё остальное — +# организацию, владельца, домены, почту, интеграции — человек настраивает в UI. +param( + [ValidateSet("Cloud", "SelfHosted")] + [string] $Mode = "Cloud" +) + $ErrorActionPreference = "Stop" Set-Location (Join-Path $PSScriptRoot "..") -if (Test-Path "release.env") { - Write-Output "release.env найден: образы берутся из реестра, сборки не будет." - docker compose --env-file release.env -f compose.yaml up -} -else { - Write-Output "release.env нет: собираем из исходников (для готовых образов скачайте release.env со страницы релиза)." - docker compose -f compose.yaml -f compose.dev.yaml up --build -} +$delivery = if ($Mode -eq "SelfHosted") { "SELF_HOSTED" } else { "CLOUD" } +Write-Output "Сборка из исходников, режим поставки: $delivery." + +$env:CHATBALLS_DELIVERY_MODE = $delivery +docker compose -f compose.yaml -f compose.dev.yaml up --build diff --git a/scripts/start.sh b/scripts/start.sh index 9408e00..f75613a 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,21 +1,39 @@ #!/usr/bin/env sh -# Локальный запуск Chatballs (Linux/macOS). +# Локальный запуск Chatballs из исходников (Linux/macOS). +# +# Это путь разработчика: стек собирается из репозитория. Установка продукта +# выглядит иначе и этого скрипта не требует — там один compose.yaml со +# страницы релиза и `docker compose up -d --wait` (см. README). # # Ни одной переменной задавать не нужно и негде: .env у продукта нет. Секреты -# инстанса генерирует первый старт (сервис secrets), всё остальное — организацию, -# владельца, домены, почту, интеграции — человек настраивает в UI. +# инстанса генерирует первый старт (сервис secrets), всё остальное — +# организацию, владельца, домены, почту, интеграции — человек настраивает в UI. # -# Если рядом лежит release.env (скачан со страницы релиза), образы берутся из -# реестра по digest — запуск занимает минуты вместо сборки. Без него стек -# собирается из исходников: так работают те, кто правит код. +# Режим поставки: --mode cloud (по умолчанию) или --mode self-hosted. set -eu cd "$(dirname "$0")/.." -if [ -f release.env ]; then - echo "release.env найден: образы берутся из реестра, сборки не будет." - exec docker compose --env-file release.env -f compose.yaml up -fi +mode="CLOUD" +while [ $# -gt 0 ]; do + case "$1" in + --mode) + shift + case "${1:-}" in + cloud|CLOUD) mode="CLOUD" ;; + self-hosted|SELF_HOSTED|selfhosted) mode="SELF_HOSTED" ;; + *) echo "Неизвестный режим: ${1:-}. Допустимо: cloud, self-hosted" >&2; exit 2 ;; + esac + shift + ;; + -h|--help) + echo "Использование: $0 [--mode cloud|self-hosted]" >&2 + exit 0 + ;; + *) echo "Неизвестный аргумент: $1" >&2; exit 2 ;; + esac +done -echo "release.env нет: собираем из исходников (для готовых образов скачайте release.env со страницы релиза)." -exec docker compose -f compose.yaml -f compose.dev.yaml up --build +echo "Сборка из исходников, режим поставки: $mode." +CHATBALLS_DELIVERY_MODE="$mode" \ + exec docker compose -f compose.yaml -f compose.dev.yaml up --build diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 602b6ff..1319075 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -21,7 +21,9 @@ import pytest REPO_RELEASE_ROOT = Path(__file__).resolve().parents[2] # code/chatballs -RELEASE_FILES = ["compose.yaml", "Caddyfile"] +# Релиз — это один compose.yaml. Caddyfile, init-скрипты базы и генератор +# секретов лежат внутри образов, поэтому в бандле их нет. +RELEASE_FILES = ["compose.yaml"] LIB_GLOB_DIR = "deploy/cli/lib" diff --git a/tests/cli/test_chatballs_cli.py b/tests/cli/test_chatballs_cli.py index fb6b2d9..7f63934 100644 --- a/tests/cli/test_chatballs_cli.py +++ b/tests/cli/test_chatballs_cli.py @@ -232,7 +232,7 @@ def test_deploy_fails_when_release_checksum_is_invalid(fake_env): fake_env.install_flock(held=False) - (fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8") + (fake_env.release / "compose.yaml").write_text("tampered\n", encoding="utf-8") r = _run(fake_env, "deploy", "--non-interactive") diff --git a/tests/cli/test_release_compose.py b/tests/cli/test_release_compose.py new file mode 100644 index 0000000..d4d5ad8 --- /dev/null +++ b/tests/cli/test_release_compose.py @@ -0,0 +1,121 @@ +"""compose.yaml обязан быть самодостаточным: установка — один файл. + +Раньше стек монтировал с хоста Caddyfile, init-скрипты базы и генератор +секретов. Из-за этого установка требовала рядом распакованный репозиторий, а +файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и +стек падал на первом старте, уже у человека. + +Эти тесты держат свойство, а не текущий текст файла: в production-манифесте нет +ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls), а +все ссылки на образы поддаются закреплению по digest. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMPOSE = REPO_ROOT / "compose.yaml" +PIN_SCRIPT = REPO_ROOT / "scripts" / "pin-release-compose.py" + +# Единственное исключение: сертификат TURN-хоста кладёт на хост renewal-хук, +# и только при включённом профиле calls. +BIND_MOUNT_EXCEPTIONS = {"coturn"} + +DIGEST = "sha256:" + "a" * 64 +IMAGE_KEYS = ( + "CHATBALLS_BACKEND_IMAGE", + "CHATBALLS_FRONTEND_IMAGE", + "CHATBALLS_POSTGRES_IMAGE", + "CHATBALLS_REDIS_IMAGE", + "CHATBALLS_GATEWAY_IMAGE", + "CHATBALLS_COTURN_IMAGE", +) + + +def _compose() -> dict: + return yaml.safe_load(COMPOSE.read_text(encoding="utf-8")) + + +def _volume_source(entry) -> str: + if isinstance(entry, str): + return entry.split(":", 1)[0] + return str(entry.get("source", "")) + + +def _is_bind(source: str) -> bool: + """Bind-mount — всё, что указывает на путь, а не на именованный том.""" + return source.startswith((".", "/", "~")) or "${" in source + + +@pytest.mark.parametrize("service", sorted(_compose()["services"])) +def test_service_has_no_host_bind_mounts(service: str) -> None: + definition = _compose()["services"][service] + binds = [ + source + for entry in definition.get("volumes", []) + if _is_bind(source := _volume_source(entry)) + ] + if service in BIND_MOUNT_EXCEPTIONS: + pytest.skip(f"{service}: bind-mount разрешён явно") + assert binds == [], ( + f"{service}: манифест монтирует с хоста {binds}. " + "Установка — один compose.yaml: всё, что нужно сервису, кладётся в образ." + ) + + +def test_named_volumes_are_declared() -> None: + compose = _compose() + declared = set(compose.get("volumes") or {}) + used = { + source + for definition in compose["services"].values() + for entry in definition.get("volumes", []) + if not _is_bind(source := _volume_source(entry)) + } + assert used <= declared, f"не объявлены тома: {sorted(used - declared)}" + + +def test_every_image_reference_can_be_pinned(tmp_path: Path) -> None: + """Все шесть ключей образов присутствуют и закрепляются по digest. + + Если из манифеста уйдёт (или переименуется) хоть один ключ, релизный + compose.yaml уедет с плавающим тегом — а найдётся это уже у человека. + """ + output = tmp_path / "compose.yaml" + pins: list[str] = [] + for key in IMAGE_KEYS: + pins += ["--pin", f"{key}=registry.test/{key.lower()}:1.0.0@{DIGEST}"] + + result = subprocess.run( + [sys.executable, str(PIN_SCRIPT), "--source", str(COMPOSE), + "--output", str(output), "--version", "1.0.0-test", *pins], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + pinned = output.read_text(encoding="utf-8") + for key in IMAGE_KEYS: + assert f"${{{key}" not in pinned, f"{key} остался подстановкой в релизном файле" + + +def test_pinning_rejects_floating_tag(tmp_path: Path) -> None: + pins: list[str] = [] + for key in IMAGE_KEYS: + ref = "registry.test/x:1.0.0" if key == "CHATBALLS_BACKEND_IMAGE" else f"registry.test/x:1.0.0@{DIGEST}" + pins += ["--pin", f"{key}={ref}"] + + result = subprocess.run( + [sys.executable, str(PIN_SCRIPT), "--source", str(COMPOSE), + "--output", str(tmp_path / "compose.yaml"), "--version", "1.0.0-test", *pins], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "@sha256" in result.stdout + result.stderr