Metadata-Version: 2.5
Name: edgevault
Version: 0.15.0
Summary: Encrypted backups for your project: Postgres, SQLite, files and secrets to S3, Google Drive, Yandex Disk, SFTP — with Telegram alerts
Project-URL: Homepage, https://github.com/Vasiliynemix/EdgeVault
Project-URL: Changelog, https://github.com/Vasiliynemix/EdgeVault/blob/main/CHANGELOG.md
Author: Vasiliynemix
License-Expression: MIT
License-File: LICENSE
Keywords: age,backup,encryption,pg_dump,postgres,s3,sqlite,telegram
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Natural Language :: Russian
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: System :: Archiving :: Backup
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: cryptography>=42
Description-Content-Type: text/markdown

# edgevault

*Encrypted backups for Python projects: Postgres, SQLite, files and secrets → S3,
Google Drive, Yandex Disk, SFTP, with Telegram, MAX and webhook alerts. Docs are in Russian.*

Зашифрованные бэкапы для проекта: Postgres, SQLite, папки, секреты. Подключается
к бэкенду как обычная библиотека, настраивается его же settings, запускается
вместе с проектом. О сбое приходит сообщение в Telegram, MAX или на webhook: какой
проект, какой сервер, что сломалось и что делать.

Базы edgevault не меняет: подключения к Postgres только на чтение (запись
отклонит сам сервер), SQLite открывается только на чтение, удаляет edgevault
только свои старые копии. Места на диске не хватит — прогон пропустит и скажет,
а не переполнит диск под базой.

Python 3.11, 3.12, 3.13, 3.14 — тесты идут на каждой. Из зависимостей — только
`cryptography`.

**Примеры на всё** — в [examples/](examples/): [`backup.py`](examples/backup.py)
(всё из кода Python), [`edgevault.toml`](examples/edgevault.toml) (все настройки),
[`minimal.toml`](examples/minimal.toml), `.env`, `docker-compose.yml`. Все команды и
аргументы в терминале — `edgevault help` и `edgevault help <команда>`.

## Внедрение в проект — чеклист

1. **Зависимость** с ограничением сверху — новая минорная версия не приедет на
   прод сама: `edgevault~=0.8`.
2. **Образ проекта:**
   - `postgresql-client` той же версии, что сервер Postgres, или новее;
   - `tzdata`, если расписание по местному времени (`check` предупредит, если нет).
3. **`app/backup.py`** (пример ниже):
   - `Postgres` — напрямую к базе, не через pgbouncer;
   - `Secrets` — `.env` целиком (`Secrets(root="/secrets", patterns=(".env",))`);
   - `Telegram(..., proxy=...)`, если Telegram с сервера доступен только через прокси.
4. **Сервис в compose:** тот же образ, отдельный процесс; копии — в
   `/srv/edgevault/<проект>` на сервере; `.env` — только на чтение:
   ```yaml
     backup:
       build: .
       command: python -m app.backup
       env_file: .env
       volumes:
         - /srv/edgevault/my-project:/backups
         - ./.env:/secrets/.env:ro
       restart: unless-stopped
   ```
5. **На сервере:** `docker compose run --rm backup python -m app.backup check`, затем
   `… run`. Ручные запуски — под тем же пользователем, что и сервис (без `-u root`):
   иначе служебные файлы станут чужими, и следующий прогон упадёт на правах.
6. **На своём компьютере:**
   `edgevault link root@host:/srv/edgevault/my-project --name my-project` — и копии
   сами скачиваются к вам.
7. **Через неделю:** `edgevault pull --status` и сообщение «восстановление проверено».

## В проект за три шага

**1. Зависимость**

```bash
uv add edgevault        # или pip install edgevault
```

**2. Файл `app/backup.py`** — всё, что нужно знать о бэкапе проекта:

```python
from edgevault import Postgres, S3, Secrets, Telegram, Vault

from app.config import settings

vault = Vault(
    "my-project",
    recipients=settings.BACKUP_AGE_KEY,  # открытый ключ age1…
    sources=[
        Postgres(settings.DATABASE_URL),  # можно postgresql+asyncpg://…
        Secrets(env=["SECRET_KEY", "FERNET_KEY"]),  # ключи, без которых дамп не прочитать
    ],
    targets=[S3(settings.BACKUP_S3_URL)],  # https://KEY:SECRET@host/bucket/папка
    notify=Telegram(settings.BOT_TOKEN, settings.ADMIN_CHAT_ID),
    every="1h",  # или schedule=["04:00"]
    verify_days=7,  # раз в неделю — пробное восстановление
    store="/backups",
)

if __name__ == "__main__":
    vault.cli()  # python -m app.backup [daemon|run|check|test-notify]
```

