Перейти до змісту

Update Delivery — доставка оновлень DOP клієнтам

Статус: 📋 архітектурне рішення (ADR), реалізація відкладена.

Контекст: desktop-installer.md описує первинну установку через tarball-based DOP-Setup.exe. Цей документ закриває вже відкладене питання звідти (Частина 2 — Auto-update) і додає рівень self-hosted-розгортань (Linux daemon, Docker Compose, Kubernetes). Без коду — перший крок: зафіксувати рішення для дискусії, потім інкрементально реалізовувати фази.


Зміст


Контекст і чому зараз ADR

Сьогодні DOP доставляється клієнту трьома способами:

  1. Docker tarballdocker save eswf-demo:latest -o ... + docker load на машині колеги. Ручне, без оновлень.
  2. Demo desktop installer (план у desktop-installer.md) — DOP-Setup.exe з вшитим tarball, без auto-update.
  3. Self-hosted — клонування репо + запуск через docker-compose (для технічного користувача).

Жоден варіант не має відповіді на питання «у клієнта DOP версії 1.4.0, ми випустили 1.5.1 — як їй потрапити до нього?». До першого реального оновлення треба зафіксувати:

  • модель версій і каналів,
  • де живе джерело правди про «остання версія для каналу X»,
  • як міграції БД і дані переживають апгрейд,
  • як відкочуватись,
  • як перевірити що плагіни не зламаються.

ADR не пропонує реалізації — він описує дизайн, який ми погоджуємо, і роадмап реалізації фазами.


Цілі і не-цілі

Цілі

  1. Однозначність версії. На кожному клієнті в будь-який момент відомо: яка версія core, які плагіни і чи є новіша.
  2. Передбачувані оновлення. Клієнт стабільного каналу не отримує сюрпризів — апгрейд лише з його згоди (manual mode) або у заплановане вікно (scheduled mode).
  3. Безпечні міграції. Перед накатом — обов'язковий снапшот; після — health-check; при провалі — автоматичний rollback.
  4. Підтримка двох форм-факторів — desktop (Docker Desktop + tray-launcher) і self-hosted (Linux daemon / Compose / K8s) через єдиний манифест-протокол.
  5. Сумісність плагінів за версіями core. Платні плагіни (logistic, containerhub, ...) декларують підтримуваний діапазон core-версій; апгрейд core блокується якщо встановлений плагін поза діапазоном.

Не-цілі (out of scope першої ітерації)

  • Continuous delivery без даунтайму — для desktop-поставки приймемо короткий downtime (10–60 с) на час migrate + container restart.
  • Multi-region active-active — вертикальне масштабування поза скопом ESWF як продукту.
  • Зворотньо-сумісні міграції (downgrade DB) — Django міграції forward-only за дизайном; rollback робимо через volume snapshot, не через migrate <prev>.
  • Делта-оновлення (patch images) — викачуємо повний image; оптимізація на потім.

Сутності і поняття

Термін Що це
Реліз Іменована, заморожена версія DOP core (1.4.2) і набір сумісних плагінів.
Канал Логічна стрічка релізів (stable / beta / dev) з власною каденцією і вимогами якості.
Manifest JSON-файл з описом «остання версія для каналу X», розміщений на well-known URL. Єдине джерело правди для агента.
Update agent Невеликий процес на стороні клієнта, що періодично читає manifest і ініціює апгрейд.
Snapshot Точка відновлення (volume + DB dump + image tag) перед апгрейдом.
Compat range Діапазон версій core, з якими плагін гарантовано працює (>=1.4 <2.0).

Канали релізів

Розглянуті варіанти

Варіант Плюси Мінуси
Один канал (latest) максимально просто немає де обкатати «бету», ризик зламати всіх клієнтів одразу
Два канали (stable, dev) мінімально достатньо beta-тестери і dev-розробники змішані
Три канали (stable / beta / dev) чітка градація: продакшн, ранній доступ, внутрішній CI більше дисципліни релізного процесу
Чотири+ (LTS / stable / beta / dev / nightly) гнучкість поки немає аудиторії що вимагає LTS — overkill

