Compare commits

18 Commits

Author SHA1 Message Date
av 2930842e3f Backups: update ADR after migration
Linting / YAML Lint (push) Waiting to run
Linting / Ansible Lint (push) Waiting to run
2026-06-23 09:38:09 +03:00
av 0f80e66b66 Backups: split restic operations into phases for Intelligent Tiering 2026-06-22 17:58:45 +03:00
av 2b22fde718 Gitea: update to 1.26.4 2026-06-22 17:44:21 +03:00
av c39de421e0 Backups: add restic errors
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-06-09 10:23:46 +03:00
av a50b399a85 Add ansible playbooks review 2026-06-09 10:17:32 +03:00
av 94b09be53c Outline: update to 1.8.1 2026-06-09 10:17:07 +03:00
av b637fea882 Memos: update to 0.29.1 2026-06-09 10:16:54 +03:00
av 933a0b9570 GoAccess: update Caddy to 2.11.3
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:43 +03:00
av 96710360d9 Dozzle: update to 10.6.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:40 +03:00
av d9f0d94e1f Caddy: update to 2.11.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:37 +03:00
av 9b853d351c Authelia: update to 4.39.20
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:33 +03:00
av 11744f776a Wakapi: update to 2.17.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:39 +03:00
av 0df5f358d0 Outline: update to 1.8.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:35 +03:00
av 62e2a72e52 Memos: update to 0.29.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:32 +03:00
av 7c91f4f355 Gramps: update to 26.6.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:26 +03:00
av 68d8bf6a68 Gramps: update to 26.5.3 2026-05-25 09:39:27 +03:00
av e585bfdca2 Gramps: update to 26.5.2
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 16:49:08 +03:00
av 41822e04e8 Gitea: update to 1.26.2 2026-05-24 16:48:50 +03:00
18 changed files with 669 additions and 110 deletions
@@ -0,0 +1,125 @@
# Разнесение restic-операций на фазы под Intelligent Tiering
- Дата: 2026-06-22
## Контекст
Бэкапы restic уже лежат в Yandex Object Storage на стандартном классе
хранения (STANDARD). Yandex выпустил класс «Умное хранилище» (Intelligent
Tiering, IT): объекты автоматически охлаждаются до архивного уровня
(примерно 0,63 ₽/ГБ против 2,38 ₽/ГБ у STANDARD) с сохранением мгновенного
доступа на всех уровнях (анонс:
<https://yandex.cloud/ru/blog/s3-intelligent-tiering>). Данные restic — это
профиль «записал один раз, читаю редко», то есть идеальный кандидат на
охлаждение. Цель — перевести уже лежащие бэкапы со STANDARD на IT и
сэкономить на хранении.
Проблема в том, что любой repack/recompress объекта создаёт *новый*
объект, который входит в IT как «Частый доступ» и заново стартует таймер
охлаждения (30 дней → «Нечастый», ещё 90 → «Архивный»). А наш оркестратор
`backup-all.py` гнал каждую ночь связку `backup → check → forget --prune →
check`. Ночной `prune` перепаковывает data-паки → постоянно сбрасывает
охлаждение → отменяет экономию IT. Нужно было перестроить обслуживание
так, чтобы не мешать охлаждению.
Дополнительное ограничение по миграции существующих данных: по докам
Yandex изменение класса бакета **по умолчанию** не трогает уже загруженные
объекты — они остаются в STANDARD, новый класс применяется только к новым
загрузкам. Перевести уже лежащие бэкапы в IT можно lifecycle-правилом или
copy-in-place (`aws s3 cp --storage-class INTELLIGENT_TIERING`); оба
тарифицируются как операция `TRANSITION`. Перезаливка объектов заново для
restic не годится — это churn, эквивалентный репаку. Управления бакетом
(terraform/aws-cli) в проекте нет, так что миграцию пришлось бы делать
вручную или заводить такое управление.
Идентификатор класса подтверждён доками — `INTELLIGENT_TIERING` (Yandex
поддерживает STANDARD, COLD, ICE, INTELLIGENT_TIERING). Явный min-retention
(12 месяцев со штрафом за раннее удаление) документирован **только для
класса ICE**; для архивного уровня внутри IT такого минимума в доках нет —
значит transition и последующий `prune` для IT низкорисковы. Финальная
проверка — по биллингу после первой реальной миграции.
## Рассмотренные варианты
- **Отдельные скрипты на репозиторий** (`backup.sh`/`check.sh`/`prune.sh`/
`verify.sh` + свои cron-записи, как в исходном гайде). Ближе к гайду
дословно, но теряем оркестратор: авто-дискавери приложений, мультистор
и единые apprise-уведомления. Плюс 4 независимые cron-записи провоцируют
наложение операций (долгий `prune` налезает на ночной `backup`
конфликт restic-локов). Отвергнут.
- **Адаптировать `backup-all.py`** — добавить фазы и расписание внутрь
оркестратора, один ночной триггер. Сохраняет всю существующую
инфраструктуру, фазы идут последовательно в одном процессе → локи не
конфликтуют. **Выбран.**
- **Расписание: простые knobs** (день недели/число/месяцы как поля
конфига) **vs cron-выражения через `croniter`**. Knobs — без
зависимости, но негибко (новая ось → правка кода). Выбран `croniter`:
пакет ставится из apt (`python3-croniter`) тем же механизмом, что и
остальное, а гибкость реальная — поменять «раз в квартал» на «раз в
месяц» = правка одной строки конфига.
- **Перевод существующих данных в IT: copy-in-place vs lifecycle vs
отложить.** Lifecycle в Yandex переводит только «на более холодный»
(STANDARD→COLD→ICE), переход именно в IT им не заявлен — отпал.
Перезаливка объектов для restic не годится (churn ≈ репак). Остаётся
**copy-in-place** (`aws s3 cp --recursive --storage-class
INTELLIGENT_TIERING`, серверная копия «на себя»). **Выбран copy-in-place**
— после того как доки сняли блокер по min-retention. Для будущих записей
отдельно — флип класса бакета по умолчанию на IT (вариант A с
`-o s3.storage-class` в коде не понадобился).
## Решение
Операции restic в `files/backups/backup-all.py` разнесены на фазы с
разной частотой, потому что у них принципиально разная цена для IT:
- `backup` + `forget`**каждый прогон**. `forget` теперь **без
`--prune`**: удаляет только метаданные снапшотов (операция DELETE не
тарифицируется), не репакует data-паки и не сбивает охлаждение.
- `check` (структурный) — еженедельно; `prune` — квартально; `verify`
(`check --read-data-subset`) — помесячно. Расписание задано
cron-выражениями в секции `[schedule]` конфига и вычисляется через
`croniter`. Триггер один ночной, фазы одного прогона идут
последовательно в одном процессе → restic-локи между ними не
конфликтуют. Наложение соседних прогонов гасится `flock -n` в cron.
`prune` тюнингован под IT (`--max-unused 20%`, `--max-repack-size 5G`):
чем меньше холодных паков переписываем, тем дольше держится охлаждение.
Перевод бакетов в IT идёт двумя действиями на каждый бакет: смена класса
**по умолчанию** на IT в консоли (будущие записи restic) + разовая
**copy-in-place** существующих объектов (`aws s3 cp s3://<bucket>/
s3://<bucket>/ --recursive --storage-class INTELLIGENT_TIERING
--metadata-directive COPY`). Класс отдаётся прямо в листинге
(`list-objects-v2 --query 'Contents[].[StorageClass,Key]'`) — им и
проверяем. Грабли: для проверки нельзя `--max-items 1` (клиентская
пагинация aws-cli дописывает в вывод токен `None`) — нужен серверный
`--max-keys`.
Статус миграции: **`rivendell` переведён 2026-06-23** (дефолт бакета = IT
со скриншота, все объекты `config`/`data/` показывают
`INTELLIGENT_TIERING`). `eos` (основная экономия) и `buckland` — следующими,
после нескольких дней наблюдения за биллингом `rivendell`.
Retention оставлен прежним (`--keep-daily 90 --keep-monthly 36`) — это
решение про охлаждение и частоту операций, а не про глубину истории.
## Последствия
- `+` Ночной prune больше не сбрасывает охлаждение — IT реально экономит
на архивном уровне.
- `+` Нет наложения restic-операций: последовательные фазы + `flock`.
- `+` Расписание обслуживания меняется правкой конфига, без релиза кода.
- `-` Новая зависимость на сервере: `python3-croniter` (и явно
зафиксированный `python3-requests`).
- `-` Структурный `check` теперь еженедельный, а не каждую ночь: битый
бэкап может остаться незамеченным до недели. Для хобби-сервера приемлемо.
- `-` Подвох croniter: при суточном триггере поля минут/часов в
выражениях декоративны (держим `* *`) — фаза идёт в момент ночного
прогона, а не во время из выражения.
- `+` Миграция существующих объектов — разовая copy-in-place, без репака
restic: содержимое и ключи паков не меняются, restic остаётся рабочим.
- `-` После перевода объекты стартуют на уровне FREQUENT и охлаждаются
~120 дней — полка экономии устанавливается не сразу.
- Осталось сделать: несколько дней последить за биллингом и бэкапами
`rivendell` (убедиться, что за transition нет штрафа), затем повторить
пару «флип дефолта + copy-in-place» для `eos` и `buckland`.
+1
View File
@@ -63,6 +63,7 @@
| Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
| 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — |
| 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — |
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
+150
View File
@@ -0,0 +1,150 @@
# Ревью плейбуков: best practices и конвенции Ansible
Дата: 2026-05-25. Статус: черновик (заметки по итогам ревью, не план работ).
Проанализированы инвентарь, `ansible.cfg`, роли (`owner`, `eget`, `secrets`) и
репрезентативная выборка плейбуков: `gitea`, `memos`, `wanderer`, `backups`,
`system`, `caddyproxy`, `authelia`, `netdata`, `docker`, `eget`, `all-*`.
Находки отсортированы по влиянию.
## Договорённость по структуре (важно для контекста)
Изначальная рекомендация «вынести общий деплой в одну generic-роль `docker_app`»
**отклонена осознанно** и не должна предлагаться снова:
- приложения реально разные, мелкие отличия больно загонять в единую абстракцию;
- catch-all роль обрастает флагами `when:` и читается хуже, чем N честных плейбуков;
- per-playbook дублирование даёт locality of behavior и возможность обкатать новый
подход на одном сервисе, затем раскатать на остальные.
Правильное направление — **набор маленьких composable-ролей на инвариантных швах**
(как уже сделано с `owner`), а не одна роль на всё. Per-app конфиг остаётся локально
в плейбуке сервиса.
## 1. Extraction только на чистых швах (не мега-роль)
Per-app конфиг (каталоги, шаблоны, env, порты, особенности compose) — оставляем в
плейбуке сервиса. Выносим лишь то, что реально инвариантно и повторилось многократно:
- **Бэкап** — самый чистый шов: `gobackup.yml` + `backup.sh` + `backup-targets` +
интеграция с restic. Механизм одинаков у всех, различается только список целей.
Роль `backup` с параметром «список targets» не трогает индивидуальность сервиса.
- `owner` уже сделан как отдельная composable-роль — это правильный размер абстракции.
## 2. `vars_files` в каждом плейбуке → `group_vars/all/`
В каждом плейбуке повторяется:
```yaml
vars_files:
- vars/secrets.yml
- vars/vars.yml
```
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
(vault) для группы `all`. Перенос `vars/vars.yml``group_vars/all/main.yml` и
`vars/secrets.yml``group_vars/all/vault.yml` убирает boilerplate из всех плейбуков.
Адаптируется по одному плейбуку за раз.
## 3. Нет handlers — `state: restarted` безусловный
Ни в одном плейбуке нет `handlers:`. Вместо этого:
- `playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`, `playbook-authelia.yml:92`
задача `state: restarted` выполняется **всегда**, рестартит контейнер на каждом
прогоне даже без изменений (не идемпотентно, лишний downtime);
- `playbook-gitea.yml` — рестарта нет вовсе (несогласованность).
Канонический паттерн: шаблон конфига `notify`-ит handler, который рестартит только при
реальном изменении.
```yaml
- name: "Copy docker compose file"
ansible.builtin.template: { ... }
notify: Restart app
handlers:
- name: Restart app
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: restarted
```
Связанное: в `playbook-memos.yml:76` результат шаблона регистрируется в
`docker_compose_file_result`, но нигде не используется — задумывалось под `when`/`notify`,
не доведено.
Внедряется инкрементально, по одному сервису.
## 4. Идемпотентность и `changed_when`
- **`playbook-netdata.yml:118-125`** — `changed_when: netdata_docker_group_output.rc != 0`
для read-only запроса лишено смысла (помечает «changed» только при ошибке). Должно быть
`changed_when: false`. Лучше заменить `shell: grep docker /etc/group | cut ...` на модуль:
```yaml
- ansible.builtin.getent:
database: group
key: docker
# далее: getent_group['docker'][1]
```
Уйдёт и `set -o pipefail`, и хрупкий парсинг.
- **`playbook-eget.yml:23-78`** — восемь `command` помечены `changed_when: false`, хотя
реально ставят/обновляют бинарники. Прогон всегда рапортует «ok» — теряется честность
`--diff`. Сама роль `eget` делает корректную проверку версии; те же инсталляции через
неё или через проверку версии были бы идемпотентны по-настоящему.
- **`playbook-memos.yml:57-67`** (и аналоги) — сборка `backup-targets` через `lineinfile`
в цикле не удаляет устаревшие строки при изменении списка, а `mode: "0750"` на
файле-списке выглядит как copy-paste. Чище — `template`/`copy: content` со всем списком.
## 5. Роль `owner` — несогласованность с ролью `eget`
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` + `when`,
причём две задачи с **идентичным именем**. Роль `eget` для того же делает `assert`
(`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert` либо современный
`meta/argument_specs.yml` (декларативная валидация).
- **`roles/owner/tasks/main.yml:32,53`** — `with_items`/`with_dict` устарели; конвенция —
`loop`: `loop: "{{ owner_ssh_keys }}"`, `loop: "{{ owner_env_dict | dict2items }}"`.
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` они есть.
- Имена задач в `owner` с точкой на конце (`"Prepare env variables."`), в остальных без —
ansible-lint в строгом профиле это ловит.
## 6. Инвентарь и `become`
- **`production.yml` и `timeweb.yml`** оба объявляют хост с именем `server` под ключом
`ungrouped:`. Хост-специфичные данные (`application_dir`, `mount_external_storage`,
`ansible_host`, `ansible_user`) вписаны инлайн. Конвенциональнее — `host_vars/server.yml`,
хосты в именованной группе. Два инвентаря с одинаковым именем хоста + `hosts: all` =
ошибка `-i` молча уедет не туда.
- `ansible_become: true` глобально в инвентаре — всё бежит под root. Для личного сервера
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям. Низкий приоритет.
## 7. Конкретный баг
- **`playbook-wanderer.yml:2`** — `name: "Configure gramps application"`, хотя
`app_name: "wanderer"`. Копипаст из gramps, поправить имя play.
## 8. Мелочи стиля и конфигурации
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через `lineinfile`.
Конвенция — отдельный файл в `/etc/sudoers.d/` (через `copy`/`template` с
`validate: visudo -cf %s`), а не модификация центрального файла.
- **`.ansible-lint.yml`** содержит только `exclude_paths` — профиль не задан явно.
AGENTS.md утверждает «профиль production»; либо прописать `profile: production`, либо
поправить документацию.
- **`ansible.cfg`** минимален. Стоит добавить `stdout_callback = yaml`,
`interpreter_python = auto_silent`, `force_handlers = true`.
- Несогласованные кавычки и пути: `'directory'` vs `"directory"`, `src: "./files/..."` vs
`src: "files/..."`, одинарные кавычки в `playbook-all-setup.yml` против двойных в остальных.
- `playbook-system.yml:24` — `apt` без `cache_valid_time`, обновляет кэш каждый прогон.
## Приоритеты
1. **#3 handlers** — убирает безусловный рестарт; внедряется по одному сервису.
2. **#1 роль `backup`** — самый чистый шов для extraction; обкатать на одном сервисе.
3. **#4, #7** — быстрые точечные фиксы без структурных изменений.
4. **#2 group_vars** — убирает boilerplate; низкий риск.
5. **#5, #6, #8** — фоновая зачистка стиля и структуры.
+4 -5
View File
@@ -1,10 +1,9 @@
services:
authelia_app:
container_name: 'authelia_app'
image: 'docker.io/authelia/authelia:4.39.19'
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
restart: 'unless-stopped'
container_name: "authelia_app"
image: "docker.io/authelia/authelia:4.39.20"
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: "unless-stopped"
networks:
- "web_proxy_network"
- "monitoring_network"
+288 -70
View File
@@ -3,21 +3,33 @@
Backup script for all applications
Automatically discovers and runs backup scripts for all users,
then creates restic backups and sends notifications.
restic-операции разнесены на фазы с разной частотой (см. секцию [schedule] в config):
- backup, forget -- каждый прогон (forget БЕЗ --prune: только метаданные снапшотов);
- check -- структурная проверка, обычно еженедельно;
- prune -- репак/освобождение места, редко (квартально);
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
"""
import argparse
import itertools
import os
import sys
import subprocess
import logging
import os
import pwd
import subprocess
import sys
import time
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Any
import requests
import tomllib
from abc import ABC
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from croniter import croniter
# Default config path
CONFIG_PATH = Path("/etc/backup/config.toml")
@@ -29,6 +41,22 @@ BACKUP_TARGETS_FILE = "backup-targets"
# Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups"
# Retention policy applied by the `forget` phase on every run.
KEEP_DAILY = "90"
KEEP_MONTHLY = "36"
# Фазы в порядке выполнения. backup и forget идут каждый прогон,
# остальные — по расписанию из config.
PHASE_BACKUP = "backup"
PHASE_FORGET = "forget"
PHASE_CHECK = "check"
PHASE_PRUNE = "prune"
PHASE_VERIFY = "verify"
ALWAYS_PHASES = [PHASE_BACKUP, PHASE_FORGET]
SCHEDULED_PHASES = [PHASE_CHECK, PHASE_PRUNE, PHASE_VERIFY]
PHASE_ORDER = ALWAYS_PHASES + SCHEDULED_PHASES
# Configure logging
logging.basicConfig(
level=logging.INFO,
@@ -46,6 +74,42 @@ class Config:
host_name: str
@dataclass
class MaintenanceOptions:
"""Параметры обслуживающих фаз (см. секцию [maintenance] в config)."""
verify_subset: str = "1/12"
prune_max_unused: str = "20%"
prune_max_repack: str = "5G"
@dataclass
class Schedule:
"""Расписание обслуживающих фаз: фаза -> cron-выражение."""
cron: Dict[str, str] = field(default_factory=dict)
def due_phases(self, now: datetime) -> List[str]:
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
phases = list(ALWAYS_PHASES)
for phase in SCHEDULED_PHASES:
expr = self.cron.get(phase)
if expr and self._due_today(expr, now):
phases.append(phase)
return phases
@staticmethod
def _due_today(expr: str, now: datetime) -> bool:
"""True, если cron-выражение срабатывает где-то в течение сегодняшних суток.
Мы не сравниваем с текущей минутой (триггер один на сутки в фиксированное
время), а проверяем, попадает ли ближайшее срабатывание выражения на сегодня.
"""
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
nxt = croniter(expr, start - timedelta(minutes=1)).get_next(datetime)
return nxt.date() == now.date()
@dataclass
class Application:
path: Path
@@ -54,11 +118,18 @@ class Application:
backup_targets: List[Path]
@dataclass
class BackupResult:
success: bool
error: Optional[str] = None
@dataclass
class StorageRunResult:
name: str
success: bool
duration: float
phases: List[str]
def format_duration(seconds: float) -> str:
@@ -76,8 +147,13 @@ def format_duration(seconds: float) -> str:
class Storage(ABC):
name: str
def backup(self, backup_dirs: List[str]) -> bool:
"""Backup directories"""
def run(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
"""Run the requested phases against this storage."""
raise NotImplementedError()
@@ -101,69 +177,125 @@ class ResticStorage(Storage):
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
)
def backup(self, backup_dirs: List[str]) -> bool:
if not backup_dirs:
logger.warning("No backup directories found")
return True
def run(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
try:
return self.__backup_internal(backup_dirs)
return self.__run_internal(backup_dirs, phases, maintenance)
except Exception as exc: # noqa: BLE001
logger.error("Restic backup process failed: %s", exc)
return False
logger.error("Restic process failed: %s", exc)
return BackupResult(success=False, error=str(exc))
def __backup_internal(self, backup_dirs: List[str]) -> bool:
logger.info("Starting restic backup for storage '%s'", self.name)
def __build_steps(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> List[tuple[str, List[str]]]:
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
steps: List[tuple[str, List[str]]] = []
for phase in PHASE_ORDER:
if phase not in phases:
continue
if phase == PHASE_BACKUP:
if not backup_dirs:
logger.warning(
"No backup directories found, skipping backup phase for '%s'",
self.name,
)
continue
steps.append(
("backup", ["restic", "backup", "--verbose"] + backup_dirs)
)
elif phase == PHASE_FORGET:
# forget БЕЗ --prune: удаляет только метаданные снапшотов, не репакует
# data-паки и не сбивает охлаждение в Intelligent Tiering.
steps.append(
(
"forget",
[
"restic",
"forget",
"--compact",
"--keep-daily",
KEEP_DAILY,
"--keep-monthly",
KEEP_MONTHLY,
],
)
)
elif phase == PHASE_CHECK:
steps.append(("check", ["restic", "check"]))
elif phase == PHASE_PRUNE:
steps.append(
(
"prune",
[
"restic",
"prune",
"--max-unused",
maintenance.prune_max_unused,
"--max-repack-size",
maintenance.prune_max_repack,
],
)
)
elif phase == PHASE_VERIFY:
steps.append(
(
"verify",
[
"restic",
"check",
f"--read-data-subset={maintenance.verify_subset}",
],
)
)
return steps
def __run_internal(
self,
backup_dirs: List[str],
phases: List[str],
maintenance: MaintenanceOptions,
) -> BackupResult:
logger.info("Starting restic run for storage '%s'", self.name)
logger.info("Destination: %s", self.restic_repository)
logger.info("Phases: %s", ", ".join(phases))
env = os.environ.copy()
env["RESTIC_REPOSITORY"] = self.restic_repository
env["RESTIC_PASSWORD"] = self.restic_password
env.update(self.env)
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
steps = self.__build_steps(backup_dirs, phases, maintenance)
for step, cmd in steps:
error = self.__run_step(step, cmd, env)
if error is not None:
return BackupResult(success=False, error=f"restic {step}: {error}")
return BackupResult(success=True)
def __run_step(
self, step: str, cmd: List[str], env: Dict[str, str]
) -> Optional[str]:
"""Run a single restic command. Return None on success or error text."""
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic backup failed: %s", result.stderr)
return False
error = result.stderr.strip() or result.stdout.strip() or "no output"
logger.error("Restic %s failed: %s", step, error)
return error
logger.info("Restic backup completed successfully")
check_cmd = ["restic", "check"]
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic check failed: %s", result.stderr)
return False
logger.info("Restic check completed successfully")
forget_cmd = [
"restic",
"forget",
"--compact",
"--prune",
"--keep-daily",
"90",
"--keep-monthly",
"36",
]
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Restic forget/prune failed: %s", result.stderr)
return False
logger.info("Restic forget/prune completed successfully")
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
logger.error("Final restic check failed: %s", result.stderr)
return False
logger.info("Final restic check completed successfully")
return True
logger.info("Restic %s completed successfully", step)
return None
class Notifier(ABC):
@@ -300,6 +432,9 @@ class BackupManager:
config: Config,
storages: List[Storage],
notifiers: List[Notifier],
schedule: Schedule,
maintenance: MaintenanceOptions,
forced_phases: Optional[List[str]] = None,
):
self.errors: List[str] = []
self.warnings: List[str] = []
@@ -307,6 +442,10 @@ class BackupManager:
self.config = config
self.storages = storages
self.notifiers = notifiers
self.schedule = schedule
self.maintenance = maintenance
self.forced_phases = forced_phases
self.active_phases: List[str] = []
self.archive_duration: float = 0.0
self.storage_results: List[StorageRunResult] = []
@@ -315,8 +454,17 @@ class BackupManager:
logger.info("Starting backup process")
logger.info(f"Found {len(applications)} application directories")
# Какие фазы выполняем в этот прогон: либо принудительно из CLI, либо по расписанию.
if self.forced_phases is not None:
self.active_phases = self.forced_phases
logger.info("Phases (forced): %s", ", ".join(self.active_phases))
else:
self.active_phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(self.active_phases))
archive_start = time.monotonic()
# Process each user's backup
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
if PHASE_BACKUP in self.active_phases:
for app in applications:
app_dir = str(app.path)
username = app.owner
@@ -331,6 +479,8 @@ class BackupManager:
continue
self._run_app_backup(str(app.backup_script), app_dir, username)
else:
logger.info("Backup phase not active, skipping per-app archive scripts")
self.archive_duration = time.monotonic() - archive_start
logger.info(
"Archive phase finished in %s", format_duration(self.archive_duration)
@@ -352,31 +502,37 @@ class BackupManager:
for storage in self.storages:
storage_start = time.monotonic()
try:
backup_result = storage.backup(backup_dirs)
backup_result = storage.run(
backup_dirs, self.active_phases, self.maintenance
)
except Exception as exc: # noqa: BLE001
logger.error(
"Storage '%s' raised an unexpected error: %s", storage.name, exc
)
backup_result = False
backup_result = BackupResult(success=False, error=str(exc))
storage_duration = time.monotonic() - storage_start
self.storage_results.append(
StorageRunResult(
name=storage.name,
success=backup_result,
success=backup_result.success,
duration=storage_duration,
phases=list(self.active_phases),
)
)
logger.info(
"Storage '%s' finished in %s (success=%s)",
storage.name,
format_duration(storage_duration),
backup_result,
backup_result.success,
)
if not backup_result:
self.errors.append(f"Storage '{storage.name}' backup failed")
if not backup_result.success:
error_msg = f"Storage '{storage.name}' backup failed"
if backup_result.error:
error_msg += f": {backup_result.error}"
self.errors.append(error_msg)
# Determine overall success
overall_success = overall_success and backup_result
overall_success = overall_success and backup_result.success
# Send notification
self._send_notification(overall_success)
@@ -436,6 +592,7 @@ class BackupManager:
"""Send notification to Notifiers"""
host = self.config.host_name
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
if success and not self.errors:
title = f"{host}: бекап успешно завершен"
@@ -459,6 +616,7 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
@@ -474,8 +632,21 @@ class BackupManager:
logger.error(f"Failed to send notification: {str(e)}")
def parse_phases(raw: str) -> List[str]:
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
requested = {p.strip() for p in raw.split(",") if p.strip()}
unknown = requested - set(PHASE_ORDER)
if unknown:
raise ValueError(
f"Unknown phases: {', '.join(sorted(unknown))}. "
f"Allowed: {', '.join(PHASE_ORDER)}"
)
return [p for p in PHASE_ORDER if p in requested]
def initialize(
config_path: Path,
forced_phases: Optional[List[str]] = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
@@ -513,18 +684,65 @@ def initialize(
if not notifiers:
raise ValueError("At least one notification backend must be configured")
schedule_raw = raw_config.get("schedule") or {}
if not isinstance(schedule_raw, dict):
raise ValueError("'schedule' must be a table in config.toml")
schedule = Schedule(
cron={
phase: str(schedule_raw[phase])
for phase in SCHEDULED_PHASES
if phase in schedule_raw
}
)
maintenance_raw = raw_config.get("maintenance") or {}
if not isinstance(maintenance_raw, dict):
raise ValueError("'maintenance' must be a table in config.toml")
defaults = MaintenanceOptions()
maintenance = MaintenanceOptions(
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
prune_max_unused=str(
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
),
prune_max_repack=str(
maintenance_raw.get("prune_max_repack", defaults.prune_max_repack)
),
)
config = Config(host_name=host_name)
app_finder = ApplicationFinder(roots)
backup_manager = BackupManager(
config=config, storages=storages, notifiers=notifiers
config=config,
storages=storages,
notifiers=notifiers,
schedule=schedule,
maintenance=maintenance,
forced_phases=forced_phases,
)
return app_finder, backup_manager
def main() -> None:
parser = argparse.ArgumentParser(description="Run application backups via restic")
parser.add_argument(
"--config",
type=Path,
default=CONFIG_PATH,
help=f"Path to config.toml (default: {CONFIG_PATH})",
)
parser.add_argument(
"--phases",
help=(
"Comma-separated phases to run, overriding the schedule "
f"(allowed: {', '.join(PHASE_ORDER)}). Useful for manual maintenance runs."
),
)
args = parser.parse_args()
try:
app_finder, backup_manager = initialize(CONFIG_PATH)
forced_phases = parse_phases(args.phases) if args.phases else None
app_finder, backup_manager = initialize(args.config, forced_phases)
applications = app_finder.find_applications()
backup_manager.warnings.extend(app_finder.warnings)
success = backup_manager.run_backup_process(applications)
+19
View File
@@ -4,6 +4,25 @@ roots = [
"{{ application_dir }}"
]
# Расписание обслуживающих фаз restic.
# Триггер — один ночной запуск (cron в playbook-backups.yml), поэтому в выражениях
# значимы ТОЛЬКО поля дня месяца / месяца / дня недели. Минуты и часы держим как
# "* *" — они декоративны: фаза всё равно выполняется в момент ночного прогона,
# а не во время, указанное в выражении.
# День недели по cron-конвенции: 0 = воскресенье.
# backup и forget выполняются КАЖДЫЙ прогон и в расписании не участвуют.
[schedule]
check = "* * * * 0" # структурный restic check — по воскресеньям
verify = "* * 5 * *" # check --read-data-subset — 5-го числа
prune = "* * 1 1,4,7,10 *" # restic prune — 1-го числа янв/апр/июл/окт
# Параметры обслуживания. Подобраны под Yandex Object Storage Intelligent Tiering:
# редкий prune с высоким --max-unused минимизирует репак и не сбивает охлаждение объектов.
[maintenance]
verify_subset = "1/12" # порция данных за один verify -> полное покрытие за год
prune_max_unused = "20%" # выше дефолтных 5% -> меньше репака -> дольше держится охлаждение
prune_max_repack = "5G" # потолок объёма перезаписи за один prune
[storage.yandex_cloud_s3]
type = "restic"
restic_repository = "{{ restic_repository }}"
+1 -2
View File
@@ -1,7 +1,6 @@
services:
caddyproxy:
image: caddy:2.11.2
image: caddy:2.11.3
restart: unless-stopped
container_name: "caddyproxy"
ports:
+1 -2
View File
@@ -1,7 +1,6 @@
services:
dozzle_app:
image: amir20/dozzle:v10.6.0
image: amir20/dozzle:v10.6.2
container_name: dozzle_app
restart: unless-stopped
volumes:
+1 -2
View File
@@ -1,7 +1,6 @@
services:
gitea_app:
image: gitea/gitea:1.26.1
image: gitea/gitea:1.26.4
restart: unless-stopped
container_name: gitea_app
ports:
+1 -2
View File
@@ -1,5 +1,4 @@
services:
goaccess_processor:
build: .
image: local/goaccess-jq:1.10.2
@@ -26,7 +25,7 @@ services:
- "web_proxy_network"
goaccess_app:
image: caddy:2.11.2
image: caddy:2.11.3
container_name: goaccess_app
restart: unless-stopped
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
+1 -2
View File
@@ -1,9 +1,8 @@
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
services:
gramps_app: &gramps_app
image: ghcr.io/gramps-project/grampsweb:26.5.1
image: ghcr.io/gramps-project/grampsweb:26.6.0
container_name: gramps_app
depends_on:
- gramps_redis
+1 -1
View File
@@ -3,7 +3,7 @@
services:
memos_app:
image: neosmemo/memos:0.28.0
image: neosmemo/memos:0.29.1
container_name: memos_app
restart: unless-stopped
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
+1 -1
View File
@@ -3,7 +3,7 @@ services:
# See sample https://github.com/outline/outline/blob/main/.env.sample
outline_app:
image: outlinewiki/outline:1.7.1
image: outlinewiki/outline:1.8.1
container_name: outline_app
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: unless-stopped
+1 -1
View File
@@ -3,7 +3,7 @@
services:
wakapi_app:
image: ghcr.io/muety/wakapi:2.17.3
image: ghcr.io/muety/wakapi:2.17.4
container_name: wakapi_app
restart: unless-stopped
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
+3 -1
View File
@@ -94,6 +94,8 @@
name: "restic backup"
minute: "0"
hour: "1"
job: "{{ backup_all_script }} 2>&1 | logger -t backup"
# flock -n: не запускать новый прогон, если предыдущий (например, затянувшийся
# квартальный prune) ещё идёт — защита от наложения restic-операций.
job: "flock -n /var/lock/backup-all.lock {{ backup_all_script }} 2>&1 | logger -t backup"
cron_file: "ansible_restic_backup"
user: "root"
+2
View File
@@ -15,7 +15,9 @@
- htop
- jq
- make
- python3-croniter
- python3-pip
- python3-requests
- sqlite3
- tree
+2
View File
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
dependencies = [
"ansible>=13.2.0",
"ansible-lint>=25.12.2",
"croniter>=6.0.0",
"invoke>=2.2.1",
"mypy>=1.19.1",
"requests>=2.32.5",
"ruff>=0.15.2",
"types-croniter>=6.0.0",
"types-requests>=2.32.4.20260107",
"yamllint>=1.37.1",
]
Generated
+46
View File
@@ -272,6 +272,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -593,10 +605,12 @@ source = { virtual = "." }
dependencies = [
{ name = "ansible" },
{ name = "ansible-lint" },
{ name = "croniter" },
{ name = "invoke" },
{ name = "mypy" },
{ name = "requests" },
{ name = "ruff" },
{ name = "types-croniter" },
{ name = "types-requests" },
{ name = "yamllint" },
]
@@ -605,10 +619,12 @@ dependencies = [
requires-dist = [
{ name = "ansible", specifier = ">=13.2.0" },
{ name = "ansible-lint", specifier = ">=25.12.2" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "invoke", specifier = ">=2.2.1" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "ruff", specifier = ">=0.15.2" },
{ name = "types-croniter", specifier = ">=6.0.0" },
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
{ name = "yamllint", specifier = ">=1.37.1" },
]
@@ -631,6 +647,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pytokens"
version = "0.3.0"
@@ -886,6 +914,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "subprocess-tee"
version = "0.4.2"
@@ -895,6 +932,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" },
]
[[package]]
name = "types-croniter"
version = "6.2.2.20260518"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/02/f03a44ded34e6abb125c339647b070f2705a0583782f5638d62ab958cdc2/types_croniter-6.2.2.20260518.tar.gz", hash = "sha256:aceb426b9187bb9255b89d17713d07ac034a2b96b437bfdd5d3a56b46b4eb656", size = 12120, upload-time = "2026-05-18T06:03:11.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/3d/f12c417944d00c42db71de3e334f36a69cafa6767ff3fb705c9e1d101e53/types_croniter-6.2.2.20260518-py3-none-any.whl", hash = "sha256:85018c7ce091428d3643be239ad348e27f9a8fb77ca94335cc39ebeb9403b240", size = 9743, upload-time = "2026-05-18T06:03:10.608Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20260107"