Backups: split restic operations into phases for Intelligent Tiering
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
# Разнесение 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 / штраф за раннее удаление архивного
|
||||||
|
уровня внутри 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`) тем же механизмом, что и
|
||||||
|
остальное, а гибкость реальная — поменять «раз в квартал» на «раз в
|
||||||
|
месяц» = правка одной строки конфига.
|
||||||
|
- **Storage class сейчас: Вариант A** (`-o s3.storage-class=INTELLIGENT_TIERING`
|
||||||
|
в restic) **vs Вариант B** (lifecycle-правило / copy-in-place на бакете)
|
||||||
|
**vs отложить.** A влияет только на новые объекты — уже лежащие бэкапы в
|
||||||
|
STANDARD так и останутся; B переводит существующие данные, но требует
|
||||||
|
завести управление бакетом, которого в проекте нет. **Выбрано отложить**
|
||||||
|
до подтверждения min-retention архивного уровня IT.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Операции 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`):
|
||||||
|
чем меньше холодных паков переписываем, тем дольше держится охлаждение.
|
||||||
|
|
||||||
|
Storage class IT и lifecycle на бакете **намеренно отложены**: пока не
|
||||||
|
подтверждён min-retention архивного уровня IT, transition существующих
|
||||||
|
данных рискован (возможен штраф за раннее удаление при последующем prune).
|
||||||
|
Сама миграция уже лежащих бэкапов делается lifecycle-правилом или
|
||||||
|
copy-in-place с `--storage-class INTELLIGENT_TIERING`, а не сменой
|
||||||
|
дефолтного класса бакета (та сработает только для новых объектов).
|
||||||
|
Retention оставлен прежним (`--keep-daily 90 --keep-monthly 36`) — это
|
||||||
|
решение про охлаждение и частоту операций, а не про глубину истории.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Ночной prune больше не сбрасывает охлаждение — IT реально экономит
|
||||||
|
на архивном уровне.
|
||||||
|
- `+` Нет наложения restic-операций: последовательные фазы + `flock`.
|
||||||
|
- `+` Расписание обслуживания меняется правкой конфига, без релиза кода.
|
||||||
|
- `-` Новая зависимость на сервере: `python3-croniter` (и явно
|
||||||
|
зафиксированный `python3-requests`).
|
||||||
|
- `-` Структурный `check` теперь еженедельный, а не каждую ночь: битый
|
||||||
|
бэкап может остаться незамеченным до недели. Для хобби-сервера приемлемо.
|
||||||
|
- `-` Подвох croniter: при суточном триггере поля минут/часов в
|
||||||
|
выражениях декоративны (держим `* *`) — фаза идёт в момент ночного
|
||||||
|
прогона, а не во время из выражения.
|
||||||
|
- Осталось сделать: подтвердить min-retention / штраф за раннее удаление
|
||||||
|
архивного уровня IT, затем перевести существующие бэкапы со STANDARD на
|
||||||
|
`INTELLIGENT_TIERING` через lifecycle-правило или copy-in-place.
|
||||||
@@ -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-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) | — |
|
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
|
||||||
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
|
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
|
||||||
|
|||||||
+258
-46
@@ -3,8 +3,17 @@
|
|||||||
Backup script for all applications
|
Backup script for all applications
|
||||||
Automatically discovers and runs backup scripts for all users,
|
Automatically discovers and runs backup scripts for all users,
|
||||||
then creates restic backups and sends notifications.
|
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 itertools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -14,11 +23,13 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from croniter import croniter
|
||||||
|
|
||||||
# Default config path
|
# Default config path
|
||||||
CONFIG_PATH = Path("/etc/backup/config.toml")
|
CONFIG_PATH = Path("/etc/backup/config.toml")
|
||||||
@@ -30,6 +41,22 @@ BACKUP_TARGETS_FILE = "backup-targets"
|
|||||||
# Used when backup-targets file not exists
|
# Used when backup-targets file not exists
|
||||||
BACKUP_DEFAULT_DIR = "backups"
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -47,6 +74,42 @@ class Config:
|
|||||||
host_name: str
|
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
|
@dataclass
|
||||||
class Application:
|
class Application:
|
||||||
path: Path
|
path: Path
|
||||||
@@ -66,6 +129,7 @@ class StorageRunResult:
|
|||||||
name: str
|
name: str
|
||||||
success: bool
|
success: bool
|
||||||
duration: float
|
duration: float
|
||||||
|
phases: List[str]
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: float) -> str:
|
def format_duration(seconds: float) -> str:
|
||||||
@@ -83,8 +147,13 @@ def format_duration(seconds: float) -> str:
|
|||||||
class Storage(ABC):
|
class Storage(ABC):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
def run(
|
||||||
"""Backup directories"""
|
self,
|
||||||
|
backup_dirs: List[str],
|
||||||
|
phases: List[str],
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
) -> BackupResult:
|
||||||
|
"""Run the requested phases against this storage."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
@@ -108,44 +177,104 @@ class ResticStorage(Storage):
|
|||||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
def run(
|
||||||
if not backup_dirs:
|
self,
|
||||||
logger.warning("No backup directories found")
|
backup_dirs: List[str],
|
||||||
return BackupResult(success=True)
|
phases: List[str],
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
) -> BackupResult:
|
||||||
try:
|
try:
|
||||||
return self.__backup_internal(backup_dirs)
|
return self.__run_internal(backup_dirs, phases, maintenance)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("Restic backup process failed: %s", exc)
|
logger.error("Restic process failed: %s", exc)
|
||||||
return BackupResult(success=False, error=str(exc))
|
return BackupResult(success=False, error=str(exc))
|
||||||
|
|
||||||
def __backup_internal(self, backup_dirs: List[str]) -> BackupResult:
|
def __build_steps(
|
||||||
logger.info("Starting restic backup for storage '%s'", self.name)
|
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("Destination: %s", self.restic_repository)
|
||||||
|
logger.info("Phases: %s", ", ".join(phases))
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["RESTIC_REPOSITORY"] = self.restic_repository
|
env["RESTIC_REPOSITORY"] = self.restic_repository
|
||||||
env["RESTIC_PASSWORD"] = self.restic_password
|
env["RESTIC_PASSWORD"] = self.restic_password
|
||||||
env.update(self.env)
|
env.update(self.env)
|
||||||
|
|
||||||
check_cmd = ["restic", "check"]
|
steps = self.__build_steps(backup_dirs, phases, maintenance)
|
||||||
steps = [
|
|
||||||
("backup", ["restic", "backup", "--verbose"] + backup_dirs),
|
|
||||||
("check", check_cmd),
|
|
||||||
(
|
|
||||||
"forget/prune",
|
|
||||||
[
|
|
||||||
"restic",
|
|
||||||
"forget",
|
|
||||||
"--compact",
|
|
||||||
"--prune",
|
|
||||||
"--keep-daily",
|
|
||||||
"90",
|
|
||||||
"--keep-monthly",
|
|
||||||
"36",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
("final check", check_cmd),
|
|
||||||
]
|
|
||||||
|
|
||||||
for step, cmd in steps:
|
for step, cmd in steps:
|
||||||
error = self.__run_step(step, cmd, env)
|
error = self.__run_step(step, cmd, env)
|
||||||
@@ -303,6 +432,9 @@ class BackupManager:
|
|||||||
config: Config,
|
config: Config,
|
||||||
storages: List[Storage],
|
storages: List[Storage],
|
||||||
notifiers: List[Notifier],
|
notifiers: List[Notifier],
|
||||||
|
schedule: Schedule,
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
forced_phases: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
self.errors: List[str] = []
|
self.errors: List[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: List[str] = []
|
||||||
@@ -310,6 +442,10 @@ class BackupManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.storages = storages
|
self.storages = storages
|
||||||
self.notifiers = notifiers
|
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.archive_duration: float = 0.0
|
||||||
self.storage_results: List[StorageRunResult] = []
|
self.storage_results: List[StorageRunResult] = []
|
||||||
|
|
||||||
@@ -318,22 +454,33 @@ class BackupManager:
|
|||||||
logger.info("Starting backup process")
|
logger.info("Starting backup process")
|
||||||
logger.info(f"Found {len(applications)} application directories")
|
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()
|
archive_start = time.monotonic()
|
||||||
# Process each user's backup
|
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
|
||||||
for app in applications:
|
if PHASE_BACKUP in self.active_phases:
|
||||||
app_dir = str(app.path)
|
for app in applications:
|
||||||
username = app.owner
|
app_dir = str(app.path)
|
||||||
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
username = app.owner
|
||||||
|
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
||||||
|
|
||||||
if app.backup_script is None:
|
if app.backup_script is None:
|
||||||
warning_msg = (
|
warning_msg = (
|
||||||
f"No backup script found for app: {app_dir} (user {username})"
|
f"No backup script found for app: {app_dir} (user {username})"
|
||||||
)
|
)
|
||||||
logger.warning(warning_msg)
|
logger.warning(warning_msg)
|
||||||
self.warnings.append(warning_msg)
|
self.warnings.append(warning_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._run_app_backup(str(app.backup_script), app_dir, username)
|
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
|
self.archive_duration = time.monotonic() - archive_start
|
||||||
logger.info(
|
logger.info(
|
||||||
"Archive phase finished in %s", format_duration(self.archive_duration)
|
"Archive phase finished in %s", format_duration(self.archive_duration)
|
||||||
@@ -355,7 +502,9 @@ class BackupManager:
|
|||||||
for storage in self.storages:
|
for storage in self.storages:
|
||||||
storage_start = time.monotonic()
|
storage_start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
backup_result = storage.backup(backup_dirs)
|
backup_result = storage.run(
|
||||||
|
backup_dirs, self.active_phases, self.maintenance
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(
|
logger.error(
|
||||||
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
||||||
@@ -367,6 +516,7 @@ class BackupManager:
|
|||||||
name=storage.name,
|
name=storage.name,
|
||||||
success=backup_result.success,
|
success=backup_result.success,
|
||||||
duration=storage_duration,
|
duration=storage_duration,
|
||||||
|
phases=list(self.active_phases),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -442,6 +592,7 @@ class BackupManager:
|
|||||||
"""Send notification to Notifiers"""
|
"""Send notification to Notifiers"""
|
||||||
|
|
||||||
host = self.config.host_name
|
host = self.config.host_name
|
||||||
|
phases_text = ", ".join(self.active_phases) if self.active_phases else "—"
|
||||||
|
|
||||||
if success and not self.errors:
|
if success and not self.errors:
|
||||||
title = f"{host}: бекап успешно завершен"
|
title = f"{host}: бекап успешно завершен"
|
||||||
@@ -465,6 +616,7 @@ class BackupManager:
|
|||||||
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
||||||
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
||||||
|
|
||||||
|
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
|
||||||
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
||||||
if self.storage_results:
|
if self.storage_results:
|
||||||
items = "".join(
|
items = "".join(
|
||||||
@@ -480,8 +632,21 @@ class BackupManager:
|
|||||||
logger.error(f"Failed to send notification: {str(e)}")
|
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(
|
def initialize(
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
|
forced_phases: Optional[List[str]] = None,
|
||||||
) -> tuple[ApplicationFinder, BackupManager]:
|
) -> tuple[ApplicationFinder, BackupManager]:
|
||||||
try:
|
try:
|
||||||
with config_path.open("rb") as config_file:
|
with config_path.open("rb") as config_file:
|
||||||
@@ -519,18 +684,65 @@ def initialize(
|
|||||||
if not notifiers:
|
if not notifiers:
|
||||||
raise ValueError("At least one notification backend must be configured")
|
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)
|
config = Config(host_name=host_name)
|
||||||
app_finder = ApplicationFinder(roots)
|
app_finder = ApplicationFinder(roots)
|
||||||
backup_manager = BackupManager(
|
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
|
return app_finder, backup_manager
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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:
|
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()
|
applications = app_finder.find_applications()
|
||||||
backup_manager.warnings.extend(app_finder.warnings)
|
backup_manager.warnings.extend(app_finder.warnings)
|
||||||
success = backup_manager.run_backup_process(applications)
|
success = backup_manager.run_backup_process(applications)
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ roots = [
|
|||||||
"{{ application_dir }}"
|
"{{ 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]
|
[storage.yandex_cloud_s3]
|
||||||
type = "restic"
|
type = "restic"
|
||||||
restic_repository = "{{ restic_repository }}"
|
restic_repository = "{{ restic_repository }}"
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
name: "restic backup"
|
name: "restic backup"
|
||||||
minute: "0"
|
minute: "0"
|
||||||
hour: "1"
|
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"
|
cron_file: "ansible_restic_backup"
|
||||||
user: "root"
|
user: "root"
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
- htop
|
- htop
|
||||||
- jq
|
- jq
|
||||||
- make
|
- make
|
||||||
|
- python3-croniter
|
||||||
- python3-pip
|
- python3-pip
|
||||||
|
- python3-requests
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- tree
|
- tree
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ansible>=13.2.0",
|
"ansible>=13.2.0",
|
||||||
"ansible-lint>=25.12.2",
|
"ansible-lint>=25.12.2",
|
||||||
|
"croniter>=6.0.0",
|
||||||
"invoke>=2.2.1",
|
"invoke>=2.2.1",
|
||||||
"mypy>=1.19.1",
|
"mypy>=1.19.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"ruff>=0.15.2",
|
"ruff>=0.15.2",
|
||||||
|
"types-croniter>=6.0.0",
|
||||||
"types-requests>=2.32.4.20260107",
|
"types-requests>=2.32.4.20260107",
|
||||||
"yamllint>=1.37.1",
|
"yamllint>=1.37.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.3"
|
version = "46.0.3"
|
||||||
@@ -593,10 +605,12 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ansible" },
|
{ name = "ansible" },
|
||||||
{ name = "ansible-lint" },
|
{ name = "ansible-lint" },
|
||||||
|
{ name = "croniter" },
|
||||||
{ name = "invoke" },
|
{ name = "invoke" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-croniter" },
|
||||||
{ name = "types-requests" },
|
{ name = "types-requests" },
|
||||||
{ name = "yamllint" },
|
{ name = "yamllint" },
|
||||||
]
|
]
|
||||||
@@ -605,10 +619,12 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "ansible", specifier = ">=13.2.0" },
|
{ name = "ansible", specifier = ">=13.2.0" },
|
||||||
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
||||||
|
{ name = "croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "invoke", specifier = ">=2.2.1" },
|
{ name = "invoke", specifier = ">=2.2.1" },
|
||||||
{ name = "mypy", specifier = ">=1.19.1" },
|
{ name = "mypy", specifier = ">=1.19.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "ruff", specifier = ">=0.15.2" },
|
{ name = "ruff", specifier = ">=0.15.2" },
|
||||||
|
{ name = "types-croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
||||||
{ name = "yamllint", specifier = ">=1.37.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "subprocess-tee"
|
name = "subprocess-tee"
|
||||||
version = "0.4.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "types-requests"
|
name = "types-requests"
|
||||||
version = "2.32.4.20260107"
|
version = "2.32.4.20260107"
|
||||||
|
|||||||
Reference in New Issue
Block a user