✨ 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>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-13 00:43:11 +03:00
1 parent 29164db094
commit 5396dda8ad
38 files changed
+1381 -9

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
+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
@@ -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):
@@ -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",
@@ -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": "Проверка кода уже неактуальна — войдите заново",
@@ -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",
@@ -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,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.
@@ -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 };
}
+24
View File
@@ -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",
+24
View File
@@ -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,