diff --git a/.env.example b/.env.example index 953468f..11f1019 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,5 @@ BACKEND_PORT=8010 VITE_API_BASE_URL=http://localhost:8010 -# Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3). -# Подставляется в src сниппета: /chat-widget.js -VITE_PUBLIC_HUB_URL=https://hub.edevs.tech +# Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin +# (ADR-HUB-0028 §10); build-time аргумент не нужен. diff --git a/.env.production.example b/.env.production.example deleted file mode 100644 index 404ddb8..0000000 --- a/.env.production.example +++ /dev/null @@ -1,91 +0,0 @@ -COMPOSE_PROJECT_NAME=edevs_hub - -# Filled by GitLab deploy job or by hand on the server. -HUB_BACKEND_IMAGE=registry.example.com/edevs/hub/backend:change-me -HUB_FRONTEND_IMAGE=registry.example.com/edevs/hub/frontend:change-me -HUB_FRONTEND_HOST_PORT=8080 - -HUB_ENV=production -HUB_DEBUG=false -HUB_SECRET_KEY=change-me-long-random-secret -HUB_FIELD_ENCRYPTION_KEY=change-me-fernet-key - -HUB_ALLOWED_HOSTS=hub.edevs.tech -HUB_CSRF_TRUSTED_ORIGINS=https://hub.edevs.tech -HUB_CORS_ALLOWED_ORIGINS=https://hub.edevs.tech -INTERNAL_UI_BASE_URL=https://hub.edevs.tech -# Host header used by Docker healthcheck inside the backend container. -# Must be present in HUB_ALLOWED_HOSTS. -HUB_HEALTHCHECK_HOST=hub.edevs.tech - -HUB_COOKIE_SECURE=true -HUB_SSL_REDIRECT=true -HUB_HSTS_SECONDS=31536000 -HUB_COOKIE_SAMESITE=Lax - -POSTGRES_DB=edevs_hub -POSTGRES_USER=edevs_hub -POSTGRES_PASSWORD=change-me-db-password -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -REDIS_URL=redis://redis:6379/0 - -# P2P calls: configurable invitation and call-access lifetimes. -HUB_CALL_INVITE_TTL_SECONDS=300 -HUB_CALL_ACCESS_TTL_SECONDS=3600 -HUB_CALL_CONNECT_GRACE_SECONDS=120 -HUB_CALL_RECONNECT_GRACE_SECONDS=60 -# STUN для WebRTC (через запятую), например: stun:hub.edevs.tech:3478 -HUB_CALL_STUN_URLS= -# --- TURN / Coturn (SPEC-HUB-0013 §11) --- -# Публичные TURN endpoints для клиента (через запятую). Пусто -> только STUN/direct. -# turns:...:443?transport=tcp обязателен для звонков через VPN/строгие сети -# (там UDP и порт 3478 обычно закрыты, а TLS-443 маскируется под обычный HTTPS): -# turn:turn.hub.edevs.tech:3478?transport=udp,turn:turn.hub.edevs.tech:3478?transport=tcp,turns:turn.hub.edevs.tech:443?transport=tcp -HUB_CALL_TURN_URLS= -# Общий static-auth-secret между backend (подпись credentials) и сервисом coturn. -# Обязателен при включённом TURN. Нужен и docker compose (интерполяция coturn). -HUB_CALL_TURN_SECRET= -# TTL краткоживущих TURN credentials (сек). Должен покрывать длительность звонка. -HUB_CALL_TURN_TTL_SECONDS=3600 -# Параметры сервиса coturn (compose.production) — realm, публичный IP и порты. -HUB_CALL_TURN_REALM=hub.edevs.tech -# Публичный IP, на котором coturn слушает и аллоцирует relay. Должен совпадать с -# доменом turns: (сертификат). Рекомендуется ОТДЕЛЬНЫЙ от web IP/порт, чтобы TURN -# занял 443 без конфликта с nginx (nginx привязывается к основному IP). -HUB_TURN_EXTERNAL_IP= -# IP, к которому coturn биндит слушатели (обычно = HUB_TURN_EXTERNAL_IP). -HUB_TURN_LISTENING_IP= -HUB_TURN_LISTENING_PORT=3478 -# Порт TURN-over-TLS (turns:). 443 — чтобы проходить VPN/строгие firewall. -HUB_TURN_TLS_PORT=443 -HUB_TURN_MIN_PORT=49160 -HUB_TURN_MAX_PORT=49200 -# В облачном firewall на TURN-IP открыть входящие: TCP 443 (turns), UDP+TCP 3478 -# (turn), UDP HUB_TURN_MIN_PORT..HUB_TURN_MAX_PORT (relay-медиа). -# Сертификат turns: класть в ./data/coturn-certs/{fullchain,privkey}.pem под uid -# coturn (nobody, 65534); обновлять certbot deploy-hook'ом. - -HUB_AI_PROVIDER=openrouter -HUB_OPENROUTER_API_KEY= -HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -HUB_AI_REQUEST_TIMEOUT=30 -HUB_AI_MAX_RETRIES=2 -HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 - -EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend -EMAIL_HOST= -EMAIL_PORT=587 -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= -EMAIL_USE_TLS=true -DEFAULT_FROM_EMAIL=CustoCRM - -# Required only for the first production seed when no OWNER exists yet. -# The seed command never resets an existing user's password. -HUB_SEED_OWNER_EMAIL= -HUB_SEED_OWNER_PASSWORD= -HUB_SEED_OWNER_NAME= - -HUB_GUNICORN_WORKERS=3 -HUB_GUNICORN_TIMEOUT=60 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..42cfb0d --- /dev/null +++ b/Caddyfile @@ -0,0 +1,23 @@ +# Caddyfile — единый HTTP/HTTPS public boundary CustoCRM (ADR-HUB-0028 §gateway). +# Подставляется в release bundle и монтируется в контейнер gateway. +# Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). +# Маршрутизация публичных путей делегирована frontend-контейнеру (internal nginx, +# deploy/nginx/frontend.production.conf) — Caddy только терминирует TLS и проксирует +# всё на frontend:80. Coturn не проксируется через Caddy (отдельная boundary, profile calls). + +{ + email {$CUSTOCRM_ACME_EMAIL:} + # Caddy admin API не публикуется наружу (default localhost:2019). +} + +# Домен экземпляра берётся из instance .env (CUSTOCRM_DOMAIN). +{$CUSTOCRM_DOMAIN} { + encode gzip zstd + + reverse_proxy frontend:80 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..05639a5 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0-dev diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production index bb02f69..c452353 100644 --- a/apps/backend/Dockerfile.production +++ b/apps/backend/Dockerfile.production @@ -14,6 +14,12 @@ RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt COPY apps/backend /app/apps/backend COPY content /app/content +# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен. +# Build-time secret нужен только чтобы settings загрузились в production-режиме; +# collectstatic не обращается к БД/Redis. whitenoise раздаёт static в runtime. +RUN cd apps/backend && HUB_SECRET_KEY=collectstatic-build HUB_DEBUG=false HUB_ENV=production \ + python manage.py collectstatic --noinput + RUN chown -R hub:hub /app WORKDIR /app/apps/backend diff --git a/apps/internal-ui/src/features/integrations/model.test.ts b/apps/internal-ui/src/features/integrations/model.test.ts index c0ed7b4..4552be1 100644 --- a/apps/internal-ui/src/features/integrations/model.test.ts +++ b/apps/internal-ui/src/features/integrations/model.test.ts @@ -1,34 +1,42 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("webWidgetSnippet", () => { beforeEach(() => { vi.resetModules(); }); - it("builds the embed snippet from the public hub url and channel code", async () => { - vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech"); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("builds the embed snippet from the current origin and channel code", async () => { + vi.stubGlobal("window", { location: { origin: "https://hub.example.com" } }); const { webWidgetSnippet } = await import("./model"); expect(webWidgetSnippet("edeves")).toBe( - ``, + ``, ); }); - it("strips trailing slashes from the hub url", async () => { - vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech/"); + it("follows whatever origin serves the page (one image, any domain)", async () => { + vi.stubGlobal("window", { location: { origin: "https://acme.test" } }); const { webWidgetSnippet } = await import("./model"); expect(webWidgetSnippet("foxray")).toBe( - ``, + ``, ); }); - it("falls back to a relative src when the hub url is empty", async () => { - vi.stubEnv("VITE_PUBLIC_HUB_URL", ""); + it("uses the origin verbatim with a custom port", async () => { + vi.stubGlobal("window", { location: { origin: "https://hub.example.com:8443" } }); const { webWidgetSnippet } = await import("./model"); expect(webWidgetSnippet("edeves")).toBe( - ``, + ``, ); }); }); + +// Гарантия отсутствия build-time привязки: сниппет выводится от текущего origin в +// рантайме, поэтому один frontend-образ работает на любом домене без пересборки +// (ADR-HUB-0028 §10). diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index cc91a2b..54c7a3a 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -55,9 +55,8 @@ export type ChannelOption = { id: number; code: string; name: string }; export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/channels/").then((r) => r.items); // Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3). -// Подставляется в src сниппета: /chat-widget.js?data-channel=. -const PUBLIC_HUB_URL = (import.meta.env.VITE_PUBLIC_HUB_URL ?? "").replace(/\/+$/, ""); - +// Один frontend-образ работает на любом домене (ADR-HUB-0028 §runtime frontend): +// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента. export function webWidgetSnippet(channelCode: string): string { - return ``; + return ``; } diff --git a/apps/internal-ui/src/vite-env.d.ts b/apps/internal-ui/src/vite-env.d.ts index bee56a1..77a9533 100644 --- a/apps/internal-ui/src/vite-env.d.ts +++ b/apps/internal-ui/src/vite-env.d.ts @@ -1,8 +1,8 @@ /// interface ImportMetaEnv { + // Dev-only: empty в production -> API/realtime на same-origin (ADR-HUB-0028 §10). readonly VITE_API_BASE_URL?: string; - readonly VITE_PUBLIC_HUB_URL?: string; } interface ImportMeta { diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..9b5c95a --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,95 @@ +# Override для локальной разработки (ADR-HUB-0028 §compose.dev). +# Применяется поверх canonical compose.yaml: +# docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up +# Переопределяет image на build, dev-команды, bind mounts и host-порты. +# Не определяет отдельную production-топологию. + +services: + postgres: + ports: + - "${POSTGRES_HOST_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + ports: + - "${REDIS_HOST_PORT:-6379}:6379" + + # Dev: migrate через init, без collectstatic (испечён только в prod-образе). + init: + build: + context: . + dockerfile: apps/backend/Dockerfile + + backend: + build: + context: . + dockerfile: apps/backend/Dockerfile + command: > + sh -c "python manage.py migrate --noinput && + uvicorn hub_backend.asgi:application --host 0.0.0.0 --port 8000 --reload" + ports: + - "${BACKEND_PORT:-8010}:8000" + volumes: + - ./apps/backend:/app/apps/backend + depends_on: + init: + condition: service_completed_successfully + postgres: + condition: service_healthy + redis: + condition: service_healthy + + worker: + build: + context: . + dockerfile: apps/backend/Dockerfile + volumes: + - ./apps/backend:/app/apps/backend + + # Dev: frontend-сервис превращается в Vite dev-сервер internal-ui (HMR). + frontend: + build: + context: . + dockerfile: apps/internal-ui/Dockerfile + command: npm run dev -- --host 0.0.0.0 --port 5173 + ports: + - "${INTERNAL_UI_PORT:-5173}:5173" + volumes: + - ./apps/internal-ui:/app/apps/internal-ui + - ./packages:/app/packages + - /app/node_modules + - /app/apps/internal-ui/node_modules + + # Dev-only: web-chat как отдельный Vite-сервер (в prod встроен в frontend-образ). + web-chat: + build: + context: . + dockerfile: apps/web-chat/Dockerfile + command: npm run dev -- --host 0.0.0.0 --port 5175 + ports: + - "${WEB_CHAT_PORT:-5175}:5175" + volumes: + - ./apps/web-chat:/app/apps/web-chat + - ./packages:/app/packages + - /app/node_modules + - /app/apps/web-chat/node_modules + + # Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly). + gateway: + image: nginx:1.27-alpine + command: ["nginx", "-g", "daemon off;"] + ports: + - "80:80" + volumes: + - ./deploy/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + web-chat: + condition: service_started + +volumes: + postgres_data: diff --git a/compose.production.yaml b/compose.production.yaml deleted file mode 100644 index cf6315e..0000000 --- a/compose.production.yaml +++ /dev/null @@ -1,129 +0,0 @@ -services: - postgres: - image: pgvector/pgvector:pg16 - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} - POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - volumes: - - ./data/postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 10 - - redis: - image: redis:7-alpine - restart: unless-stopped - command: ["redis-server", "--appendonly", "yes"] - volumes: - - ./data/redis:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 20s - - backend: - image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required} - restart: unless-stopped - env_file: - - .env.production - command: > - sh -c "gunicorn hub_backend.asgi:application - --worker-class uvicorn.workers.UvicornWorker - --bind 0.0.0.0:8000 - --workers $${HUB_GUNICORN_WORKERS:-3} - --timeout $${HUB_GUNICORN_TIMEOUT:-60}" - volumes: - # Файловые вложения знаний (ADR-HUB-0023). - - ./data/media:/app/apps/backend/media - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('HUB_HEALTHCHECK_HOST', 'hub.edevs.tech'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", - ] - interval: 10s - timeout: 5s - retries: 10 - start_period: 20s - - worker: - image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required} - restart: unless-stopped - env_file: - - .env.production - command: python manage.py run_worker - volumes: - - ./data/media:/app/apps/backend/media - depends_on: - backend: - condition: service_healthy - - frontend: - image: ${HUB_FRONTEND_IMAGE:?HUB_FRONTEND_IMAGE is required} - restart: unless-stopped - ports: - - "127.0.0.1:${HUB_FRONTEND_HOST_PORT:-8080}:80" - depends_on: - backend: - condition: service_healthy - - # Coturn — отдельный медиа-relay сервис для TURN fallback (SPEC-HUB-0013 §11). - # Host networking: relay использует широкий UDP-диапазон и реальный внешний IP, - # публикуется напрямую и не проходит через HTTP reverse proxy. credentials - # выдаёт backend по общему HUB_CALL_TURN_SECRET (static-auth-secret). - coturn: - image: coturn/coturn:4.6 - restart: unless-stopped - network_mode: host - command: - - -n - - --log-file=stdout - - --no-cli - - --fingerprint - - --use-auth-secret - - --static-auth-secret=${HUB_CALL_TURN_SECRET:?HUB_CALL_TURN_SECRET is required} - - --realm=${HUB_CALL_TURN_REALM:?HUB_CALL_TURN_REALM is required} - # Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), nginx — на - # основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web. - - --listening-ip=${HUB_TURN_LISTENING_IP:?HUB_TURN_LISTENING_IP is required} - - --relay-ip=${HUB_TURN_EXTERNAL_IP:?HUB_TURN_EXTERNAL_IP is required} - - --external-ip=${HUB_TURN_EXTERNAL_IP} - - --listening-port=${HUB_TURN_LISTENING_PORT:-3478} - # TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут. - - --tls-listening-port=${HUB_TURN_TLS_PORT:-443} - - --cert=/etc/coturn/certs/fullchain.pem - - --pkey=/etc/coturn/certs/privkey.pem - - --min-port=${HUB_TURN_MIN_PORT:-49160} - - --max-port=${HUB_TURN_MAX_PORT:-49200} - # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - - --no-multicast-peers - - --no-tcp-relay - - --denied-peer-ip=10.0.0.0-10.255.255.255 - - --denied-peer-ip=172.16.0.0-172.31.255.255 - - --denied-peer-ip=192.168.0.0-192.168.255.255 - - --no-tlsv1 - - --no-tlsv1_1 - volumes: - # LE-сертификат TURN-хоста (turns:), скопированный под uid coturn (nobody) - # renewal deploy-hook'ом. См. docs по развёртыванию TURN-over-TLS. - - ./data/coturn-certs:/etc/coturn/certs:ro - healthcheck: - # Allocation smoke: STUN binding к собственному listener на выделенном IP. - test: ["CMD", "turnutils_stunclient", "-p", "${HUB_TURN_LISTENING_PORT:-3478}", "${HUB_TURN_LISTENING_IP}"] - interval: 30s - timeout: 5s - retries: 5 - start_period: 15s diff --git a/compose.yaml b/compose.yaml index 32fe0d2..0544dff 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,19 +1,27 @@ -x-hub-env: &hub-env - - path: .env.example - - path: .env - required: false +# Канонический production-манифест CustoCRM (ADR-HUB-0028). +# Один Compose для: box-установки клиента, hub.edevs.tech, staging, smoke. +# Локальная разработка — через override compose.dev.yaml; этот файл не содержит +# dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway). +# +# Image references берутся из release.env (immutable digest refs, см. ADR §release). +# В dev они не задаются — срабатывают безопасные defaults, а override подменяет +# image на build. +# +# Запуск production/box: +# docker compose --env-file .env --env-file release.env up -d +# Запуск разработки: +# docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up services: postgres: - image: pgvector/pgvector:pg16 + image: ${CUSTOCRM_POSTGRES_IMAGE:-pgvector/pgvector:pg16} + restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-edevs_hub} - POSTGRES_USER: ${POSTGRES_USER:-edevs_hub} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-edevs_hub} - ports: - - "${POSTGRES_HOST_PORT:-5432}:5432" + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} volumes: - - postgres_data:/var/lib/postgresql/data + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s @@ -21,29 +29,50 @@ services: retries: 10 redis: - image: redis:7-alpine - ports: - - "${REDIS_HOST_PORT:-6379}:6379" + image: ${CUSTOCRM_REDIS_IMAGE:-redis:7-alpine} + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 10 + start_period: 20s + + # One-shot init: миграции (ADR-HUB-0028 §one-shot init). Не запускает Edevs seed, + # не создаёт OWNER, не трогает demo — это отдельные ответственности (этап 2). + # collectstatic испечён в backend-образ, здесь не вызывается. + init: + image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} + env_file: + - ${CUSTOCRM_INSTANCE_DIR:-.}/.env + command: ["python", "manage.py", "migrate", "--noinput"] + restart: "no" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy backend: - build: - context: . - dockerfile: apps/backend/Dockerfile - env_file: *hub-env + image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} + restart: unless-stopped + env_file: + - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: > - sh -c "python manage.py collectstatic --noinput && - python manage.py migrate && - uvicorn hub_backend.asgi:application --host 0.0.0.0 --port 8000" - ports: - - "${BACKEND_PORT:-8010}:8000" + sh -c "gunicorn hub_backend.asgi:application + --worker-class uvicorn.workers.UvicornWorker + --bind 0.0.0.0:8000 + --workers $${HUB_GUNICORN_WORKERS:-3} + --timeout $${HUB_GUNICORN_TIMEOUT:-60}" volumes: - - ./apps/backend:/app/apps/backend + # Файловые вложения знаний (ADR-HUB-0023). + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: + init: + condition: service_completed_successfully postgres: condition: service_healthy redis: @@ -54,65 +83,102 @@ services: "CMD", "python", "-c", - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live/', timeout=3)", + "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('HUB_HEALTHCHECK_HOST', 'localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", ] interval: 10s timeout: 5s retries: 10 + start_period: 20s worker: - build: - context: . - dockerfile: apps/backend/Dockerfile - env_file: *hub-env - command: python manage.py run_worker + image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} + restart: unless-stopped + env_file: + - ${CUSTOCRM_INSTANCE_DIR:-.}/.env + command: ["python", "manage.py", "run_worker"] volumes: - - ./apps/backend:/app/apps/backend + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: backend: condition: service_healthy - internal-ui: - build: - context: . - dockerfile: apps/internal-ui/Dockerfile - env_file: *hub-env - command: npm run dev -- --host 0.0.0.0 --port 5173 - ports: - - "5173:5173" - volumes: - - ./apps/internal-ui:/app/apps/internal-ui - - ./packages:/app/packages - - /app/node_modules - - /app/apps/internal-ui/node_modules - - web-chat: - build: - context: . - dockerfile: apps/web-chat/Dockerfile - env_file: *hub-env - command: npm run dev -- --host 0.0.0.0 --port 5175 - ports: - - "5175:5175" - volumes: - - ./apps/web-chat:/app/apps/web-chat - - ./packages:/app/packages - - /app/node_modules - - /app/apps/web-chat/node_modules - - proxy: - image: nginx:1.27-alpine - ports: - - "80:80" - volumes: - - ./deploy/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro + # Frontend: один nginx-образ со static-сборкой internal-ui и web-chat, + # внутренняя маршрутизация (/api/, /ws/, /admin/, /static/, /chat-widget.js, + # /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует + # host-порт: public boundary — gateway (Caddy). + frontend: + image: ${CUSTOCRM_FRONTEND_IMAGE:-custocrm-frontend:dev} + restart: unless-stopped depends_on: backend: condition: service_healthy - internal-ui: - condition: service_started - web-chat: - condition: service_started -volumes: - postgres_data: + # Gateway: единственный HTTP/HTTPS public boundary (ADR-HUB-0028 §gateway). + # Caddy: TLS termination, ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). + # Маршрутизация публичных путей делегируется frontend (internal). Coturn не + # проксируется через Caddy — отдельная network boundary (profile calls). + gateway: + image: ${CUSTOCRM_GATEWAY_IMAGE:-caddy:2.8.4} + restart: unless-stopped + ports: + - "${CUSTOCRM_WEB_LISTENING_IP:-0.0.0.0}:80:80" + - "${CUSTOCRM_WEB_LISTENING_IP:-0.0.0.0}:443:443" + volumes: + - ${CUSTOCRM_RELEASE_DIR:-.}/Caddyfile:/etc/caddy/Caddyfile:ro + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/caddy:/data + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/caddy-config:/config + depends_on: + frontend: + condition: service_started + backend: + condition: service_healthy + + # Coturn — медиа-relay для TURN fallback (SPEC-HUB-0013 §11). + # Опциональный profile `calls` (ADR-HUB-0028): включается через COMPOSE_PROFILES=calls. + # Host networking: relay использует широкий UDP-диапазон и реальный внешний IP, + # публикуется напрямую и не проходит через HTTP reverse proxy. credentials + # выдаёт backend по общему HUB_CALL_TURN_SECRET (static-auth-secret). + coturn: + profiles: ["calls"] + image: ${CUSTOCRM_COTURN_IMAGE:-coturn/coturn:4.6} + restart: unless-stopped + network_mode: host + command: + - -n + - --log-file=stdout + - --no-cli + - --fingerprint + - --use-auth-secret + - --static-auth-secret=${HUB_CALL_TURN_SECRET} + - --realm=${HUB_CALL_TURN_REALM} + # Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на + # основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web. + - --listening-ip=${HUB_TURN_LISTENING_IP} + - --relay-ip=${HUB_TURN_EXTERNAL_IP} + - --external-ip=${HUB_TURN_EXTERNAL_IP} + - --listening-port=${HUB_TURN_LISTENING_PORT:-3478} + # TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут. + - --tls-listening-port=${HUB_TURN_TLS_PORT:-443} + - --cert=/etc/coturn/certs/fullchain.pem + - --pkey=/etc/coturn/certs/privkey.pem + - --min-port=${HUB_TURN_MIN_PORT:-49160} + - --max-port=${HUB_TURN_MAX_PORT:-49200} + # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). + - --no-multicast-peers + - --no-tcp-relay + - --denied-peer-ip=10.0.0.0-10.255.255.255 + - --denied-peer-ip=172.16.0.0-172.31.255.255 + - --denied-peer-ip=192.168.0.0-192.168.255.255 + - --no-tlsv1 + - --no-tlsv1_1 + volumes: + # LE-сертификат TURN-хоста (turns:), скопированный под uid coturn (nobody) + # renewal deploy-hook'ом. См. docs по развёртыванию TURN-over-TLS. + - ${CUSTOCRM_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro + healthcheck: + # Allocation smoke: STUN binding к собственному listener на выделенном IP. + test: ["CMD", "turnutils_stunclient", "-p", "${HUB_TURN_LISTENING_PORT:-3478}", "${HUB_TURN_LISTENING_IP}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 15s diff --git a/custocrm b/custocrm new file mode 100644 index 0000000..80ff9ff --- /dev/null +++ b/custocrm @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# custocrm — единый deployment entrypoint CustoCRM (ADR-HUB-0028 §CLI). +# Команды этапа 1: doctor, deploy, status, logs. +# (install/setup-url/update/backup/restore/rollback — этап 2.) +# +# Не требует Python/Node на host. POSIX bash + flock. Запускается из instance dir; +# release-файлы (compose.yaml, Caddyfile, release.env) лежат в release dir и +# проектируются в instance через symlink (см. lib/compose.sh). +set -euo pipefail + +CUSTOCRM_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +: "${CUSTOCRM_RELEASE_DIR:=$CUSTOCRM_SCRIPT_DIR}" +: "${CUSTOCRM_INSTANCE_DIR:=$PWD}" +export CUSTOCRM_RELEASE_DIR CUSTOCRM_INSTANCE_DIR +CLI_LIB_DIR="$CUSTOCRM_RELEASE_DIR/deploy/cli/lib" + +# shellcheck source=deploy/cli/lib/common.sh +. "$CLI_LIB_DIR/common.sh" +# shellcheck source=deploy/cli/lib/compose.sh +. "$CLI_LIB_DIR/compose.sh" +# shellcheck source=deploy/cli/lib/doctor.sh +. "$CLI_LIB_DIR/doctor.sh" +# shellcheck source=deploy/cli/lib/deploy.sh +. "$CLI_LIB_DIR/deploy.sh" +# shellcheck source=deploy/cli/lib/status.sh +. "$CLI_LIB_DIR/status.sh" +# shellcheck source=deploy/cli/lib/logs.sh +. "$CLI_LIB_DIR/logs.sh" + +usage() { + cat >&2 <<'USAGE' +CustoCRM deployment CLI (stage 1). + +Usage: custocrm [options] + +Commands: + doctor Pre-flight checks (no container starts, no data changes). + deploy Apply the active release (canonical workflow). + status Show versions, services, profiles. + logs [service] Tail compose logs (optional service filter). + +Global options: + --non-interactive Suppress prompts (CI). Destructive ops require this or a tty. + --json Machine-readable output (status/doctor errors). + +Environment: + CUSTOCRM_RELEASE_DIR Release bundle dir (default: script dir). + CUSTOCRM_INSTANCE_DIR Instance dir with .env and data/ (default: $PWD). +USAGE +} + +cmd="${1:-}" +[[ -n "$cmd" ]] || { usage; exit 2; } +shift || true + +# Разбор глобальных флагов: --non-interactive/--json могут стоять до или после команды. +rest=() +while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) CUSTOCRM_NON_INTERACTIVE=1; shift ;; + --json) CUSTOCRM_JSON=1; shift ;; + *) rest+=("$1"); shift ;; + esac +done +set -- "${rest[@]}" + +case "$cmd" in + doctor) cmd_doctor "$@" ;; + deploy) cmd_deploy "$@" ;; + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + -h|--help|help) usage; exit 0 ;; + *) log_err "unknown command: $cmd"; usage; exit 2 ;; +esac diff --git a/deploy/cli/lib/common.sh b/deploy/cli/lib/common.sh new file mode 100644 index 0000000..1ec5d76 --- /dev/null +++ b/deploy/cli/lib/common.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# common.sh — shared helpers for the CustoCRM deployment CLI. + +CUSTOCRM_NON_INTERACTIVE=0 +CUSTOCRM_JSON=0 + +log() { printf '[custocrm] %s\n' "$*" >&2; } +log_ok() { printf '[custocrm] OK: %s\n' "$*" >&2; } +log_warn() { printf '[custocrm] WARN: %s\n' "$*" >&2; } +log_err() { printf '[custocrm] ERROR: %s\n' "$*" >&2; } + +die() { + local msg="$1" + local code="${2:-1}" + if [[ "$CUSTOCRM_JSON" == "1" ]]; then + printf '{"status":"error","error":%s}\n' "$(json_escape "$msg")" + else + log_err "$msg" + fi + exit "$code" +} + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '"%s"' "$s" +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" 2 +} + +release_dir() { + printf '%s' "${CUSTOCRM_RELEASE_DIR:?CUSTOCRM_RELEASE_DIR is not set}" +} + +instance_dir() { + printf '%s' "${CUSTOCRM_INSTANCE_DIR:?CUSTOCRM_INSTANCE_DIR is not set}" +} + +instance_env_file() { printf '%s/.env' "$(instance_dir)"; } +release_env_file() { printf '%s/release.env' "$(release_dir)"; } +release_checksums_file() { printf '%s/checksums.txt' "$(release_dir)"; } +compose_file() { printf '%s/compose.yaml' "$(release_dir)"; } +state_dir() { printf '%s/state' "$(instance_dir)"; } +data_dir() { printf '%s/data' "$(instance_dir)"; } + +ensure_instance_dirs() { + local d + for d in "$(instance_dir)" "$(state_dir)" "$(data_dir)" \ + "$(instance_dir)/backups" "$(instance_dir)/logs"; do + mkdir -p "$d" || die "cannot create directory: $d" + done +} + +LOCK_FD=9 +acquire_lock() { + local lock_file + lock_file="$(state_dir)/deploy.lock" + exec 9>"$lock_file" || die "cannot open lock file: $lock_file" + if ! flock -n 9; then + die "another operation is in progress (lock held): $lock_file" 3 + fi +} + +release_lock() { + flock -u 9 2>/dev/null || true + exec 9>&- 2>/dev/null || true +} + +env_get() { + local file="$1" key="$2" + [[ -f "$file" ]] || return 0 + awk -F= -v k="$key" '$1==k && $0 !~ /^#/ {sub(/^[^=]*=/,""); print; exit}' "$file" +} + +verify_release_checksums() { + local checksums + checksums="$(release_checksums_file)" + [[ -f "$checksums" ]] || { + log_err "release checksums missing: $checksums" + return 1 + } + command -v sha256sum >/dev/null 2>&1 || { + log_err "required command not found: sha256sum" + return 1 + } + ( + cd "$(release_dir)" || exit 1 + sha256sum -c checksums.txt >/dev/null + ) || { + log_err "release checksum verification failed" + return 1 + } +} + +validate_calls_network_boundary() { + local web_ip turn_ip + web_ip="$(env_get "$(instance_env_file)" CUSTOCRM_WEB_LISTENING_IP)" + turn_ip="$(env_get "$(instance_env_file)" HUB_TURN_LISTENING_IP)" + + [[ -n "$web_ip" ]] || { + log_err "CUSTOCRM_WEB_LISTENING_IP is required for calls profile" + return 1 + } + [[ "$web_ip" != "0.0.0.0" ]] || { + log_err "CUSTOCRM_WEB_LISTENING_IP cannot be 0.0.0.0 when calls profile uses TURN TLS on 443" + return 1 + } + [[ "$web_ip" != "$turn_ip" ]] || { + log_err "web and TURN listeners must use different public IP addresses" + return 1 + } +} + +release_image_keys() { + printf '%s\n' \ + CUSTOCRM_BACKEND_IMAGE \ + CUSTOCRM_FRONTEND_IMAGE \ + CUSTOCRM_POSTGRES_IMAGE \ + CUSTOCRM_REDIS_IMAGE \ + CUSTOCRM_GATEWAY_IMAGE \ + CUSTOCRM_COTURN_IMAGE +} + +validate_release_image_refs() { + local key ref failed=0 + while IFS= read -r key; do + ref="$(env_get "$(release_env_file)" "$key")" + if [[ ! "$ref" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then + log_err "$key must be an immutable @sha256 reference" + failed=1 + fi + done < <(release_image_keys) + [[ "$failed" == "0" ]] +} diff --git a/deploy/cli/lib/compose.sh b/deploy/cli/lib/compose.sh new file mode 100644 index 0000000..86d6b92 --- /dev/null +++ b/deploy/cli/lib/compose.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# compose.sh — canonical Docker Compose invocation for a release + instance pair. +# Release files remain immutable; runtime data and .env stay in INSTANCE_DIR. + +run_compose() { + local inst rel + inst="$(instance_dir)" + rel="$(release_dir)" + ( + cd "$inst" || exit 1 + docker compose \ + --project-directory "$inst" \ + --env-file "$inst/.env" \ + --env-file "$rel/release.env" \ + -f "$rel/compose.yaml" \ + "$@" + ) +} + +compose_config_validate() { + run_compose config -q +} + +profile_enabled() { + local p="$1" profiles + profiles="$(env_get "$(instance_env_file)" COMPOSE_PROFILES)" + [[ ",$profiles," == *",$p,"* ]] +} diff --git a/deploy/cli/lib/deploy.sh b/deploy/cli/lib/deploy.sh new file mode 100644 index 0000000..522944c --- /dev/null +++ b/deploy/cli/lib/deploy.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# deploy.sh — canonical deployment workflow (ADR-HUB-0028 / SPEC-HUB-0019). + +cmd_deploy() { + ensure_instance_dirs + acquire_lock + trap release_lock EXIT + + log "deploy: validating release and instance config" + _deploy_validate || die "deploy: validation failed" 1 + + log "deploy: pulling immutable images" + run_compose pull || die "deploy: image pull failed" 1 + + log "deploy: starting infrastructure (postgres, redis)" + run_compose up -d postgres redis || die "deploy: infrastructure start failed" 1 + _wait_healthy postgres 60 || die "deploy: postgres did not become healthy" 1 + _wait_healthy redis 30 || die "deploy: redis did not become healthy" 1 + + log "deploy: running one-shot init (migrate)" + run_compose run --rm init || die "deploy: init (migrate) failed" 1 + + log "deploy: starting application services" + local app_services=(backend worker frontend gateway) + if profile_enabled calls; then + app_services+=(coturn) + fi + run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1 + + log "deploy: waiting for application health" + _wait_healthy backend 90 || die "deploy: backend did not become healthy" 1 + _wait_running frontend 30 || die "deploy: frontend did not start" 1 + _wait_running gateway 30 || die "deploy: gateway did not start" 1 + if profile_enabled calls; then + _wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1 + fi + + log "deploy: running smoke checks" + _smoke || die "deploy: smoke checks failed" 1 + + _record_release + log_ok "deploy: complete (release: $(_applied_version_target))" +} + +_deploy_validate() { + [[ -f "$(compose_file)" ]] || { log_err "compose.yaml missing"; return 1; } + [[ -f "$(instance_env_file)" ]] || { log_err "instance .env missing"; return 1; } + [[ -f "$(release_env_file)" ]] || { log_err "release.env missing"; return 1; } + + verify_release_checksums || return 1 + validate_release_image_refs || return 1 + + local domain + domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" + [[ -n "$domain" ]] || { log_err "CUSTOCRM_DOMAIN not set"; return 1; } + + if profile_enabled calls; then + validate_calls_network_boundary || return 1 + fi + + compose_config_validate >/dev/null 2>&1 || { + log_err "compose config invalid" + return 1 + } +} + +_wait_healthy() { + local svc="$1" timeout="$2" waited=0 state + while [[ "$waited" -lt "$timeout" ]]; do + state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)" + [[ "$state" == "healthy" ]] && return 0 + sleep 3 + waited=$((waited + 3)) + done + return 1 +} + +_wait_running() { + local svc="$1" timeout="$2" waited=0 state + while [[ "$waited" -lt "$timeout" ]]; do + state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)" + case "$state" in + healthy|running|Up*) return 0 ;; + esac + sleep 3 + waited=$((waited + 3)) + done + return 1 +} + +_first_json_service_state() { + local line + IFS= read -r line || return 0 + if [[ $line =~ \"Health\":\"([^\"]*)\" ]] && [[ -n "${BASH_REMATCH[1]}" ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + elif [[ $line =~ \"State\":\"([^\"]*)\" ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + elif [[ $line =~ \"Status\":\"([^\"]*)\" ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + fi +} + +_smoke() { + local domain + domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" + + run_compose exec -T backend python - <<'PY' >/dev/null 2>&1 || { +import os +import urllib.error +import urllib.request + +health_host = os.environ.get("HUB_HEALTHCHECK_HOST") or os.environ["CUSTOCRM_DOMAIN"] +health_request = urllib.request.Request( + "http://127.0.0.1:8000/api/v1/health/ready/", + headers={"Host": health_host, "X-Forwarded-Proto": "https"}, +) +with urllib.request.urlopen(health_request, timeout=5) as response: + assert response.status == 200 + +with urllib.request.urlopen("http://frontend/", timeout=5) as response: + assert response.status == 200 + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, new_url): + return None + +opener = urllib.request.build_opener(NoRedirect) +domain = os.environ["CUSTOCRM_DOMAIN"] +gateway_request = urllib.request.Request("http://gateway/", headers={"Host": domain}) +try: + opener.open(gateway_request, timeout=5) +except urllib.error.HTTPError as error: + assert error.code in {301, 302, 303, 307, 308} + assert error.headers.get("Location", "").startswith(f"https://{domain}") +else: + raise AssertionError("gateway did not redirect HTTP to HTTPS") +PY + log_err "smoke: internal backend/frontend/gateway checks failed" + return 1 + } + log_ok "smoke: backend, frontend and gateway" + + if command -v curl >/dev/null 2>&1; then + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$domain/" 2>/dev/null || true)" + if [[ "$code" =~ ^(200|30[12378])$ ]]; then + log_ok "smoke: public HTTPS endpoint" + else + log_warn "smoke: public HTTPS endpoint is not reachable yet (HTTP $code)" + fi + fi +} + +_state_file() { printf '%s/applied_release' "$(state_dir)"; } + +_record_release() { + local ver + ver="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)" + printf 'applied_version=%s\napplied_at=%s\n' "${ver:-unknown}" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" > "$(_state_file)" +} + +_applied_version_target() { + env_get "$(release_env_file)" CUSTOCRM_VERSION || printf 'unknown' +} diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh new file mode 100644 index 0000000..ab72c3f --- /dev/null +++ b/deploy/cli/lib/doctor.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# doctor.sh — read-only pre-flight checks before deployment. + +cmd_doctor() { + local failures=0 inst rel + inst="$(instance_dir)" + rel="$(release_dir)" + + _doctor_report() { + local ok="$1" + shift + local msg="$*" + if [[ "$ok" == "1" ]]; then + [[ "$CUSTOCRM_JSON" == "1" ]] || log_ok "$msg" + else + [[ "$CUSTOCRM_JSON" == "1" ]] || log_err "$msg" + failures=$((failures + 1)) + fi + } + + require_cmd docker + _doctor_report 1 "docker found" + require_cmd flock + _doctor_report 1 "flock found" + + if command -v sha256sum >/dev/null 2>&1; then + _doctor_report 1 "sha256sum found" + else + _doctor_report 0 "sha256sum missing" + fi + + if docker info >/dev/null 2>&1; then + _doctor_report 1 "docker daemon reachable" + else + _doctor_report 0 "docker daemon not reachable" + fi + + if docker compose version >/dev/null 2>&1; then + _doctor_report 1 "docker compose plugin available" + else + _doctor_report 0 "docker compose plugin missing" + fi + + local arch + arch="$(uname -m 2>/dev/null || echo unknown)" + if [[ "$arch" == "x86_64" ]]; then + _doctor_report 1 "host arch x86_64" + else + _doctor_report 0 "unsupported host arch: $arch (target: x86_64)" + fi + + if [[ -d "$inst" ]] && [[ -w "$inst" ]]; then + _doctor_report 1 "instance dir writable: $inst" + else + _doctor_report 0 "instance dir not writable: $inst" + fi + + if [[ -f "$(compose_file)" ]]; then + _doctor_report 1 "compose.yaml present in release: $rel" + else + _doctor_report 0 "compose.yaml missing in release: $rel" + fi + + if [[ -f "$(instance_env_file)" ]]; then + _doctor_report 1 "instance .env present" + else + _doctor_report 0 "instance .env missing: $(instance_env_file)" + fi + + if [[ -f "$(release_env_file)" ]]; then + _doctor_report 1 "release.env present" + else + _doctor_report 0 "release.env missing: $(release_env_file)" + fi + + if verify_release_checksums; then + _doctor_report 1 "release checksums valid" + else + _doctor_report 0 "release checksums invalid" + fi + + if validate_release_image_refs; then + _doctor_report 1 "release image references are immutable" + else + _doctor_report 0 "release image references are invalid" + fi + + local domain + domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" + if [[ -n "$domain" ]]; then + _doctor_report 1 "CUSTOCRM_DOMAIN set: $domain" + else + _doctor_report 0 "CUSTOCRM_DOMAIN not set" + fi + + local pg_pwd + pg_pwd="$(env_get "$(instance_env_file)" POSTGRES_PASSWORD)" + if [[ -n "$pg_pwd" ]]; then + _doctor_report 1 "POSTGRES_PASSWORD set" + else + _doctor_report 0 "POSTGRES_PASSWORD empty" + fi + + local secret + secret="$(env_get "$(instance_env_file)" HUB_SECRET_KEY)" + if [[ -n "$secret" ]] && [[ "$secret" != "change-me-long-random-secret" ]]; then + _doctor_report 1 "HUB_SECRET_KEY set" + else + _doctor_report 0 "HUB_SECRET_KEY default/empty" + fi + + if profile_enabled calls; then + local missing=0 k + for k in HUB_CALL_TURN_SECRET HUB_CALL_TURN_REALM HUB_TURN_EXTERNAL_IP HUB_TURN_LISTENING_IP; do + if [[ -z "$(env_get "$(instance_env_file)" "$k")" ]]; then + _doctor_report 0 "$k required for calls profile" + missing=1 + fi + done + if [[ "$missing" == "0" ]] && validate_calls_network_boundary; then + _doctor_report 1 "calls profile network boundary valid" + else + _doctor_report 0 "calls profile network boundary invalid" + fi + fi + + if { [[ -d "$inst/backups" ]] && [[ -w "$inst/backups" ]]; } || [[ -w "$inst" ]]; then + _doctor_report 1 "backup dir available" + else + _doctor_report 0 "backup dir not creatable: $inst/backups" + fi + + if compose_config_validate >/dev/null 2>&1; then + _doctor_report 1 "compose config valid" + else + _doctor_report 0 "compose config invalid" + fi + + if [[ "$failures" != "0" ]]; then + die "doctor: $failures check(s) failed" 1 + fi + + if [[ "$CUSTOCRM_JSON" == "1" ]]; then + printf '{"status":"ok","checks":"passed"}\n' + else + log_ok "doctor: all checks passed" + fi +} diff --git a/deploy/cli/lib/logs.sh b/deploy/cli/lib/logs.sh new file mode 100644 index 0000000..ba6e128 --- /dev/null +++ b/deploy/cli/lib/logs.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# logs.sh — обёртка над `docker compose logs` с фильтром сервиса. + +cmd_logs() { + local svc="" + while [[ $# -gt 0 ]]; do + case "$1" in + -f|--follow) shift; ;; + *) svc="$1"; shift; ;; + esac + done + + if [[ -n "$svc" ]]; then + run_compose logs --tail=200 "$svc" || die "logs: service not found or not running: $svc" 1 + else + run_compose logs --tail=200 || die "logs: cannot fetch logs (not deployed yet?)" 1 + fi +} diff --git a/deploy/cli/lib/status.sh b/deploy/cli/lib/status.sh new file mode 100644 index 0000000..8767586 --- /dev/null +++ b/deploy/cli/lib/status.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# status.sh — observability: версия, состояние сервисов, profiles, последняя +# ошибка (SPEC-HUB-0019 §29). Installation-status (NEW/READY/…) — этап 2. + +cmd_status() { + local inst rel applied target + inst="$(instance_dir)" + rel="$(release_dir)" + target="$(env_get "$(release_env_file)" CUSTOCRM_VERSION)" + applied="$(env_get "$(_state_file)" applied_version)" + + if [[ "$CUSTOCRM_JSON" == "1" ]]; then + printf '{"status":"info","instance":%s,"release_dir":%s,"target_version":%s,"applied_version":%s}\n' \ + "$(json_escape "$inst")" "$(json_escape "$rel")" \ + "$(json_escape "${target:-unknown}")" "$(json_escape "${applied:-none}")" + return 0 + fi + + printf 'CustoCRM status\n' + printf ' instance dir: %s\n' "$inst" + printf ' release dir: %s\n' "$rel" + printf ' target version: %s\n' "${target:-unknown}" + printf ' applied version: %s\n' "${applied:-none}" + printf ' profiles: %s\n' "$(env_get "$(instance_env_file)" COMPOSE_PROFILES || echo none)" + + if [[ -f "$(_state_file)" ]]; then + printf ' last applied at: %s\n' "$(env_get "$(_state_file)" applied_at)" + else + printf ' last applied at: (none)\n' + fi + + if docker info >/dev/null 2>&1; then + printf '\nServices:\n' + run_compose ps 2>/dev/null || log_warn "compose ps failed (not deployed yet?)" + else + printf '\nServices: (docker daemon not reachable)\n' + fi +} + +_state_file() { printf '%s/applied_release' "$(state_dir)"; } diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile index 0582cb4..7130f9e 100644 --- a/deploy/docker/frontend.Dockerfile +++ b/deploy/docker/frontend.Dockerfile @@ -14,12 +14,11 @@ COPY apps/internal-ui ./apps/internal-ui COPY apps/web-chat ./apps/web-chat COPY packages ./packages +# Build-time домен не привязывается (ADR-HUB-0028 §10): один образ работает на любом +# домене; сниппет/WS/API выводятся от текущего origin в рантайме. ARG VITE_API_BASE_URL= ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} -ARG VITE_PUBLIC_HUB_URL= -ENV VITE_PUBLIC_HUB_URL=${VITE_PUBLIC_HUB_URL} - RUN npm --workspace @edevs/internal-ui run build RUN npm --workspace @edevs/web-chat run build diff --git a/deploy/nginx/hub.edevs.tech.conf b/deploy/nginx/hub.edevs.tech.conf deleted file mode 100644 index 30eca9b..0000000 --- a/deploy/nginx/hub.edevs.tech.conf +++ /dev/null @@ -1,34 +0,0 @@ -server { - listen 80; - server_name hub.edevs.tech; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl http2; - server_name hub.edevs.tech; - - # Set these paths after issuing the certificate with certbot. - ssl_certificate /etc/letsencrypt/live/hub.edevs.tech/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/hub.edevs.tech/privkey.pem; - - client_max_body_size 20m; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - } -} diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf index 565a576..a472e7e 100644 --- a/deploy/nginx/local.conf +++ b/deploy/nginx/local.conf @@ -42,7 +42,7 @@ server { } location / { - proxy_pass http://internal-ui:5173; + proxy_pass http://frontend:5173; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/env.example b/env.example new file mode 100644 index 0000000..4f9d03e --- /dev/null +++ b/env.example @@ -0,0 +1,87 @@ +# Instance configuration CustoCRM (ADR-HUB-0028 / SPEC-HUB-0019 §7.2). +# Поставляется в release bundle. Копируется в instance/.env и редактируется +# под конкретный экземпляр. Image references НЕ здесь — они в release.env (CI). +# Здесь нет initial OWNER password и нет demo-флага (см. SPEC §7.2 запреты). + +COMPOSE_PROJECT_NAME=custocrm + +# --- Instance identity / gateway --- +CUSTOCRM_DOMAIN=hub.example.com +CUSTOCRM_ACME_EMAIL=admin@example.com +# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите +# отдельный публичный IP, отличный от HUB_TURN_LISTENING_IP. +CUSTOCRM_WEB_LISTENING_IP=0.0.0.0 +# Опциональные profiles: calls (coturn), и в будущем voice. +# COMPOSE_PROFILES=calls + +# --- Django core --- +HUB_ENV=production +HUB_DEBUG=false +HUB_SECRET_KEY=change-me-long-random-secret +# Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key(). +HUB_FIELD_ENCRYPTION_KEY= + +HUB_ALLOWED_HOSTS=hub.example.com +HUB_CSRF_TRUSTED_ORIGINS=https://hub.example.com +HUB_CORS_ALLOWED_ORIGINS=https://hub.example.com +INTERNAL_UI_BASE_URL=https://hub.example.com +# Host header для Docker healthcheck внутри backend-контейнера. +# Должен присутствовать в HUB_ALLOWED_HOSTS. +HUB_HEALTHCHECK_HOST=hub.example.com + +# Транспортная безопасность (вне HUB_DEBUG включается автоматически). +HUB_COOKIE_SECURE=true +HUB_SSL_REDIRECT=true +HUB_HSTS_SECONDS=31536000 +HUB_COOKIE_SAMESITE=Lax + +# --- PostgreSQL / Redis --- +POSTGRES_DB=custocrm +POSTGRES_USER=custocrm +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +REDIS_URL=redis://redis:6379/0 + +# --- P2P calls (profile calls) --- +# Включается только при COMPOSE_PROFILES=calls. Параметры coturn обязательны, +# если profile активен. +HUB_CALL_INVITE_TTL_SECONDS=300 +HUB_CALL_ACCESS_TTL_SECONDS=3600 +HUB_CALL_CONNECT_GRACE_SECONDS=120 +HUB_CALL_RECONNECT_GRACE_SECONDS=60 +HUB_CALL_STUN_URLS= +# Публичные TURN endpoints (через запятую). Пусто -> только STUN/direct. +HUB_CALL_TURN_URLS= +# Общий static-auth-secret между backend и coturn. +HUB_CALL_TURN_SECRET= +HUB_CALL_TURN_TTL_SECONDS=3600 +HUB_CALL_TURN_REALM=hub.example.com +# Публичный IP coturn listener/relay. ОТДЕЛЬНЫЙ от web IP, чтобы TURN занял 443. +HUB_TURN_EXTERNAL_IP= +HUB_TURN_LISTENING_IP= +HUB_TURN_LISTENING_PORT=3478 +HUB_TURN_TLS_PORT=443 +HUB_TURN_MIN_PORT=49160 +HUB_TURN_MAX_PORT=49200 + +# --- AI provider --- +HUB_AI_PROVIDER=openrouter +HUB_OPENROUTER_API_KEY= +HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +HUB_AI_REQUEST_TIMEOUT=30 +HUB_AI_MAX_RETRIES=2 +HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 + +# --- Email --- +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=true +DEFAULT_FROM_EMAIL=CustoCRM + +# --- Gunicorn --- +HUB_GUNICORN_WORKERS=3 +HUB_GUNICORN_TIMEOUT=60 diff --git a/release.env.example b/release.env.example new file mode 100644 index 0000000..9be67e1 --- /dev/null +++ b/release.env.example @@ -0,0 +1,14 @@ +# release.env.example — CI-generated release configuration. +# The file is immutable inside a release bundle and is never edited per instance. +# Every image reference is pinned by manifest digest; :latest is not a release source. + +CUSTOCRM_VERSION=1.0.0 +CUSTOCRM_BACKEND_IMAGE=registry.example.com/custocrm/backend:1.0.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +CUSTOCRM_FRONTEND_IMAGE=registry.example.com/custocrm/frontend:1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +CUSTOCRM_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc +CUSTOCRM_REDIS_IMAGE=redis:7-alpine@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +CUSTOCRM_GATEWAY_IMAGE=caddy:2.8.4@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +CUSTOCRM_COTURN_IMAGE=coturn/coturn:4.6@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + +# Optional when voice-gateway is implemented: +# CUSTOCRM_VOICE_GATEWAY_IMAGE=registry.example.com/custocrm/voice-gateway:1.0.0@sha256:1111111111111111111111111111111111111111111111111111111111111111 diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 26b93c0..6d16695 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -4,4 +4,5 @@ if (-not (Test-Path ".env")) { Copy-Item ".env.example" ".env" } -docker compose up --build +# Dev-контур: canonical compose.yaml + dev override (ADR-HUB-0028). +docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..304b626 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,202 @@ +"""Pytest fixtures для custocrm CLI-харнесса. + +Тесты запускают реальный bash-скрипт custocrm против временного layout'а +instance + release, с замоканными `docker` и `flock` на PATH. Реальный Docker +не требуется (ADR-HUB-0028 §testing — machine-readable, без внешних зависимостей). + +На Windows pytest запускает скрипт через Git Bash (не WSL), поэтому бинарник +bash детектится явно. Все генерируемые файлы пишутся с LF, чтобы `\r` не ломал +awk-парсинг env-файлов и shebang-строки. +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import stat +from pathlib import Path + +import pytest + +REPO_RELEASE_ROOT = Path(__file__).resolve().parents[2] # code/custocrm + +RELEASE_FILES = ["compose.yaml", "Caddyfile"] +LIB_GLOB_DIR = "deploy/cli/lib" + + +def _write_lf(path: Path, text: str) -> None: + path.write_bytes(text.replace("\r\n", "\n").encode("utf-8")) + + +def _chmod_x(path: Path) -> None: + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _find_bash() -> str: + if os.name != "nt": + bash = shutil.which("bash") + if bash: + return bash + raise RuntimeError("bash not found") + + candidates = [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ] + for c in candidates: + if Path(c).exists(): + return c + # Через git: git installation -> bash. + git = shutil.which("git") + if git: + g = Path(git).resolve() + for parent in [g.parent, g.parent.parent]: + cand = parent / "bin" / "bash.exe" + if cand.exists(): + return str(cand) + raise RuntimeError("Git bash not found; install Git for Windows or set GIT_BASH") + + +BASH_EXECUTABLE = _find_bash() + +FAKE_DOCKER = r"""#!/usr/bin/env bash +# Записывает каждую invocation в $FAKE_DOCKER_LOG и отвечает успехом на +# canonical workflow. Эмулирует `compose ps` (healthy) и `config -q`. +set -u +echo "docker $*" >> "$FAKE_DOCKER_LOG" + +if [[ "$1" == "info" ]]; then exit 0; fi +if [[ "$1" == "compose" ]]; then + shift + cmd="" + prev="" + for a in "$@"; do + case "$a" in + --project-directory|--env-file|-f) prev="$a"; continue ;; + *) if [[ -n "$prev" ]]; then prev=""; continue; fi ;; + esac + cmd="$cmd $a" + done + case "$cmd" in + *"version"*) exit 0 ;; + *"config"*) exit 0 ;; + *"pull"*) exit 0 ;; + *"up -d"*) exit 0 ;; + *"run --rm init"*) exit 0 ;; + *"ps --format json"*) + svc="${@: -1}" + echo "{\"Service\":\"$svc\",\"Health\":\"healthy\"}" + exit 0 ;; + *"exec -T backend"*) exit 0 ;; + *"logs"*) exit 0 ;; + *) exit 0 ;; + esac +fi +exit 0 +""" + +FAKE_FLOCK_OK = "#!/usr/bin/env bash\nexit 0\n" +FAKE_FLOCK_HELD = '#!/usr/bin/env bash\necho "flock: lock held" >&2\nexit 1\n' + + +@pytest.fixture +def fake_env(tmp_path: Path): + release = tmp_path / "release" + instance = tmp_path / "instance" + bin_dir = tmp_path / "bin" + release.mkdir() + instance.mkdir() + (instance / "data").mkdir() + (instance / "state").mkdir() + bin_dir.mkdir() + + for name in RELEASE_FILES: + shutil.copy(REPO_RELEASE_ROOT / name, release / name) + lib_src = REPO_RELEASE_ROOT / LIB_GLOB_DIR + lib_dst = release / LIB_GLOB_DIR + lib_dst.mkdir(parents=True) + for f in lib_src.glob("*.sh"): + shutil.copy(f, lib_dst / f.name) + + digest = "a" * 64 + _write_lf( + release / "release.env", + "CUSTOCRM_VERSION=1.0.0-test\n" + f"CUSTOCRM_BACKEND_IMAGE=registry.test/backend:1.0.0@sha256:{digest}\n" + f"CUSTOCRM_FRONTEND_IMAGE=registry.test/frontend:1.0.0@sha256:{digest}\n" + f"CUSTOCRM_POSTGRES_IMAGE=pgvector/pgvector:pg16@sha256:{digest}\n" + f"CUSTOCRM_REDIS_IMAGE=redis:7-alpine@sha256:{digest}\n" + f"CUSTOCRM_GATEWAY_IMAGE=caddy:2.8.4@sha256:{digest}\n" + f"CUSTOCRM_COTURN_IMAGE=coturn/coturn:4.6@sha256:{digest}\n", + ) + + checksum_lines = [] + for file_path in sorted(path for path in release.rglob("*") if path.is_file()): + relative = file_path.relative_to(release).as_posix() + checksum = hashlib.sha256(file_path.read_bytes()).hexdigest() + checksum_lines.append(f"{checksum} ./{relative}\n") + _write_lf(release / "checksums.txt", "".join(checksum_lines)) + + log_file = tmp_path / "docker.log" + + def write_env(**overrides) -> Path: + lines = { + "COMPOSE_PROJECT_NAME": "custocrm_test", + "CUSTOCRM_DOMAIN": "hub.test", + "CUSTOCRM_ACME_EMAIL": "admin@test", + "HUB_SECRET_KEY": "test-secret-not-default", + "HUB_FIELD_ENCRYPTION_KEY": "", + "POSTGRES_DB": "custocrm", + "POSTGRES_USER": "custocrm", + "POSTGRES_PASSWORD": "pg-secret", + "POSTGRES_HOST": "postgres", + "POSTGRES_PORT": "5432", + "REDIS_URL": "redis://redis:6379/0", + "HUB_ALLOWED_HOSTS": "hub.test", + "HUB_HEALTHCHECK_HOST": "hub.test", + } + lines.update(overrides) + body = "".join(f"{k}={v}\n" for k, v in lines.items()) + _write_lf(instance / ".env", body) + return instance / ".env" + + def install_flock(held: bool = False) -> None: + flock = bin_dir / "flock" + _write_lf(flock, FAKE_FLOCK_HELD if held else FAKE_FLOCK_OK) + _chmod_x(flock) + + def install_docker() -> None: + docker = bin_dir / "docker" + _write_lf(docker, FAKE_DOCKER) + _chmod_x(docker) + + custocrm = REPO_RELEASE_ROOT / "custocrm" + + def make_env() -> dict: + env = os.environ.copy() + sys_path = os.environ.get("PATH", "") + env["PATH"] = str(bin_dir) + os.pathsep + sys_path + env["CUSTOCRM_RELEASE_DIR"] = str(release) + env["CUSTOCRM_INSTANCE_DIR"] = str(instance) + env["FAKE_DOCKER_LOG"] = str(log_file) + # BASH-интерпретатор для скриптов-моков (env bash резолвится из PATH баша). + return env + + class Env: + pass + + e = Env() + e.tmp = tmp_path + e.release = release + e.instance = instance + e.bin = bin_dir + e.log = log_file + e.custocrm = custocrm + e.bash = BASH_EXECUTABLE + e.write_env = write_env + e.install_flock = install_flock + e.install_docker = install_docker + e.make_env = make_env + return e diff --git a/tests/cli/pytest.ini b/tests/cli/pytest.ini new file mode 100644 index 0000000..692dd80 --- /dev/null +++ b/tests/cli/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +python_files = test_*.py +# CLI-тесты не зависят от Django settings; изолированы от apps/backend/pytest.ini. diff --git a/tests/cli/test_custocrm_cli.py b/tests/cli/test_custocrm_cli.py new file mode 100644 index 0000000..8733219 --- /dev/null +++ b/tests/cli/test_custocrm_cli.py @@ -0,0 +1,214 @@ +"""Тесты custocrm CLI (этап 1): deploy workflow ordering, validation, lock, doctor, status. + +Запускают реальный bash-скрипт против замоканного docker/flock (см. conftest.py). +Покрывают: SPEC-HUB-0019 §11 (lock), §12 (doctor), §17 (deploy workflow), +§29 (observability). Не требуют Docker daemon. +""" + +from __future__ import annotations + +import hashlib +import subprocess + + +def _run(env, *args): + """Вызывает custocrm через bash с окружением из fake_env.""" + return subprocess.run( + [env.bash, str(env.custocrm), *args], + env=env.make_env(), + cwd=str(env.instance), + capture_output=True, + text=True, + ) + + +def _rewrite_checksums(env): + lines = [] + release_files = ( + path for path in env.release.rglob("*") if path.is_file() and path.name != "checksums.txt" + ) + for path in sorted(release_files): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + relative = path.relative_to(env.release).as_posix() + lines.append(f"{digest} ./{relative}\n") + (env.release / "checksums.txt").write_text("".join(lines), encoding="utf-8") + + +def _log_lines(env): + if not env.log.exists(): + return [] + return [line for line in env.log.read_text().splitlines() if line.strip()] + + +def _index_of(log, fragment): + return next(index for index, line in enumerate(log) if fragment in line) + + +# --------------------------------------------------------------------------- +# deploy +# --------------------------------------------------------------------------- + + +def test_deploy_success_orders_canonical_workflow(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + + r = _run(fake_env, "deploy", "--non-interactive") + + assert r.returncode == 0, r.stderr + log = _log_lines(fake_env) + joined = "\n".join(log) + + # Канонический порядок (ADR-HUB-0028 §workflow). + idx_pull = _index_of(log, " pull") + idx_infra = _index_of(log, " up -d postgres redis") + idx_init = _index_of(log, "run --rm init") + idx_app = _index_of(log, " up -d backend worker frontend gateway") + idx_exec = _index_of(log, "exec -T backend") + + assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined + assert "seed_hub_initial_data" not in joined # init не запускает Edevs seed + # applied_release записан. + assert (fake_env.instance / "state" / "applied_release").exists() + assert not (fake_env.instance / "compose.yaml").exists() + + +def test_deploy_includes_coturn_when_calls_profile_active(fake_env): + fake_env.write_env( + COMPOSE_PROFILES="calls", + CUSTOCRM_WEB_LISTENING_IP="203.0.113.10", + HUB_CALL_TURN_SECRET="turn-secret", + HUB_CALL_TURN_REALM="turn.hub.test", + HUB_TURN_EXTERNAL_IP="203.0.113.11", + HUB_TURN_LISTENING_IP="203.0.113.11", + ) + fake_env.install_docker() + fake_env.install_flock(held=False) + + r = _run(fake_env, "deploy", "--non-interactive") + assert r.returncode == 0, r.stderr + joined = "\n".join(_log_lines(fake_env)) + assert "up -d backend worker frontend gateway coturn" in joined + + +def test_deploy_rejects_shared_web_and_turn_ip(fake_env): + fake_env.write_env( + COMPOSE_PROFILES="calls", + CUSTOCRM_WEB_LISTENING_IP="203.0.113.10", + HUB_CALL_TURN_SECRET="turn-secret", + HUB_CALL_TURN_REALM="turn.hub.test", + HUB_TURN_EXTERNAL_IP="203.0.113.10", + HUB_TURN_LISTENING_IP="203.0.113.10", + ) + fake_env.install_docker() + fake_env.install_flock(held=False) + + result = _run(fake_env, "deploy", "--non-interactive") + + assert result.returncode != 0 + assert "different public IP" in result.stderr + assert " pull" not in "\n".join(_log_lines(fake_env)) + + +def test_deploy_fails_when_release_env_missing(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + # Удаляем release.env — релиз не активирован. + (fake_env.release / "release.env").unlink() + + r = _run(fake_env, "deploy", "--non-interactive") + assert r.returncode != 0 + joined = "\n".join(_log_lines(fake_env)) + assert " up -d " not in joined # до запуска контейнеров не дошло + + +def test_deploy_fails_when_release_checksum_is_invalid(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + (fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8") + + r = _run(fake_env, "deploy", "--non-interactive") + + assert r.returncode != 0 + assert "checksum" in r.stderr.lower() + assert " pull" not in "\n".join(_log_lines(fake_env)) + + +def test_deploy_fails_when_release_image_is_not_digest_pinned(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + release_env = fake_env.release / "release.env" + release_env.write_text( + release_env.read_text().replace( + "registry.test/backend:1.0.0@sha256:" + "a" * 64, + "registry.test/backend:latest", + ) + ) + _rewrite_checksums(fake_env) + + r = _run(fake_env, "deploy", "--non-interactive") + + assert r.returncode != 0 + assert "immutable" in r.stderr.lower() + assert " pull" not in "\n".join(_log_lines(fake_env)) + + +def test_deploy_fails_on_lock_held(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=True) # блокировка занята + + r = _run(fake_env, "deploy", "--non-interactive") + assert r.returncode == 3, r.stderr + assert "lock" in r.stderr.lower() or "lock" in r.stdout.lower() + joined = "\n".join(_log_lines(fake_env)) + assert " up -d " not in joined # destructive операция не началась + + +# --------------------------------------------------------------------------- +# doctor +# --------------------------------------------------------------------------- + + +def test_doctor_passes_on_valid_env(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + + r = _run(fake_env, "doctor") + assert r.returncode == 0, r.stderr + + +def test_doctor_fails_on_default_secret_and_missing_password(fake_env): + fake_env.write_env( + HUB_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться + POSTGRES_PASSWORD="", # пусто -> должно провалиться + ) + fake_env.install_docker() + fake_env.install_flock(held=False) + + r = _run(fake_env, "doctor") + assert r.returncode == 1 + assert "POSTGRES_PASSWORD" in r.stderr + assert "HUB_SECRET_KEY" in r.stderr + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +def test_status_json_reports_target_version(fake_env): + fake_env.write_env() + fake_env.install_docker() + fake_env.install_flock(held=False) + + r = _run(fake_env, "status", "--json") + assert r.returncode == 0, r.stderr + out = r.stdout.strip() + assert out.startswith("{") and '"target_version"' in out + assert "1.0.0-test" in out