**3. Запуск** — как удобно:

| Как | Что написать |
|---|---|
| Отдельный сервис рядом (рекомендую) | в `docker-compose.yml` — сервис ниже |
| Внутри приложения, фоном | `vault.start()` при старте, `vault.stop()` при остановке |
| Один раз | `python -m app.backup run`, или `vault.run()` из своего кода |

```yaml
  backup:
    build: .                        # тот же образ, что у бэкенда
    command: python -m app.backup
    env_file: .env
    volumes: ["backups:/backups"]
    restart: unless-stopped
```

Внутри приложения — например, FastAPI:

```python
@asynccontextmanager
async def lifespan(app):
    vault.start()  # расписание в своём потоке, приложению не мешает
    yield
    vault.stop()
```

Несколько воркеров uvicorn с одной папкой копий — не страшно: прогон делает
один, остальные молча пропускают. Но если API масштабируется на несколько
машин, берите отдельный сервис.

### Sync или async — без разницы, и проект он не ломает

- `start()` возвращается сразу. Проверки, сеть, pg_dump — в своём потоке с
  пониженным приоритетом: цикл событий asyncio и потоки приложения не ждут.
- Async-код: `async with vault:` или `vault.start()` … `await vault.astop()`,
  разовый прогон — `await vault.arun()`. Sync-код (Django, Flask, скрипты):
  `with vault:` или `start()`/`stop()`, разовый — `vault.run()`.
- Ошибка в окружении (пустой `BACKUP_AGE_KEY`, опечатка в ключе) не роняет
  импорт и старт приложения: бэкап выключается и говорит почему — в журнал,
  в `check` и в чат. Выключить явно, например в dev: `Vault(..., enabled=settings.IS_PROD)`.
- `run()` и прогоны по расписанию не бросают исключений: всё — в отчёт и в чат.
- Запускайте `start()` в процессе воркера (lifespan, `on_startup`), а не при
  импорте: с `gunicorn --preload` поток, запущенный до fork, в воркеры не попадёт.

## Ключ — один раз, на своём компьютере

```bash
uv tool install edgevault
edgevault keygen --name my-project   # → ~/.config/edgevault/keys/my-project.txt (права 600)
```

Ключ ложится не в текущую папку, а в `~/.config/edgevault/keys/` — из папки
проекта его легко закоммитить или потерять вместе с ней. Путь внутри рабочей копии
git, не закрытый `.gitignore`, `keygen` не примет. Открытый `age1…` — в настройки
проекта (`recipients`), закрытый — в менеджер паролей и `link --key`.

**Две роли edgevault.** Библиотека (`Vault` в коде) живёт в `.venv` проекта — своя
версия на проект. Команды машины (`link`, `unlink`, `pull`, `systemd`, `keygen`,
`authorize`) меняют общее состояние — службы, `~/.config/edgevault`, ключи — и
выполняются одной общей программой из `uv tool` (или pipx). Запустили такую
команду из `.venv` проекта — edgevault сам выполнит её общей программой; если та
старше — остановится и скажет обновить (`uv tool upgrade edgevault`); если её нет —
команды, ставящие службу, откажутся (`--allow-project-env`, если сознательно).
Служба никогда не переезжает с общей программы на программу из `.venv`.

На сервер кладут только открытый `age1…`: украденная копия без закрытого ключа
бесполезна. Программа `age` не нужна — шифрование встроено, но копии
расшифровывает и обычный `age -d`.

## До старта скажет, чего не хватает

`python -m app.backup check` (и сам `start()`/`daemon` при запуске — в журнал и
сообщением) проверяет всё, что можно проверить, ничего не делая:

```
  ✗ Программа pg_dump: не установлена, а нужна, чтобы снимать Postgres по адресу
    → Добавьте в Dockerfile образа: RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client
  ✗ Программа pg_dump: версии 17, а сервер Postgres 18: старый pg_dump новую базу не снимет
  ✗ Отправка → s3://storage.yandexcloud.net/backups: HTTP 403 SignatureDoesNotMatch …
    → Ключи S3 неверны или у них нет прав на бакет
```

Команда установки — под систему образа (Debian, Alpine) или машины. Внешние
программы нужны не всегда:

| Что в конфиге | Что нужно поставить |
|---|---|
| `Postgres(url)` | `postgresql-client` той же версии, что сервер, или новее |
| `S3`, `Folder`, `Sqlite`, `Files`, `Secrets`, шифрование | ничего |
| `Rclone("gdrive:…")` — Google/Яндекс Диск, SFTP | `rclone` |
| Postgres в соседних контейнерах (`container`, `discover`) | `docker` и смонтированный `docker.sock` |

## Что сохранять

```python
Postgres(settings.DATABASE_URL)  # по адресу; база из адреса
Postgres(host="db", user="app", password=..., databases=["app", "auth"])
Postgres(container="app-db")  # pg_dump внутри контейнера базы
Postgres(discover=True, compose_project="app")  # все базы своего compose-проекта
Sqlite("data/*.sqlite3")  # согласованная копия, даже если пишут
Files("uploads", exclude=["cache/*"])  # папка архивом
Secrets(env=["SECRET_KEY"])  # переменные окружения
Secrets(root=".", patterns=[".env", "config.py"])  # или файлы
```

### Сжатие

Всё сжимается до шифрования (зашифрованное уже не сжать):

| Что | Как | Копия |
|---|---|---|
| Postgres | `pg_dump -Fc` (gzip внутри дампа) | `…__база.dump.age` |
| Postgres, `compression="zstd"` | zstd: меньше и быстрее, нужен Postgres 16+ | то же имя |
| SQLite | gzip, обычно в 3–5 раз | `sqlite__имя.sqlite3.gz.age` |
| Папки, секреты | tar.gz | `files__….tar.gz.age`, `secrets.tar.gz.age` |

В отчёте видно, сколько дало сжатие: `SQLite app: 120,0 МБ → 31,2 МБ (в 3,8 раза)`.
`restore` и пробное восстановление разжимают сами; `decrypt` одного файла
подскажет `gunzip`. Испорченный архив не распакуется молча: в gzip есть
контрольная сумма. `compression` — `"zstd"`, `"zstd:5"`, `"lz4"`, `"gzip:9"`;
`check` скажет заранее, если pg_dump старше 16 для zstd/lz4.

## Куда отправлять

```python
S3("https://KEY:SECRET@s3.ru-1.storage.selcloud.ru/bucket/my-project")
S3(
    endpoint="https://storage.yandexcloud.net",
    bucket="b",
    prefix="app",
    access_key=...,
    secret_key=...,
    region="ru-central1",
)
Folder("/mnt/backup-disk/my-project")  # другой диск, NFS, volume
Rclone("gdrive:Backups/my-project", {"type": "drive", "token": settings.GDRIVE_TOKEN})
Rclone("yadisk:Backups/my-project", {"type": "yandex", "token": settings.YADISK_TOKEN})
Rclone(
    "box:/backups",
    {
        "type": "sftp",
        "host": "backup.example.ru",
        "user": "b",
        "key_file": "/run/secrets/backup_key",
    },
)
```

Мест можно несколько: не дошло в одно — остальные всё равно получают копию.
Хотя бы одно — у другого провайдера, не там, где сервер. Без мест копии лежат
только в `store`; их можно забирать к себе: `edgevault pull`.

Токен Google/Яндекс Диска: `edgevault authorize gdrive` (или `yandex`) на своём
компьютере — откроется вход в браузере. Полученная строка — пароль от Диска:
сразу в `.env` сервера и никуда больше. У Google Диска доступ только к файлам,
которые положил сам edgevault. У Яндекс Диска токен даёт доступ ко **всему**
Диску аккаунта: ограничить папкой через rclone не получится (доступ только к
папке приложения rclone не поддерживает — по крайней мере, это не подтверждено),
поэтому для бэкапов лучше отдельный аккаунт Яндекса.

**Яндекс Диск — только через своё приложение.** С октября 2026 Яндекс не пускает
к файлам токены встроенного приложения rclone: квота видна, а отправка падает с
`[410 - GoneError] Gone (Ресурс больше недоступен.)`. Своё приложение делается
один раз и подходит для всех ваших Дисков:

1. https://oauth.yandex.ru/client/new → платформа **только «Веб-сервисы»**.
   «Для доступа к API или отладки» не подойдёт: там Яндекс ставит свой Redirect URI
   (`https://oauth.yandex.ru/verification_code`), и изменить его нельзя.