Обране рішення — три канали

Канал Призначення Каденція Хто на ньому Quality gate
stable продакшн-клієнти, default ~раз/місяць бухгалтери, менеджери, всі платні tenant-и ручний QA + проходження beta ≥7 днів без P0
beta ранній доступ, opt-in ~раз/2 тижні tenant-и з прапором release_channel=beta в admin tools автотести + smoke на demo-tenant
dev внутрішнє, CI-збірки per-merge у master тільки розробники / Render demo проходження автотестів

Чому саме так: - Один канал — занадто ризиковано: одна регресія валить усіх. - Чотири — поки немає LTS-вимог від клієнтів (армія, держустанови). Додамо коли зʼявиться попит (🔮 Deferred). - Перехід між каналами — рішення tenant-а в admin tools; даунгрейд (stablebetadev) дозволяється лише в межах major-версії і потребує snapshot-restore.

Маппінг каналу на артефакти

release-channel  →  git ref           →  docker tag           →  manifest URL
─────────────────────────────────────────────────────────────────────────────────
stable           →  tags/v1.4.2       →  eswf/dop:stable      →  updates.eswf.dev/stable.json
beta             →  branches/release  →  eswf/dop:beta        →  updates.eswf.dev/beta.json
dev              →  master            →  eswf/dop:dev         →  updates.eswf.dev/dev.json

Версіонування

Core

SemVer 2.0MAJOR.MINOR.PATCH:

  • MAJOR — несумісні зміни: домен (нові обовʼязкові поля без default), API (видалені/перейменовані ендпоінти), формат БД що потребує міграції з втратою даних. Очікувана каденція — 1–2 рази на рік.
  • MINOR — нові фічі, нові модулі/плагіни, нові поля з default-ами, нові ендпоінти. Зворотньо-сумісно. Каденція ~раз/місяць.
  • PATCH — виправлення без зміни схеми/контракту. Каденція on-demand.

Версія core — у backend/eswf/__version__.py (один single source of truth), з нього зчитується для: - API ендпоінт GET /api/v1/system/version/ (фронтенд показує у footer/about), - Docker labels (org.opencontainers.image.version=1.4.2), - Update manifest.

Tag scheme для Docker-образів

Tag Семантика Use-case
eswf/dop:1.4.2 immutable конкретна версія детермінований deploy, CI, debug
eswf/dop:1.4 floating до останнього patch у minor self-hosted з опт-ін на patch-апдейти
eswf/dop:stable floating до останнього у каналі desktop-tray-агент за замовчуванням
eswf/dop:beta floating до останнього у каналі beta-тестери
eswf/dop:dev floating, CI per-merge внутрішнє

Принцип: агент за замовчуванням слухає manifest, а не :latest-тег. Тобто навіть якщо хтось перезапише :stable випадково — агент звірить версію з manifest і не оновиться, поки той не оновлений атомарно.

Плагіни — незалежні версії

Кожен платний плагін (eswf-logistic, eswf-containerhub, ...) має власний SemVer: eswf-logistic 0.9.3 не звʼязаний з dop core 1.4.2. Сумісність задається compat range у Product/манифесті плагіна: core_min_version="1.4", core_max_version="<2.0".


Update Manifest

JSON-файл фіксованого формату на well-known URL — https://updates.eswf.dev/<channel>.json:

{
  "channel": "stable",
  "version": "1.4.2",
  "released_at": "2026-04-27T10:00:00Z",
  "image": {
    "registry": "ghcr.io/ynikolaenko/eswf",
    "tag": "1.4.2",
    "digest": "sha256:abc123...",       // immutable, перевіряємо при pull
    "size_bytes": 612482000
  },
  "migration_level": 142,                // максимальний номер django migration
  "min_supported_from": "1.3.0",         // нижче цієї — апгрейд через проміжний reлiз
  "plugin_compat": {                     // дозволені плагіни на цій версії
    "logistic":     ">=0.8 <1.0",
    "containerhub": ">=0.5",
    "essentials_quality": ">=0.2"
  },
  "release_notes_url": "https://eswf.dev/releases/1.4.2",
  "kill_switch": false,                  // якщо true — клієнти на цій версії примушуються до апгрейду
  "signature": "ed25519:...",            // підпис над усім обʼєктом без поля signature
  "signed_by": "release-key-2026"
}

