diff --git a/docs/adr/ADR-2026-06-22-restic-intelligent-tiering-phases.md b/docs/adr/ADR-2026-06-22-restic-intelligent-tiering-phases.md new file mode 100644 index 0000000..821542b --- /dev/null +++ b/docs/adr/ADR-2026-06-22-restic-intelligent-tiering-phases.md @@ -0,0 +1,107 @@ +# Разнесение restic-операций на фазы под Intelligent Tiering + +- Дата: 2026-06-22 + +## Контекст + +Бэкапы restic уже лежат в Yandex Object Storage на стандартном классе +хранения (STANDARD). Yandex выпустил класс «Умное хранилище» (Intelligent +Tiering, IT): объекты автоматически охлаждаются до архивного уровня +(примерно 0,63 ₽/ГБ против 2,38 ₽/ГБ у STANDARD) с сохранением мгновенного +доступа на всех уровнях (анонс: +). Данные 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7d02b07..b4940f9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | — | diff --git a/files/backups/backup-all.py b/files/backups/backup-all.py index 7e70acd..62791da 100644 --- a/files/backups/backup-all.py +++ b/files/backups/backup-all.py @@ -3,8 +3,17 @@ 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 logging import os @@ -14,11 +23,13 @@ import sys import time import tomllib from abc import ABC -from dataclasses import dataclass +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") @@ -30,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, @@ -47,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 @@ -66,6 +129,7 @@ class StorageRunResult: name: str success: bool duration: float + phases: List[str] def format_duration(seconds: float) -> str: @@ -83,8 +147,13 @@ def format_duration(seconds: float) -> str: class Storage(ABC): name: str - def backup(self, backup_dirs: List[str]) -> BackupResult: - """Backup directories""" + def run( + self, + backup_dirs: List[str], + phases: List[str], + maintenance: MaintenanceOptions, + ) -> BackupResult: + """Run the requested phases against this storage.""" raise NotImplementedError() @@ -108,44 +177,104 @@ class ResticStorage(Storage): f"Missing storage configuration values for backend ResticStorage: '{self.name}'" ) - def backup(self, backup_dirs: List[str]) -> BackupResult: - if not backup_dirs: - logger.warning("No backup directories found") - return BackupResult(success=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) + logger.error("Restic process failed: %s", exc) return BackupResult(success=False, error=str(exc)) - def __backup_internal(self, backup_dirs: List[str]) -> BackupResult: - 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) - check_cmd = ["restic", "check"] - 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), - ] + steps = self.__build_steps(backup_dirs, phases, maintenance) for step, cmd in steps: error = self.__run_step(step, cmd, env) @@ -303,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] = [] @@ -310,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] = [] @@ -318,22 +454,33 @@ 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 - for app in applications: - app_dir = str(app.path) - username = app.owner - logger.info(f"Processing backup for app: {app_dir} (user {username})") + # 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 + logger.info(f"Processing backup for app: {app_dir} (user {username})") - if app.backup_script is None: - warning_msg = ( - f"No backup script found for app: {app_dir} (user {username})" - ) - logger.warning(warning_msg) - self.warnings.append(warning_msg) - continue + if app.backup_script is None: + warning_msg = ( + f"No backup script found for app: {app_dir} (user {username})" + ) + logger.warning(warning_msg) + self.warnings.append(warning_msg) + 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 logger.info( "Archive phase finished in %s", format_duration(self.archive_duration) @@ -355,7 +502,9 @@ 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 @@ -367,6 +516,7 @@ class BackupManager: name=storage.name, success=backup_result.success, duration=storage_duration, + phases=list(self.active_phases), ) ) logger.info( @@ -442,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}: бекап успешно завершен" @@ -465,6 +616,7 @@ class BackupManager: items = "".join(f"
  • {e}
  • " for e in self.errors) message += f"

    ❌ Ошибки:

    " + message += f"

    🔧 Фазы restic: {phases_text}

    " message += f"

    ⏱ Время архивации: {format_duration(self.archive_duration)}

    " if self.storage_results: items = "".join( @@ -480,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: @@ -519,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) diff --git a/files/backups/config.template.toml b/files/backups/config.template.toml index 474a38c..8b8f302 100644 --- a/files/backups/config.template.toml +++ b/files/backups/config.template.toml @@ -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 }}" diff --git a/playbook-backups.yml b/playbook-backups.yml index 651008e..a5ddd6c 100644 --- a/playbook-backups.yml +++ b/playbook-backups.yml @@ -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" diff --git a/playbook-system.yml b/playbook-system.yml index a1d899c..a898afd 100644 --- a/playbook-system.yml +++ b/playbook-system.yml @@ -15,7 +15,9 @@ - htop - jq - make + - python3-croniter - python3-pip + - python3-requests - sqlite3 - tree diff --git a/pyproject.toml b/pyproject.toml index 1e383c8..68dc4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/uv.lock b/uv.lock index 53c823b..7a9d14c 100644 --- a/uv.lock +++ b/uv.lock @@ -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"