2. Redirect URI — ровно `http://127.0.0.1:53682/` (со слешем на конце): туда rclone
   принимает вход. Не тот — Яндекс в браузере покажет ошибку про `redirect_uri`, а
   `authorize` будет ждать: Ctrl+C, поправьте приложение и повторите.
3. Доступ к Яндекс Диску: «Чтение всего Диска», «Запись в любом месте на Диске»,
   «Доступ к информации о Диске».
4. `edgevault authorize yandex --client-id <ClientID> --client-secret '<Client secret>'`
   — под каждым аккаунтом, чей Диск нужен. Строка токена уже содержит ClientID и
   Client secret — её целиком в `.env` вместо старой. **Код и конфиг проекта менять
   не нужно**: `Rclone(...)`, `Rclone.many(...)` и `[remotes.*]` остаются как были.

**Токен продлевается сам.** Токен Яндекса живёт около года, а когда срок вышел,
спасти его уже нельзя. edgevault продлевает его за 30 дней до конца через ваше
приложение и хранит новый рядом с копиями (`<store>/.edgevault-tokens.json`, права
600) — строка в `.env` не меняется, и лезть в проекты раз в год не придётся.
Положили в `.env` новую строку — берётся она. Если продлить нечем (токен без
своего приложения), `check` и отчёт предупредят за 30 дней.

**Перевести работающий проект** (ошибка 410 в сообщениях):

1. Приложение Яндекса — один раз на все проекты (шаги 1–3 выше). Возможно,
   Яндекс попросит подтвердить личность через Госуслуги.
2. В проекте — edgevault 0.14 или новее (`uv add "edgevault>=0.14"` или поднять версию).
3. На своём компьютере, под нужным аккаунтом Яндекса:
   `edgevault authorize yandex --client-id … --client-secret …`.
4. Новую строку — в `.env` сервера вместо старой (`BACKUP_YADISK_TOKEN`, как у вас
   называется). Несколько Дисков — так же для каждого.
5. Перезапустить сервис бэкапа и проверить: `check` (все места ✓), затем `run`.

**Одна строка токена — на один проект.** Каждый проект продлевает токен сам; если
одну строку вставить в несколько проектов, после продления в одном у остальных
токен может перестать работать. Для другого проекта — свой `authorize` (тот же
аккаунт и то же приложение можно): токены будут независимы.

ClientID и Client secret можно задать и отдельно — ключами `client_id` и
`client_secret` места (`[remotes.yadisk]` или `Rclone(..., {"client_id": …})`):
явные важнее тех, что в строке токена.

Старые копии на Яндекс Диске edgevault удаляет насовсем, а не в Корзину (Корзина
занимает ту же квоту): `hard_delete = "true"` подставляется сам, явное `"false"`
уважается. `check` показывает свободное место на Диске и предупреждает, если после
следующей отправки останется меньше 10%.

## Несколько Дисков (или баз) из одной переменной

Копию стоит держать в нескольких местах: не дошло в одно — остальные всё равно
её получают. Несколько Яндекс Дисков (разных аккаунтов), несколько баз или S3
задаются одной переменной в `.env`:

```python
from edgevault import Postgres, Rclone, Secrets, Vault

vault = Vault(
    "my-project",
    recipients=settings.BACKUP_AGE_KEY,
    sources=[
        *Postgres.many(settings.DATABASE_URLS, compression="zstd"),
        Secrets(root="/secrets", patterns=(".env",)),
    ],
    targets=Rclone.many(
        "yadisk",  # места: yadisk, yadisk2, yadisk3…
        "Backups/my-project",  # одна папка на всех
        type="yandex",
        tokens=[settings.BACKUP_YADISK_TOKEN, settings.BACKUP_YADISK_TOKENS_EXTRA],
    ),
    every="6h",
    store="/backups",
)
```

```dotenv
# основной Диск — одна строка JSON от `edgevault authorize yandex --client-id … --client-secret …`
BACKUP_YADISK_TOKEN='{"access_token":"y0_…","token_type":"bearer","refresh_token":"…","expiry":"…"}'
# ещё Диски — другие аккаунты: через запятую или JSON-массивом [...]
BACKUP_YADISK_TOKENS_EXTRA='{"access_token":"y0_…",…},{"access_token":"y0_…",…}'
# несколько баз — адреса через запятую
DATABASE_URLS=postgresql://app:pw@db/app, postgresql://app:pw@db/auth
```

- **Одинарные кавычки** вокруг значения в `.env`: внутри JSON — двойные. edgevault
  снимает внешние кавычки сам.