Чому manifest а не сам registry: - Може хоститись на статичному CDN (CF Pages, S3, GitHub Pages) — нульова інфраструктура. - Дешева верифікація підпису без TLS-mutual-auth. - Незалежність від registry — registry може мінятись (Docker Hub → ghcr.io → власний), manifest лишається.


Розповсюдження артефактів

Варіанти

Варіант Плюси Мінуси
Public registry (Docker Hub / ghcr.io public) безкоштовно, CDN, прості docker pull образ публічний, не для приватних релізів
Private registry (Docker Hub paid / ghcr.io private) приватність, той самий API потрібен docker login на стороні клієнта (PAT, ротація)
Власний registry на VPS повний контроль, брендинг (registry.eswf.dev) треба хостити, TLS, auth, моніторинг
Manifest + tarball на CDN нуль registry-інфраструктури, офлайн-friendly ручний docker load; складніше підписувати рівень шарів

Обране рішення — гібрид

  • Phase 1 (offline-first): manifest + tarball на статичному CDN (CF Pages або GitHub Releases). Агент завантажує .tar за URL з manifest, робить docker load. Підходить для desktop-поставки і офлайн-середовищ.
  • Phase 2 (online): ghcr.io public для community-каналів (stable, beta) + manifest як єдиний source of truth. Агент робить docker pull за digest.
  • Phase 3 (paid): ghcr.io private або власний registry.eswf.dev для платних редакцій з ліцензійним гейтом.

Чому такий порядок

  • Phase 1 повністю покриває MVP сценарій (бухгалтер → desktop installer → апдейт раз/місяць) без жодного хостингу окрім CF Pages.
  • Phase 2 потрібна лише коли частота оновлень виросте і tarball-шлях стане незручним.
  • Phase 3 — комерційний крок, потребує auth-flow (PAT / token), не блокує перші дві.

Міграції БД

Принципи

  1. Forward-only. Django міграції не призначені для downgrade на проді з даними. Не намагаємось зробити симетричні 0042_..._reverse.py.
  2. Backwards-compatible bump. Між patch-версіями і у межах одного minor — нові поля тільки з default або null=True; видалення колонок робимо через двофазну міграцію (release N+1 — RemoveField).
  3. Atomic per-release. Один реліз — один migrate запуск. Не дозволяємо проміжний стан «половина міграцій застосовано».
  4. Pre-flight check. Агент запускає manage.py migrate --check у новому контейнері до зупинки старого (ефемерно, на тестовій БД-копії), а не наосліп.

Алгоритм апгрейду (стандартний шлях)

1. agent: pull нового image за digest з manifest
2. agent: тригер snapshot (volume + db dump) — див. "Rollback"
3. agent: stop старого контейнера
4. agent: docker run --rm new_image manage.py migrate --noinput
       └── якщо exit ≠ 0 → rollback з snapshot, abort
5. agent: docker run new_image (новий контейнер на тому ж volume)
6. agent: чекати health-check 60 с (`GET /api/health/`)
       └── якщо timeout → rollback з snapshot
7. agent: помітити в локальному state «installed_version=X», відписати в telemetry (опційно)
8. agent: повідомити користувача «оновлено до 1.4.2»

Складні міграції (data migrations, довгі переіндексації)

Для міграцій що довше ~30 с на типовому tenant — окрема стратегія: - Маркуємо їх is_long_running=True у манифесті (нове поле в MigrationMeta або через naming convention 0050_long_...). - Агент переходить у явний maintenance mode — показує користувачу банер «оновлюємо, очікуйте N хвилин» замість мовчазного даунтайму. - Розглянемо background migrations (Django RunPython з порціями) у 🔮 Deferred.


Rollback

Принцип

