Compare commits

..

3 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
9 changed files with 457 additions and 48 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) | — |
+258 -46
View File
@@ -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"<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(
@@ -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)
+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 -1
View File
@@ -1,6 +1,6 @@
services:
gitea_app:
image: gitea/gitea:1.26.2
image: gitea/gitea:1.26.4
restart: unless-stopped
container_name: gitea_app
ports:
+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"