mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
fix(ci): harden release and deployment workflow
This commit is contained in:
1 parent
ae98760824
commit
e13542fc45
27 files changed
+1424
-348
No files matched your search
+2
-3
@@ -59,6 +59,5 @@ BACKEND_PORT=8010
|
|||||||
|
|
||||||
VITE_API_BASE_URL=http://localhost:8010
|
VITE_API_BASE_URL=http://localhost:8010
|
||||||
|
|
||||||
# Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
|
# Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin
|
||||||
# Подставляется в src сниппета: <VITE_PUBLIC_HUB_URL>/chat-widget.js
|
# (ADR-HUB-0028 §10); build-time аргумент не нужен.
|
||||||
VITE_PUBLIC_HUB_URL=https://hub.edevs.tech
|
|
||||||
@@ -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 <no-reply@edevs.tech>
|
|
||||||
|
|
||||||
# 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
|
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
|
|||||||
COPY apps/backend /app/apps/backend
|
COPY apps/backend /app/apps/backend
|
||||||
COPY content /app/content
|
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
|
RUN chown -R hub:hub /app
|
||||||
|
|
||||||
WORKDIR /app/apps/backend
|
WORKDIR /app/apps/backend
|
||||||
|
|||||||
@@ -1,34 +1,42 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
describe("webWidgetSnippet", () => {
|
describe("webWidgetSnippet", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the embed snippet from the public hub url and channel code", async () => {
|
afterEach(() => {
|
||||||
vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech");
|
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");
|
const { webWidgetSnippet } = await import("./model");
|
||||||
|
|
||||||
expect(webWidgetSnippet("edeves")).toBe(
|
expect(webWidgetSnippet("edeves")).toBe(
|
||||||
`<script src="https://hub.edevs.tech/chat-widget.js" data-channel="edeves" async></script>`,
|
`<script src="https://hub.example.com/chat-widget.js" data-channel="edeves" async></script>`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips trailing slashes from the hub url", async () => {
|
it("follows whatever origin serves the page (one image, any domain)", async () => {
|
||||||
vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech/");
|
vi.stubGlobal("window", { location: { origin: "https://acme.test" } });
|
||||||
const { webWidgetSnippet } = await import("./model");
|
const { webWidgetSnippet } = await import("./model");
|
||||||
|
|
||||||
expect(webWidgetSnippet("foxray")).toBe(
|
expect(webWidgetSnippet("foxray")).toBe(
|
||||||
`<script src="https://hub.edevs.tech/chat-widget.js" data-channel="foxray" async></script>`,
|
`<script src="https://acme.test/chat-widget.js" data-channel="foxray" async></script>`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a relative src when the hub url is empty", async () => {
|
it("uses the origin verbatim with a custom port", async () => {
|
||||||
vi.stubEnv("VITE_PUBLIC_HUB_URL", "");
|
vi.stubGlobal("window", { location: { origin: "https://hub.example.com:8443" } });
|
||||||
const { webWidgetSnippet } = await import("./model");
|
const { webWidgetSnippet } = await import("./model");
|
||||||
|
|
||||||
expect(webWidgetSnippet("edeves")).toBe(
|
expect(webWidgetSnippet("edeves")).toBe(
|
||||||
`<script src="/chat-widget.js" data-channel="edeves" async></script>`,
|
`<script src="https://hub.example.com:8443/chat-widget.js" data-channel="edeves" async></script>`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Гарантия отсутствия build-time привязки: сниппет выводится от текущего origin в
|
||||||
|
// рантайме, поэтому один frontend-образ работает на любом домене без пересборки
|
||||||
|
// (ADR-HUB-0028 §10).
|
||||||
@@ -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);
|
export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/channels/").then((r) => r.items);
|
||||||
|
|
||||||
// Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
|
// Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
|
||||||
// Подставляется в src сниппета: <hub>/chat-widget.js?data-channel=<code>.
|
// Один frontend-образ работает на любом домене (ADR-HUB-0028 §runtime frontend):
|
||||||
const PUBLIC_HUB_URL = (import.meta.env.VITE_PUBLIC_HUB_URL ?? "").replace(/\/+$/, "");
|
// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.
|
||||||
|
|
||||||
export function webWidgetSnippet(channelCode: string): string {
|
export function webWidgetSnippet(channelCode: string): string {
|
||||||
return `<script src="${PUBLIC_HUB_URL}/chat-widget.js" data-channel="${channelCode}" async></script>`;
|
return `<script src="${window.location.origin}/chat-widget.js" data-channel="${channelCode}" async></script>`;
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
// Dev-only: empty в production -> API/realtime на same-origin (ADR-HUB-0028 §10).
|
||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
readonly VITE_PUBLIC_HUB_URL?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
|
||||||
+137
-71
@@ -1,19 +1,27 @@
|
|||||||
x-hub-env: &hub-env
|
# Канонический production-манифест CustoCRM (ADR-HUB-0028).
|
||||||
- path: .env.example
|
# Один Compose для: box-установки клиента, hub.edevs.tech, staging, smoke.
|
||||||
- path: .env
|
# Локальная разработка — через override compose.dev.yaml; этот файл не содержит
|
||||||
required: false
|
# 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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: ${CUSTOCRM_POSTGRES_IMAGE:-pgvector/pgvector:pg16}
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-edevs_hub}
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-edevs_hub}
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-edevs_hub}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
ports:
|
|
||||||
- "${POSTGRES_HOST_PORT:-5432}:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -21,29 +29,50 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: ${CUSTOCRM_REDIS_IMAGE:-redis:7-alpine}
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- "${REDIS_HOST_PORT:-6379}:6379"
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/data/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
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:
|
backend:
|
||||||
build:
|
image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
|
||||||
context: .
|
restart: unless-stopped
|
||||||
dockerfile: apps/backend/Dockerfile
|
env_file:
|
||||||
env_file: *hub-env
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/.env
|
||||||
command: >
|
command: >
|
||||||
sh -c "python manage.py collectstatic --noinput &&
|
sh -c "gunicorn hub_backend.asgi:application
|
||||||
python manage.py migrate &&
|
--worker-class uvicorn.workers.UvicornWorker
|
||||||
uvicorn hub_backend.asgi:application --host 0.0.0.0 --port 8000"
|
--bind 0.0.0.0:8000
|
||||||
ports:
|
--workers $${HUB_GUNICORN_WORKERS:-3}
|
||||||
- "${BACKEND_PORT:-8010}:8000"
|
--timeout $${HUB_GUNICORN_TIMEOUT:-60}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/backend:/app/apps/backend
|
# Файловые вложения знаний (ADR-HUB-0023).
|
||||||
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
init:
|
||||||
|
condition: service_completed_successfully
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
@@ -54,65 +83,102 @@ services:
|
|||||||
"CMD",
|
"CMD",
|
||||||
"python",
|
"python",
|
||||||
"-c",
|
"-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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev}
|
||||||
context: .
|
restart: unless-stopped
|
||||||
dockerfile: apps/backend/Dockerfile
|
env_file:
|
||||||
env_file: *hub-env
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/.env
|
||||||
command: python manage.py run_worker
|
command: ["python", "manage.py", "run_worker"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/backend:/app/apps/backend
|
- ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
internal-ui:
|
# Frontend: один nginx-образ со static-сборкой internal-ui и web-chat,
|
||||||
build:
|
# внутренняя маршрутизация (/api/, /ws/, /admin/, /static/, /chat-widget.js,
|
||||||
context: .
|
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
|
||||||
dockerfile: apps/internal-ui/Dockerfile
|
# host-порт: public boundary — gateway (Caddy).
|
||||||
env_file: *hub-env
|
frontend:
|
||||||
command: npm run dev -- --host 0.0.0.0 --port 5173
|
image: ${CUSTOCRM_FRONTEND_IMAGE:-custocrm-frontend:dev}
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- "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
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
internal-ui:
|
|
||||||
condition: service_started
|
|
||||||
web-chat:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
volumes:
|
# Gateway: единственный HTTP/HTTPS public boundary (ADR-HUB-0028 §gateway).
|
||||||
postgres_data:
|
# 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
|
||||||
@@ -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 <command> [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
|
||||||
@@ -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" ]]
|
||||||
|
}
|
||||||
@@ -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,"* ]]
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)"; }
|
||||||
@@ -14,12 +14,11 @@ COPY apps/internal-ui ./apps/internal-ui
|
|||||||
COPY apps/web-chat ./apps/web-chat
|
COPY apps/web-chat ./apps/web-chat
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
|
||||||
|
# Build-time домен не привязывается (ADR-HUB-0028 §10): один образ работает на любом
|
||||||
|
# домене; сниппет/WS/API выводятся от текущего origin в рантайме.
|
||||||
ARG VITE_API_BASE_URL=
|
ARG VITE_API_BASE_URL=
|
||||||
ENV VITE_API_BASE_URL=${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/internal-ui run build
|
||||||
RUN npm --workspace @edevs/web-chat run build
|
RUN npm --workspace @edevs/web-chat run build
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://internal-ui:5173;
|
proxy_pass http://frontend:5173;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
+87
@@ -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 <no-reply@example.com>
|
||||||
|
|
||||||
|
# --- Gunicorn ---
|
||||||
|
HUB_GUNICORN_WORKERS=3
|
||||||
|
HUB_GUNICORN_TIMEOUT=60
|
||||||
@@ -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
|
||||||
+2
-1
@@ -4,4 +4,5 @@ if (-not (Test-Path ".env")) {
|
|||||||
Copy-Item ".env.example" ".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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
python_files = test_*.py
|
||||||
|
# CLI-тесты не зависят от Django settings; изолированы от apps/backend/pytest.ini.
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user