- **Каждый Диск — свой `edgevault authorize yandex --client-id … --client-secret …`**
  под своим аккаунтом, приложение — одно на всех. Токен —
  это JSON со своими запятыми внутри, поэтому список токенов — это JSON-объекты
  через запятую, а не простой `split(",")`. Повторы убираются.
- **Диск чужой** (заказчика, у него Windows и ничего не установлено) — ставить
  Python и edgevault ему не нужно:
  1. `edgevault authorize yandex --for-someone --client-id … --client-secret …`
     печатает инструкцию для владельца Диска: где скачать rclone и точную команду
     `rclone authorize "yandex" "<ClientID>" "<secret>"`. Перешлите её ему.
  2. Он входит в свой Яндекс и присылает строку `{"access_token":…}`.
  3. У себя: `edgevault token yandex --client-id … --client-secret … < строка.txt` —
     на выходе та же строка с приложением внутри. Её — в `.env` сервера (например,
     в `BACKUP_YADISK_TOKENS_EXTRA`). Без этого шага edgevault не сможет продлить токен.

  Другой путь — не трогать строки, а задать приложение у мест явно: `client_id=…`,
  `client_secret=…` в `Rclone.many(...)` действуют на все Диски списка.
- **Имена мест — по позиции**: первый — `yadisk`, дальше `yadisk2`, `yadisk3`. Если
  вы раньше называли места так же, переход на `Rclone.many` их не переименует, и
  хранение продолжит чистить старые копии.
- **Битый элемент не роняет бэкап.** Токен с опечаткой или без `access_token`
  пропускается, остальные Диски работают, а в `check`, отчёте каждого прогона и
  сообщении — пункт «Яндекс Диск №2: не токен (нет access_token) — пропущен».
  Содержимое токена туда не попадает. То же — у `Postgres.many` и `S3.many`.
- `S3.many(settings.BACKUP_S3_URLS)` — несколько S3 (`https://KEY:SECRET@host/bucket/папка`);
  `Rclone.many("box", "/backups", remotes=[settings.BACKUP_SFTP_LIST])` — несколько
  мест любого вида из готовых описаний (`{"type": "sftp", "host": …}`).
- Разобрать такую строку и самому: `from edgevault import env_list`.

## Как часто и сколько хранить

`schedule=["03:30", "15:30"]` — в эти часы, `every="1h"` — каждый час (`30m`,
`2h`, `6h`…). Между бэкапами данные могут потеряться — это окно и задаёт
расписание: при `every="1h"` теряется не больше часа. Время — по `TZ` контейнера;
`check` пишет, по какому поясу идёт расписание, и предупреждает, если `TZ` задан, а
`tzdata` в образе нет (тогда время молча считалось бы по UTC).

Хранение — `keep=Keep(...)`:

| | По умолчанию | Что это |
|---|---|---|
| `local_days` | 3 | сколько дней копии лежат в `store` |
| `daily_days` | 14 | сколько дней — в местах хранения |
| `monthly_days` | 365 | копия первого числа — год |
| `all_runs_days` | 2 | все прогоны дня — за последние 2 дня, раньше — последний прогон дня |

Так `every="1h"` не превращается в сотни копий: сутки-двое — каждый час, дальше —
по одной на день, месячные — год.

`min_free_gb=1` — сколько места должно остаться на диске после бэкапа (и не
меньше 5% диска). Не хватает — прогон пропускается с сообщением.

## Пробное восстановление

То, что дамп читается, ещё не значит, что он восстановится. С `verify_days=7` раз
в неделю edgevault разворачивает свежий дамп во временный Postgres, смотрит, что
все таблицы на месте, и удаляет его:

```
· восстановление app-db / app проверено: 42 таблиц, ~1830000 строк, 38 с
```

- Рабочую базу не трогает: временный Postgres — отдельный контейнер без сети, на
  одном ядре, сравнение — с оглавлением самого дампа.
- Образ — тот же, что у базы (с её расширениями: PostGIS, pgvector), для базы по
  адресу — `postgres` той же версии, что в дампе. Свой — `verify_image="…"`.
- Нужен Docker: в сервисе бэкапа — смонтированный `/var/run/docker.sock`. Без
  него `check` скажет об этом, а бэкапы идут как обычно.
- Не прошла — сообщение с причиной и повтор через сутки.
- SQLite проверяется каждый раз сам: копия — это готовая база, и
  `integrity_check` проходит по ней целиком.

