Compare commits

...
5 Commits
Author SHA1 Message Date
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
56 changed files with 1762 additions and 35 deletions

No files matched your search

+25 -1
View File
@@ -39,6 +39,7 @@ env:
GATEWAY_BASE_IMAGE: caddy:2.8.4
REDIS_IMAGE: redis:7-alpine
COTURN_IMAGE: coturn/coturn:4.6
UPDATER_BASE_IMAGE: docker:27-cli
jobs:
images:
@@ -78,6 +79,7 @@ jobs:
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
- name: Backend
id: backend
@@ -85,6 +87,8 @@ jobs:
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
build-args: |
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
@@ -137,6 +141,23 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
# и два сценария внутри; единственный, кому монтируется docker.sock.
- name: Updater
id: updater
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
build-args: |
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Релизный compose.yaml и release.env
id: artifacts
run: |
@@ -148,6 +169,7 @@ jobs:
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
redis="${{ steps.bases.outputs.redis }}"
coturn="${{ steps.bases.outputs.coturn }}"
@@ -160,7 +182,8 @@ jobs:
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
{
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
@@ -172,6 +195,7 @@ jobs:
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
echo "CHATBALLS_REDIS_IMAGE=$redis"
echo "CHATBALLS_COTURN_IMAGE=$coturn"
echo "CHATBALLS_UPDATER_IMAGE=$updater"
} > dist/release.env
# Файл, который скачает человек, обязан быть валидным сам по себе —
+3 -1
View File
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
### Updating
Download the new release's `compose.yaml` over the old one and restart:
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
```bash
docker compose pull && docker compose up -d --wait
+3 -1
View File
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
### Обновление
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
```bash
docker compose pull && docker compose up -d --wait
+1 -1
View File
@@ -1 +1 @@
1.3.1
1.5.0
+5
View File
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
# Версия релиза попадает в образ при сборке: по ней приложение решает,
# есть ли обновление. Сборка из исходников остаётся «dev».
ARG CHATBALLS_VERSION=dev
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
RUN addgroup --system hub && adduser --system --ingroup hub hub
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
and not integration.config.get("demoSeed")
]
total = 0
for integration in integrations:
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
messages, marker = email_transport.poll_updates(integration)
with self.assertRaises(PollFailed):
email_transport.poll_updates(integration)
self.assertEqual(messages, [])
self.assertEqual(marker, "7:99")
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
self.assertEqual(integration.poll_marker, "7:99")
@@ -0,0 +1,83 @@
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
первый сбой, выход на максимальную паузу и восстановление, а курсор при
сбое не двигается. Демо-подключения не опрашиваются вовсе.
"""
from __future__ import annotations
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations import poller
from chatballs.conversations.transports import backoff, poll
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
class PollBackoffTests(TestCase):
def setUp(self) -> None:
backoff.reset()
self.addCleanup(backoff.reset)
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
with tenant_atomic(self.organization.id):
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="0000:not-a-token",
channel=self.channel,
poll_marker="41",
)
def _fail(self, *_args, **_kwargs):
raise PollFailed("HTTP Error 401: Unauthorized")
def test_failure_is_logged_once_and_then_skipped(self) -> None:
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
self.assertEqual(poll(self.integration), ([], "41"))
self.assertEqual(len(logs.output), 1)
self.assertIn("401", logs.output[0])
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
self.assertEqual(poll(self.integration), ([], "41"))
self.assertTrue(backoff.should_skip(self.integration.id))
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
clock = [1000.0]
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
delays = []
for _ in range(10):
poll(self.integration)
state = backoff._failures[self.integration.id]
delays.append(state.delay)
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
self.assertEqual(poll(self.integration), ([], "42"))
self.assertIn("recovered", logs.output[0])
self.assertNotIn(self.integration.id, backoff._failures)
def test_demo_seed_connections_are_not_polled(self) -> None:
with tenant_atomic(self.organization.id):
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
context = TenantContext.for_resource(self.organization)
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
poller.poll_all_messengers(context)
polled.assert_not_called()
@@ -1,6 +1,8 @@
from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
def poll(integration):
return _POLL[integration.provider](integration)
"""Опрос подключения с паузой после сбоя (transports.backoff).
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
подключение пропускается с растущей паузой, а журнал видит только смену
состояния. Курсор при сбое не двигается.
"""
if backoff.should_skip(integration.id):
return [], integration.poll_marker
try:
result = _POLL[integration.provider](integration)
except PollFailed as error:
backoff.record_failure(integration, error)
return [], integration.poll_marker
backoff.record_success(integration)
return result
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -0,0 +1,76 @@
"""Пауза между попытками опроса подключения после сбоя.
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
подключение пропускается с растущей паузой, а в журнал попадают только
изменения состояния: первый сбой, выход на максимальную паузу и
восстановление.
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
попытка всё равно нужна.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
@dataclass
class _Failure:
failures: int
next_attempt_at: float
delay: float
_failures: dict[int, _Failure] = {}
def _now() -> float:
return time.monotonic()
def should_skip(integration_id: int) -> bool:
state = _failures.get(integration_id)
return state is not None and _now() < state.next_attempt_at
def record_failure(integration, error: object) -> None:
previous = _failures.get(integration.id)
failures = (previous.failures if previous else 0) + 1
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
if failures == 1:
logger.warning(
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
integration.provider, integration.id, error, delay,
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
)
def record_success(integration) -> None:
state = _failures.pop(integration.id, None)
if state is not None:
logger.info(
"%s poll recovered for integration %s after %s failure(s)",
integration.provider, integration.id, state.failures,
)
def reset() -> None:
"""Для тестов: забыть все сбои."""
_failures.clear()
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
guess_content_type,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
logger = logging.getLogger(__name__)
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
client = _imap_connect(integration)
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
try:
client.select("INBOX", readonly=True)
validity = _status_value(client, "UIDVALIDITY")
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
return messages, new_marker
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
finally:
try:
client.logout()
@@ -0,0 +1,6 @@
class PollFailed(Exception):
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
записать, принимает слой пауз между попытками (``transports.backoff``).
"""
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
new_marker = data.get("marker")
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
if not data.get("ok"):
return [], integration.poll_marker
updates = data.get("result") or []
@@ -15,6 +15,7 @@ from chatballs.notifications.binding import poll_notifier_bots
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import iter_organizations
from chatballs.updates.services import check_for_updates
logger = logging.getLogger(__name__)
@@ -92,6 +93,12 @@ class Command(BaseCommand):
logger.exception("Call sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
# интервал держит сама проверка по времени последнего ответа.
try:
check_for_updates()
except Exception: # pragma: no cover
logger.exception("Update check cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
+6 -1
View File
@@ -213,6 +213,7 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_employee_invited": "Operator invited",
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
"audit.action_updates_install_requested": "Installation update started",
"audit.action_identity_employee_groups_changed": "Operator groups changed",
"audit.action_identity_employee_password_reset": "Operator password reset",
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
@@ -333,6 +334,9 @@ MESSAGES: dict[str, object] = {
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.install_in_progress": "An update is already in progress",
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
@@ -355,7 +359,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: a record identifier is expected",
"settings.storage_unavailable": "The storage is unavailable: {error}",
"admin.actor_system": "System",
"admin.image_formats": "PNG, JPEG and WebP are supported",
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
"admin.logo_too_large": "The logo must not exceed 2 MB",
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
"calls.failure_code_required": "FAILED needs a normalized failure_code",
+6 -1
View File
@@ -217,6 +217,7 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
"audit.action_updates_install_requested": "Запущено обновление установки",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -337,6 +338,9 @@ MESSAGES: dict[str, object] = {
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
"updates.install_in_progress": "Обновление уже идёт",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
@@ -359,7 +363,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: ожидается идентификатор записи",
"settings.storage_unavailable": "Хранилище недоступно: {error}",
"admin.actor_system": "Система",
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
@@ -11,6 +11,7 @@ from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -85,6 +86,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
if looks_like_svg(data):
return SVG_CONTENT_TYPE, ".svg"
return None
@@ -102,6 +105,10 @@ def replace_organization_logo(
detected = _image_type(data)
if detected is None:
raise ValidationError({"file": t("admin.image_formats")})
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
# файл отклоняется целиком, а не переписывается молча.
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
content_type, suffix = detected
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
@@ -107,11 +107,18 @@ class OrganizationLogoView(APIView):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
response = FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
filename="organization-logo",
)
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
# браузеру нельзя угадывать тип.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
response["X-Content-Type-Options"] = "nosniff"
return response
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
@@ -81,6 +81,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"identity.employee_invited": "audit.action_identity_employee_invited",
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
"updates.install_requested": "audit.action_updates_install_requested",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -115,7 +115,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
provider=item["provider"],
name=item["name"],
secret=item.get("secret", ""),
config=item.get("config", {}),
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
config={**item.get("config", {}), "demoSeed": True},
),
)
Integration.objects.filter(pk=integration.pk).update(
@@ -10,6 +10,7 @@ from django.urls import path
from chatballs.identity import instance_views
from chatballs.tenancy import storage_views
from chatballs.updates import views as update_views
urlpatterns = [
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
@@ -25,4 +26,8 @@ urlpatterns = [
storage_views.StorageMigrateView.as_view(),
name="instance-storage-migrate",
),
# Обновления установки: состояние, проверка канала, установка по кнопке.
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
]
@@ -0,0 +1,72 @@
"""Проверка SVG-логотипа организации.
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
хуже, чем попросить другой файл.
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_CONTENT_TYPE = "image/svg+xml"
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
def looks_like_svg(data: bytes) -> bool:
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
if head.startswith(b"<svg"):
return True
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _dangerous_value(value: str) -> bool:
compact = re.sub(r"\s+", "", value).lower()
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
def svg_is_safe(data: bytes) -> bool:
if _DECLARATION.search(data):
return False
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
if _local(root.tag) != "svg":
return False
for element in root.iter():
tag = _local(element.tag) if isinstance(element.tag, str) else ""
if tag in _FORBIDDEN_TAGS:
return False
for name, value in element.attrib.items():
local = _local(name)
if local.startswith("on"):
return False
if _dangerous_value(value):
return False
if name in _HREF_ATTRIBUTES or local == "href":
stripped = value.strip()
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
return False
if local == "style" and _EXTERNAL_URL.search(value):
return False
if tag == "style" and element.text and (
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
):
return False
return True
@@ -172,16 +172,16 @@ class AdministrationApiTests(TestCase):
"/api/v1/company/administration/logo/",
{
"file": SimpleUploadedFile(
"logo.svg",
b"<svg></svg>",
content_type="image/svg+xml",
"logo.txt",
b"not an image at all",
content_type="text/plain",
)
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
record_audit_event(
@@ -0,0 +1,77 @@
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
заголовки как второй рубеж.
"""
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.logo_svg import svg_is_safe
from chatballs.testing import TenantAPIClient
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
<style>.mark { fill: url(#g); }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
<use xlink:href="#mark"/>
</svg>
"""
class SvgSafetyTests(TestCase):
def test_clean_svg_is_accepted(self) -> None:
self.assertTrue(svg_is_safe(CLEAN_SVG))
def test_dangerous_svg_is_rejected(self) -> None:
samples = {
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"not svg": b'<html><body>hi</body></html>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
}
for name, sample in samples.items():
with self.subTest(name):
self.assertFalse(svg_is_safe(sample))
class SvgLogoApiTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _upload(self, data: bytes):
return self.client.post(
"/api/v1/company/administration/logo/",
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
format="multipart",
)
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
uploaded = self._upload(CLEAN_SVG)
self.assertEqual(uploaded.status_code, 200, uploaded.content)
downloaded = self.client.get("/api/v1/company/administration/logo/")
self.assertEqual(downloaded.status_code, 200)
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
self.assertEqual(response.status_code, 400)
self.assertIn("file", response.json()["errors"])
@@ -12,6 +12,7 @@ import secrets
from datetime import timedelta
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from chatballs.conversations import transports
@@ -39,7 +40,11 @@ def notifier_integrations(context):
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
return qs.filter(organization=context.organization)
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
return qs.filter(organization=context.organization).filter(
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
)
def deep_link(integration: Integration, code: str) -> str:
@@ -0,0 +1,26 @@
# Состояние обновлений установки (updates_updatestate): одна строка на
# инсталляцию, не тенантная таблица, без RLS — как настройки установки (0028).
# Пишут её приложение (запрос установки, синхронизация статуса) и воркер
# (периодическая проверка канала релизов) — оба ролью app.
from django.db import migrations
GRANTS = """
GRANT SELECT, INSERT, UPDATE ON updates_updatestate
TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE updates_updatestate_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVOKE = """
REVOKE ALL ON updates_updatestate
FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0033_organization_select_scope"),
("updates", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UpdatesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chatballs.updates"
verbose_name = "Обновления установки"
+106
View File
@@ -0,0 +1,106 @@
"""Канал релизов: страница релизов GitHub.
Установка спрашивает у GitHub последний релиз репозитория продукта и берёт
из него версию, заметки и ссылку на compose.yaml. Никаких токенов: релизы
публичные, лимита анонимных запросов хватает при проверке раз в несколько
часов.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from django.conf import settings
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?$")
_TIMEOUT_SECONDS = 15
@dataclass(frozen=True, slots=True)
class Release:
version: str
name: str
notes: str
published_at: datetime | None
compose_url: str
page_url: str
class ReleaseChannelError(Exception):
"""Канал релизов недоступен или ответил не тем, что ожидалось."""
def parse_version(tag: str) -> str | None:
version = tag.strip()
if version.startswith(("v", "V")):
version = version[1:]
return version if VERSION_RE.match(version) else None
def version_key(version: str) -> tuple:
"""Ключ сравнения: числовая тройка, затем метка (без метки — новее)."""
core, _, label = version.partition("-")
core = core.split(".")[:3]
numbers = tuple(int(part) for part in core if part.isdigit())
return (numbers, label == "", label)
def is_newer(candidate: str, current: str) -> bool:
if not VERSION_RE.match(candidate) or not VERSION_RE.match(current or ""):
# Dev-сборка без версии: обновлять нечего и не с чем сравнивать.
return False
return version_key(candidate) > version_key(current)
def expected_compose_url(version: str) -> str:
return f"https://github.com/{settings.CHATBALLS_UPDATE_REPO}/releases/download/v{version}/compose.yaml"
def fetch_latest_release() -> Release:
url = f"https://api.github.com/repos/{settings.CHATBALLS_UPDATE_REPO}/releases/latest"
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"chatballs/{settings.CHATBALLS_VERSION}",
},
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
raise ReleaseChannelError(str(error)) from error
version = parse_version(str(payload.get("tag_name", "")))
if version is None:
raise ReleaseChannelError(f"unexpected tag {payload.get('tag_name')!r}")
compose_url = next(
(
str(asset.get("browser_download_url", ""))
for asset in payload.get("assets", [])
if asset.get("name") == "compose.yaml"
),
"",
)
if compose_url != expected_compose_url(version):
raise ReleaseChannelError("release has no compose.yaml at the expected address")
published = None
raw_published = str(payload.get("published_at", "")).strip()
if raw_published:
try:
published = datetime.fromisoformat(raw_published.replace("Z", "+00:00"))
except ValueError:
published = None
return Release(
version=version,
name=str(payload.get("name") or f"v{version}"),
notes=str(payload.get("body") or ""),
published_at=published,
compose_url=compose_url,
page_url=str(payload.get("html_url") or ""),
)
@@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UpdateState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("latest_version", models.CharField(blank=True, default="", max_length=32)),
("latest_name", models.CharField(blank=True, default="", max_length=128)),
("latest_notes", models.TextField(blank=True, default="")),
("latest_published_at", models.DateTimeField(blank=True, null=True)),
("latest_compose_url", models.URLField(blank=True, default="", max_length=512)),
("latest_page_url", models.URLField(blank=True, default="", max_length=512)),
("checked_at", models.DateTimeField(blank=True, null=True)),
("check_error", models.CharField(blank=True, default="", max_length=500)),
("install_version", models.CharField(blank=True, default="", max_length=32)),
(
"install_status",
models.CharField(
choices=[
("IDLE", "Не запускалась"),
("REQUESTED", "Запрошена"),
("RUNNING", "Идёт"),
("DONE", "Завершена"),
("FAILED", "Ошибка"),
],
default="IDLE",
max_length=12,
),
),
("install_message", models.CharField(blank=True, default="", max_length=1000)),
("install_requested_at", models.DateTimeField(blank=True, null=True)),
("install_updated_at", models.DateTimeField(blank=True, null=True)),
(
"install_requested_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"verbose_name": "Обновления установки"},
),
]
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
"""Состояние обновлений установки: одна строка на инсталляцию.
Не тенантная таблица: версия у установки одна на всех. Проверка релизов
живёт в воркере, установку запрашивает администратор установки, а выполняет
сервис updater вне приложения (ADR-CHATBALLS-0049).
"""
from __future__ import annotations
from django.db import models
class InstallStatus(models.TextChoices):
IDLE = "IDLE", "Не запускалась"
REQUESTED = "REQUESTED", "Запрошена"
RUNNING = "RUNNING", "Идёт"
DONE = "DONE", "Завершена"
FAILED = "FAILED", "Ошибка"
class UpdateState(models.Model):
SINGLETON_PK = 1
# Что известно о последнем релизе на канале.
latest_version = models.CharField(max_length=32, blank=True, default="")
latest_name = models.CharField(max_length=128, blank=True, default="")
latest_notes = models.TextField(blank=True, default="")
latest_published_at = models.DateTimeField(null=True, blank=True)
latest_compose_url = models.URLField(max_length=512, blank=True, default="")
latest_page_url = models.URLField(max_length=512, blank=True, default="")
checked_at = models.DateTimeField(null=True, blank=True)
check_error = models.CharField(max_length=500, blank=True, default="")
# Последняя запрошенная установка.
install_version = models.CharField(max_length=32, blank=True, default="")
install_status = models.CharField(
max_length=12, choices=InstallStatus.choices, default=InstallStatus.IDLE
)
install_message = models.CharField(max_length=1000, blank=True, default="")
install_requested_at = models.DateTimeField(null=True, blank=True)
install_updated_at = models.DateTimeField(null=True, blank=True)
# Кто нажал кнопку — для аудита и подписи в интерфейсе.
install_requested_by = models.ForeignKey(
"identity.HumanUser", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
class Meta:
verbose_name = "Обновления установки"
def __str__(self) -> str:
return f"updates:{self.latest_version or 'нет данных'}"
def save(self, *args, **kwargs):
self.pk = self.SINGLETON_PK
super().save(*args, **kwargs)
@classmethod
def load(cls) -> UpdateState:
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
return obj
+179
View File
@@ -0,0 +1,179 @@
"""Проверка релизов и запрос установки обновления.
Приложение не перезапускает само себя: у публичного процесса нет и не должно
быть доступа к Docker. Оно лишь кладёт запрос в общий том
``CHATBALLS_UPDATES_DIR``, а сервис updater (deploy/updater) забирает его,
проверяет и выполняет ``docker compose pull && up`` уже снаружи. Состояние
установки updater пишет в тот же том файлом ``status``; приложение
переносит его в UpdateState при каждом чтении.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from chatballs.updates.github import (
ReleaseChannelError,
fetch_latest_release,
is_newer,
)
from chatballs.updates.models import InstallStatus, UpdateState
logger = logging.getLogger(__name__)
REQUEST_FILE = "request.json"
STATUS_FILE = "status.json"
HEARTBEAT_FILE = "heartbeat"
# Пока updater не отметился дольше этого, считаем, что установки из интерфейса нет.
HEARTBEAT_STALE_SECONDS = 90
_STATUS_MAP = {
"running": InstallStatus.RUNNING,
"done": InstallStatus.DONE,
"failed": InstallStatus.FAILED,
}
def updates_dir() -> Path:
return Path(settings.CHATBALLS_UPDATES_DIR)
def check_for_updates(*, force: bool = False) -> UpdateState:
"""Спросить канал релизов, если пора (или если попросили явно)."""
state = UpdateState.load()
interval = timedelta(seconds=settings.CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS)
if not force and state.checked_at and timezone.now() - state.checked_at < interval:
return state
try:
release = fetch_latest_release()
except ReleaseChannelError as error:
state.check_error = str(error)[:500]
state.checked_at = timezone.now()
state.save(update_fields=["check_error", "checked_at"])
logger.warning("Update check failed: %s", error)
return state
state.latest_version = release.version
state.latest_name = release.name
state.latest_notes = release.notes
state.latest_published_at = release.published_at
state.latest_compose_url = release.compose_url
state.latest_page_url = release.page_url
state.check_error = ""
state.checked_at = timezone.now()
state.save()
return state
def update_available(state: UpdateState) -> bool:
return bool(state.latest_version) and is_newer(state.latest_version, settings.CHATBALLS_VERSION)
def updater_online() -> bool:
"""Жив ли сервис updater: он отмечается в томе каждые несколько секунд."""
path = updates_dir() / HEARTBEAT_FILE
try:
age = timezone.now().timestamp() - path.stat().st_mtime
except OSError:
return False
return age < HEARTBEAT_STALE_SECONDS
def sync_install_status(state: UpdateState) -> UpdateState:
"""Перенести статус, который пишет updater, в состояние приложения."""
if state.install_status not in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
return state
path = updates_dir() / STATUS_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return state
if str(payload.get("version", "")) != state.install_version:
return state
status = _STATUS_MAP.get(str(payload.get("status", "")).lower())
if status is None:
return state
message = str(payload.get("message", ""))[:1000]
if status != state.install_status or message != state.install_message:
state.install_status = status
state.install_message = message
state.install_updated_at = timezone.now()
state.save(update_fields=["install_status", "install_message", "install_updated_at"])
return state
class InstallNotPossible(Exception):
def __init__(self, message: str, *, code: str) -> None:
super().__init__(message)
self.code = code
def request_install(*, actor, state: UpdateState | None = None) -> UpdateState:
"""Положить запрос на установку последней версии в том updater'а."""
from chatballs.i18n import t
state = sync_install_status(state or UpdateState.load())
if not update_available(state):
raise InstallNotPossible(t("updates.nothing_to_install"), code="nothing_to_install")
if not updater_online():
raise InstallNotPossible(t("updates.updater_offline"), code="updater_offline")
if state.install_status in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
raise InstallNotPossible(t("updates.install_in_progress"), code="install_in_progress")
directory = updates_dir()
directory.mkdir(parents=True, exist_ok=True)
request_path = directory / REQUEST_FILE
now = timezone.now()
request_path.write_text(
json.dumps(
{
"version": state.latest_version,
"compose_url": state.latest_compose_url,
"requested_at": now.isoformat(),
"requested_by": getattr(actor, "email", ""),
}
),
encoding="utf-8",
)
# Прежний статус относится к прошлой установке: убрать, чтобы не спутать.
try:
(directory / STATUS_FILE).unlink()
except OSError:
pass
state.install_version = state.latest_version
state.install_status = InstallStatus.REQUESTED
state.install_message = ""
state.install_requested_at = now
state.install_updated_at = now
state.install_requested_by = actor if getattr(actor, "pk", None) else None
state.save()
return state
def update_payload(state: UpdateState) -> dict[str, object]:
return {
"currentVersion": settings.CHATBALLS_VERSION,
"latestVersion": state.latest_version or None,
"latestName": state.latest_name,
"latestNotes": state.latest_notes,
"latestPublishedAt": state.latest_published_at.isoformat() if state.latest_published_at else None,
"latestPageUrl": state.latest_page_url,
"available": update_available(state),
"checkedAt": state.checked_at.isoformat() if state.checked_at else None,
"checkError": state.check_error,
"updaterOnline": updater_online(),
"install": {
"version": state.install_version or None,
"status": state.install_status,
"message": state.install_message,
"requestedAt": state.install_requested_at.isoformat() if state.install_requested_at else None,
"updatedAt": state.install_updated_at.isoformat() if state.install_updated_at else None,
},
}
+173
View File
@@ -0,0 +1,173 @@
"""Обновления из интерфейса: канал релизов, запрос установки, статус, права."""
from __future__ import annotations
import io
import json
import os
import time
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.testing import TenantAPIClient
from chatballs.updates import github, services
from chatballs.updates.models import InstallStatus, UpdateState
PASSWORD = "Owner-Password-2026!"
RELEASE = {
"tag_name": "v1.5.0",
"name": "v1.5.0",
"body": "## Что нового\n- тест",
"published_at": "2026-09-13T10:00:00Z",
"html_url": "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0",
"assets": [
{"name": "compose.yaml", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/compose.yaml"},
{"name": "release.env", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/release.env"},
],
}
def _response(payload: dict) -> mock.MagicMock:
"""urlopen отдаёт контекстный менеджер с read(): подделываем ровно это."""
response = mock.MagicMock()
response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode("utf-8"))
return response
class VersionTests(TestCase):
def test_semantic_comparison(self) -> None:
self.assertTrue(github.is_newer("1.5.0", "1.4.0"))
self.assertTrue(github.is_newer("1.10.0", "1.9.9"))
self.assertTrue(github.is_newer("1.5.0", "1.5.0-rc1"))
self.assertFalse(github.is_newer("1.4.0", "1.4.0"))
self.assertFalse(github.is_newer("1.3.9", "1.4.0"))
# Dev-сборка: сравнивать не с чем.
self.assertFalse(github.is_newer("1.5.0", "dev"))
def test_tag_parsing(self) -> None:
self.assertEqual(github.parse_version("v1.5.0"), "1.5.0")
self.assertEqual(github.parse_version("1.5.0-rc1"), "1.5.0-rc1")
self.assertIsNone(github.parse_version("release-1"))
self.assertIsNone(github.parse_version("v1.5"))
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class UpdateCheckTests(TestCase):
def test_check_records_latest_release(self) -> None:
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
state = services.check_for_updates(force=True)
self.assertEqual(state.latest_version, "1.5.0")
self.assertEqual(state.latest_compose_url, RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(state.check_error, "")
self.assertTrue(services.update_available(state))
def test_check_respects_interval_unless_forced(self) -> None:
state = UpdateState.load()
state.checked_at = timezone.now() - timedelta(minutes=5)
state.save()
with mock.patch("urllib.request.urlopen") as fetch:
services.check_for_updates()
fetch.assert_not_called()
def test_channel_error_is_recorded_not_raised(self) -> None:
with mock.patch("urllib.request.urlopen", side_effect=OSError("offline")):
state = services.check_for_updates(force=True)
self.assertIn("offline", state.check_error)
self.assertFalse(services.update_available(state))
def test_release_without_compose_asset_is_refused(self) -> None:
release = {**RELEASE, "assets": []}
with mock.patch("urllib.request.urlopen", return_value=_response(release)):
state = services.check_for_updates(force=True)
self.assertIn("compose.yaml", state.check_error)
self.assertEqual(state.latest_version, "")
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class InstallRequestTests(TestCase):
def setUp(self) -> None:
self.tmp = TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.override = override_settings(CHATBALLS_UPDATES_DIR=self.tmp.name)
self.override.enable()
self.addCleanup(self.override.disable)
self.result = bootstrap_owner(email="update-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
services.check_for_updates(force=True)
def _heartbeat(self) -> None:
(services.updates_dir() / services.HEARTBEAT_FILE).write_text("", encoding="utf-8")
def test_install_needs_a_live_updater(self) -> None:
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "updater_offline")
def test_install_writes_request_and_tracks_status_from_updater(self) -> None:
self._heartbeat()
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 202, response.content)
request = json.loads((services.updates_dir() / services.REQUEST_FILE).read_text(encoding="utf-8"))
self.assertEqual(request["version"], "1.5.0")
self.assertEqual(request["compose_url"], RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(response.json()["update"]["install"]["status"], InstallStatus.REQUESTED)
# Повторный запрос, пока идёт установка, отклоняется.
again = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(again.status_code, 409)
self.assertEqual(again.json()["code"], "install_in_progress")
# Updater пишет статус в том — приложение подхватывает его при чтении.
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "running", "version": "1.5.0", "message": "pulling"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.RUNNING)
self.assertEqual(state["install"]["message"], "pulling")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.5.0", "message": "updated"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.DONE)
def test_status_for_another_version_is_ignored(self) -> None:
self._heartbeat()
self.client.post("/api/v1/instance/update/install/", {}, format="json")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.4.9", "message": "stale"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.REQUESTED)
def test_stale_heartbeat_means_offline(self) -> None:
path = services.updates_dir() / services.HEARTBEAT_FILE
path.write_text("", encoding="utf-8")
old = time.time() - services.HEARTBEAT_STALE_SECONDS - 5
os.utime(path, (old, old))
self.assertFalse(services.updater_online())
def test_organization_owner_reads_but_cannot_install_or_check(self) -> None:
other = Organization.objects.create(name="Other", slug="other-upd")
owner = HumanUser.objects.create_user(email="other-owner@example.com", password=PASSWORD)
OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner")
client = TenantAPIClient()
client.force_authenticate(owner)
self.assertEqual(client.get("/api/v1/instance/update/").status_code, 200)
self.assertEqual(client.post("/api/v1/instance/update/check/", {}, format="json").status_code, 403)
self.assertEqual(client.post("/api/v1/instance/update/install/", {}, format="json").status_code, 403)
+59
View File
@@ -0,0 +1,59 @@
"""Обновления установки в интерфейсе: состояние, проверка, установка.
Читает менеджер любой организации (баннер показывается только администратору
установки, но версия видна всем менеджерам в «Платформе»), проверять канал и
запускать установку может только администратор установки.
"""
from __future__ import annotations
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.updates.models import UpdateState
from chatballs.updates.services import (
InstallNotPossible,
check_for_updates,
request_install,
sync_install_status,
update_payload,
)
class UpdateStateView(APIView):
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
state = sync_install_status(UpdateState.load())
return Response({"update": update_payload(state)})
class UpdateCheckView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
state = sync_install_status(check_for_updates(force=True))
return Response({"update": update_payload(state)})
class UpdateInstallView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
try:
state = request_install(actor=request.user)
except InstallNotPossible as error:
return Response({"detail": str(error), "code": error.code}, status=409)
record_audit_event(
action="updates.install_requested",
actor=request.user,
organization=None,
object_type="UpdateState",
object_id=state.install_version,
payload={"version": state.install_version},
request=request,
)
return Response({"update": update_payload(state)}, status=202)
@@ -33,6 +33,17 @@ if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
)
CHATBALLS_DELIVERY_MODE = _delivery_mode
# Версия установки: CI передаёт её в образ при сборке релиза; в сборке из
# исходников остаётся «dev», и тогда обновлений из интерфейса нет — сравнивать
# не с чем.
CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
# Канал релизов и обмен с сервисом updater (ADR-CHATBALLS-0049).
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(6 * 3600))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
@@ -67,6 +78,7 @@ INSTALLED_APPS = [
"chatballs.events",
"chatballs.support_portals",
"chatballs.calls",
"chatballs.updates",
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
# конфликтует с доменным chatballs.channels, а без runserver-оверрайда
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
@@ -11,6 +11,7 @@ import {
type EmailPayload,
type InstancePayload,
} from "./instance";
import { UpdatesCard } from "../updates/UpdatesCard";
import { t } from "../../i18n";
// «Платформа» (Настройки): свойства инсталляции, а не организации. Адрес
@@ -62,6 +63,7 @@ export function PlatformSettingsCard({ canManage }: { canManage: boolean }) {
return (
<>
<UpdatesCard canManage={canManage} />
<AddressCard canManage={canManage} current={current} onSaved={setCurrent} />
<LanguageCard canManage={canManage} current={current} onSaved={setCurrent} />
<EmailCard canManage={canManage} current={current} onSaved={setCurrent} />
@@ -582,6 +582,40 @@
справа, снизу разделитель (кадр N6). Один стандарт на все разделы —
«Хранилище файлов», «Платформа», relay в «Голосовых и звонках». */
.settings-facts {
display: grid;
grid-template-columns: 180px 1fr;
gap: 6px 16px;
margin: 14px 0 0;
font-size: 13.5px;
}
.settings-facts > div {
display: contents;
}
.settings-facts dt {
color: var(--n-4);
}
.settings-facts dd {
margin: 0;
color: var(--n-1);
}
.settings-card-note {
margin: 12px 0 0;
color: var(--n-3);
font-size: 13px;
}
.settings-card-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.settings-card-head {
display: flex;
align-items: center;
@@ -0,0 +1,108 @@
import { useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon, LogoSpinner } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { installUpdate } from "./api";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Полоса поверх рабочей области у администратора установки: есть новая
// версия, идёт установка, установка завершена или сорвалась. Та же полоса,
// что у установки демо-данных (ADR-CHATBALLS-0049).
export function UpdateBanner({ enabled }: { enabled: boolean }) {
const { info, setInfo, installing, unreachable, finished } = useUpdateInfo(enabled);
const [dismissed, setDismissed] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
if (!enabled) return null;
async function install() {
setBusy(true);
setErrorText("");
try {
setInfo(await installUpdate());
setConfirming(false);
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
const version = info?.install.version ?? info?.latestVersion ?? "";
if (installing) {
const stage = unreachable ? "restarting" : info?.install.message || "starting";
const stageKey = (["downloading", "pulling", "restarting"].includes(stage) ? stage : "starting") as "downloading" | "pulling" | "restarting" | "starting";
return (
<div className="demo-install-banner update-banner">
<LogoSpinner size={17} />
<span>{t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)}</span>
</div>
);
}
if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) {
return (
<div className="demo-install-banner update-banner is-done">
<Icon name="check" size={15} strokeWidth={2.2} />
<span>{t("updates.done", { version })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("common.show")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`done:${version}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
);
}
if (info?.install.status === "FAILED" && dismissed !== `failed:${version}`) {
return (
<div className="demo-install-banner update-banner is-failed">
<Icon name="warning" size={15} strokeWidth={2} />
<span>{t("updates.failed", { version })}{info.install.message ? `: ${info.install.message}` : ""}</span>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`failed:${version}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
);
}
if (!info?.available || dismissed === `available:${info.latestVersion}`) return null;
return (
<>
<div className="demo-install-banner update-banner">
<Icon name="download" size={15} strokeWidth={2} />
<span>
{t("updates.available_title", { version: info.latestVersion ?? "" })}
{!info.updaterOnline && ` · ${t("updates.updater_offline")}`}
</span>
{info.latestPageUrl && (
<a className="link is-neutral" href={info.latestPageUrl} target="_blank" rel="noreferrer">{t("updates.whats_new")}</a>
)}
<button type="button" disabled={!info.updaterOnline} onClick={() => setConfirming(true)}>{t("updates.install")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`available:${info.latestVersion}`)} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
<DecisionDialog
open={confirming}
tone="warning"
icon="download"
title={t("updates.confirm_title", { version: info.latestVersion ?? "" })}
description={errorText || t("updates.confirm_text")}
onClose={() => setConfirming(false)}
actions={(
<>
<Button variant="secondary" disabled={busy} onClick={() => setConfirming(false)}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={busy} onClick={() => void install()}>{t("updates.install")}</Button>
</>
)}
/>
</>
);
}
@@ -0,0 +1,82 @@
import { useState } from "react";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { checkUpdates, installUpdate } from "./api";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Карточка «Обновления» в разделе «Платформа»: версия установки, последняя
// версия на канале релизов, проверка и установка по кнопке. Сама установка
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние.
export function UpdatesCard({ canManage }: { canManage: boolean }) {
const { info, setInfo, installing, unreachable } = useUpdateInfo(true);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
async function run(action: () => Promise<typeof info>) {
setBusy(true);
setErrorText("");
try {
const next = await action();
if (next) setInfo(next);
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
if (!info && !unreachable) return null;
const latest = info?.latestVersion;
const checked = info?.checkedAt ? t("updates.checked_at", { time: shortDateTime(info.checkedAt) }) : t("updates.never_checked");
const status = info?.install.status;
return (
<div className="administration-card">
<div className="settings-card-head">
<div>
<strong>{t("updates.card_title")}</strong>
<small>{t("updates.card_hint")}</small>
</div>
{canManage && (
<Button variant="secondary" disabled={busy || installing} onClick={() => void run(checkUpdates)}>{t("updates.check")}</Button>
)}
</div>
<dl className="settings-facts">
<div><dt>{t("updates.current_version")}</dt><dd>{info?.currentVersion ?? "—"}</dd></div>
<div>
<dt>{t("updates.latest_version")}</dt>
<dd>
{latest ?? "—"}
{latest && info?.latestPageUrl && (
<> · <a className="link is-neutral" href={info.latestPageUrl} target="_blank" rel="noreferrer">{t("updates.whats_new")}</a></>
)}
</dd>
</div>
<div><dt>{t("updates.checked")}</dt><dd>{checked}</dd></div>
</dl>
{info?.checkError && <div className="settings-section-error">{t("updates.check_error", { error: info.checkError })}</div>}
{errorText && <div className="settings-section-error">{errorText}</div>}
{installing && (
<p className="settings-card-note">{t("updates.installing", { version: info?.install.version ?? latest ?? "" })} {t(`updates.stage_${unreachable ? "restarting" : (["downloading", "pulling", "restarting"].includes(info?.install.message ?? "") ? info?.install.message : "starting") as "downloading" | "pulling" | "restarting" | "starting"}`)}</p>
)}
{!installing && status === "FAILED" && info?.install.version && (
<div className="settings-section-error">{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}</div>
)}
{!installing && info?.available && (
<div className="settings-card-actions">
{canManage
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={() => void run(installUpdate)}>{t("updates.install_version", { version: latest ?? "" })}</Button>
: <span className="settings-card-note">{t("updates.available_title", { version: latest ?? "" })}</span>}
{!info.updaterOnline && <span className="settings-card-note">{t("updates.updater_offline")}</span>}
</div>
)}
{!installing && info && !info.available && latest && !info.checkError && (
<p className="settings-card-note">{t("updates.up_to_date")}</p>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { api } from "../../api/client";
// Обновления установки (ADR-CHATBALLS-0049): состояние канала релизов и
// установки читается одним запросом; проверку и установку запускает только
// администратор установки. Путь без организации — это свойство инсталляции.
export type InstallStatus = "IDLE" | "REQUESTED" | "RUNNING" | "DONE" | "FAILED";
export type UpdateInfo = {
currentVersion: string;
latestVersion: string | null;
latestName: string;
latestNotes: string;
latestPublishedAt: string | null;
latestPageUrl: string;
available: boolean;
checkedAt: string | null;
checkError: string;
updaterOnline: boolean;
install: {
version: string | null;
status: InstallStatus;
// Стадия от сервиса обновления: starting · downloading · pulling · restarting · updated,
// либо текст ошибки.
message: string;
requestedAt: string | null;
updatedAt: string | null;
};
};
const BASE = "/api/v1/instance/update/";
export function fetchUpdate(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(BASE).then((payload) => payload.update);
}
export function checkUpdates(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(`${BASE}check/`, { method: "POST", body: "{}" }).then((payload) => payload.update);
}
export function installUpdate(): Promise<UpdateInfo> {
return api<{ update: UpdateInfo }>(`${BASE}install/`, { method: "POST", body: "{}" }).then((payload) => payload.update);
}
export function installInProgress(info: UpdateInfo | null): boolean {
return info?.install.status === "REQUESTED" || info?.install.status === "RUNNING";
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchUpdate, installInProgress, type UpdateInfo } from "./api";
// Состояние обновления с опросом, пока идёт установка. Во время перезапуска
// бэкенд не отвечает — это не ошибка, а стадия: запросы продолжают уходить,
// пока новая версия не ответит статусом DONE или FAILED.
const POLL_INTERVAL_MS = 3000;
export function useUpdateInfo(enabled: boolean) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [unreachable, setUnreachable] = useState(false);
const wasInstalling = useRef(false);
const load = useCallback(async () => {
try {
const next = await fetchUpdate();
setInfo(next);
setUnreachable(false);
} catch {
// Нет прав, сеть моргнула или бэкенд перезапускается.
setUnreachable(true);
}
}, []);
useEffect(() => {
if (enabled) void load();
}, [enabled, load]);
const installing = installInProgress(info) || (unreachable && wasInstalling.current);
if (installInProgress(info)) wasInstalling.current = true;
useEffect(() => {
if (!enabled || !installing) return undefined;
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [enabled, installing, load]);
return { info, setInfo, reload: load, installing, unreachable, finished: wasInstalling.current && !installing };
}
+25 -1
View File
@@ -157,7 +157,7 @@ export const en: Record<MessageKey, Message> = {
"admin.password_was_generated_shown_once": "The password was generated and is shown once — copy it now.",
"admin.people_count": "{count} people",
"admin.period": "Period",
"admin.png_jpeg_or_webp_up": "PNG, JPEG or WebP · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP or SVG · up to 2 MB. Shown in the sidebar, the widget and emails.",
"admin.position_groups": "Position and groups",
"admin.qr_code_setting_up_totp": "QR code for setting up TOTP",
"admin.recover_access": "Recover access",
@@ -1052,6 +1052,30 @@ export const en: Record<MessageKey, Message> = {
"auth.invitation_invalid": "The invitation is invalid or has expired",
"auth.join_subtitle": "You are invited to \u201c{organization}\u201d as {email}. Enter your name and set a password to sign in.",
"auth.join_accept": "Accept the invitation",
"updates.available_title": "Version {version} is available",
"updates.whats_new": "What's new",
"updates.install": "Update",
"updates.install_version": "Update to {version}",
"updates.installing": "Updating to {version}:",
"updates.stage_starting": "starting",
"updates.stage_downloading": "downloading the release file",
"updates.stage_pulling": "pulling images",
"updates.stage_restarting": "restarting services",
"updates.done": "Version {version} is installed. Reload the page.",
"updates.failed": "Update to {version} failed",
"updates.confirm_title": "Update to {version}?",
"updates.confirm_text": "Services will restart and the application will be unavailable for about a minute. Conversations and data are kept.",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.card_title": "Updates",
"updates.card_hint": "Installation version and updates from the release channel",
"updates.current_version": "Current version",
"updates.latest_version": "Latest version",
"updates.checked": "Check",
"updates.check": "Check now",
"updates.checked_at": "{time}",
"updates.never_checked": "Not checked yet",
"updates.up_to_date": "The latest version is installed",
"updates.check_error": "Could not check: {error}",
"profile.groups_list": "· groups: {groups}",
"profile.in_organization_since": "· in the organization since {date}",
"profile.interface_language": "Interface language",
+25 -1
View File
@@ -158,7 +158,7 @@ export const ru = {
"admin.password_was_generated_shown_once": "Пароль сгенерирован и показан один раз — скопируйте его сейчас.",
"admin.people_count": "{count} чел.",
"admin.period": "Период",
"admin.png_jpeg_or_webp_up": "PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.png_jpeg_or_webp_up": "PNG, JPEG, WebP или SVG · до 2 МБ. Виден в сайдбаре, виджете и письмах.",
"admin.position_groups": "Должность и группы",
"admin.qr_code_setting_up_totp": "QR-код для подключения TOTP",
"admin.recover_access": "Восстановить доступ",
@@ -1053,6 +1053,30 @@ export const ru = {
"auth.invitation_invalid": "Приглашение недействительно или истекло",
"auth.join_subtitle": "Вас приглашают в «{organization}» как {email}. Укажите имя и задайте пароль для входа.",
"auth.join_accept": "Принять приглашение",
"updates.available_title": "Доступна версия {version}",
"updates.whats_new": "Что нового",
"updates.install": "Обновить",
"updates.install_version": "Обновить до {version}",
"updates.installing": "Обновление до {version}:",
"updates.stage_starting": "запуск",
"updates.stage_downloading": "загрузка файла релиза",
"updates.stage_pulling": "загрузка образов",
"updates.stage_restarting": "перезапуск сервисов",
"updates.done": "Установлена версия {version}. Обновите страницу.",
"updates.failed": "Обновление до {version} не удалось",
"updates.confirm_title": "Обновить до {version}?",
"updates.confirm_text": "Сервисы перезапустятся, приложение будет недоступно около минуты. Диалоги и данные сохранятся.",
"updates.updater_offline": "Сервис обновления не запущен, обновление из интерфейса недоступно",
"updates.card_title": "Обновления",
"updates.card_hint": "Версия установки и обновления с канала релизов",
"updates.current_version": "Текущая версия",
"updates.latest_version": "Последняя версия",
"updates.checked": "Проверка",
"updates.check": "Проверить",
"updates.checked_at": "{time}",
"updates.never_checked": "Ещё не проверялось",
"updates.up_to_date": "Установлена последняя версия",
"updates.check_error": "Не удалось проверить: {error}",
"profile.groups_list": "· группы: {groups}",
"profile.in_organization_since": "· в организации с {date}",
"profile.interface_language": "Язык интерфейса",
+2
View File
@@ -11,6 +11,7 @@ import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { UpdateBanner } from "../features/updates/UpdateBanner";
import { ShellRouteContent } from "./ShellRouteContent";
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
@@ -115,6 +116,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
+10
View File
@@ -779,6 +779,16 @@
color: var(--success-text);
}
.demo-install-banner.is-failed {
background: var(--error-bg);
color: var(--error-text);
}
.update-banner a.link {
flex: none;
font-size: 13px;
}
.demo-install-banner > svg {
flex: none;
}
+7
View File
@@ -116,6 +116,13 @@ services:
- ./apps/backend:/app/apps/backend
- ./data/media:/app/apps/backend/media
# Dev: образ сервиса обновления собирается локально; сам сервис в dev
# бесполезен (стек поднят из исходников), но должен собираться и стартовать.
updater:
build:
context: .
dockerfile: deploy/docker/updater.Dockerfile
# Dev: frontend-сервис превращается в Vite dev-сервер internal-ui (HMR).
frontend:
build:
+23
View File
@@ -115,6 +115,9 @@ services:
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
# bind-mount достался бы контейнеру как root:root и падал с EACCES.
- chatballs-media:/app/apps/backend/media
# Обмен с сервисом updater: сюда кладётся запрос на обновление, отсюда
# читается его статус. Доступа к Docker у приложения нет и не будет.
- chatballs-updates:/run/chatballs/updates
depends_on:
init:
condition: service_completed_successfully
@@ -214,6 +217,24 @@ services:
backend-app:
condition: service_healthy
# Обновление по кнопке из интерфейса (ADR-CHATBALLS-0049). Единственный
# сервис с доступом к docker.sock: забирает запрос из тома chatballs-updates,
# проверяет, что это официальный релиз (адрес со страницы релизов, образы по
# digest), и запускает `docker compose pull && up` отдельным контейнером вне
# проекта. Без сокета (rootless, podman) просто спит — кнопка в интерфейсе
# тогда сообщает, что обновление из интерфейса недоступно.
updater:
image: ${CHATBALLS_UPDATER_IMAGE:-chatballs-updater:dev}
restart: unless-stopped
environment:
CHATBALLS_UPDATE_REPO: ${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- chatballs-updates:/run/chatballs/updates
depends_on:
backend-app:
condition: service_healthy
# Frontend: один nginx-образ со static-сборкой internal-ui и web-chat,
# внутренняя маршрутизация (/api/, /ws/, /chat-widget.js,
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
@@ -332,3 +353,5 @@ volumes:
chatballs-media:
chatballs-caddy-data:
chatballs-caddy-config:
# Обмен приложения с сервисом updater: запрос и статус обновления.
chatballs-updates:
+11
View File
@@ -0,0 +1,11 @@
# Сервис updater: обновление установки по кнопке из интерфейса (ADR-CHATBALLS-0049).
# Официальный образ docker CLI с плагином compose плюс два сценария; больше в
# нём ничего нет. Базовый образ закрепляется по digest при сборке релиза.
ARG CHATBALLS_UPDATER_BASE_IMAGE=docker:27-cli
FROM ${CHATBALLS_UPDATER_BASE_IMAGE}
COPY deploy/updater/chatballs-updater.sh /usr/local/bin/chatballs-updater.sh
COPY deploy/updater/chatballs-updater-apply.sh /usr/local/bin/chatballs-updater-apply.sh
RUN chmod 0755 /usr/local/bin/chatballs-updater.sh /usr/local/bin/chatballs-updater-apply.sh
ENTRYPOINT ["/bin/sh", "/usr/local/bin/chatballs-updater.sh"]
+92
View File
@@ -0,0 +1,92 @@
#!/bin/sh
# Помощник обновления: скачать релизный compose.yaml, проверить, применить.
# Запускается сервисом updater отдельным контейнером вне проекта compose, чтобы
# пережить пересоздание самого updater'а. Пишет статус в том chatballs-updates.
set -u
VERSION="${1:?version}"
COMPOSE_URL="${2:?compose url}"
DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
REPO="${CHATBALLS_UPDATE_REPO:?}"
PROJECT="${CHATBALLS_PROJECT:?}"
WORKDIR="${CHATBALLS_WORKDIR:-}"
VOLUME="${CHATBALLS_UPDATES_VOLUME:?}"
SELF_IMAGE="${CHATBALLS_UPDATER_IMAGE:?}"
STATUS="$DIR/status.json"
LOG="$DIR/apply.log"
FILE="$DIR/compose.$VERSION.yaml"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) apply $VERSION: $*" | tee -a "$LOG"; }
write_status() {
message=$(printf '%s' "$2" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-900)
printf '{"status":"%s","version":"%s","message":"%s","at":"%s"}\n' \
"$1" "$VERSION" "$message" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS.tmp"
mv "$STATUS.tmp" "$STATUS"
}
fail() {
log "ОШИБКА: $1"
write_status failed "$1"
exit 1
}
: > "$LOG"
write_status running "downloading"
log "скачивание $COMPOSE_URL"
if ! wget -q -T 60 -O "$FILE" "$COMPOSE_URL"; then
fail "compose.yaml не скачался"
fi
# Файл релиза обязан ссылаться на образы только по digest и только из
# реестра продукта (свои образы) или из закреплённых сторонних (redis, coturn).
if grep -E '^\s*image:' "$FILE" | grep -vqE '@sha256:[0-9a-f]{64}'; then
fail "в compose.yaml есть образ без digest"
fi
if grep -E '^\s*image:\s*ghcr\.io/' "$FILE" | grep -vq "ghcr.io/$REPO/"; then
fail "в compose.yaml есть образ из чужого реестра"
fi
compose() {
if [ -n "$WORKDIR" ]; then
docker compose -p "$PROJECT" --project-directory "$WORKDIR" -f "$FILE" "$@"
else
docker compose -p "$PROJECT" -f "$FILE" "$@"
fi
}
# Профиль звонков включён, если coturn уже работает в проекте.
PROFILE_ARGS=""
if docker ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "label=com.docker.compose.service=coturn" | grep -q .; then
PROFILE_ARGS="--profile calls"
fi
if ! compose $PROFILE_ARGS config -q >>"$LOG" 2>&1; then
fail "compose.yaml не прошёл проверку"
fi
write_status running "pulling"
log "загрузка образов"
if ! compose $PROFILE_ARGS pull >>"$LOG" 2>&1; then
fail "не удалось загрузить образы: $(tail -n 3 "$LOG" | tr '\n' ' ')"
fi
write_status running "restarting"
log "перезапуск сервисов"
if ! compose $PROFILE_ARGS up -d --wait >>"$LOG" 2>&1; then
fail "стек не поднялся: $(tail -n 3 "$LOG" | tr '\n' ' ')"
fi
# Файл на хосте должен соответствовать работающей версии: иначе следующий
# ручной `docker compose up` откатил бы установку.
if [ -n "$WORKDIR" ]; then
if docker run --rm -v "$WORKDIR:/host" -v "$VOLUME:/updates" "$SELF_IMAGE" \
sh -c "cp /updates/compose.$VERSION.yaml /host/compose.yaml" >>"$LOG" 2>&1; then
log "compose.yaml в $WORKDIR обновлён"
else
log "предупреждение: не удалось обновить compose.yaml в $WORKDIR"
fi
fi
write_status done "updated"
log "готово"
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# Сервис updater: обновление установки по кнопке из интерфейса (ADR-CHATBALLS-0049).
#
# Приложение не имеет доступа к Docker и не может перезапустить само себя:
# оно кладёт запрос в том chatballs-updates, а этот сервис — единственный,
# у кого смонтирован docker.sock, — забирает запрос, проверяет его и запускает
# `docker compose pull && up` для всего проекта.
#
# Запрос принимается только на официальный релиз: версия проверяется по
# формату, compose.yaml скачивается только со страницы релизов репозитория
# продукта, а все образы в нём обязаны быть закреплены по digest. Больше
# ничего этот сервис делать не умеет, что бы ни лежало в запросе.
#
# Сам сервис тоже входит в проект и пересоздаётся при обновлении, поэтому
# работу выполняет отдельный контейнер вне проекта (chatballs-updater-apply.sh):
# он переживает пересоздание и дописывает статус в том до конца.
set -eu
DIR="${CHATBALLS_UPDATES_DIR:-/run/chatballs/updates}"
REPO="${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}"
POLL_SECONDS="${CHATBALLS_UPDATER_POLL_SECONDS:-5}"
SOCKET="/var/run/docker.sock"
REQUEST="$DIR/request.json"
STATUS="$DIR/status.json"
HEARTBEAT="$DIR/heartbeat"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) updater: $*"; }
write_status() {
# $1 status, $2 version, $3 message
message=$(printf '%s' "$3" | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-900)
printf '{"status":"%s","version":"%s","message":"%s","at":"%s"}\n' \
"$1" "$2" "$message" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS.tmp"
mv "$STATUS.tmp" "$STATUS"
}
json_field() {
# Значение строкового поля $2 из JSON-файла $1 (одна строка, без вложенности).
sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
mkdir -p "$DIR"
chmod 777 "$DIR"
if [ ! -S "$SOCKET" ]; then
log "docker.sock не смонтирован — обновление из интерфейса недоступно"
# Без сокета сервис бесполезен, но и падать в перезапусках незачем.
while true; do sleep 3600; done
fi
# Имя проекта и рабочий каталог берутся из меток собственного контейнера:
# именно так их видит compose на хосте.
SELF_ID="$(cat /etc/hostname)"
PROJECT="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' "$SELF_ID" 2>/dev/null || true)"
WORKDIR="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$SELF_ID" 2>/dev/null || true)"
UPDATES_VOLUME="$(docker inspect -f '{{ range .Mounts }}{{ if eq .Destination "'"$DIR"'" }}{{ .Name }}{{ end }}{{ end }}' "$SELF_ID" 2>/dev/null || true)"
SELF_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$SELF_ID" 2>/dev/null || true)"
if [ -z "$PROJECT" ] || [ -z "$UPDATES_VOLUME" ] || [ -z "$SELF_IMAGE" ]; then
log "не удалось определить проект compose по меткам контейнера (project=$PROJECT volume=$UPDATES_VOLUME image=$SELF_IMAGE)"
while true; do sleep 3600; done
fi
log "готов: проект $PROJECT, каталог ${WORKDIR:-?}, том $UPDATES_VOLUME"
while true; do
touch "$HEARTBEAT"
if [ -f "$REQUEST" ]; then
version="$(json_field "$REQUEST" version)"
compose_url="$(json_field "$REQUEST" compose_url)"
rm -f "$REQUEST"
case "$version" in
''|*[!0-9A-Za-z.-]*) log "отклонён запрос с недопустимой версией: $version"; write_status failed "$version" "invalid version"; sleep "$POLL_SECONDS"; continue ;;
esac
expected="https://github.com/$REPO/releases/download/v$version/compose.yaml"
if [ "$compose_url" != "$expected" ]; then
log "отклонён запрос: адрес compose.yaml не со страницы релизов ($compose_url)"
write_status failed "$version" "compose.yaml address is not the release page"
sleep "$POLL_SECONDS"; continue
fi
if docker ps -q --filter "name=^chatballs-updater-apply$" | grep -q .; then
log "установка уже идёт — запрос $version пропущен"
sleep "$POLL_SECONDS"; continue
fi
log "установка $version: запуск"
write_status running "$version" "starting"
# Помощник вне проекта: свой образ, docker.sock и тот же том; переживает
# пересоздание этого сервиса и дописывает статус до конца.
if ! docker run -d --rm --name chatballs-updater-apply \
-e CHATBALLS_UPDATE_REPO="$REPO" \
-e CHATBALLS_PROJECT="$PROJECT" \
-e CHATBALLS_WORKDIR="$WORKDIR" \
-e CHATBALLS_UPDATES_VOLUME="$UPDATES_VOLUME" \
-e CHATBALLS_UPDATER_IMAGE="$SELF_IMAGE" \
-v "$SOCKET:$SOCKET" \
-v "$UPDATES_VOLUME:$DIR" \
"$SELF_IMAGE" chatballs-updater-apply.sh "$version" "$expected" >/dev/null 2>"$DIR/apply.err"; then
write_status failed "$version" "could not start helper: $(cat "$DIR/apply.err" 2>/dev/null | tail -c 400)"
fi
fi
sleep "$POLL_SECONDS"
done
+1
View File
@@ -30,6 +30,7 @@ IMAGE_KEYS = (
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
"CHATBALLS_UPDATER_IMAGE",
)
DIGEST_RE = re.compile(r"@sha256:[0-9a-fA-F]{64}$")
+9 -6
View File
@@ -6,8 +6,9 @@
стек падал на первом старте, уже у человека.
Эти тесты держат свойство, а не текущий текст файла: в production-манифесте нет
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls), а
все ссылки на образы поддаются закреплению по digest.
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls и
Docker-сокета у сервиса обновлений), а все ссылки на образы поддаются
закреплению по digest.
"""
from __future__ import annotations
@@ -23,9 +24,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE = REPO_ROOT / "compose.yaml"
PIN_SCRIPT = REPO_ROOT / "scripts" / "pin-release-compose.py"
# Единственное исключение: сертификат TURN-хоста кладёт на хост renewal-хук,
# и только при включённом профиле calls.
BIND_MOUNT_EXCEPTIONS = {"coturn"}
# Исключения: сертификат TURN-хоста кладёт на хост renewal-хук (только при
# включённом профиле calls), а сервис обновлений управляет стеком через
# Docker-сокет хоста — это его назначение (ADR-CHATBALLS-0049).
BIND_MOUNT_EXCEPTIONS = {"coturn", "updater"}
DIGEST = "sha256:" + "a" * 64
IMAGE_KEYS = (
@@ -35,6 +37,7 @@ IMAGE_KEYS = (
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
"CHATBALLS_UPDATER_IMAGE",
)
@@ -82,7 +85,7 @@ def test_named_volumes_are_declared() -> None:
def test_every_image_reference_can_be_pinned(tmp_path: Path) -> None:
"""Все шесть ключей образов присутствуют и закрепляются по digest.
"""Все семь ключей образов присутствуют и закрепляются по digest.
Если из манифеста уйдёт (или переименуется) хоть один ключ, релизный
compose.yaml уедет с плавающим тегом — а найдётся это уже у человека.
+28
View File
@@ -350,6 +350,34 @@ test("гость по ссылке-приглашению задаёт имя и
expect(registered[0]).toContain("guest-token");
});
test("администратор установки видит баннер о новой версии и запускает обновление", async ({ page }) => {
await mockSession(page, OWNER_IDENTITY);
await mockInstance(page);
await mockEmployees(page);
const update = {
currentVersion: "1.4.0", latestVersion: "1.5.0", latestName: "v1.5.0", latestNotes: "", latestPublishedAt: null,
latestPageUrl: "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0", available: true, checkedAt: null,
checkError: "", updaterOnline: true, install: { version: null, status: "IDLE", message: "", requestedAt: null, updatedAt: null },
};
await page.route("**/api/v1/instance/update/", (route) => route.fulfill({ json: { update } }));
const installs: string[] = [];
await page.route("**/api/v1/instance/update/install/", (route) => {
installs.push(route.request().method());
return route.fulfill({ status: 202, json: { update: { ...update, install: { ...update.install, version: "1.5.0", status: "REQUESTED" } } } });
});
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/chat`);
const banner = page.locator(".update-banner");
await expect(banner).toContainText("Доступна версия 1.5.0");
await banner.getByRole("button", { name: "Обновить", exact: true }).click();
await expect(page.getByText("Обновить до 1.5.0?")).toBeVisible();
await page.locator(".decision-dialog").getByRole("button", { name: "Обновить", exact: true }).click();
await expect.poll(() => installs).toEqual(["POST"]);
await expect(page.locator(".update-banner")).toContainText("Обновление до 1.5.0");
});
test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => {
const secondMembership = membershipFor("OWNER", {
id: 3,