Rollback робимо через snapshot-restore, не через migration revert. Reverting Django migrations на проді з даними = data-loss-roulette. Snapshot — детермінований шлях.

Snapshot — що саме

Перед апгрейдом, обовʼязково:

  1. Volume snapshot. Для Docker Desktop — docker run --rm -v dop-data:/from -v ./snapshots:/to alpine tar -czf /to/snap-$timestamp.tar.gz /from. Для self-hosted з ZFS/LVM — нативний snapshot.
  2. DB dump. Через існуючий механізм backup-recovery.mdmanage.py dop_backup.
  3. Image tag. Запамʼятовуємо який image-digest був попередній — щоб мати чим повернутись.
  4. Migration state. Нічого не зберігаємо окремо — стан у БД (django_migrations), volume snapshot його містить.

Snapshot живе локально (%USERPROFILE%\Documents\DOP-Backups\snapshots\ на Windows, /var/lib/dop/snapshots/ на Linux). Retention — N останніх (default 3), старіші чистимо.

Тригери rollback

Тригер Дія
migrate --check exit ≠ 0 abort — image ще не запущений, snapshot не потрібен
migrate --noinput exit ≠ 0 restore volume → restart старого image → notify
Health-check timeout 60 с після старту нового restore volume → restart старого image → notify
Користувач натиснув «Rollback» в admin tools (manual) restore volume → restart попереднього image

Кросс-major rollback (e.g. 2.0 → 1.4)

Особливий випадок: між major-версіями структура volume може бути несумісна з попереднім image (нові обовʼязкові колонки в раніше-чистих таблицях, нові індекси). Rollback дозволено лише на той snapshot, що був перед апгрейдом — не на довільну стару версію. Це фіксується у документації для адміна.


Auto-update agent

Розгортання — два форм-фактори

Форм-фактор Де живе агент Стиль
Desktop (Docker Desktop + launcher) вшитий у launcher-gui/ (Electron tray app, додаємо update-модуль) tray icon показує «оновлення доступне», користувач підтверджує
Self-hosted (Linux daemon) окремий dop-updater systemd-сервіс або cron job конфіг в /etc/dop/updater.yml, лог у journald
Compose / K8s side-car контейнер dop-updater:stable або external CI-pipeline патчить compose image:docker compose up; для K8s — через operator (далеко в будущому)

Режими

Режим Поведінка Default для каналу
Manual агент опитує manifest, нотифікує, чекає дії користувача stable
Scheduled агент опитує, апгрейдить у заплановане вікно (e.g. «3:00 ночі неділі») self-hosted production
Aggressive апгрейд при першій же детекції dev, demo-tenant

Перемикач — у AdminToolsPage → нова вкладка «Оновлення»: вибір каналу, режим, вікно scheduled, історія апгрейдів, кнопка «Перевірити зараз», кнопка «Rollback до попередньої».

Polling cadence

  • stable — раз/добу.
  • beta — раз/6 годин.
  • dev — раз/15 хв.

Polling — простий GET <manifest-url>, ETags для дешевизни. Без push-каналу (WebSocket / SSE) у MVP — додамо коли частота оновлень виросте.


Сумісність плагінів

Існуюча модель

Зараз (plugin-instruction.md) плагін описаний у shop.Product з полями code, plugin_key, pricing. Сумісність з версіями core ніяк не зафіксована — це чистий gap для апгрейдів.

Що додаємо

Розширення Product (або новий PluginManifest як 1:1 до Product):

Поле Семантика
core_min_version мінімальна core-версія (>=1.4.0)
core_max_version максимальна (exclusive) (<2.0.0)
tested_versions масив явно перевірених — для UI («перевірено на 1.4.0, 1.4.1, 1.4.2»)
breaking_changes вільний текст для випадків коли upgrade плагіна потребує дій від адміна

Алгоритм перевірки