## Копии — к себе на компьютер

```bash
edgevault link root@203.0.113.10:/srv/edgevault/my-project --name my-project
```

Одна команда на своём компьютере. Ваш обычный доступ по SSH (агент, пароль)
нужен один раз — для настройки:

- ключи сервера сверяются с `ssh-keyscan` и записываются все (ed25519, ecdsa, rsa);
- создаётся отдельный ключ `~/.ssh/edgevault_<сервер>`;
- на сервере — **свой пользователь у каждого проекта** (`edgevault-<проект>`):
  только чтение и только своя папка `/srv/edgevault/<проект>` (chroot +
  `internal-sftp -R`, без оболочки). На сервере могут быть проекты разных
  владельцев: чужие копии не читаются, даже список проектов не виден —
  `/srv/edgevault` получает права 711, а папки проектов — без доступа «для всех»
  (контейнерам-владельцам, root и выданным правам это не мешает). Основной
  `sshd_config` не меняется — свой файл в `sshd_config.d` (`Match Group
  edgevault-pull`), перед применением `sshd -t`, применяется через `reload`.
  Копии читаются по ACL;
- сразу проверяется новым ключом: свои копии читаются, а чужое — нет (ни вне
  /srv/edgevault, ни другие проекты), запись не проходит, оболочка не
  открывается. Хоть что-то не так — ошибка. Не удалось подключиться для
  проверки — тоже ошибка, а не «проверено»;
- проект дописывается в общий `~/.config/edgevault/pull.toml` (права 600),
  пробное скачивание, служба автозапуска.

Повтор безопасен — ничего не дублирует. `--dry-run` показывает, что будет сделано,
и ничего не меняет. Нет root на сервере — `--print-server-steps` напечатает шаги
для админа. Сообщения: `--telegram-token-from root@host:/path/.env:BOT_TOKEN` и
`--telegram-chat-from …` (токен на экран не выводится). Отключить —
`edgevault unlink my-project` (с `--server` — и ключ на сервере); скачанное остаётся.

```bash
edgevault pull --status      # когда скачивали, свежая копия, место, проверка, служба
edgevault pull --install     # служба systemd пользователя (link делает это сам)
edgevault pull --uninstall   # убрать службу; копии остаются
```

Служба запускает ту же программу, которой вы выполнили `--install`, а не первую
найденную в PATH. Ставьте её общей программой (`uv tool install edgevault`, затем
`~/.local/bin/edgevault pull --install`): из `.venv` проекта команда предупредит —
при пересоздании окружения служба сломалась бы. Повторный `--install` сверяет
юнит целиком и, если что-то поменялось, переписывает и перезапускает службу.

`pull --once` и `link` показывают, сколько предстоит скачать, ход скачивания и
итог; служба пишет в журнал коротко.

### Проверка восстановления у себя

На компьютере есть и Docker, и закрытый ключ — проверяется ровно та копия, что
скачана. В `[pull]` pull.toml:

```toml
key_file = "~/age-key.txt"   # общий: для проектов без своего ключа
verify_days = 7              # 0 — выключить

[pull.keys]                  # у каждого проекта — свой ключ age
shop = "~/.config/edgevault/keys/shop.txt"
blog = "~/.config/edgevault/keys/blog.txt"
```

`link --key <ключ>` записывает ключ проекта сам и сразу проверяет, что он
открывает скачанные копии: чужой ключ — ошибка в `link` (а не тревога через
неделю), и `pull.toml` при этом не меняется. Есть `age-key.txt` в текущей папке —
`link` предложит его. `pull --status` по каждому проекту пишет, какой ключ
используется и открывает ли он свежую копию.

Раз в неделю свежий дамп расшифровывается во временную папку (700),
разворачивается во временный Postgres той же версии (контейнер без сети, одно
ядро, 2 ГБ памяти) и сверяется с оглавлением; архив секретов — расшифровывается,
перечисляются файлы (содержимое — нет). Всё временное удаляется всегда — и после
ошибки, и по Ctrl+C. В чат: «восстановление my-project проверено: 58 таблиц,
~55 000 строк, 41 с»; не прошла — сообщение со звуком. Сейчас: `pull --verify-now`.

## Сообщения

```
🟠 my-project · бэкап с проблемами
prod-1 · 07.11.2026 04:00

Что не так (1):
• Отправка → s3://storage.yandexcloud.net/backups
  HTTP 403 SignatureDoesNotMatch
  💡 Ключи S3 неверны или у них нет прав на бакет

Копии: 2 · 8,9 МБ
Места хранения: s3://storage.yandexcloud.net/backups ✗
```

