diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88bf074..e9c5e5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 # Файл, который скачает человек, обязан быть валидным сам по себе — diff --git a/README.md b/README.md index b3eab63..1947693 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru.md b/README.ru.md index dd1e05e..d0f2125 100644 --- a/README.ru.md +++ b/README.ru.md @@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко ### Обновление -Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск: +Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты. + +Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск: ```bash docker compose pull && docker compose up -d --wait diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production index 92e6d41..db899d1 100644 --- a/apps/backend/Dockerfile.production +++ b/apps/backend/Dockerfile.production @@ -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 diff --git a/apps/backend/chatballs/events/management/commands/run_worker.py b/apps/backend/chatballs/events/management/commands/run_worker.py index 88bd4c3..2808c87 100644 --- a/apps/backend/chatballs/events/management/commands/run_worker.py +++ b/apps/backend/chatballs/events/management/commands/run_worker.py @@ -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): diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 19129ae..68d1a6d 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -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", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index e69675f..0621b0d 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -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": "Проверка кода уже неактуальна — войдите заново", diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index a039d0e..509bc75 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -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", diff --git a/apps/backend/chatballs/identity/instance_urls.py b/apps/backend/chatballs/identity/instance_urls.py index 04503db..f621e7f 100644 --- a/apps/backend/chatballs/identity/instance_urls.py +++ b/apps/backend/chatballs/identity/instance_urls.py @@ -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"), ] diff --git a/apps/backend/chatballs/tenancy/migrations/0034_update_state_grants.py b/apps/backend/chatballs/tenancy/migrations/0034_update_state_grants.py new file mode 100644 index 0000000..58fd551 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0034_update_state_grants.py @@ -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)] diff --git a/apps/backend/chatballs/updates/__init__.py b/apps/backend/chatballs/updates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/chatballs/updates/apps.py b/apps/backend/chatballs/updates/apps.py new file mode 100644 index 0000000..d046f8b --- /dev/null +++ b/apps/backend/chatballs/updates/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class UpdatesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chatballs.updates" + verbose_name = "Обновления установки" diff --git a/apps/backend/chatballs/updates/github.py b/apps/backend/chatballs/updates/github.py new file mode 100644 index 0000000..9042db2 --- /dev/null +++ b/apps/backend/chatballs/updates/github.py @@ -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 ""), + ) diff --git a/apps/backend/chatballs/updates/migrations/0001_initial.py b/apps/backend/chatballs/updates/migrations/0001_initial.py new file mode 100644 index 0000000..54fa5ca --- /dev/null +++ b/apps/backend/chatballs/updates/migrations/0001_initial.py @@ -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": "Обновления установки"}, + ), + ] diff --git a/apps/backend/chatballs/updates/migrations/__init__.py b/apps/backend/chatballs/updates/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/chatballs/updates/models.py b/apps/backend/chatballs/updates/models.py new file mode 100644 index 0000000..5ae1831 --- /dev/null +++ b/apps/backend/chatballs/updates/models.py @@ -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 diff --git a/apps/backend/chatballs/updates/services.py b/apps/backend/chatballs/updates/services.py new file mode 100644 index 0000000..25a7637 --- /dev/null +++ b/apps/backend/chatballs/updates/services.py @@ -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, + }, + } diff --git a/apps/backend/chatballs/updates/tests.py b/apps/backend/chatballs/updates/tests.py new file mode 100644 index 0000000..c42dd5d --- /dev/null +++ b/apps/backend/chatballs/updates/tests.py @@ -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) diff --git a/apps/backend/chatballs/updates/views.py b/apps/backend/chatballs/updates/views.py new file mode 100644 index 0000000..ffbcb8f --- /dev/null +++ b/apps/backend/chatballs/updates/views.py @@ -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) diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index d344765..e9ef185 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -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. diff --git a/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx b/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx index 269f57b..a583e95 100644 --- a/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx +++ b/apps/internal-ui/src/features/settings/PlatformSettingsCard.tsx @@ -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 ( <> + diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index bfa7192..7d4cf01 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -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; diff --git a/apps/internal-ui/src/features/updates/UpdateBanner.tsx b/apps/internal-ui/src/features/updates/UpdateBanner.tsx new file mode 100644 index 0000000..bdd4409 --- /dev/null +++ b/apps/internal-ui/src/features/updates/UpdateBanner.tsx @@ -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(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 ( +
+ + {t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)} +
+ ); + } + + if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) { + return ( +
+ + {t("updates.done", { version })} + + +
+ ); + } + + if (info?.install.status === "FAILED" && dismissed !== `failed:${version}`) { + return ( +
+ + {t("updates.failed", { version })}{info.install.message ? `: ${info.install.message}` : ""} + +
+ ); + } + + if (!info?.available || dismissed === `available:${info.latestVersion}`) return null; + + return ( + <> +
+ + + {t("updates.available_title", { version: info.latestVersion ?? "" })} + {!info.updaterOnline && ` · ${t("updates.updater_offline")}`} + + {info.latestPageUrl && ( + {t("updates.whats_new")} + )} + + +
+ setConfirming(false)} + actions={( + <> + + + + )} + /> + + ); +} diff --git a/apps/internal-ui/src/features/updates/UpdatesCard.tsx b/apps/internal-ui/src/features/updates/UpdatesCard.tsx new file mode 100644 index 0000000..d300a21 --- /dev/null +++ b/apps/internal-ui/src/features/updates/UpdatesCard.tsx @@ -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) { + 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 ( +
+
+
+ {t("updates.card_title")} + {t("updates.card_hint")} +
+ {canManage && ( + + )} +
+
+
{t("updates.current_version")}
{info?.currentVersion ?? "—"}
+
+
{t("updates.latest_version")}
+
+ {latest ?? "—"} + {latest && info?.latestPageUrl && ( + <> · {t("updates.whats_new")} + )} +
+
+
{t("updates.checked")}
{checked}
+
+ {info?.checkError &&
{t("updates.check_error", { error: info.checkError })}
} + {errorText &&
{errorText}
} + {installing && ( +

{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"}`)}

+ )} + {!installing && status === "FAILED" && info?.install.version && ( +
{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}
+ )} + {!installing && info?.available && ( +
+ {canManage + ? + : {t("updates.available_title", { version: latest ?? "" })}} + {!info.updaterOnline && {t("updates.updater_offline")}} +
+ )} + {!installing && info && !info.available && latest && !info.checkError && ( +

{t("updates.up_to_date")}

+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/updates/api.ts b/apps/internal-ui/src/features/updates/api.ts new file mode 100644 index 0000000..c7a6b04 --- /dev/null +++ b/apps/internal-ui/src/features/updates/api.ts @@ -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 { + return api<{ update: UpdateInfo }>(BASE).then((payload) => payload.update); +} + +export function checkUpdates(): Promise { + return api<{ update: UpdateInfo }>(`${BASE}check/`, { method: "POST", body: "{}" }).then((payload) => payload.update); +} + +export function installUpdate(): Promise { + 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"; +} diff --git a/apps/internal-ui/src/features/updates/useUpdateInfo.ts b/apps/internal-ui/src/features/updates/useUpdateInfo.ts new file mode 100644 index 0000000..3eaeb00 --- /dev/null +++ b/apps/internal-ui/src/features/updates/useUpdateInfo.ts @@ -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(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 }; +} diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index d57ddff..7af0e14 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -1052,6 +1052,30 @@ export const en: Record = { "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", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index d06a4a8..83574a6 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -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": "Язык интерфейса", diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index ba449ae..01f49f4 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -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 { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
{manager && } + {/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и «назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
diff --git a/apps/internal-ui/src/layout/styles.css b/apps/internal-ui/src/layout/styles.css index 0009bab..a53903f 100644 --- a/apps/internal-ui/src/layout/styles.css +++ b/apps/internal-ui/src/layout/styles.css @@ -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; } diff --git a/compose.dev.yaml b/compose.dev.yaml index 723ae53..e55414b 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -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: diff --git a/compose.yaml b/compose.yaml index d4dd501..4fae595 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/deploy/docker/updater.Dockerfile b/deploy/docker/updater.Dockerfile new file mode 100644 index 0000000..feb675f --- /dev/null +++ b/deploy/docker/updater.Dockerfile @@ -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"] diff --git a/deploy/updater/chatballs-updater-apply.sh b/deploy/updater/chatballs-updater-apply.sh new file mode 100644 index 0000000..e87d181 --- /dev/null +++ b/deploy/updater/chatballs-updater-apply.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 "готово" diff --git a/deploy/updater/chatballs-updater.sh b/deploy/updater/chatballs-updater.sh new file mode 100644 index 0000000..a55f414 --- /dev/null +++ b/deploy/updater/chatballs-updater.sh @@ -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 diff --git a/scripts/pin-release-compose.py b/scripts/pin-release-compose.py index fd6a305..26ffb80 100644 --- a/scripts/pin-release-compose.py +++ b/scripts/pin-release-compose.py @@ -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}$") diff --git a/tests/cli/test_release_compose.py b/tests/cli/test_release_compose.py index d4d5ad8..7df7ff1 100644 --- a/tests/cli/test_release_compose.py +++ b/tests/cli/test_release_compose.py @@ -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 уедет с плавающим тегом — а найдётся это уже у человека. diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index f659bc9..435899a 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -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,