Перед migrate нового core агент:

  1. Опитує локальний реєстр встановлених плагінів через GET /api/v1/system/plugins/installed/ (новий endpoint).
  2. Для кожного звіряє core_target_version з compat range плагіна.
  3. Якщо хтось не вписується:
  4. Hard block — апгрейд core не починається.
  5. Користувачу показуємо список несумісних плагінів і два шляхи: «Оновити плагін до 0.9 → потім ретрай core», або «Видалити плагін → апгрейд core → перевстановити коли вийде сумісна версія».
  6. Якщо вписуються, але tested_versions не містить нову — попередження (yellow), не блок.

Manifest плагіна

Окремий manifest на canalу плагіна: https://updates.eswf.dev/plugins/logistic/<channel>.json. Той самий формат що й core, плюс core_compat поле. Плагіни оновлюються незалежно від core.


Phased rollout і kill-switch

Phased rollout (canary)

Ризиковані релізи (особливо MAJOR) розкочуємо поступово:

  1. День 0 — manifest для beta оновлений. Beta-tenant-и оновлюються.
  2. Дні 1–7 — моніторинг telemetry/підтримки.
  3. День 7+ — stable manifest оновлено, beta-версія промотується у stable.

Реалізується просто перемиканням манифесту — не потрібна окрема інфраструктура.

Kill-switch

Поле "kill_switch": true у манифесті каналу для попередньої версії (не нової). Семантика: «всі хто на 1.4.1 повинні негайно перейти на 1.4.2 — там критичний баг».

Поведінка агента: - Якщо installed_version присутня у kill_switch списку — режим тимчасово примушується до aggressive, нотифікація показується як warning а не info. - Користувач формально може ігнорувати — primary-кнопка все одно «Оновити зараз».

Не плутати з мандаторним блокуванням — у DOP не блокуємо роботу примусово (це може коштувати грошей бухгалтеру у дедлайн-день).


Trade-offs (зведено)

Рішення Виграємо Платимо
Manifest замість :latest детермінізм, підпис, незалежність від registry додатковий артефакт що треба підтримувати у синку з образами
3 канали чітка градація QA релізна дисципліна (треба робити QA перед stable)
Snapshot-rollback замість migration revert надійність, проста ментальна модель місце на диску (3 × volume size)
Manifest + tarball на CDN (Phase 1) нуль інфраструктури registry агент мусить вміти docker load (більше коду на Windows)
Hard block по plugin compat захист від руїни адмін може застрягнути якщо плагін давно не оновлювався — потрібен fallback «видалити плагін»
Polling замість push нульова інфраструктура (без WS/SSE) затримка до доби на stable (прийнятно)

Відкриті питання

1. Хостинг manifest + registry

Кандидати: CF Pages (безкоштовно, статика), GitHub Releases (як CDN для tarball), власний VPS (registry + manifest), AWS S3 + CloudFront. Рішення відкладено до Phase 2 — для Phase 1 достатньо GitHub Releases.

2. Підпис manifest — який алгоритм

Варіанти: GPG (звично, але громіздко), ed25519 з ротацією ключів через manifest-of-manifests, Sigstore (cosign — індустріальний стандарт, але потребує Fulcio/Rekor). Рішення відкладено — Phase 1 без підпису (HTTPS достатньо), Phase 2 — ed25519.

3. Telemetry — чи треба знати які версії на яких клієнтах

За: оперувати kill-switch, планувати deprecation period, бачити чи апгрейд успішний. Проти: privacy, GDPR, треба explicit consent. Поточне нахилення: opt-in (галочка в admin tools), за замовчуванням вимкнено. Якщо ввімкнено — агент відсилає {tenant_id (hashed), installed_version, last_upgrade_at, last_upgrade_status} на https://telemetry.eswf.dev/v1/heartbeat раз/добу.

4. Apple Silicon / ARM64

Dockerfile.demo сьогодні linux/amd64. ARM64 — окрема збірка (docker buildx --platform linux/arm64), окремий manifest channel stable-arm64.json. Не пріоритет до Phase 2.

5. Зворотній порядок — як апгрейдити плагін до апгрейду core