🔴 — копий нет, 🟠 — часть не сделана, ✅ — снова в порядке. Параметры `Vault`:
`on_success` (сообщать и об успехах, без звука), `on_recovery` (по умолчанию да),
`remind_hours` (тот же сбой — не чаще раза в N часов), `server` (имя сервера в
сообщении).

Каналы — `Telegram`, `Max`, `Webhook`, **сколько угодно и в любом сочетании**, и в
каждом — сколько угодно получателей:

```python
notify = [
    Telegram(settings.TG_TOKEN, "-100…, @ops", thread=42),  # несколько чатов, тема форума
    Telegram(settings.TG_TOKEN_2, "-100456"),  # второй бот
    Max(settings.MAX_TOKEN, user=[111, 222], chat=-333),  # MAX: личные и группа
    Max(settings.MAX_TOKEN_2, user=444),  # второй бот MAX
    Webhook("https://hooks.example/backup"),  # JSON со всеми полями
    *Telegram.many(settings.BACKUP_TELEGRAM),  # или списком из одной переменной
]
```

- **MAX**: токен бота — business.max.ru → Чат-боты → Интеграция. Личные
  сообщения — по `user` (user_id; человек должен сначала начать диалог с ботом),
  группы и каналы — по `chat` (chat_id, бот — участник). Сертификат API MAX выдан
  НУЦ Минцифры — корневой уже внутри edgevault, ставить ничего не нужно.
- **Из одной переменной** — `Telegram.many`, `Max.many`, `Webhook.many`: JSON-объекты
  `{"token": …, "chat": …}` через запятую (для webhook — просто адреса). Битый
  элемент выключается сам и виден в `check`, остальные работают.
- **В edgevault.toml** — `max_token`, `max_user`, `max_chat` в `[notify]`, а ещё
  каналы — таблицами `[[notify.also]]` (см. [examples/edgevault.toml](examples/edgevault.toml)).

Проверить: `python -m app.backup test-notify` — строка на каждого получателя:

```
  ✓ Telegram -100123
  ✓ MAX личные 111
  ✗ MAX личные 222: HTTP 403 … — бот не может писать сюда: человек должен сначала
    начать диалог с ботом, а в группе бот должен быть участником
✗ не дошло: 1 из 3 — исправьте отмеченное ✗
```

`on_success="daily"` — об успехах одна тихая сводка в день (сколько прогонов,
копий, какого размера, когда последний; время — `daily_digest="09:00"`), о сбоях —
сразу. `on_success=True` — как раньше, после каждого прогона.

Секреты в выводе не появляются: токен бота (`bot***`), логины и пароли в адресах
(`://***@`), пароли, токены и ключи из конфига заменяются на `***` — в журнале,
чате, webhook, `check`, ошибках rclone и pg_dump, трейсбеках.

### Через прокси

Если с сервера Telegram доступен только через VPN или прокси, задайте его только
для сообщений, бэкапы через него не пойдут:

```python
notify = Telegram(settings.BOT_TOKEN, settings.ADMIN_CHAT_ID, proxy="http://vpn:1080")
Webhook("https://hooks.example/backup", proxy="http://user:pass@proxy:3128")
```

В edgevault.toml: `telegram_proxy`, `max_proxy` и `webhook_proxy` в `[notify]`;
в коде — `proxy=` у `Telegram`, `Max`, `Webhook`. Поддерживаются
только http(s)-прокси, socks — нет: такой адрес будет ошибкой сразу, а не в день
аварии. Пароль прокси и токен бота в журналах и выводе заменяются на `***`.
`test-notify` пишет, через какой прокси ушло сообщение.

Без `proxy` сообщения идут, как настроена система (`HTTPS_PROXY`, `NO_PROXY`).
Встроенный S3 и `Folder` прокси из окружения не читают, а rclone читает:
`HTTPS_PROXY` на весь процесс погонит через прокси и копии в Google/Яндекс Диск.

## Вывод и журнал

Каждое действие — одной строкой со временем, значок говорит, чем кончилось:

