mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ 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:
1 parent
29164db094
commit
5396dda8ad
38 files changed
+1381
-9
No files matched your search
@@ -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
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
|
||||
@@ -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
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
|
||||
@@ -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.
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UpdatesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chatballs.updates"
|
||||
verbose_name = "Обновления установки"
|
||||
@@ -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.
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Язык интерфейса",
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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 "готово"
|
||||
@@ -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
|
||||
@@ -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}$")
|
||||
|
||||
@@ -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 уедет с плавающим тегом — а найдётся это уже у человека.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user