Сценарій: новий core 1.5 потребує плагіна 0.9, але плагін вийшов раніше core. Тоді апгрейд core блокується (hard block за compat). Розвʼязка: - Варіант A: плагіни випускаємо одночасно з core (release-train). - Варіант B: плагін у compat range декларує і next_planned_version, агент попереджає заздалегідь. - Варіант C: агент пропонує «попередньо оновити плагін» з тимчасовим --no-core-check — ризиковано.

Поточне нахилення — A (release-train), як в Linux distro.

6. Multi-tenant в одній інсталяції

DOP уже multi-tenant у БД, але апгрейд робиться на рівні install-у (один Docker volume), не на рівні tenant-а. Це нормально для desktop (single-tenant) і для self-hosted (admin вирішує за всіх). Per-tenant rolling upgrade — out of scope.


Roadmap

Фаза Скоп Тригер реалізації
0 — ADR (цей документ) дизайн і обговорення поточна сесія
1 — Manual update via tarball manifest на GitHub Releases, окрема manage.py upgrade_to <version> команда, snapshot+restore, без агента перший зовнішній клієнт desktop installer
2 — Tray-агент + manual mode update-модуль у launcher-gui/, polling, нотифікації, кнопка «Оновити», history-вкладка ≥3 встановлень desktop installer
3 — Plugin compat enforcement поля в Product, endpoint /system/plugins/installed/, hard block перший платний плагін на новій major-версії
4 — Self-hosted updater dop-updater systemd-сервіс, scheduled mode перший клієнт self-hosted production
5 — Manifest signing + ghcr.io registry ed25519, ротація ключів, перехід з tarball на pull частота оновлень >1/тиждень
6 — Telemetry + kill-switch heartbeat endpoint, opt-in, dashboard поява критичного багу що вимагає массового апгрейду
7 — Phased rollout automation автопромоушен beta → stable після N днів без P0 релізна каденція стає тижневою

Посилання


🔮 Deferred / Ideas

Частина 1 — LTS-канал

Мотивація: держустанови / великі бухгалтерії хочуть «один реліз на рік + патчі безпеки», без MINOR-апдейтів. Чому відкладено: немає аудиторії з такими вимогами; додавання каналу — простий manifest + reлiз-policy, не блокує ніщо. Trigger: перший клієнт зі SLA на LTS у договорі.

Частина 2 — Background data migrations (без maintenance window)

Мотивація: довгі data migrations (>5 хв) роблять апгрейд незручним — клієнт чекає у вікні «оновлюємо». Чому відкладено: на поточних обʼємах даних найдовші міграції <30 с; інфраструктура RunPython порцiями вимагає окремого фреймворку. Trigger: перший клієнт з БД >10 GB або скарга на «довге оновлення».

Частина 3 — Delta-оновлення (image layer diff)

Мотивація: повний образ ~600 MB → за рік клієнт викачує 7 GB на 12 апдейтів. Delta дала б ~50 MB на апдейт. Чому відкладено: Docker layer caching уже частково вирішує (нові шари — лише змінені); справжня delta — окрема технологія (e.g. casync). Trigger: скарги на трафік / повільне оновлення з мобільного інтернету.

Частина 4 — Push-нотифікації про реліз (WS/SSE)

Мотивація: замість polling раз/добу — миттєва нотифікація через WebSocket з api.eswf.dev. Чому відкладено: polling достатньо для каденції раз/місяць. Trigger: kill-switch епізод де доба-затримки виявилась критичною.

Частина 5 — K8s operator для self-hosted enterprise

Мотивація: один-нодовий self-hosted виходить дешево, але enterprise-клієнти хочуть HA + rolling upgrade у K8s. Чому відкладено: немає такої аудиторії, дизайн HA — окрема велика тема. Trigger: перший корпоративний клієнт зі ставкою на K8s.

Частина 6 — Per-tenant feature flags

Мотивація: ввімкнути новий ризикований модуль (e.g. AI-агент) лише для частини tenant-ів каналу stable без окремого каналу. Чому відкладено: немає інфраструктури flags (рішення: GrowthBook / Unleash / власне). Канали поки покривають 80% потреби. Trigger: перший випадок «треба випустити фічу тільки для tenant X для тестування».