```
2026-11-08 04:00:01 → Postgres app-db / shop: снимаю…
2026-11-08 04:00:04 ✓ Postgres app-db / shop: зашифровано, 12,4 МБ за 3 с
2026-11-08 04:00:04 → s3://storage.yandexcloud.net/backups: отправляю…
2026-11-08 04:00:09 ✓ s3://storage.yandexcloud.net/backups: отправлено за 5 с
2026-11-08 04:00:09 ✗ SQLite ~/app/data.db: нет файлов по этому пути
2026-11-08 04:00:09 ✉ сообщение отправлено: бэкап с проблемами
```

✓ — готово, ✗ — ошибка (следом «→ что делать»), ! — внимание, → — что идёт,
· — подробность. В терминале значки цветные; в журнал службы, в файл и при
`NO_COLOR` — чистый текст. Подробнее или тише — `EDGEVAULT_LOG=debug|info|warning`.
Внутри вашего приложения (`vault.start()`) библиотека пишет в логгер `edgevault`
и молчит, пока вы не настроите logging; ошибки — уровнем WARNING.

Цвет — только в настоящем терминале. `docker compose run -T`, `docker logs`,
перенаправление в файл — без терминала, там текст без цвета (значки и разделы
остаются). Посмотреть в цвете: `docker compose run --rm backup python -m app.backup check`
(без `-T`). Другую версию, ничего не устанавливая:
`uvx edgevault@0.12.0 -c ~/.config/edgevault/pull.toml pull --status`.

## Восстановление

```bash
edgevault restore ~/Backups/my-project/daily/2026-11-07/1736 -k age-key.txt -o ./restore
```

Расшифровывает весь прогон (папка — права 700), секреты распаковывает в
`restore/secrets`, печатает готовые команды (`createdb …`, `pg_restore …`).
`--into postgresql://…` — сразу в базу, но **только в пустую**: в непустую
откажется, флага в обход нет. Открытый ключ вместо закрытого — понятная ошибка.
Один файл — `edgevault decrypt <копия>.age -k age-key.txt -o <выход>`.

Раскладка копий везде одна: `daily/ГГГГ-ММ-ДД/ЧЧММ/<копия>.age`,
`monthly/ГГГГ-ММ/<копия>.age`.

## Без кода: edgevault.toml

Для проектов, в код которых лезть не хочется, — отдельная программа и один
конфиг: `edgevault init` в папке проекта напишет его сам (найдёт Postgres в
compose-файлах, SQLite, `.env`), справочник — [`examples/edgevault.toml`](examples/edgevault.toml).
Запуск — `edgevault daemon`, таймером `sudo edgevault systemd --install` или
образом `ghcr.io/vasiliynemix/edgevault:0.7` рядом с проектом.

## Обновление

[semver](https://semver.org/lang/ru/), что изменилось — в [CHANGELOG.md](CHANGELOG.md).

- в проекте: `uv add edgevault@latest` (или поднять версию в зависимостях) и `uv lock`;
- программа: `uv tool upgrade edgevault`;
- образ: тег `0.7` сам указывает на последнее исправление — `docker compose pull`.

После обновления — `check`.

## Разработка

```bash
uv sync
uv run pytest && uv run ruff check . && uv run ruff format --check . && uv run mypy edgevault tests
scripts/release.sh 0.5.1     # выпуск: проверки → версия → коммит, тег, push → CI → PyPI
```

Где что лежит:

| Модуль | Что делает |
|---|---|
| `vault.py` | `Vault` — бэкап из кода: расписание, `start`/`stop`, `cli()` |
| `cli.py`, `helptext.py` | команды `edgevault …` и `edgevault help` |
| `config.py` | edgevault.toml и все настройки (`Config`, `Notify`, `Postgres`…) |
| `runner.py` | один прогон: снять → сжать → зашифровать → отправить → почистить |
| `sources/` | откуда: Postgres, SQLite, папки, секреты |
| `destinations.py`, `store.py`, `tokens.py` | куда: S3, rclone, папка; хранение; токены облаков |
| `notify/` | сообщения: `message.py` (вид), `policy.py` (когда), `senders/` (по файлу на канал) |
| `checkview.py` | общие разделы вывода `check` |
| `pull.py`, `link.py`, `service.py` | копии к себе на компьютер |
| `restore.py`, `age.py`, `crypto.py` | расшифровка и восстановление |

Новый канал сообщений (VK, почта…) — один файл в `notify/senders/` по образцу
`telegram.py`; что ещё дописать — по шагам в `notify/senders/base.py`. Новая
настройка, класс или команда — сразу в `examples/`: иначе упадёт
`tests/test_examples.py`. Старые пути импорта держит `tests/test_compat.py`.
