fix(ci): harden release and deployment workflow

This commit is contained in:
Andrey committed 2026-07-14 12:19:38 +03:00
1 parent ae98760824
commit e13542fc45
27 files changed
+1424 -348

No files matched your search

+2 -3
View File
@@ -59,6 +59,5 @@ BACKEND_PORT=8010
VITE_API_BASE_URL=http://localhost:8010
# Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
# Подставляется в src сниппета: <VITE_PUBLIC_HUB_URL>/chat-widget.js
VITE_PUBLIC_HUB_URL=https://hub.edevs.tech
# Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin
# (ADR-HUB-0028 §10); build-time аргумент не нужен.
-91
View File
@@ -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
+23
View File
@@ -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}
}
}
+1
View File
@@ -0,0 +1 @@
1.0.0-dev
+6
View File
@@ -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
@@ -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(
`<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 () => {
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(
`<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 () => {
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(
`<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);
// Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
// Подставляется в src сниппета: <hub>/chat-widget.js?data-channel=<code>.
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 `<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>`;
}
+1 -1
View File
@@ -1,8 +1,8 @@
/// <reference types="vite/client" />
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 {
+95
View File
@@ -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:
-129
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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
+140
View File
@@ -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" ]]
}
+28
View File
@@ -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,"* ]]
}
+165
View File
@@ -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'
}
+148
View File
@@ -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
}
+18
View File
@@ -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
}
+40
View File
@@ -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)"; }
+2 -3
View File
@@ -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
-34
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
+87
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+202
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[pytest]
python_files = test_*.py
# CLI-тесты не зависят от Django settings; изолированы от apps/backend/pytest.ini.
+214
View File
@@ -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