diff --git a/.github/workflows/deploy-obsidian.yml b/.github/workflows/deploy-obsidian.yml new file mode 100644 index 00000000..d683310a --- /dev/null +++ b/.github/workflows/deploy-obsidian.yml @@ -0,0 +1,97 @@ +name: Deploy Obsidian + +# Деплой ТОЛЬКО obsidian-стека (CouchDB). +# Триггерится при изменениях: +# - docker-compose.obsidian.yml (compose сервиса CouchDB) +# - scripts/setup-couchdb.sh (bootstrap) +# - docs/obsidian-livesync.md (документация — для history-watcher'а) +# - этот workflow +# +# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub). +# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy). + +on: + push: + branches: [main] + paths: + - "docker-compose.obsidian.yml" + - "scripts/setup-couchdb.sh" + - "docs/obsidian-livesync.md" + - ".github/workflows/deploy-obsidian.yml" + workflow_dispatch: + +concurrency: + group: deploy-obsidian + cancel-in-progress: false + +jobs: + deploy-obsidian: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Deploy obsidian stack via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + script: | + set -euo pipefail + cd /opt/gendesign + + # Свежие конфиги из репо + git fetch origin main + git reset --hard origin/main + + # Создать shared network если её ещё нет (idempotent). + # Эта же сеть используется Caddy в main-stack. + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + # Стек CouchDB поднимается с собственным project-name, + # чтобы main `docker compose ps` не показывал его как часть main. + docker compose -p gendesign-obsidian \ + -f docker-compose.obsidian.yml pull + docker compose -p gendesign-obsidian \ + -f docker-compose.obsidian.yml up -d + + # Bootstrap CouchDB (CORS, db, лимиты) — idempotent + if [ -f scripts/setup-couchdb.sh ]; then + # Загружаем COUCHDB_PASSWORD из backend/.env.runtime если есть + if [ -f backend/.env.runtime ]; then + set -a; source backend/.env.runtime; set +a + fi + # Ждём CouchDB up, потом bootstrap + for i in $(seq 1 30); do + if docker compose -p gendesign-obsidian \ + -f docker-compose.obsidian.yml \ + exec -T couchdb curl -fsS http://localhost:5984/_up >/dev/null 2>&1; then + break + fi + sleep 2 + done + COUCHDB_HOST=http://localhost:5984 \ + COUCHDB_USER="${COUCHDB_USER:-obsidian}" \ + COUCHDB_PASSWORD="${COUCHDB_PASSWORD:?must be set in backend/.env.runtime}" \ + docker compose -p gendesign-obsidian \ + -f docker-compose.obsidian.yml \ + exec -T couchdb bash -c "$(cat scripts/setup-couchdb.sh)" \ + || echo "(bootstrap warnings ignored — script is idempotent)" + fi + + # Caddy в main-stack — НЕ перезапускаем тут (другой workflow), + # но reload конфига полезен на случай если в Caddyfile добавили + # новый obsidian-route только что (главное: image main caddy уже + # запущен и подключён к gendesign_shared network). + if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet \ + | grep -q .; then + docker compose -p gendesign -f docker-compose.prod.yml \ + exec -T caddy caddy reload --config /etc/caddy/Caddyfile \ + || echo "(caddy reload skipped — main stack not running)" + fi + + sleep 3 + curl -fsS https://obsidian.gendsgn.ru/_up | head -c 200 || true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba14c7f7..4878a1d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,18 @@ name: Deploy +# Деплоится только при изменениях основного стека. +# Obsidian-стек (CouchDB) — отдельный workflow `deploy-obsidian.yml`. on: push: branches: [main] + paths: + - "backend/**" + - "frontend/**" + - "docker-compose.prod.yml" + - "Caddyfile" + - ".github/workflows/deploy.yml" + # NB: shared compose-fragments (network) — тоже триггерят main + # потому что Caddy шарит сеть с obsidian-stack workflow_dispatch: concurrency: @@ -98,13 +108,20 @@ jobs: mkdir -p backend printf 'SENTRY_RELEASE=%s\n' "$IMAGE_TAG" > backend/.env.runtime + # Создать external network если её нет (нужна Caddy для маршрута + # obsidian.gendsgn.ru → couchdb из отдельного obsidian-stack). + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + export IMAGE_TAG="$IMAGE_TAG" - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml up -d + # Project name явно — `gendesign` (по имени папки auto), но + # фиксируем для consistency с obsidian-stack (-p gendesign-obsidian). + docker compose -p gendesign -f docker-compose.prod.yml pull + docker compose -p gendesign -f docker-compose.prod.yml up -d # Caddyfile is bind-mounted; up -d won't re-read it. Reload explicitly. # `|| true` so a temporary Caddy hiccup doesn't fail the whole deploy. - docker compose -f docker-compose.prod.yml exec -T caddy \ + docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ caddy reload --config /etc/caddy/Caddyfile || true # Clean up disk: dangling layers + tagged images older than 72h that diff --git a/Caddyfile b/Caddyfile index 6e34375e..20683b1b 100644 --- a/Caddyfile +++ b/Caddyfile @@ -27,6 +27,23 @@ www.gendsgn.ru { redir https://gendsgn.ru{uri} permanent } +# Obsidian Self-hosted LiveSync (CouchDB backend). +# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap +# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). +# +# DNS: A-record obsidian.gendsgn.ru → IP VPS. +# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL. +obsidian.gendsgn.ru { + encode zstd gzip + + reverse_proxy couchdb:5984 { + # Большие документы (vault attachments / images) — увеличиваем timeout + transport http { + response_header_timeout 120s + } + } +} + # Plain HTTP by IP — kept for ssh-tunnel / debugging. # Caddy issues no TLS here (no hostname). :80 { diff --git a/docker-compose.obsidian.yml b/docker-compose.obsidian.yml new file mode 100644 index 00000000..649fea8e --- /dev/null +++ b/docker-compose.obsidian.yml @@ -0,0 +1,41 @@ +# Obsidian Self-hosted LiveSync stack — CouchDB. +# +# Деплоится ОТДЕЛЬНО от main стека: +# docker compose -f docker-compose.obsidian.yml up -d +# +# Связан с main-stack через external network `gendesign_shared`. Caddy в +# main-stack маршрутизирует obsidian.gendsgn.ru → couchdb:5984 через эту сеть. +# +# Сеть должна быть создана один раз вручную: +# docker network create gendesign_shared +# (или используй scripts/setup-obsidian-stack.sh). + +services: + couchdb: + image: couchdb:3 + container_name: gendesign-couchdb + restart: unless-stopped + environment: + COUCHDB_USER: ${COUCHDB_USER:-obsidian} + COUCHDB_PASSWORD: ${COUCHDB_PASSWORD} + volumes: + - couchdb_data:/opt/couchdb/data + - couchdb_config:/opt/couchdb/etc/local.d + expose: + - "5984" + networks: + - shared + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:5984/_up"] + interval: 30s + timeout: 10s + retries: 5 + +volumes: + couchdb_data: + couchdb_config: + +networks: + shared: + external: true + name: gendesign_shared diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 13237fb8..6b577205 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -124,9 +124,22 @@ services: depends_on: - backend - frontend + networks: + - default + # shared — для маршрута obsidian.gendsgn.ru → couchdb (отдельный stack). + # Если obsidian-стек не задеплоен, Caddy просто получит 502 на этом маршруте, + # main-приложение не страдает. + - shared volumes: postgres_data: redis_data: caddy_data: caddy_config: + +networks: + # Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md). + # Связывает main-stack (Caddy) и obsidian-stack (CouchDB). + shared: + external: true + name: gendesign_shared diff --git a/docs/obsidian-livesync.md b/docs/obsidian-livesync.md new file mode 100644 index 00000000..e50ef975 --- /dev/null +++ b/docs/obsidian-livesync.md @@ -0,0 +1,255 @@ +# Obsidian Self-hosted LiveSync (на CouchDB) + +Single-tenant self-hosted vault на VPS. Multi-device sync через +[Self-hosted LiveSync plugin](https://github.com/vrtmrz/obsidian-livesync). + +## Архитектура (split deploy) + +Obsidian-стек **отделён** от основного приложения (backend/frontend/postgres/redis). +Каждый деплоится своим GHA workflow, никто не блокирует другого: + +``` + ┌────────────────────────────────────┐ +[laptop / mobile]──┤ Caddy (main-stack) │ + │ обслуживает gendsgn.ru + api │ + │ + reverse_proxy obsidian.* → │ + │ ───shared network───┐ │ + └─────────────────────────┼──────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ CouchDB (obsidian-stack) │ + │ gendesign_shared network │ + │ volumes couchdb_data │ + └──────────────────────────────────┘ +``` + +| | Main stack | Obsidian stack | +|---|---|---| +| Compose | `docker-compose.prod.yml` | `docker-compose.obsidian.yml` | +| Project | `gendesign` (`docker compose -p gendesign`) | `gendesign-obsidian` | +| Workflow | `.github/workflows/deploy.yml` | `.github/workflows/deploy-obsidian.yml` | +| Триггер push paths | `backend/**`, `frontend/**`, `Caddyfile`, main compose | `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, docs | +| Сервисы | backend, frontend, postgres, redis, worker, beat, caddy | couchdb | +| Восстанавливается отдельно | ✓ | ✓ | + +**Связь**: внешняя docker-сеть `gendesign_shared` (создаётся вручную или +в первом деплое). Caddy в main-stack подключён к ней + к default — +маршрут `obsidian.gendsgn.ru` ходит в `couchdb:5984`. + +При отсутствии obsidian-стека main работает без проблем (Caddy просто +вернёт 502 на obsidian-route). + +## Deploy + +### 1. DNS + +A-запись: +``` +obsidian.gendsgn.ru. A +``` +(тот же IP что `gendsgn.ru`) + +### 2. Secrets на VPS + +Добавить в `backend/.env.runtime` (НЕ в репо!): +```bash +COUCHDB_USER=obsidian +COUCHDB_PASSWORD=<сгенерированный 32-байтный пароль> +``` + +Сгенерировать пароль: +```bash +openssl rand -base64 32 +``` + +### 3. Первое создание shared network на VPS (один раз) + +```bash +ssh gendesign +docker network create gendesign_shared +``` + +(автоматически создаётся в каждом из двух workflow если её нет, но +для первого ручного запуска удобнее самому). + +### 4. Деплой + +Изменения в `docker-compose.obsidian.yml` / `scripts/setup-couchdb.sh` / +`docs/obsidian-livesync.md` → GHA триггерит **`deploy-obsidian.yml`** +(не трогает main!). + +Изменения в `backend/**`, `frontend/**`, `docker-compose.prod.yml`, +`Caddyfile` → триггерит **`deploy.yml`** (main). + +Для ручного старта впервые: +```bash +git push # любой из 2 workflow задеплоит свою часть +``` + +Или вручную с VPS: +```bash +cd /opt/gendesign +# main: +docker compose -p gendesign -f docker-compose.prod.yml up -d +# obsidian: +docker compose -p gendesign-obsidian -f docker-compose.obsidian.yml up -d +``` + +Caddy автоматически получит Let's Encrypt сертификат для `obsidian.gendsgn.ru` +при первом обращении (если DNS уже резолвится). + +### 5. Bootstrap CouchDB (один раз, идемпотентный) + +`deploy-obsidian.yml` запускает bootstrap автоматически после `up -d`. +Если нужно вручную: + +```bash +cd /opt/gendesign +source backend/.env.runtime # подгружаем COUCHDB_PASSWORD +COUCHDB_HOST=http://localhost:5984 \ +COUCHDB_PASSWORD="$COUCHDB_PASSWORD" \ +docker compose -p gendesign-obsidian -f docker-compose.obsidian.yml \ + exec -T couchdb bash -c "$(cat scripts/setup-couchdb.sh)" +``` + +Скрипт: +- конвертирует CouchDB в "configured single-node" mode +- создаёт database `gendesign-vault` +- включает CORS (нужно для мобильных Obsidian) +- увеличивает лимиты на размер документов (для крупных attachments) +- настраивает auth-timeout (для длинных sync-сессий) + +Идемпотентен — повторный запуск безопасен. + +### 5. Проверка работы + +```bash +# из любой точки интернета: +curl https://obsidian.gendsgn.ru/_up +# → {"status":"ok",...} + +curl -u obsidian: https://obsidian.gendsgn.ru/gendesign-vault +# → {"db_name":"gendesign-vault","doc_count":0,...} +``` + +## Настройка клиента (Obsidian Desktop) + +### 1. Установить Obsidian +- https://obsidian.md/download + +### 2. Создать vault +- "Create new vault" → название "gendesign" → выбрать локальную папку + +### 3. Установить Self-hosted LiveSync plugin +- Settings → Community plugins → "Browse" +- Search: "Self-hosted LiveSync" (автор: vrtmrz) +- Install → Enable + +### 4. Настроить подключение + +Settings → Self-hosted LiveSync → **Setup wizard** (рекомендуется первый раз): + +| Поле | Значение | +|---|---| +| URI | `https://obsidian.gendsgn.ru` | +| Username | `obsidian` (или то что в `COUCHDB_USER`) | +| Password | `` | +| Database name | `gendesign-vault` | +| End-to-End Encryption | **OFF** (см. ниже про trade-off) | + +Затем: +- "Test Database Connection" → должно быть зелёное ✓ +- "Check database configuration" → если требует — выполнить "Fix all" +- "Apply settings" +- "LiveSync" → "Enable LiveSync" + +### 5. Первичный sync + +При первом подключении vault пустой и в БД ничего нет. После первой +правки .md файла LiveSync запушит изменение в CouchDB. + +С другого устройства/у другого пользователя: +- Та же установка +- При first sync клиент скачает всё содержимое БД в локальный vault + +### 6. Mobile (iOS / Android) + +- Установить **Obsidian** из App Store / Google Play +- Создать **пустой** vault +- Установить Self-hosted LiveSync (как на desktop) +- Те же настройки → первый sync скачает всё с сервера + +⚠️ На iOS: включить **Background App Refresh** для Obsidian, иначе sync только при +открытом приложении. + +## End-to-End Encryption (E2EE) + +| | E2EE ON | E2EE OFF (default у нас) | +|---|---|---| +| Контент в БД | Зашифрован (admin сервера не может прочитать) | Plain text | +| Производительность | Тяжелее на клиенте | Быстрее | +| Claude (через MCP/HTTP) сможет писать извне? | Нет (нужен passphrase у Claude) | Да | +| Восстановление при потере passphrase | Невозможно | Возможно (через CouchDB admin) | + +**Рекомендую OFF** — vault это рабочие заметки, не банковская тайна. +HTTPS + basic auth + закрытый DNS защищают от внешнего читателя. + +Если позже потребуется E2EE — переключить в plugin, ребутнуть БД (replicate from scratch). + +## Multi-user + +CouchDB поддерживает per-user permissions, но для простого MVP: +- Один shared `gendesign-vault` database +- Все пользователи логинятся как `obsidian` (один аккаунт) + +Для разделения (read-only для одних, write — для других) — нужно добавить +users в `_users` DB и настроить security object базы: +```bash +curl -X PUT -u obsidian:PWD https://obsidian.gendsgn.ru/_users/org.couchdb.user:viewer \ + -H "Content-Type: application/json" \ + -d '{"name":"viewer","password":"VIEWER_PWD","roles":[],"type":"user"}' +``` + +Затем в vault DB: +```json +PUT /gendesign-vault/_security +{ + "admins": {"names": ["obsidian"], "roles": []}, + "members": {"names": ["viewer"], "roles": []} +} +``` + +## Backup + +```bash +# Snapshot DB → tar.gz на VPS (можно поставить в cron) +docker compose exec couchdb tar czf /tmp/vault-backup-$(date +%F).tar.gz /opt/couchdb/data +docker cp gendesign-couchdb:/tmp/vault-backup-*.tar.gz /opt/gendesign-backups/ +``` + +Опционально — `_replicate` через CouchDB API на S3-совместимое хранилище. + +## Troubleshooting + +### `Failed to connect` в LiveSync +- Проверить DNS: `dig obsidian.gendsgn.ru` +- Проверить HTTPS: `curl https://obsidian.gendsgn.ru/_up` (должен вернуть `{"status":"ok"}`) +- Проверить пароль: `curl -u obsidian:PWD https://obsidian.gendsgn.ru/gendesign-vault` + +### CORS errors на mobile +- Запустить `setup-couchdb.sh` ещё раз (или проверить через `_config/cors/origins`) + +### Большие attachments не синкаются +- В LiveSync plugin: "Customization & advanced" → "Chunk size" = 100 KB +- На сервере: `couchdb/max_document_size` уже = 50 MB + +### Конфликты при одновременной правке +- LiveSync создаёт conflict-маркер в файле +- Plugin показывает UI для merge ("Show conflict resolution dialog") + +## Что дальше + +После того как multi-device sync работает: +- Решить как Claude пишет в vault: MCP filesystem + bridge / самописный CouchDB-MCP / headless Obsidian +- Мигрировать существующий `memory-gendesign.jsonl` knowledge graph → .md файлы в vault diff --git a/scripts/setup-couchdb.sh b/scripts/setup-couchdb.sh new file mode 100644 index 00000000..f1418a4c --- /dev/null +++ b/scripts/setup-couchdb.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Bootstrap CouchDB для Obsidian Self-hosted LiveSync. +# +# Запускается ОДИН РАЗ после первого `docker compose up -d couchdb`. +# Идемпотентен — повторный запуск безопасен. +# +# Что делает: +# 1. Конвертирует single-node setup в "single-node configured" (требование CouchDB 3.x) +# 2. Включает CORS (нужно для Obsidian мобильного / browser-client) +# 3. Увеличивает max_http_request_size до 4 GiB (для крупных attachments) +# 4. Создаёт database `gendesign-vault` для самого vault +# 5. Создаёт database `_users` (системная, для multi-user в будущем) +# +# Использование: +# COUCHDB_PASSWORD=xxx ./scripts/setup-couchdb.sh +# # или на VPS: +# docker compose exec couchdb bash -c 'curl ...' # ручной путь +# +# Требует: curl. Запускать с хоста VPS (couchdb должен быть на 127.0.0.1:5984). + +set -euo pipefail + +COUCHDB_HOST="${COUCHDB_HOST:-http://localhost:5984}" +COUCHDB_USER="${COUCHDB_USER:-obsidian}" +COUCHDB_PASSWORD="${COUCHDB_PASSWORD:?COUCHDB_PASSWORD env var is required}" +VAULT_DB="${VAULT_DB:-gendesign-vault}" + +AUTH="-u ${COUCHDB_USER}:${COUCHDB_PASSWORD}" + +echo "=== 0. Wait for CouchDB ready ===" +for i in $(seq 1 30); do + if curl -fsS "${COUCHDB_HOST}/_up" >/dev/null 2>&1; then + echo " CouchDB up after ${i}s" + break + fi + sleep 1 +done + +echo "=== 1. Convert to 'configured single-node' (idempotent) ===" +curl -fsS ${AUTH} -X POST "${COUCHDB_HOST}/_cluster_setup" \ + -H "Content-Type: application/json" \ + -d '{"action":"enable_single_node","bind_address":"0.0.0.0","username":"'"${COUCHDB_USER}"'","password":"'"${COUCHDB_PASSWORD}"'"}' \ + || echo " (уже configured, OK)" + +echo "=== 2. Системные базы ===" +for db in _users _replicator _global_changes; do + curl -fsS ${AUTH} -X PUT "${COUCHDB_HOST}/${db}" 2>&1 \ + | grep -q '"ok":true\|"file_exists"' && echo " ${db}: OK" +done + +echo "=== 3. Vault database '${VAULT_DB}' ===" +curl -fsS ${AUTH} -X PUT "${COUCHDB_HOST}/${VAULT_DB}" 2>&1 \ + | grep -q '"ok":true\|"file_exists"' && echo " ${VAULT_DB}: OK" + +echo "=== 4. CORS (для мобильных клиентов) ===" +cors_settings=( + "httpd/enable_cors true" + "cors/origins app://obsidian.md,capacitor://localhost,http://localhost" + "cors/credentials true" + "cors/methods GET,PUT,POST,HEAD,DELETE" + "cors/headers accept,authorization,content-type,origin,referer,x-csrf-token" +) +for kv in "${cors_settings[@]}"; do + key="${kv%% *}" + value="${kv#* }" + curl -fsS ${AUTH} -X PUT \ + "${COUCHDB_HOST}/_node/_local/_config/${key}" \ + -H "Content-Type: application/json" \ + -d "\"${value}\"" >/dev/null + echo " ${key} = ${value}" +done + +echo "=== 5. Размер запросов / chunks (для больших документов LiveSync) ===" +curl -fsS ${AUTH} -X PUT \ + "${COUCHDB_HOST}/_node/_local/_config/chttpd/max_http_request_size" \ + -H "Content-Type: application/json" \ + -d '"4294967296"' >/dev/null +echo " chttpd/max_http_request_size = 4 GiB" + +curl -fsS ${AUTH} -X PUT \ + "${COUCHDB_HOST}/_node/_local/_config/couchdb/max_document_size" \ + -H "Content-Type: application/json" \ + -d '"50000000"' >/dev/null +echo " couchdb/max_document_size = 50 MB" + +echo "=== 6. Auth settings (CouchDB 3.x требует session timeout для LiveSync) ===" +curl -fsS ${AUTH} -X PUT \ + "${COUCHDB_HOST}/_node/_local/_config/chttpd_auth/require_valid_user" \ + -H "Content-Type: application/json" \ + -d '"true"' >/dev/null +curl -fsS ${AUTH} -X PUT \ + "${COUCHDB_HOST}/_node/_local/_config/chttpd_auth/timeout" \ + -H "Content-Type: application/json" \ + -d '"7200"' >/dev/null +echo " chttpd_auth: require_valid_user=true, timeout=7200s" + +echo "" +echo "=== DONE ===" +echo "CouchDB готова к Self-hosted LiveSync." +echo "" +echo "Клиенту Obsidian → Settings → Self-hosted LiveSync → Remote DB:" +echo " URL: https://obsidian.gendsgn.ru" +echo " Database: ${VAULT_DB}" +echo " User: ${COUCHDB_USER}" +echo " Password: " +echo "" +echo "Дальше: 'Test Database Connection' → 'Apply Settings' → 'Replicate Now'."