From c2a45dcc301c12b56f0f5b27b088e741d44df557 Mon Sep 17 00:00:00 2001 From: CruxExperts Date: Fri, 23 Oct 2026 13:13:23 -0500 Subject: [PATCH 1/3] fix(maintenance): review stale locks and preserve retention safety Implement reviewed repository lock maintenance, optional local retention counts, strict Restic keep-all parsing, B2 lifecycle-read compatibility and consistent nested cache handling. Update operator documentation and retain qualification limits. Refs: #30 Release-Type: none --- CHANGELOG.md | 22 ++ QUICKSTART.md | 54 ++++ README.md | 7 + bbackup/b2.py | 2 +- bbackup/cli_metadata.py | 5 + bbackup/data/production-schemas.json | 41 +++ bbackup/models.py | 14 +- bbackup/production_operations.py | 8 + bbackup/service.py | 412 +++++++++++++++++++++++++-- docs/AGENT_INTEGRATION.md | 8 + docs/README.md | 6 + docs/architecture.md | 19 +- docs/cloud-storage.md | 6 + docs/development/version-2.md | 38 +++ docs/production-cli.md | 17 +- scripts/generate_cli_skills.py | 2 +- tests/test_b2.py | 51 ++++ tests/test_models.py | 13 + tests/test_production_cli.py | 30 ++ tests/test_repository_locks.py | 345 ++++++++++++++++++++++ tests/test_service.py | 42 ++- 21 files changed, 1106 insertions(+), 36 deletions(-) create mode 100644 tests/test_repository_locks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9007d..ea7647f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ current uv-based install, development, and release instructions. ## [Unreleased] +### Added + +- Reviewed repository lock inspection and stale-lock removal through the native + production dispatcher. Unlock requires an exact review token, explicit + approval, a confirmed quiet window, and only own-host, same-user locks whose + processes are absent. Changed or uncertain lock inventories block removal. +- Optional `local_last` and `local_weekly` retention counts, disabled by default, + alongside daily retention. Counts are part of the reviewed retention policy. + +### Fixed + +- Accept Restic's explicit `remove: null` keep-all selection only when its keep + group contains valid full snapshot IDs; malformed selections remain errors. +- Recognize B2's read-only `readBucketLifecycleRules` capability without allowing + administrative or unknown permissions or qualifying a permanent-delete key as + a restricted source writer. +- Preserve the resolved Restic cache policy in nested repository queries, + including execution without `HOME` or `XDG_CACHE_HOME`. + +These source changes address issue [#30](https://github.com/CruxExperts/BestBackup/issues/30). +They do not publish a new release or expand cloud or machine-recovery qualification. + ## [2.0.5] - 2026-10-22 ### Fixed diff --git a/QUICKSTART.md b/QUICKSTART.md index c4b25df..ae314c2 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -128,6 +128,60 @@ qualify complete-machine disaster recovery. Persistent timers can catch up after an idle interruption. An interrupted mutation remains uncertain and blocks automatic replay until explicit reconciliation. Retention remains a separate reviewed action; scheduling does not silently prune data. +## Review local retention and stale locks (unreleased) + +The current source includes the following maintenance features for issue +[#30](https://github.com/CruxExperts/BestBackup/issues/30). They are not part of +the published v2.0.5 artifacts. Use an explicitly enrolled policy and bindings; +the examples below use the default configuration paths. + +For a job's `retention` object, optional `local_last` and `local_weekly` counts +default to zero (disabled). For example: + +```json +{"local_last": 12, "local_daily": 14, "local_weekly": 8} +``` + +The optional counts must be nonnegative integers; booleans and null are rejected. +`local_daily` retains its positive-integer requirement. Restic +keeps a snapshot when any enabled rule matches, within the existing host/job +group. Review the resulting exact snapshot selection before applying it: + +```bash +uv run bbackup production retention plan --job daily +uv run bbackup production retention apply --input-json \ + '{"job":"daily","plan_token":"EXACT_PLAN_TOKEN","approved":true}' +``` + +The plan token includes the counts, snapshot inventory and replica evidence. +Changing them requires a new plan. Required replica obligations recorded at +capture still apply after editing a job. This operation forgets selected local +snapshots; it does not prune repository data or apply cloud retention. + +For a suspected interrupted Restic lock, inspect first: + +```bash +uv run bbackup production repositories locks --name local +``` + +Review the returned lock inventory and eligibility. Stop every external +repository writer and keep them stopped throughout inspection and unlock. +Only an exact, bounded set of this host's same-user locks with demonstrably +absent processes can qualify. Then explicitly approve the current token: + +```bash +uv run bbackup production repositories unlock-stale --input-json \ + '{"name":"local","plan_token":"EXACT_REVIEW_TOKEN","approved":true,"quiescent_window_confirmed":true}' +``` + +The service reinspects under its mutation lease, uses ordinary repository-wide +Restic unlock (which cannot target individual lock IDs), and verifies no locks +remain. It cannot prevent an external writer from +starting, so the quiet window is essential. Live, foreign, changed, malformed +or incomplete locks block removal. Never repeat an uncertain unlock; use the +[administrator reconciliation workflow](docs/AGENT_INTEGRATION.md#reconcile-uncertain-mutations). +This is a deliberate maintenance action, not automatic lock cleanup. + ## Existing Docker and YAML users The retained 1.x commands use their own configuration. Legacy Docker restores require unused container/volume names; bind-mounted container restoration is refused. SFTP requires a previously trusted host key. See [legacy management](docs/management.md) and [encryption compatibility](docs/encryption.md). diff --git a/README.md b/README.md index 5c84876..4a86a15 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,13 @@ qualify the experimental B2 workflow. This release adds no new live provider qualification; the existing workstation results remain historical. The qualified core scope is unchanged from v2.0.4. +Current source changes for [issue #30](https://github.com/CruxExperts/BestBackup/issues/30) +add reviewed stale-lock maintenance, optional local last/weekly retention, +strict keep-all parsing and B2 lifecycle-read compatibility. They are +[unreleased](docs/development/version-2.md#unreleased-maintenance-issue-30), +separate from the v2.0.5 downloads. See the +[maintenance quick start](QUICKSTART.md#review-local-retention-and-stale-locks-unreleased). + ## Start with the guided workflow Experimental restricted cloud credentials, central recovery and retention, diff --git a/bbackup/b2.py b/bbackup/b2.py index e04a280..e4bde39 100644 --- a/bbackup/b2.py +++ b/bbackup/b2.py @@ -37,7 +37,7 @@ class B2Error(RuntimeError): "listBuckets", "listAllBucketNames", "listFiles", "readFiles", "writeFiles", "readBuckets", "readBucketEncryption", "readBucketRetentions", "readFileRetentions", "readFileLegalHolds", "readBucketReplications", - "readBucketNotifications", + "readBucketNotifications", "readBucketLifecycleRules", } _INSPECTION_CAPABILITIES = {"listBuckets", "listFiles", "readFiles"} _PROFILE_CAPABILITIES = _INSPECTION_CAPABILITIES | {"writeFiles"} diff --git a/bbackup/cli_metadata.py b/bbackup/cli_metadata.py index 1f5fa21..b716816 100644 --- a/bbackup/cli_metadata.py +++ b/bbackup/cli_metadata.py @@ -1957,6 +1957,8 @@ class ProductionCommand(TypedDict): "repositories list": {"summary": "List declared repositories without host credentials.", "fields": {}}, "repositories init": {"summary": "Initialize an encrypted repository.", "fields": {"name": "string", "source": "optional-string"}}, "repositories check": {"summary": "Read and verify repository data.", "fields": {"name": "string"}}, + "repositories locks": {"summary": "Inspect a bounded Restic lock inventory and issue a review token without changing the repository.", "fields": {"name": "string"}}, + "repositories unlock-stale": {"summary": "Remove only reviewed same-host, same-user Restic locks whose PIDs are demonstrably absent, after explicit approval and a confirmed quiet window.", "fields": {"name": "string", "plan_token": "string", "approved": "boolean", "quiescent_window_confirmed": "boolean"}}, "snapshots list": {"summary": "List successful local snapshot receipts.", "fields": {"limit": "limit", "offset": "offset"}}, "snapshots copy": {"summary": "Copy a successful snapshot into an independent repository.", "fields": {"source": "string", "destination": "string", "snapshot": "string"}}, "retention plan": {"summary": "Review a read-only local retention plan bound to current snapshot and replica evidence.", "fields": {"job": "string"}}, @@ -2051,6 +2053,7 @@ def production_schema(command: str) -> dict: "target": "Choose a new absolute destination that does not overlap protected data.", "executable": "Use the absolute path to the installed bbackup executable.", "approved": "Approve only the exact retention plan already reviewed.", + "plan_token": "Use the complete token returned by the matching read-only review command.", "activate": "Enable and start this job's exact user timer after installing its units.", "operation_id": "Use the complete operation ID from the run ledger.", "outcome": "Record the outcome already verified on the target; this does not replay work.", @@ -2133,6 +2136,8 @@ def production_schema(command: str) -> dict: "jobs run": "Run a backup", "repositories init": "Initialize storage", "repositories check": "Verify repository data", + "repositories locks": "Review repository locks", + "repositories unlock-stale": "Unlock reviewed stale locks", "snapshots copy": "Make an additional copy", "retention plan": "Preview retention", "retention apply": "Apply reviewed retention", diff --git a/bbackup/data/production-schemas.json b/bbackup/data/production-schemas.json index da63daf..40172a1 100644 --- a/bbackup/data/production-schemas.json +++ b/bbackup/data/production-schemas.json @@ -931,6 +931,47 @@ "required": [], "type": "object" }, + "repositories locks": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "repositories unlock-stale": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "approved": { + "default": false, + "type": "boolean" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "plan_token": { + "minLength": 1, + "type": "string" + }, + "quiescent_window_confirmed": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "name", + "plan_token" + ], + "type": "object" + }, "restore": { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, diff --git a/bbackup/models.py b/bbackup/models.py index 3fb345c..6553dbb 100644 --- a/bbackup/models.py +++ b/bbackup/models.py @@ -39,6 +39,12 @@ def _positive(value: Any) -> int: return value +def _nonnegative(value: Any) -> int: + if type(value) is not int or value < 0: + raise ConfigurationError("Expected a nonnegative integer") + return value + + def _strings(value: Any) -> tuple[str, ...]: if not isinstance(value, list) or any( not isinstance(v, str) or not v or "\0" in v for v in value @@ -188,6 +194,8 @@ def parse_policy(cls, raw): @dataclass(frozen=True) class Retention: local_daily: int = 7 + local_last: int = 0 + local_weekly: int = 0 cloud_daily: int = 30 cloud_weekly: int = 8 cloud_monthly: int = 12 @@ -197,7 +205,11 @@ class Retention: def parse(cls, raw): fields = set(cls.__dataclass_fields__) raw = _object(raw, fields, set()) - values = {key: _positive(value) for key, value in raw.items()} + values = { + key: (_nonnegative(value) if key in {"local_last", "local_weekly"} + else _positive(value)) + for key, value in raw.items() + } if values.get("protection_days", 30) < 30: raise ConfigurationError( "Cloud recovery protection must be at least thirty days" diff --git a/bbackup/production_operations.py b/bbackup/production_operations.py index 7af26ed..f029995 100644 --- a/bbackup/production_operations.py +++ b/bbackup/production_operations.py @@ -171,6 +171,14 @@ def _service_operation(service, command, values, config_path, bindings_path, can return service.initialize(values["name"], source=values.get("source"), cancel=cancel) if command == "repositories check": return service.check(values["name"], cancel=cancel) + if command == "repositories locks": + return service.repository_locks(values["name"], cancel=cancel) + if command == "repositories unlock-stale": + return service.repository_unlock_stale( + values["name"], values["plan_token"], values["approved"], + quiescent_window_confirmed=values["quiescent_window_confirmed"], + cancel=cancel, + ) if command == "snapshots list": return service.snapshots(**values) if command == "retention plan": diff --git a/bbackup/service.py b/bbackup/service.py index f6f6fb3..1c29d10 100644 --- a/bbackup/service.py +++ b/bbackup/service.py @@ -4,6 +4,7 @@ from contextlib import ExitStack from collections.abc import Callable from datetime import datetime, timezone +import errno import gzip import hashlib import json @@ -11,10 +12,12 @@ from pathlib import Path import re import shutil +import socket import sqlite3 import stat import sys import tempfile +import time import uuid from urllib.parse import urlsplit @@ -26,7 +29,7 @@ from .snapshot import ResticRunner from .resource_limits import ResourceLimitError, ResourceMonitor from .progress import stdout_progress_handler -from .enrollment import _read_regular +from .enrollment import _read_regular, machine_identity from .operations import ( OperationLedger, OperationPlan, @@ -58,6 +61,14 @@ def __init__(self, result: dict): super().__init__("Remote-primary resource budget was exceeded or cannot be verified") +_MAX_RESTIC_LOCKS = 128 +_MAX_RESTIC_LOCK_LIST_BYTES = 64 * 1024 +_MAX_RESTIC_LOCK_PAYLOAD_BYTES = 16 * 1024 +_RESTIC_LOCK_INSPECTION_SECONDS = 120 +_RESTIC_LOCK_QUERY_SECONDS = 30 +_RESTIC_LOCK_ID = re.compile(r"[0-9a-f]{64}\Z") + + class _RemoteResticRunner(ResticRunner): def base_args(self): args = super().base_args() @@ -425,6 +436,30 @@ def _identity(self, name): value = str(Path(value).resolve()) return value + def _restic_cache_args(self, name, argv, env, *, force_no_cache=False): + """Apply one cache policy to direct and nested Restic commands.""" + args = tuple(argv) + if not args or args[0] != "restic": + return args + has_explicit_cache = any( + argument == "--no-cache" or argument == "--cache-dir" + or argument.startswith("--cache-dir=") + for argument in args[1:] + ) + has_cache_environment = any( + (env or {}).get(variable) + for variable in ("HOME", "XDG_CACHE_HOME", "RESTIC_CACHE_DIR") + ) + binding = self.bindings.repositories[name] + remote_backend = (self.repository_roles[name] == "remote-primary" + or not binding.repository.startswith("/")) + if force_no_cache or remote_backend: + if "--no-cache" not in args: + return (args[0], "--no-cache", *args[1:]) + elif not has_explicit_cache and not has_cache_environment: + return (args[0], "--no-cache", *args[1:]) + return args + def _maintenance_bindings(self, path, adapter): """Resolve the separate Restic pair from one bounded private declaration.""" try: @@ -684,20 +719,15 @@ def _execute( repositories = sorted(set((name, *other)), key=self._identity) remote_participants = [repository for repository in repositories if self.repository_roles[repository] == "remote-primary"] + remote_cache_participants = [ + repository for repository in repositories + if (self.repository_roles[repository] == "remote-primary" + or not self.bindings.repositories[repository].repository.startswith("/")) + ] env = self._operation_environment(remote_participants[0] if remote_participants else name) - if remote_participants and argv and argv[0] == "restic" and "--no-cache" not in argv: - argv = (argv[0], "--no-cache", *argv[1:]) - elif argv and argv[0] == "restic": - has_explicit_cache = any( - argument == "--no-cache" or argument == "--cache-dir" - or argument.startswith("--cache-dir=") - for argument in argv[1:] - ) - has_cache_environment = any( - env.get(variable) for variable in ("HOME", "XDG_CACHE_HOME", "RESTIC_CACHE_DIR") - ) - if not has_explicit_cache and not has_cache_environment: - argv = (argv[0], "--no-cache", *argv[1:]) + argv = self._restic_cache_args( + name, argv, env, force_no_cache=bool(remote_cache_participants) + ) plans = {} for repository in repositories: identity = self._identity(repository) @@ -1368,7 +1398,11 @@ def copy(self, source, destination, snapshot, *, cancel=None, timeout_seconds=36 def finalize_copy(plan, _copy_result): # The source tag is unique to this capture, so --latest bounds the # listing even when unrelated repository history is large. - inspect_args = [*dst.base_args(), "snapshots", "--tag", capture_tag, "--latest", "1"] + inspect_args = self._restic_cache_args( + destination, + [*dst.base_args(), "snapshots", "--tag", capture_tag, "--latest", "1"], + plan.env, + ) inspect_plan = OperationPlan.create( self._identity(destination), "inspect", inspect_args, env=plan.env ) @@ -1424,6 +1458,302 @@ def check(self, repository, *, cancel=None): return {"operation_id": plan.operation_id, "repository": repository, "repository_verified": True, "recovery_demonstrated": False} + @staticmethod + def _restic_pid_state(pid): + try: + os.kill(pid, 0) + except OSError as exc: + if exc.errno == errno.ESRCH: + return "absent" + if exc.errno == errno.EPERM: + return "present" + return "unknown" + return "present" + + @staticmethod + def _parse_restic_lock_payload(raw): + try: + payload = strict_json(raw.decode("utf-8")) + required = {"time", "exclusive", "hostname", "username", "pid"} + allowed = required | {"uid", "gid"} + if (not isinstance(payload, dict) or not required <= set(payload) + or set(payload) - allowed): + raise ValueError() + lock_time = payload["time"] + if (not isinstance(lock_time, str) or not lock_time + or any(character in lock_time for character in "\0\r\n")): + raise ValueError() + parsed_time = datetime.fromisoformat(lock_time.replace("Z", "+00:00")) + if parsed_time.tzinfo is None: + raise ValueError() + if type(payload["exclusive"]) is not bool: + raise ValueError() + for field in ("hostname", "username"): + value = payload[field] + if (not isinstance(value, str) or not value or len(value) > 255 + or any(character in value for character in "\0\r\n")): + raise ValueError() + if (type(payload["pid"]) is not int or not 1 <= payload["pid"] <= 2**31 - 1 + or type(payload.get("uid", 0)) is not int or payload.get("uid", 0) < 0 + or type(payload.get("gid", 0)) is not int or payload.get("gid", 0) < 0): + raise ValueError() + payload.setdefault("uid", 0) + payload.setdefault("gid", 0) + return payload + except (UnicodeError, ValueError, TypeError, RecursionError): + raise ServiceError("Restic lock payload is malformed") from None + + @staticmethod + def _restic_lock_ids(raw): + if len(raw) > _MAX_RESTIC_LOCK_LIST_BYTES: + raise ServiceError("Restic lock inventory exceeds the supported bound") + try: + text = raw.decode("ascii") + except UnicodeDecodeError: + raise ServiceError("Restic lock inventory is invalid") from None + if not text: + return [] + if text.strip() == "[]": + return [] + if text.lstrip().startswith("["): + try: + listed = strict_json(text) + except ValueError: + raise ServiceError("Restic lock inventory is invalid") from None + if not isinstance(listed, list) or any( + not isinstance(lock_id, str) for lock_id in listed): + raise ServiceError("Restic lock inventory is invalid") + ids = listed + else: + ids = text.splitlines() + if (len(ids) > _MAX_RESTIC_LOCKS + or any(not _RESTIC_LOCK_ID.fullmatch(lock_id) for lock_id in ids) + or len(ids) != len(set(ids))): + raise ServiceError("Restic lock inventory is invalid or excessive") + return sorted(ids) + + def _run_lock_query(self, repository, operation_plan, argv, environment, deadline, *, + cancel, resource_monitor, max_output_bytes): + if cancel is not None and cancel.is_set(): + raise ServiceError("Repository lock operation cancelled before dispatch") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ServiceError("Restic lock inspection exceeded its time bound") + if resource_monitor is not None: + resource_monitor.check() + query_plan = OperationPlan( + operation_id=operation_plan.operation_id, + repository_id=operation_plan.repository_id, + label=operation_plan.label, + argv=tuple(self._restic_cache_args( + repository, argv, environment, + )), + env=environment, + timeout_seconds=min(_RESTIC_LOCK_QUERY_SECONDS, remaining), + ) + result = self.runner.run( + query_plan, cancel_event=cancel, + **({"on_tick": resource_monitor.check} if resource_monitor is not None else {}), + ) + if (result.status is not OperationStatus.SUCCEEDED or result.exit_code != 0 + or result.stdout_bytes != len(result.stdout_tail) + or result.stdout_bytes > max_output_bytes): + raise ServiceError("Restic lock inspection was incomplete") + return result.stdout_tail + + def _collect_repository_locks(self, repository, operation_plan, engine, environment, + deadline, *, cancel, resource_monitor): + base = tuple(engine.base_args()) + + def run(args, max_output_bytes): + return self._run_lock_query( + repository, operation_plan, [*base, *args], environment, deadline, + cancel=cancel, resource_monitor=resource_monitor, + max_output_bytes=max_output_bytes, + ) + + lock_ids = self._restic_lock_ids(run(("--no-lock", "list", "locks"), + _MAX_RESTIC_LOCK_LIST_BYTES)) + try: + host_digest, uid = machine_identity() + hostname = socket.gethostname() + except (OSError, ValueError): + raise ServiceError("Local machine identity is unavailable for lock inspection") from None + if (not isinstance(hostname, str) or not hostname or "\0" in hostname + or type(uid) is not int or uid != os.geteuid()): + raise ServiceError("Local machine identity is unavailable for lock inspection") + + locks = [] + for lock_id in lock_ids: + raw = run(("--no-lock", "cat", "lock", lock_id), + _MAX_RESTIC_LOCK_PAYLOAD_BYTES) + payload = self._parse_restic_lock_payload(raw) + own_host = payload["hostname"] == hostname + own_uid = payload["uid"] == uid + pid_state = (self._restic_pid_state(payload["pid"]) + if own_host and own_uid else "not_checked") + eligible = own_host and own_uid and pid_state == "absent" + locks.append({ + "id": lock_id, + "hostname": payload["hostname"], + "uid": payload["uid"], + "pid": payload["pid"], + "time": payload["time"], + "exclusive": payload["exclusive"], + "payload_sha256": hashlib.sha256(raw).hexdigest(), + "pid_state": pid_state, + "unlock_eligible": eligible, + }) + if self._restic_lock_ids(run(("--no-lock", "list", "locks"), + _MAX_RESTIC_LOCK_LIST_BYTES)) != lock_ids: + raise ServiceError("Repository lock inventory changed during inspection") + + approval = { + "schema_version": 1, + "repository_id": repository_key(self._identity(repository)), + "host_identity": host_digest, + "hostname": hostname, + "uid": uid, + "locks": [ + {key: lock[key] for key in + ("id", "payload_sha256", "hostname", "uid", "pid", "pid_state", "unlock_eligible")} + for lock in locks + ], + } + plan_token = hashlib.sha256(json.dumps( + approval, sort_keys=True, separators=(",", ":"), ensure_ascii=False, + ).encode("utf-8")).hexdigest() + return { + "plan_token": plan_token, + "repository": repository, + "host": hostname, + "uid": uid, + "lock_count": len(locks), + "unlock_eligible": bool(locks) and all(lock["unlock_eligible"] for lock in locks), + "locks": locks, + "read_only": True, + } + + def repository_locks(self, repository, *, cancel=None): + """Inspect a bounded Restic lock inventory without changing the repository.""" + engine = self._engine(repository) + environment = self._operation_environment(repository) + argv = self._restic_cache_args( + repository, [*engine.base_args(), "--no-lock", "list", "locks"], environment, + ) + operation_plan = OperationPlan.create( + self._identity(repository), "repository-locks", argv, + env=environment, timeout_seconds=_RESTIC_LOCK_INSPECTION_SECONDS, + ) + deadline = time.monotonic() + _RESTIC_LOCK_INSPECTION_SECONDS + resource_monitor = self._resource_monitor(repository) + try: + with self.ledger.begin(operation_plan) as lease: + finished = False + try: + result = self._collect_repository_locks( + repository, operation_plan, engine, environment, deadline, + cancel=cancel, resource_monitor=resource_monitor, + ) + lease.finish(OperationStatus.SUCCEEDED, 0) + finished = True + return result + except BaseException: + if not finished: + lease.finish(OperationStatus.FAILED, None) + raise + finally: + if resource_monitor is not None: + resource_monitor.close() + + def repository_unlock_stale(self, repository, plan_token, approved, *, + quiescent_window_confirmed, cancel=None): + """Unlock only the exact reviewed set of this user's demonstrably dead locks.""" + if approved is not True or quiescent_window_confirmed is not True: + raise ServiceError("Stale lock removal requires explicit approval and a confirmed quiet window") + if not isinstance(plan_token, str) or not re.fullmatch(r"[0-9a-f]{64}", plan_token): + raise ServiceError("A complete Restic lock review token is required") + engine = self._engine(repository) + environment = self._operation_environment(repository) + unlock_argv = self._restic_cache_args( + repository, [*engine.base_args(), "unlock"], environment, + ) + operation_plan = OperationPlan.create( + self._identity(repository), "repository-unlock-stale", unlock_argv, + env=environment, timeout_seconds=_RESTIC_LOCK_INSPECTION_SECONDS, + destructive=True, + ) + deadline = time.monotonic() + _RESTIC_LOCK_INSPECTION_SECONDS + resource_monitor = self._resource_monitor(repository) + mutation_started = False + finished = False + try: + with self.ledger.begin(operation_plan) as lease: + try: + current = self._collect_repository_locks( + repository, operation_plan, engine, environment, deadline, + cancel=cancel, resource_monitor=resource_monitor, + ) + if current["plan_token"] != plan_token: + raise ServiceError("Restic lock review is stale; inspect the current lock inventory") + if not current["unlock_eligible"]: + raise ServiceError("Every Restic lock must belong to this host and user with an absent PID") + if cancel is not None and cancel.is_set(): + raise ServiceError("Repository lock operation cancelled before unlock dispatch") + if resource_monitor is not None: + resource_monitor.check() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ServiceError("Restic lock inspection exceeded its time bound") + mutation_started = True + result = self.runner.run( + OperationPlan( + operation_id=operation_plan.operation_id, + repository_id=operation_plan.repository_id, + label=operation_plan.label, + argv=operation_plan.argv, + env=operation_plan.env, + timeout_seconds=min(_RESTIC_LOCK_QUERY_SECONDS, remaining), + destructive=True, + ), cancel_event=cancel, + **({"on_tick": resource_monitor.check} + if resource_monitor is not None else {}), + ) + if (result.status is not OperationStatus.SUCCEEDED + or result.exit_code != 0 + or result.stdout_bytes != len(result.stdout_tail) + or result.stdout_bytes > _MAX_RESTIC_LOCK_PAYLOAD_BYTES): + raise ServiceError("Restic unlock outcome is uncertain; inspect locks before retrying") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ServiceError("Restic unlock outcome is uncertain; lock verification timed out") + output = self._run_lock_query( + repository, operation_plan, + [*engine.base_args(), "--no-lock", "list", "locks"], + environment, deadline, cancel=cancel, + resource_monitor=resource_monitor, + max_output_bytes=_MAX_RESTIC_LOCK_LIST_BYTES, + ) + if self._restic_lock_ids(output): + raise ServiceError("Restic unlock outcome is uncertain; locks remain") + lease.finish(OperationStatus.SUCCEEDED, 0) + finished = True + except BaseException: + if not mutation_started and not finished: + lease.finish(OperationStatus.FAILED, None) + raise + finally: + if resource_monitor is not None: + resource_monitor.close() + return { + "operation_id": operation_plan.operation_id, + "repository": repository, + "unlocked_lock_ids": [lock["id"] for lock in current["locks"]], + "unlocked_count": len(current["locks"]), + "plan_token": plan_token, + "replayed": False, + } + def restore(self, repository, snapshot, target: Path, *, cancel=None): snapshot = _snapshot_id(snapshot) if not target.is_absolute() or ".." in target.parts: @@ -1607,12 +1937,16 @@ def docker_reconcile( } def _retention_snapshot_plan(self, job, operation_plan, cancel): - """Inspect snapshots and restic's daily forget selection under one lease.""" + """Inspect snapshots and Restic's local forget selection under one lease.""" engine = self._engine(job.repository) + environment = operation_plan.env tag = f"bbackup-job={job.name}" forget_args = [ - *engine.base_args(), "forget", "--dry-run", + *self._restic_cache_args(job.repository, [*engine.base_args()], environment), + "forget", "--dry-run", + "--keep-last", str(job.retention.local_last), "--keep-daily", str(job.retention.local_daily), + "--keep-weekly", str(job.retention.local_weekly), "--group-by", "host", "--tag", tag, ] @@ -1633,7 +1967,9 @@ def run_read(argv, message): return result.stdout_tail snapshots_json = run_read( - [*engine.base_args(), "snapshots"], + [*self._restic_cache_args( + job.repository, [*engine.base_args(), "snapshots"], environment, + )], "Repository snapshot inventory could not be verified", ) forget_json = run_read(forget_args, "Restic retention selection could not be verified") @@ -1652,7 +1988,11 @@ def _required_copy_present(self, destination, snapshot, source_snapshot, capture operation_id=operation_plan.operation_id, repository_id=repository_key(self._identity(destination)), label=operation_plan.label, - argv=tuple([*engine.base_args(), "snapshots", "--tag", tag, "--", snapshot]), + argv=self._restic_cache_args( + destination, + [*engine.base_args(), "snapshots", "--tag", tag, "--", snapshot], + operation_plan.env, + ), env=operation_plan.env, timeout_seconds=operation_plan.timeout_seconds, ) @@ -1708,10 +2048,10 @@ def _forget_snapshot_ids(raw): raise ServiceError("Restic retention selection is invalid") from None payloads = [] try: - payloads.append(json.loads(text)) + payloads.append(strict_json(text)) except ValueError: try: - payloads.extend(json.loads(line) for line in text.splitlines() if line.strip()) + payloads.extend(strict_json(line) for line in text.splitlines() if line.strip()) except ValueError: raise ServiceError("Restic retention selection is invalid") from None if not payloads: @@ -1729,6 +2069,15 @@ def _forget_snapshot_ids(raw): continue raise ServiceError("Restic retention selection is incomplete") snapshots = group["remove"] + if snapshots is None: + keep = group.get("keep") + if not isinstance(keep, list) or not keep: + raise ServiceError("Restic retention selection is incomplete") + for item in keep: + if not isinstance(item, dict): + raise ServiceError("Restic retention selection is invalid") + _snapshot_id(item.get("id")) + continue if not isinstance(snapshots, list): raise ServiceError("Restic retention selection is invalid") for item in snapshots: @@ -1829,7 +2178,9 @@ def required_replicas(value): targets.append(snapshot_id) policy = { + "local_last": job.retention.local_last, "local_daily": job.retention.local_daily, + "local_weekly": job.retention.local_weekly, "group_by": "host", "job_tag": job_tag, } @@ -1864,11 +2215,13 @@ def retention_plan(self, job_name, *, cancel=None): if not self.bindings.repositories[job.repository].repository.startswith("/"): raise ServiceError("Local retention requires an absolute local repository path") engine = self._engine(job.repository) - env = {key: value for key, value in os.environ.items() - if not key.startswith("RESTIC_")} + env = self._operation_environment(job.repository) + snapshots_args = self._restic_cache_args( + job.repository, [*engine.base_args(), "snapshots"], env, + ) plan = OperationPlan.create( self._identity(job.repository), "retention-plan", - [*engine.base_args(), "snapshots"], env=env, + snapshots_args, env=env, ) with self.ledger.begin(plan) as lease: finished = False @@ -1901,9 +2254,10 @@ def retention_apply(self, job_name, plan_token, approved, *, cancel=None): } engine = self._engine(job.repository) - env = {key: value for key, value in os.environ.items() - if not key.startswith("RESTIC_")} - forget_args = [*engine.base_args(), "forget", "--", *target_ids] + env = self._operation_environment(job.repository) + forget_args = self._restic_cache_args( + job.repository, [*engine.base_args(), "forget", "--", *target_ids], env, + ) plan = OperationPlan.create( self._identity(job.repository), "retention-apply", forget_args, env=env, destructive=True, @@ -1959,7 +2313,9 @@ def _run_retention_query(self, job, operation_plan, cancel): operation_id=operation_plan.operation_id, repository_id=operation_plan.repository_id, label=operation_plan.label, - argv=tuple([*engine.base_args(), "snapshots"]), + argv=self._restic_cache_args( + job.repository, [*engine.base_args(), "snapshots"], operation_plan.env, + ), env=operation_plan.env, timeout_seconds=operation_plan.timeout_seconds, ) diff --git a/docs/AGENT_INTEGRATION.md b/docs/AGENT_INTEGRATION.md index cf0038c..99232ec 100644 --- a/docs/AGENT_INTEGRATION.md +++ b/docs/AGENT_INTEGRATION.md @@ -103,6 +103,14 @@ bbackup production \ Allowed outcomes are `succeeded`, `failed`, and `abandoned`. Review the destination before recording one. Use one shared state directory for all bbackup processes that coordinate a repository; local locking cannot coordinate another host or an external restic writer. +The unreleased `repositories locks` command returns a bounded inspection and an +exact review token. `repositories unlock-stale` requires that token, +`approved: true`, and `quiescent_window_confirmed: true`. It reinspects under the mutation +lease and refuses foreign/live processes, changed inventories and incomplete +reads. An uncertain unlock uses the same reconciliation barrier above; agents +must never retry it automatically. Keep external writers stopped throughout +the operation. See the [maintenance quick start](../QUICKSTART.md#review-local-retention-and-stale-locks-unreleased). + ## Recovery checkpoints The cloud recovery commands require explicit storage identity, full repository and snapshot IDs, trusted signing fingerprints, and independent password custody. A checkpoint additionally requires a full repository check and an administrator-coordinated quiescent window across every writer. Lifecycle inspection reports configuration; it does not establish provider protection. See [cloud storage](cloud-storage.md) and [the recovery limits](development/version-2.md#independent-recovery-artifacts). diff --git a/docs/README.md b/docs/README.md index 55f9391..a66ad5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,12 @@ These guides describe v2.0.5. The [qualification record](development/version-2.m The [release dependency evidence](development/release-dependencies.json) records the reviewed versions, source timestamps, lock hash, and audit limits. +The current source also includes [unreleased maintenance changes](development/version-2.md#unreleased-maintenance-issue-30) +for reviewed stale locks, local retention counts, Restic keep-all parsing, and +B2 lifecycle-read compatibility. They are separate from published v2.0.5. +See the [maintenance quick start](../QUICKSTART.md#review-local-retention-and-stale-locks-unreleased) +before using the new commands. + ## Choose a starting point | If you need to... | Start here | diff --git a/docs/architecture.md b/docs/architecture.md index e5aa991..20763be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This page describes the selected stable v2.0.3 tested scope and the retained 1.x implementation. The v2 command is `bbackup production`; legacy 1.x Docker/YAML commands remain available. +This page describes the stable v2.0.5 tested scope, current source changes, and the retained 1.x implementation. The v2 command is `bbackup production`; legacy 1.x Docker/YAML commands remain available. ## Version 2 architecture @@ -16,15 +16,28 @@ Restic encrypts repository content using the password file bound to that reposit CLI and dashboard requests pass through a shared service, bounded subprocess runner, SQLite operation ledger, and cross-process repository lock. The ledger stores sanitized lifecycle events, not command lines, environment values, raw output, or secrets. Interrupted mutations stay uncertain and require administrator reconciliation; bbackup never automatically replays them. +Unreleased repository maintenance uses that same dispatcher and ledger. Lock +inspection bounds the encrypted inventory and binds review to repository, +host/user identity, payloads and process observations. Stale unlock requires +explicit review and a quiet window across every repository user, repeats the +inspection under the mutation lease, uses ordinary Restic unlock, and verifies +the lock set is empty. Incomplete outcomes retain uncertainty. Nested queries +reuse the operation's resolved cache policy, including cache-free execution. + +Local retention combines daily rules with optional last/weekly counts using +Restic's union semantics. The approval token binds the policy and exact snapshot +and replica evidence; applying it replans before forgetting exact snapshot IDs. +It does not physically prune repository data or replace cloud retention. + File restore requires a new directory and asks restic to verify restored data. PostgreSQL restore creates a new database from a trusted custom archive and snapshot-time locale metadata. MySQL and MariaDB restores are implemented with their separate empty-recovery-server safeguards. File or database verification does not prove application startup or application-level correctness. Cloud recovery kits inventory exact object versions, bind the provider endpoint and complete repository/snapshot identities, and use detached OpenPGP signing plus encryption. Checkpoint creation requires a full restic check and an administrator-coordinated quiet window across every writer. Lifecycle inspection is read-only and does not establish thirty-day cloud protection; off-host custody remains unqualified. ### Interfaces and scope -The `bbackup production` CLI is strict and schema-discoverable; the Textual dashboard provides mouse and keyboard job operation. Headless dashboard happy paths are tested. The stable v2.0.3 release scope is local/file capture and restore, the listed live database drills, dashboard happy paths, and AMD64 Ubuntu 24.04/26.04 packages. User-timer reboot evidence is limited to the recorded guest drills. B2/AWS, native Docker, ARM64 backup/restore, root system units and off-host recovery remain unqualified or deferred. See [version 2 development](development/version-2.md) for release boundaries, [agent integration](AGENT_INTEGRATION.md) for the JSON contract, and [cloud storage](cloud-storage.md) for B2/S3 limits. +The `bbackup production` CLI is strict and schema-discoverable; the Textual dashboard provides mouse and keyboard job operation. Headless dashboard happy paths are tested. The stable v2.0.5 release scope is local/file capture and restore, the listed live database drills, dashboard happy paths, and AMD64 Ubuntu 24.04/26.04 packages. User-timer reboot evidence is limited to the recorded guest drills. B2/AWS, native Docker, ARM64 backup/restore, root system units and off-host recovery remain unqualified or deferred. See [version 2 development](development/version-2.md) for release boundaries, [agent integration](AGENT_INTEGRATION.md) for the JSON contract, and [cloud storage](cloud-storage.md) for B2/S3 limits. -Unreleased work adds restricted B2 source-key assessment, independent central +The v2.0.4 and v2.0.5 experimental implementations include restricted B2 source-key assessment, independent central acceptance of signed historical checkpoints, centrally authorized noncurrent version reclamation, remote-primary capture with bounded staging, and reviewed Linux file and transactional SQLite coverage. These paths use the same operation boundary and existing diff --git a/docs/cloud-storage.md b/docs/cloud-storage.md index 3ee3fe5..993ed4f 100644 --- a/docs/cloud-storage.md +++ b/docs/cloud-storage.md @@ -74,6 +74,12 @@ identity; the source key does not need it. Do not add `writeBucketLifecycleRules` for a read-only inspection. See [Backblaze's S3-compatible app-key capabilities](https://www.backblaze.com/docs/cloud-storage-s3-compatible-app-keys). +The unreleased source recognizes `readBucketLifecycleRules` as a safe read-only +capability during native inspection. This compatibility change does not grant +the permission, bypass bucket/prefix checks, or establish provider protection. +A checkpoint or maintenance key with `deleteFiles` still fails the restricted +source-writer profile; keep that authority separate from source credentials. + ### Restricted source profile (experimental) The `restricted_source_profile` result is a versioned assessment of the SDK's diff --git a/docs/development/version-2.md b/docs/development/version-2.md index 6a6a720..e8acba4 100644 --- a/docs/development/version-2.md +++ b/docs/development/version-2.md @@ -4,6 +4,44 @@ > below. Experimental additions remain unqualified. The dated records retain > historical evidence and limits without expanding that scope. +## Unreleased maintenance (issue #30) + +The source checkout implements the reviewed portions of +[#30](https://github.com/CruxExperts/BestBackup/issues/30): bounded repository +lock inspection and explicitly approved stale unlock, optional `local_last` +and `local_weekly` counts, strict parsing of valid Restic keep-all output, +read-only B2 lifecycle capability compatibility, and consistent cache policy +for nested Restic commands in minimal environments. + +These changes use the existing enrolled production dispatcher, leases and +reconciliation barriers. Unlock is limited to the reviewed own-host, same-user +dead-process lock set and requires an externally coordinated quiet window. +Local retention preserves recorded required-replica obligations and replans +before applying exact snapshot IDs. See the +[maintenance quick start](../../QUICKSTART.md#review-local-retention-and-stale-locks-unreleased) +for configuration and commands. + +On 2026-10-23, the local Python 3.12 suite passed with 1,287 tests and one +skipped test, including integration tests with Restic 0.19.1 and an available +Docker daemon. The disposable lock drill preserved an existing snapshot, +planned keep-all retention with 12 last/14 daily/8 weekly counts, refused a live +lock, and removed its reviewed stale lock after the owned process was killed. +Ruff, compilation, version and generated-command checks, Markdown, artwork +provenance, publishing readiness and whitespace checks passed. The wheel and +source archive built and passed installed-artifact smoke checks. Independent +review found no remaining blocking correctness findings. These are development +results, not new published-artifact or production qualification. + +The existing legacy Docker volume restore roundtrip skipped after its backup +step returned failure. A focused rerun confirmed that skip; this validation +does not qualify that restore path. + +This is unreleased source work, not acceptance of private candidate builds or +permission to modify deployed installations. The package version remains +2.0.5 until a separate release. No B2/AWS protection, off-host custody, +thirty-day recovery window or complete-machine recovery qualification is added; +the canceled scopes of #23-#26 remain canceled and unqualified. + ## v2.0.5 release scope and known limits The v2.0.5 stable qualified scope is unchanged from v2.0.4: local file capture diff --git a/docs/production-cli.md b/docs/production-cli.md index a341bbc..1a64c32 100644 --- a/docs/production-cli.md +++ b/docs/production-cli.md @@ -2,7 +2,7 @@ > Generated by `scripts/generate_cli_skills.py` from `bbackup/cli_metadata.py`. -The command contract is stable in v2.0.5. Production qualification remains limited to the tested scope in [version 2 release evidence](development/version-2.md). +The package version is v2.0.5. This reference follows the current source checkout, including any unreleased commands. Production qualification and release availability remain limited to the tested scope in [version 2 release evidence](development/version-2.md). Prefix commands with `bbackup production --config CONFIG --bindings BINDINGS`. @@ -74,6 +74,21 @@ Read and verify repository data. - `--name`: string. +## `repositories locks` + +Inspect a bounded Restic lock inventory and issue a review token without changing the repository. + +- `--name`: string. + +## `repositories unlock-stale` + +Remove only reviewed same-host, same-user Restic locks whose PIDs are demonstrably absent, after explicit approval and a confirmed quiet window. + +- `--name`: string. +- `--plan-token`: string. +- `--approved`: boolean. +- `--quiescent-window-confirmed`: boolean. + ## `snapshots list` List successful local snapshot receipts. diff --git a/scripts/generate_cli_skills.py b/scripts/generate_cli_skills.py index 7a62938..0749cab 100644 --- a/scripts/generate_cli_skills.py +++ b/scripts/generate_cli_skills.py @@ -208,7 +208,7 @@ def production_artifacts() -> dict[Path, str]: "", "> Generated by `scripts/generate_cli_skills.py` from `bbackup/cli_metadata.py`.", "", - f"The command contract is stable in v{BBACKUP_VERSION}. Production qualification remains limited to the tested scope in [version 2 release evidence](development/version-2.md).", + f"The package version is v{BBACKUP_VERSION}. This reference follows the current source checkout, including any unreleased commands. Production qualification and release availability remain limited to the tested scope in [version 2 release evidence](development/version-2.md).", "", "Prefix commands with `bbackup production --config CONFIG --bindings BINDINGS`.", "", diff --git a/tests/test_b2.py b/tests/test_b2.py index 563aefe..098ef5c 100644 --- a/tests/test_b2.py +++ b/tests/test_b2.py @@ -225,6 +225,57 @@ def test_administrative_and_unrecognized_capabilities_disqualify_observed_profil assert profile["negative_test_proof"]["administrative_denial_demonstrated"] is False +def test_lifecycle_read_capability_is_compatible_with_scoped_source_metadata(): + capabilities = ["listBuckets", "listFiles", "readFiles", "writeFiles", + "readBucketLifecycleRules"] + result = inspect_sdk("backup-bucket", prefix="restic/", api=api(capabilities)) + assert result["unsafe_capabilities"] == [] + assert result["credential_administration_denied"] is True + assert result["restricted_source_profile"]["status"] == "eligible_from_observed_metadata" + assert result["protection_qualified"] is False + + +def test_lifecycle_read_does_not_qualify_a_permanent_delete_checkpoint_key(): + result = inspect_sdk("backup-bucket", prefix="restic/", api=api([ + "listBuckets", "readBuckets", "listFiles", "readFiles", "writeFiles", + "deleteFiles", "readBucketLifecycleRules", + ])) + assert result["unsafe_capabilities"] == ["deleteFiles"] + profile = result["restricted_source_profile"] + assert profile["status"] == "unqualified" + assert profile["reasons"] == ["permanent_deletion_capability_present"] + assert result["protection_qualified"] is False + + +@pytest.mark.parametrize("capability", [ + "writeBucketLifecycleRules", "writeBuckets", "futureWritePermission", +]) +def test_lifecycle_read_does_not_allow_administrative_or_unknown_capabilities(capability): + result = inspect_sdk("backup-bucket", prefix="restic/", api=api([ + "listBuckets", "listFiles", "readFiles", "writeFiles", + "readBucketLifecycleRules", capability, + ])) + assert result["unsafe_capabilities"] + assert result["credential_administration_denied"] is False + assert result["restricted_source_profile"]["status"] == "unqualified" + + +def test_native_lock_reader_accepts_lifecycle_read_with_exact_scoped_authority(): + from datetime import datetime, timezone + + reader = B2VersionLockReader( + "backup-bucket", "bucket-id-1", "restic/", "https://s3.us-west-004.backblazeb2.com", + api=_native_lock_api(allowed={"capabilities": [ + "listBuckets", "readFiles", "readFileRetentions", "readFileLegalHolds", + "readBucketLifecycleRules", + ]}), access_key_id="key", secret_access_key="secret", + ) + evidence, protected = reader.read("restic/data", "file-v1", now=datetime.now(timezone.utc)) + assert evidence["provider"] == "b2-native" + assert evidence["file_id"] == "file-v1" + assert protected is False + + @pytest.mark.parametrize( "auth", [ diff --git a/tests/test_models.py b/tests/test_models.py index 928fb92..15c0e96 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -27,11 +27,24 @@ def config(): def test_defaults(config): parsed = Configuration.parse(config) assert parsed.jobs[0].retention.local_daily == 7 + assert parsed.jobs[0].retention.local_last == 0 + assert parsed.jobs[0].retention.local_weekly == 0 assert parsed.jobs[0].retention.cloud_monthly == 12 assert parsed.jobs[0].overdue_hours == 26 assert parsed.jobs[0].schedule == "daily" +@pytest.mark.parametrize("field", ["local_last", "local_weekly"]) +def test_local_retention_last_and_weekly_are_nonnegative_integers(config, field): + config["jobs"][0]["retention"] = {field: 3} + parsed = Configuration.parse(config) + assert getattr(parsed.jobs[0].retention, field) == 3 + for invalid in (-1, True, 1.5, "2"): + config["jobs"][0]["retention"] = {field: invalid} + with pytest.raises(ConfigurationError): + Configuration.parse(config) + + def test_coverage_opt_in_requires_portable_scope_and_exact_private_job_mapping(config, tmp_path): from bbackup.models import HostBindings scope = {"schema_version": 1, "approved_roots": ["data"], diff --git a/tests/test_production_cli.py b/tests/test_production_cli.py index 97f67ea..e5f68ed 100644 --- a/tests/test_production_cli.py +++ b/tests/test_production_cli.py @@ -70,6 +70,36 @@ def test_schema_and_sanitized_listing(service): assert help_data["ui"]["activate"]["help"].startswith("Enable and start") +def test_repository_lock_commands_use_reviewed_inputs(service, monkeypatch): + from bbackup.service import BackupService + + calls = [] + monkeypatch.setattr(BackupService, "repository_locks", lambda self, name, *, cancel=None: + calls.append(("locks", name)) or {"read_only": True}) + monkeypatch.setattr( + BackupService, "repository_unlock_stale", + lambda self, name, token, approved, *, quiescent_window_confirmed, cancel=None: + calls.append(("unlock", name, token, approved, quiescent_window_confirmed)) + or {"replayed": False}, + ) + + reviewed = invoke(service, ["repositories", "locks", "--name", "local"]) + assert reviewed.exit_code == 0, reviewed.output + unlocked = invoke(service, [ + "repositories", "unlock-stale", "--name", "local", + "--plan-token", "a" * 64, "--approved", "--quiescent-window-confirmed", + ]) + assert unlocked.exit_code == 0, unlocked.output + assert calls == [ + ("locks", "local"), + ("unlock", "local", "a" * 64, True, True), + ] + schema = json.loads(invoke(service, ["repositories", "unlock-stale", "--schema"]).output)["data"] + assert {"name", "plan_token"} <= set(schema["required"]) + assert schema["properties"]["approved"]["default"] is False + assert schema["properties"]["quiescent_window_confirmed"]["default"] is False + + def test_manual_postgresql_window_flag_is_explicit_and_defaults_false(service, monkeypatch): from bbackup.service import BackupService observed = [] diff --git a/tests/test_repository_locks.py b/tests/test_repository_locks.py new file mode 100644 index 0000000..89f27a6 --- /dev/null +++ b/tests/test_repository_locks.py @@ -0,0 +1,345 @@ +"""Focused Restic repository lock inspection and stale-unlock regressions.""" + +from dataclasses import replace +from datetime import datetime, timezone +import json +import os +import shutil +import subprocess +import time + +import pytest + +from bbackup.operations import OperationStatus, UncertainOperationError +from bbackup.service import BackupService, ServiceError +from tests import test_service as service_fixtures + +service = service_fixtures.service + + +HOST = "fixture-host" +HOST_DIGEST = "b" * 64 +LOCK_ID = "a" * 64 + + +def _lock_payload(*, hostname=HOST, uid=None, pid=2**31 - 1): + return json.dumps({ + "time": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "exclusive": False, + "hostname": hostname, + "username": "fixture-user", + "pid": pid, + "uid": os.geteuid() if uid is None else uid, + "gid": os.getegid(), + }, separators=(",", ":")).encode() + + +def _fake_restic(service, monkeypatch, *, ids=None, payloads=None, list_responses=None, + unlock_status=None, postflight_response=None, + retain_locks_after_unlock=False): + state = { + "ids": [LOCK_ID] if ids is None else list(ids), + "payloads": {LOCK_ID: _lock_payload()} if payloads is None else dict(payloads), + "list_responses": list(list_responses or []), + "unlock_args": [], + "plans": [], + "unlock_status": unlock_status, + "postflight_response": postflight_response, + "unlocked": False, + "retain_locks_after_unlock": retain_locks_after_unlock, + } + + def fake_run(plan, **_kwargs): + state["plans"].append(plan) + argv = list(plan.argv) + if "list" in argv and "locks" in argv: + if state["list_responses"]: + output = state["list_responses"].pop(0) + elif state["unlocked"] and state["postflight_response"] is not None: + output = state["postflight_response"] + else: + output = "\n".join(state["ids"]).encode() + if state["ids"]: + output += b"\n" + return service_fixtures._run(plan, stdout=output) + if "cat" in argv and "lock" in argv: + lock_id = argv[-1] + return service_fixtures._run(plan, stdout=state["payloads"].get(lock_id, b"")) + if argv[-1:] == ["unlock"]: + state["unlock_args"].append(argv) + if state["unlock_status"] is not None: + return service_fixtures._run(plan, status=state["unlock_status"], exit_code=-9) + state["unlocked"] = True + if not state["retain_locks_after_unlock"]: + state["ids"] = [] + state["payloads"] = {} + return service_fixtures._run(plan) + pytest.fail(f"unexpected Restic command: {argv!r}") + + monkeypatch.setattr(service.runner, "run", fake_run) + monkeypatch.setattr("bbackup.service.socket.gethostname", lambda: HOST) + monkeypatch.setattr("bbackup.service.machine_identity", lambda: (HOST_DIGEST, os.geteuid())) + monkeypatch.setattr(service, "_restic_pid_state", lambda _pid: "absent") + return state + + +def test_reviewed_unlock_reinspects_under_lease_and_uses_normal_restic_unlock( + service, monkeypatch): + state = _fake_restic(service, monkeypatch) + review = service.repository_locks("local") + assert review["unlock_eligible"] is True + assert review["locks"][0]["id"] == LOCK_ID + assert review["locks"][0]["pid_state"] == "absent" + assert state["unlock_args"] == [] + + result = service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert result["unlocked_lock_ids"] == [LOCK_ID] + assert result["replayed"] is False + assert state["unlock_args"] + assert "--remove-all" not in state["unlock_args"][0] + assert service.ledger.events(result["operation_id"])[-1].kind.value == "succeeded" + + +@pytest.mark.parametrize("reason", ["foreign_host", "foreign_uid", "live_pid"]) +def test_foreign_or_live_lock_never_reaches_restic_unlock(service, monkeypatch, reason): + payload = _lock_payload( + hostname="another-host" if reason == "foreign_host" else HOST, + uid=os.geteuid() + 1 if reason == "foreign_uid" else os.geteuid(), + ) + state = _fake_restic(service, monkeypatch, payloads={LOCK_ID: payload}) + if reason == "live_pid": + monkeypatch.setattr(service, "_restic_pid_state", lambda _pid: "present") + review = service.repository_locks("local") + assert review["unlock_eligible"] is False + with pytest.raises(ServiceError): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] == [] + + +def test_mixed_local_and_foreign_inventory_never_unlocks_any_lock(service, monkeypatch): + second_id = "c" * 64 + state = _fake_restic( + service, monkeypatch, + ids=[LOCK_ID, second_id], + payloads={LOCK_ID: _lock_payload(), second_id: _lock_payload(hostname="another-host")}, + ) + review = service.repository_locks("local") + assert review["unlock_eligible"] is False + with pytest.raises(ServiceError): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] == [] + + +def test_changed_payload_invalidates_lock_review(service, monkeypatch): + state = _fake_restic(service, monkeypatch) + review = service.repository_locks("local") + state["payloads"][LOCK_ID] = _lock_payload(pid=2**31 - 2) + with pytest.raises(ServiceError, match="review is stale"): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] == [] + + +def test_changed_pid_liveness_invalidates_lock_review(service, monkeypatch): + state = _fake_restic(service, monkeypatch) + review = service.repository_locks("local") + monkeypatch.setattr(service, "_restic_pid_state", lambda _pid: "present") + with pytest.raises(ServiceError, match="review is stale"): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] == [] + + +@pytest.mark.parametrize("bad_inventory", [ + [b"not-a-lock-id\n", b"not-a-lock-id\n"], + [b"a" * (64 * 1024 + 1), b"a" * (64 * 1024 + 1)], + [b"a" * 64 + b"\n", b""], +]) +def test_malformed_truncated_or_changed_lock_inventory_never_unlocks( + service, monkeypatch, bad_inventory): + state = _fake_restic(service, monkeypatch, list_responses=bad_inventory) + with pytest.raises(ServiceError): + service.repository_locks("local") + assert state["unlock_args"] == [] + + +def test_excessive_lock_inventory_fails_closed(service, monkeypatch): + ids = [f"{index:064x}" for index in range(129)] + state = _fake_restic(service, monkeypatch, ids=ids, payloads={}) + with pytest.raises(ServiceError, match="excessive"): + service.repository_locks("local") + assert state["unlock_args"] == [] + + +def test_malformed_lock_payload_fails_closed(service, monkeypatch): + state = _fake_restic(service, monkeypatch, payloads={LOCK_ID: b'{"pid":1}'}) + with pytest.raises(ServiceError, match="payload is malformed"): + service.repository_locks("local") + assert state["unlock_args"] == [] + + +def test_restic_lock_json_omitted_zero_uid_and_gid_are_normalized(): + payload = json.loads(_lock_payload()) + payload.pop("uid") + payload.pop("gid") + normalized = BackupService._parse_restic_lock_payload( + json.dumps(payload, separators=(",", ":")).encode() + ) + assert normalized["uid"] == 0 + assert normalized["gid"] == 0 + payload["pid"] = 2**31 + with pytest.raises(ServiceError, match="payload is malformed"): + BackupService._parse_restic_lock_payload( + json.dumps(payload, separators=(",", ":")).encode() + ) + + +def test_restic_lock_id_inventory_accepts_empty_json_and_plain_id_lines(): + assert BackupService._restic_lock_ids(b"[]") == [] + assert BackupService._restic_lock_ids(f"{LOCK_ID}\n".encode()) == [LOCK_ID] + assert BackupService._restic_lock_ids(json.dumps([LOCK_ID]).encode()) == [LOCK_ID] + + +def test_unlock_requires_both_explicit_approvals(service, monkeypatch): + state = _fake_restic(service, monkeypatch) + review = service.repository_locks("local") + for approved, quiet in ((False, True), (True, False)): + with pytest.raises(ServiceError, match="explicit approval"): + service.repository_unlock_stale( + "local", review["plan_token"], approved, + quiescent_window_confirmed=quiet, + ) + assert state["unlock_args"] == [] + + +def test_interrupted_unlock_keeps_repository_behind_uncertain_barrier(service, monkeypatch): + state = _fake_restic(service, monkeypatch, unlock_status=OperationStatus.TIMED_OUT) + review = service.repository_locks("local") + with pytest.raises(ServiceError, match="outcome is uncertain"): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] + with pytest.raises(UncertainOperationError): + service.repository_locks("local") + + +@pytest.mark.parametrize("postflight", ["locks_remain", "malformed", "truncated"]) +def test_postflight_unlock_failure_keeps_uncertain_barrier(service, monkeypatch, postflight): + state = _fake_restic( + service, monkeypatch, + postflight_response=(b"not-a-lock-id\n" if postflight == "malformed" + else b"x" * (64 * 1024 + 1) if postflight == "truncated" + else None), + retain_locks_after_unlock=postflight == "locks_remain", + ) + review = service.repository_locks("local") + with pytest.raises(ServiceError): + service.repository_unlock_stale( + "local", review["plan_token"], True, quiescent_window_confirmed=True, + ) + assert state["unlock_args"] + with pytest.raises(UncertainOperationError): + service.repository_locks("local") + + +def test_lock_queries_disable_cache_when_home_and_xdg_are_missing( + service, monkeypatch): + state = _fake_restic(service, monkeypatch, ids=[], payloads={}) + monkeypatch.delenv("HOME", raising=False) + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.delenv("RESTIC_CACHE_DIR", raising=False) + service.repository_locks("local") + assert state["plans"] + assert all("--no-cache" in plan.argv for plan in state["plans"]) + + +def test_b2_lock_queries_disable_cache_even_with_home(service, monkeypatch): + state = _fake_restic(service, monkeypatch, ids=[], payloads={}) + repositories = dict(service.bindings.repositories) + repositories["local"] = replace( + repositories["local"], + repository="s3:https://s3.us-west-004.backblazeb2.com/bucket/repository", + ) + service.bindings = replace(service.bindings, repositories=repositories) + service.repository_locks("local") + assert state["plans"] + assert all("--no-cache" in plan.argv for plan in state["plans"]) + + +@pytest.mark.integration +@pytest.mark.skipif(shutil.which("restic") is None, reason="requires restic") +def test_disposable_local_restic_unlocks_only_a_reviewed_dead_lock(service): + service.initialize("local") + job = service.config.jobs[0] + service.config = replace( + service.config, + jobs=(replace(job, retention=replace( + job.retention, local_last=12, local_daily=14, local_weekly=8, + )),), + ) + service.capture("daily") + retention_review = service.retention_plan("daily") + assert retention_review["total_snapshots"] == 1 + assert retention_review["target_snapshot_ids"] == [] + assert retention_review["policy"] == { + "local_last": 12, "local_daily": 14, "local_weekly": 8, + "group_by": "host", "job_tag": "bbackup-job=daily", + } + engine = service._engine("local") + environment = service._operation_environment("local") + + def snapshot_ids(): + result = subprocess.run( + [*engine.base_args(), "snapshots"], check=True, capture_output=True, + env=environment, timeout=15, + ) + return sorted(item["id"] for item in json.loads(result.stdout)) + + snapshots_before = snapshot_ids() + process = subprocess.Popen( + [*engine.base_args(), "backup", "--stdin", "--stdin-filename", "held-open.bin"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, + ) + try: + deadline = time.monotonic() + 15 + live_review = None + while time.monotonic() < deadline and process.poll() is None: + live_review = service.repository_locks("local") + if live_review["lock_count"]: + break + time.sleep(0.05) + assert process.poll() is None + assert live_review is not None and live_review["lock_count"] > 0 + assert live_review["unlock_eligible"] is False + with pytest.raises(ServiceError): + service.repository_unlock_stale( + "local", live_review["plan_token"], True, + quiescent_window_confirmed=True, + ) + + process.kill() + process.communicate(timeout=10) + stale_review = service.repository_locks("local") + assert stale_review["lock_count"] > 0 + assert stale_review["unlock_eligible"] is True + result = service.repository_unlock_stale( + "local", stale_review["plan_token"], True, + quiescent_window_confirmed=True, + ) + assert result["unlocked_count"] == stale_review["lock_count"] + assert service.repository_locks("local")["lock_count"] == 0 + assert snapshot_ids() == snapshots_before + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=10) diff --git a/tests/test_service.py b/tests/test_service.py index 8a6a3e2..2fcfe14 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1538,6 +1538,9 @@ def test_unknown_repository_identity_is_a_service_error(service): def test_copy_uses_capture_tag_and_latest_to_bound_large_listing(service, monkeypatch): + monkeypatch.delenv("HOME", raising=False) + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.delenv("RESTIC_CACHE_DIR", raising=False) source_snapshot = "a" * 64 destination_snapshot = "b" * 64 capture_token = "c" * 32 @@ -1574,6 +1577,7 @@ def fake_run(plan, **_kwargs): assert result["destination_snapshot_id"] == destination_snapshot assert len(plans) == 2 and len(inspected) == 1 + assert all("--no-cache" in plan.argv for plan in plans) with service._receipts() as db: row = db.execute( "SELECT capture_token FROM copies WHERE source_snapshot = ?", @@ -1695,7 +1699,8 @@ def _record_retention_copy(service, snapshot_id, *, destination="copy", capture_ def _mock_retention_restic(service, monkeypatch, snapshots, selected, *, apply_status=None): - state = {"snapshots": list(snapshots), "apply_args": [], "apply_operation_id": None, + state = {"snapshots": list(snapshots), "apply_args": [], "selection_args": [], + "apply_operation_id": None, "replica_snapshots": [{"id": "d" * 64, "original": item["id"], "tags": ["bbackup-capture=capture-token"]} for item in snapshots]} @@ -1706,6 +1711,7 @@ def fake_run(plan, **_kwargs): return _run(plan, stdout=json.dumps(state["replica_snapshots"]).encode()) return _run(plan, stdout=json.dumps(state["snapshots"]).encode()) if "forget" in argv and "--dry-run" in argv: + state["selection_args"].append(argv) payload = [{"remove": [{"id": snapshot_id} for snapshot_id in selected]}] return _run(plan, stdout=json.dumps(payload).encode()) if "forget" in argv: @@ -1833,6 +1839,40 @@ def test_retention_policy_change_invalidates_approval(service, monkeypatch): assert state["apply_args"] == [] +@pytest.mark.parametrize("field", ["local_last", "local_weekly"]) +def test_retention_local_last_and_weekly_are_in_selection_and_review_token( + service, monkeypatch, field): + state = _mock_retention_restic(service, monkeypatch, [], []) + reviewed = service.retention_plan("daily") + selection = state["selection_args"][0] + assert selection[selection.index("--keep-last") + 1] == "0" + assert selection[selection.index("--keep-weekly") + 1] == "0" + changed_job = replace( + service.config.jobs[0], + retention=replace(service.config.jobs[0].retention, **{field: 2}), + ) + service.config = replace(service.config, jobs=(changed_job,)) + changed = service.retention_plan("daily") + assert changed["plan_token"] != reviewed["plan_token"] + changed_selection = state["selection_args"][1] + assert changed_selection[changed_selection.index(f"--keep-{field.removeprefix('local_')}") + 1] == "2" + with pytest.raises(ServiceError, match="approval is stale"): + service.retention_apply("daily", reviewed["plan_token"], True) + + +def test_restic_null_remove_requires_a_nonempty_valid_keep_array(): + valid = json.dumps([{"remove": None, "keep": [{"id": "a" * 64}]}]).encode() + assert BackupService._forget_snapshot_ids(valid) == set() + for payload in ( + [{"remove": None}], + [{"remove": None, "keep": []}], + [{"remove": None, "keep": [{"id": "short"}]}], + [{"remove": None, "keep": ["a" * 64]}], + ): + with pytest.raises(ServiceError): + BackupService._forget_snapshot_ids(json.dumps(payload).encode()) + + def test_lost_replica_invalidates_approval_and_holds_remaining_local_snapshot(service, monkeypatch): snapshot_id = "7" * 64 _record_retention_source(service, snapshot_id) From 52504c3982ffabf352b3070f4d8de1a6cc89dc44 Mon Sep 17 00:00:00 2001 From: CruxExperts Date: Fri, 23 Oct 2026 13:23:28 -0500 Subject: [PATCH 2/3] test(maintenance): wait for stable Restic lock readiness Wait for a stable lock owned by the held test process before checking live-lock refusal. Retry only the expected read-only inventory-change refusal during bounded startup; preserve production safety checks. Refs: #30 Release-Type: none --- docs/development/version-2.md | 6 ++++++ tests/test_repository_locks.py | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/development/version-2.md b/docs/development/version-2.md index e8acba4..355c8d3 100644 --- a/docs/development/version-2.md +++ b/docs/development/version-2.md @@ -36,6 +36,12 @@ The existing legacy Docker volume restore roundtrip skipped after its backup step returned failure. A focused rerun confirmed that skip; this validation does not qualify that restore path. +The live-lock integration fixture waits for a stable inventory containing its +owned Restic process. It tolerates only the explicit inventory-change refusal +during bounded startup; the production inspector still rejects changing lock +sets, and uncertain unlocks are never retried. This covers slower hosted lock +acquisition without weakening the maintenance safety checks. + This is unreleased source work, not acceptance of private candidate builds or permission to modify deployed installations. The package version remains 2.0.5 until a separate release. No B2/AWS protection, off-host custody, diff --git a/tests/test_repository_locks.py b/tests/test_repository_locks.py index 89f27a6..3915ba1 100644 --- a/tests/test_repository_locks.py +++ b/tests/test_repository_locks.py @@ -311,15 +311,22 @@ def snapshot_ids(): env=environment, ) try: - deadline = time.monotonic() + 15 + deadline = time.monotonic() + 60 live_review = None while time.monotonic() < deadline and process.poll() is None: - live_review = service.repository_locks("local") - if live_review["lock_count"]: - break + try: + candidate = service.repository_locks("local") + except ServiceError as exc: + if str(exc) != "Repository lock inventory changed during inspection": + raise + else: + if any(lock["pid"] == process.pid for lock in candidate["locks"]): + live_review = candidate + break time.sleep(0.05) assert process.poll() is None - assert live_review is not None and live_review["lock_count"] > 0 + assert live_review is not None, "did not observe a stable lock for the held backup" + assert live_review["lock_count"] > 0 assert live_review["unlock_eligible"] is False with pytest.raises(ServiceError): service.repository_unlock_stale( From 0a141a4d6bcb4430b2d1e8654be65db8eb090edc Mon Sep 17 00:00:00 2001 From: CruxExperts Date: Fri, 23 Oct 2026 14:43:29 -0500 Subject: [PATCH 3/3] fix(maintenance): repair compact layouts and refresh dependencies Keep terminal forms, resource summaries, failure status and cancellation controls reachable at compact sizes. Improve documentation navigation and refresh the reviewed Python and immutable CI action targets with bound dependency evidence. Refs: #20, #28, #30 Release-Type: none --- .github/workflows/ci.yml | 8 +- .github/workflows/release-notes.yml | 10 +- .github/workflows/stale.yml | 2 +- CHANGELOG.md | 13 + QUICKSTART.md | 6 + README.md | 71 ++--- bbackup/dashboard.py | 48 +++- bbackup/dashboard_operations.py | 2 +- bbackup/dashboard_setup.py | 4 +- bbackup/tui.py | 292 ++++++++++++--------- docs/README.md | 69 ++--- docs/architecture.md | 11 +- docs/assets/README.md | 3 + docs/assets/bbackup-dashboard-preview.svg | 184 ++++++------- docs/development/release-dependencies.json | 225 ++++++++++++---- docs/development/version-2.md | 44 ++++ pyproject.toml | 24 +- tests/test_b2.py | 8 +- tests/test_dashboard.py | 175 +++++++++++- tests/test_tui.py | 41 ++- uv.lock | 216 +++++++-------- 21 files changed, 992 insertions(+), 464 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb50d74..ddc453b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -96,7 +96,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -131,7 +131,7 @@ jobs: persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -171,7 +171,7 @@ jobs: persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index b771451..5f61247 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.12" @@ -154,7 +154,7 @@ jobs: fetch-depth: 1 - name: Download validated source wheel - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: name: release-source path: source @@ -254,13 +254,13 @@ jobs: persist-credentials: false - name: Download source archives - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: name: release-source path: release-inputs - name: Download target package outputs - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: pattern: "bbackup-deb-*" merge-multiple: true @@ -342,7 +342,7 @@ jobs: actual_sha="$(jq -r '.head_sha' "$RUNNER_TEMP/build-run.json")" test "$actual_sha" = "$expected_sha" - name: Download exact successful build artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: name: release-inputs run-id: ${{ inputs.build_run_id }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 030d70a..fa4039b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7647f..96b93ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ current uv-based install, development, and release instructions. ### Fixed +- Keep dashboard forms and setup navigation reachable in short terminals, and + preserve resource summaries, failure status and cancel hints in the compact + legacy dashboard. +- Put getting-started instructions before detailed support history and keep + the documentation index readable on narrow screens. - Accept Restic's explicit `remove: null` keep-all selection only when its keep group contains valid full snapshot IDs; malformed selections remain errors. - Recognize B2's read-only `readBucketLifecycleRules` capability without allowing @@ -32,6 +37,14 @@ current uv-based install, development, and release instructions. These source changes address issue [#30](https://github.com/CruxExperts/BestBackup/issues/30). They do not publish a new release or expand cloud or machine-recovery qualification. +### Changed + +- Refresh the reviewed Python maintenance dependencies from pull request #28 + and the immutable GitHub Actions references from #20. Keep the established + Ruff rule profile explicit across its default-rule migration. See the + [dependency evidence](docs/development/release-dependencies.json) for exact + versions, source timestamps and limits. + ## [2.0.5] - 2026-10-22 ### Fixed diff --git a/QUICKSTART.md b/QUICKSTART.md index ae314c2..dcd9137 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -135,6 +135,12 @@ The current source includes the following maintenance features for issue the published v2.0.5 artifacts. Use an explicitly enrolled policy and bindings; the examples below use the default configuration paths. +The unreleased dashboard layout keeps forms and setup navigation reachable in +compact terminals. Use `Tab` and `Shift+Tab` to move between controls; the focused +control scrolls into view. Use `F1` for field help. The legacy live dashboard +summarizes resource categories and keeps failures and cancel hints visible when +there is not enough room for its full layout. + For a job's `retention` object, optional `local_last` and `local_weekly` counts default to zero (disabled). For example: diff --git a/README.md b/README.md index 4a86a15..6c7434a 100644 --- a/README.md +++ b/README.md @@ -22,41 +22,13 @@ Use bbackup when you want to know what a job captured, whether each repository c ![Real bbackup dashboard with disposable example data and illustrative capacity](docs/assets/bbackup-dashboard-preview.svg) -## v2.0.5 tested scope - -The qualified scope is unchanged from v2.0.4: local file capture and restore; live capture and restore for PostgreSQL 17.11, MySQL 8.4.12, and MariaDB 11.8.9; headless dashboard happy paths; and AMD64 Ubuntu 24.04/26.04 packages. A separate real-guest user-timer reboot drill covers only the components and conditions recorded in the [qualification evidence](docs/development/version-2.md#development-candidate-rebootable-systemd-qualification). - -B2/AWS, native Docker, ARM64 backup/restore, cross-application-version upgrades and rollback, root-owned systemd package templates, and cloud or off-host protection remain unqualified. ARM64 packages may be built, but build or package-install smoke checks alone do not qualify backup/restore. Keep an independently retrievable copy of repository passwords; a second repository on the same host does not protect against host loss. See [installation](INSTALL.md) for prerequisites and release install instructions. - -Recent live B2 evidence covers a small interrupted maintenance run -whose prune outcome remained uncertain, and a separate lifecycle-overlap guard -that made central retention refuse the configured bucket. Neither test proves -prune completion, elapsed lifecycle expiry, physical reclamation, or provider -protection. These workflows ship in v2.0.5 as experimental and remain -unqualified. See the [maintenance interruption record](docs/development/version-2.md#live-b2-central-maintenance-interruption-trial) -and [lifecycle guard evidence](docs/cloud-storage.md#b2-lifecycle-overlap-guard-experimental). -Issues #23–#26 were canceled and closed as not planned. Cancellation does not -qualify the experimental B2 workflow. This release adds no new live provider -qualification; the existing workstation results remain historical. -The qualified core scope is unchanged from v2.0.4. - -Current source changes for [issue #30](https://github.com/CruxExperts/BestBackup/issues/30) -add reviewed stale-lock maintenance, optional local last/weekly retention, -strict keep-all parsing and B2 lifecycle-read compatibility. They are -[unreleased](docs/development/version-2.md#unreleased-maintenance-issue-30), -separate from the v2.0.5 downloads. See the -[maintenance quick start](QUICKSTART.md#review-local-retention-and-stale-locks-unreleased). - ## Start with the guided workflow -Experimental restricted cloud credentials, central recovery and retention, -bounded remote-primary capture, and reviewed Linux coverage ship in v2.0.5 and -are [documented with their limits](docs/development/version-2.md#experimental-cloud-and-machine-recovery-features). -The reviewed Linux coverage work includes an experimental manual PostgreSQL -path. These features do not expand the tested stable scope. - For packaged releases, start with the [latest release downloads](https://github.com/CruxExperts/BestBackup/releases/latest) and [installation guide](INSTALL.md). +Check the [tested scope](#v205-tested-scope) before choosing a workflow. Cloud +protection and complete-machine recovery remain unqualified. + From a development checkout with Python 3.12+, uv, and restic installed: ```bash @@ -68,6 +40,43 @@ uv run bbackup production tui Choose a source folder, encrypted storage, and a schedule. Review and enroll once. The dashboard then guides you through initializing storage, running the first backup, and restoring into a new folder. Advanced settings and the command line remain available. The [quick start](QUICKSTART.md) covers migration, password custody, replica setup, and deliberate schedule activation. +## v2.0.5 tested scope + +The qualified scope is unchanged from v2.0.4: local file capture and restore; +live capture and restore for PostgreSQL 17.11, MySQL 8.4.12, and MariaDB 11.8.9; +headless dashboard happy paths; and AMD64 Ubuntu 24.04/26.04 packages. User-timer +reboot qualification covers only the components and conditions in the +[recorded guest drill](docs/development/version-2.md#development-candidate-rebootable-systemd-qualification). + +B2/AWS protection, native Docker, ARM64 backup/restore, cross-application-version +upgrades and rollback, root-owned systemd package templates, and off-host recovery +remain unqualified. Package-build or install checks alone do not qualify those +paths. Keep an independently retrievable copy of repository passwords; a second +repository on the same host does not protect against host loss. + +Experimental cloud credentials, central recovery and retention, remote-primary +capture, and reviewed Linux coverage ship with +[documented limits](docs/development/version-2.md#experimental-cloud-and-machine-recovery-features). +Issues #23–#26 were canceled and closed as not planned. Historical B2 +[maintenance interruption](docs/development/version-2.md#live-b2-central-maintenance-interruption-trial) +and [lifecycle guard](docs/cloud-storage.md#b2-lifecycle-overlap-guard-experimental) +trials do not prove prune completion, elapsed expiry, physical reclamation or +provider protection. The [qualification record](docs/development/version-2.md) +owns the complete evidence and support limits. + +### Current source changes + +The source checkout also includes [unreleased issue #30 maintenance](docs/development/version-2.md#unreleased-maintenance-issue-30): +reviewed stale-lock handling, optional local last/weekly retention, strict +keep-all parsing, B2 lifecycle-read compatibility and nested cache handling. +These changes are separate from the v2.0.5 downloads. Follow the +[maintenance quick start](QUICKSTART.md#review-local-retention-and-stale-locks-unreleased) +for the new commands. + +The [current maintenance batch](docs/development/version-2.md#unreleased-layout-and-dependency-maintenance) +also repairs compact-terminal layouts and refreshes reviewed Python and CI +dependencies. Those changes are likewise unreleased. + ## Choose a guide - [Install](INSTALL.md): prerequisites, release packages, and development setup. diff --git a/bbackup/dashboard.py b/bbackup/dashboard.py index 1209430..9b4dfad 100644 --- a/bbackup/dashboard.py +++ b/bbackup/dashboard.py @@ -28,7 +28,9 @@ class BackupDashboard(App[None]): SUB_TITLE = "Encrypted backups • verified file restores" CSS = """ Screen { background: $surface; } - TabPane { padding: 1 2; overflow-y: auto; } + TabbedContent { height: 1fr; min-height: 5; } + TabPane { height: 1fr; min-height: 3; padding: 1 2 3 2; overflow-y: auto; } + #restore > VerticalScroll { height: 1fr; min-height: 1; } .intro { height: auto; margin-bottom: 1; } .toolbar { height: 3; margin-bottom: 1; } .toolbar Button { margin-right: 1; } @@ -53,6 +55,18 @@ def __init__(self, service: BackupService, *, workspace: ConfigurationWorkspace self._restore_worker = None self._restoring = False + def _fit_toolbars(self, width: int) -> None: + if not self.is_mounted: + return + layout = "vertical" if width < 72 else "horizontal" + height = "auto" if width < 72 else 3 + for toolbar in self.query(".toolbar"): + toolbar.styles.layout = layout + toolbar.styles.height = height + + def on_resize(self, event): + self._fit_toolbars(event.size.width) + def compose(self) -> ComposeResult: yield Header() with TabbedContent(initial="overview"): @@ -142,6 +156,7 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self): + self._fit_toolbars(self.size.width) columns = {"job-table": ("Job", "Local repository", "Replicas", "Schedule"), "repository-table": ("Repository", "Role"), "snapshot-table": ("Repository", "Snapshot ID", "Job"), @@ -357,9 +372,38 @@ def action_help(self): "Tab moves between controls; Enter selects a row or activates a button. Select a job, repository or snapshot to prepare its next action. Restore always uses a new folder.")) def on_descendant_focus(self, event): - if event.widget.tooltip and self.is_mounted: + if not self.is_mounted: + return + self.call_after_refresh(self._reveal_focused_control, event.widget) + if event.widget.tooltip: self.query_one("#notice", Static).update(str(event.widget.tooltip)) + def _reveal_focused_control(self, widget): + if not widget.is_mounted or not widget.region.height or not widget.region.width: + return + operation_panel = next( + (ancestor for ancestor in widget.ancestors if isinstance(ancestor, OperationPanel)), + None, + ) + if operation_panel is not None: + scroll_region = operation_panel.scrollable_content_region + target_scroll = operation_panel.scroll_y + if widget.region.bottom > scroll_region.bottom: + target_scroll += widget.region.bottom - scroll_region.bottom + elif widget.region.y < scroll_region.y: + target_scroll -= scroll_region.y - widget.region.y + if target_scroll != operation_panel.scroll_y: + operation_panel.scroll_to( + y=target_scroll, animate=False, immediate=True, force=True + ) + pane = next((ancestor for ancestor in operation_panel.ancestors + if isinstance(ancestor, TabPane)), None) + if pane is not None: + pane.scroll_to_widget(operation_panel, top=True, animate=False, immediate=True) + visible = widget.region.intersection(self.screen.region) + if (visible.height != widget.region.height or visible.width != widget.region.width): + self.screen.scroll_to_widget(widget, top=True, animate=False, immediate=True) + def _report_restore_progress(self, data): self.post_message(EngineProgress(data)) diff --git a/bbackup/dashboard_operations.py b/bbackup/dashboard_operations.py index 99b3811..e185529 100644 --- a/bbackup/dashboard_operations.py +++ b/bbackup/dashboard_operations.py @@ -35,7 +35,7 @@ class OperationPanel(VerticalScroll): """Keep execution, cancellation, and approvals identical across native forms.""" DEFAULT_CSS = """ - OperationPanel { height: auto; min-height: 20; border-top: solid $panel; margin-top: 1; } + OperationPanel { height: 1fr; min-height: 8; border-top: solid $panel; margin-top: 1; } OperationPanel TextArea { height: 9; margin-bottom: 1; } OperationPanel Static { height: auto; } OperationPanel .operation-status { margin-top: 1; } diff --git a/bbackup/dashboard_setup.py b/bbackup/dashboard_setup.py index 45cad8f..12b06b7 100644 --- a/bbackup/dashboard_setup.py +++ b/bbackup/dashboard_setup.py @@ -26,8 +26,8 @@ class BackupSetup(App[BackupService | None]): CSS = """ Screen { padding: 0 2; } #setup-progress, #setup-status { height: auto; margin: 1 0; } - ContentSwitcher { height: 1fr; } - VerticalScroll { padding: 0 1; } + ContentSwitcher { height: 1fr; min-height: 1; } + ContentSwitcher VerticalScroll { height: 1fr; min-height: 1; padding: 0 1; } Label { margin-top: 1; } .setup-help { height: auto; color: $text-muted; margin-bottom: 1; } #setup-review { height: auto; } diff --git a/bbackup/tui.py b/bbackup/tui.py index 970dc32..1a244ed 100644 --- a/bbackup/tui.py +++ b/bbackup/tui.py @@ -198,23 +198,105 @@ def show_header(self, title: str = "bbackup - Docker Backup Tool"): border_style="cyan", ) self.console.print(header) + + @staticmethod + def _item_progress(status_info: Any) -> tuple[str, str]: + if isinstance(status_info, dict): + status = str(status_info.get("status", "unknown")) + size = status_info.get("size", "-") + speed = status_info.get("speed", "") + else: + status = str(status_info) + size = "-" + speed = "" + progress = str(size if size != "-" else speed if speed else status) + return status, progress + + def _resource_panel(self, title: str, heading: str, values: dict[str, Any]) -> Panel: + """Build one status panel sized for its share of the terminal width.""" + table = Table(show_header=True, box=box.SIMPLE, show_edge=False, expand=True) + name_width = 12 + status_width = 7 + progress_width = 10 + table.add_column(heading, style="cyan", width=name_width, + no_wrap=True, overflow="ellipsis") + table.add_column("Status", width=status_width, no_wrap=True, overflow="ellipsis") + table.add_column("Progress", style="dim", width=progress_width, + no_wrap=True, overflow="ellipsis") + + for name, status_info in list(values.items())[:10]: + status, progress = self._item_progress(status_info) + color = "green" if status == "success" else "red" if status == "failed" else "yellow" + table.add_row( + name, + f"[{color}]{status}[/{color}]", + progress, + ) + + if not values: + table.add_row("No items", "", "") + + return Panel(table, title=title, border_style="green" if title == "Containers" else + "yellow" if title == "Volumes" else "cyan", box=box.ROUNDED) + + def _compact_resources_panel(self, resources: list[tuple[str, dict[str, Any]]]) -> Panel: + """Summarize every resource type without letting one group fill the screen.""" + table = Table(show_header=True, box=box.SIMPLE, show_edge=False, expand=True) + item_width = max(14, (self.console.size.width - 8) // 2) + detail_width = max(10, self.console.size.width - 8 - item_width) + table.add_column("Type (count)", style="cyan", width=item_width, + no_wrap=True, overflow="ellipsis") + table.add_column("Sample / status", width=detail_width, + no_wrap=True, overflow="ellipsis") + + for category, values in resources: + if not values: + table.add_row(f"{category} (0)", "No items") + continue + name, status_info = next(iter(values.items())) + status, progress = self._item_progress(status_info) + color = "green" if status == "success" else "red" if status == "failed" else "yellow" + detail = status if progress == status else f"{status} Ā· {progress}" + remaining = len(values) - 1 + if remaining: + detail += f" Ā· +{remaining} more" + table.add_row( + f"{category} ({len(values)})", + f"[{color}]{name}: {detail}[/{color}]", + ) + + return Panel(table, title="Backup resources", border_style="cyan", box=box.ROUNDED) def create_live_dashboard(self) -> Layout: """Create live-updating dashboard layout (BTOP-like).""" layout = Layout() - layout.split_column( - Layout(name="header", size=5), - Layout(name="main", ratio=2), - Layout(name="progress", size=8), - Layout(name="status", size=6), - Layout(name="footer", size=3), - ) - - layout["main"].split_row( - Layout(name="containers", ratio=1), - Layout(name="volumes", ratio=1), - Layout(name="filesystems", ratio=1), - ) + compact_height = self.console.size.height < 32 + compact_width = self.console.size.width < 120 + if compact_height: + layout.split_column( + Layout(name="header", size=2), + Layout(name="main", ratio=1), + Layout(name="progress", size=2), + Layout(name="status", size=2), + Layout(name="footer", size=1), + ) + else: + layout.split_column( + Layout(name="header", size=5), + Layout(name="main", ratio=2), + Layout(name="progress", size=8), + Layout(name="status", size=6), + Layout(name="footer", size=3), + ) + + if compact_width: + layout["main"].split_column(Layout(name="resources", ratio=1)) + else: + layout["main"].split_row( + Layout(name="containers", ratio=1), + Layout(name="volumes", ratio=1), + Layout(name="filesystems", ratio=1), + ) # Header current_status = self.status.get_status() @@ -270,16 +352,31 @@ def create_live_dashboard(self) -> Layout: [bold]Item:[/bold] {self.status.current_item if self.status.current_item else 'N/A'} {('[bold]File:[/bold] ' + self.status.current_file[:60]) if self.status.current_file else ''} """ - layout["header"].update(Panel(header_content.strip(), border_style="cyan", box=box.ROUNDED)) + if compact_height: + action_limit = min(24, max(8, (self.console.size.width - 21) // 2)) + item_limit = max(8, self.console.size.width - action_limit - 21) + current_line = ( + f"[bold]Current:[/bold] {self.status.current_action[:action_limit]} Ā· " + f"[bold]Item:[/bold] {(self.status.current_item or 'N/A')[:item_limit]}" + ) + compact_header = ( + f"[bold cyan]bbackup[/bold cyan] [dim]v{__version__}[/dim] Ā· " + f"Status: [{status_color}]{current_status.upper()}[/{status_color}]\n" + f"{current_line}" + ) + layout["header"].update(compact_header) + else: + layout["header"].update(Panel(header_content.strip(), border_style="cyan", box=box.ROUNDED)) # Progress bar with enhanced metrics # Use bytes-based progress if available, otherwise use item-based if self.status.total_bytes > 0: # Bytes-based progress (more accurate for file transfers) + bar_width = max(8, min(40, self.console.size.width - 48)) if compact_width else 40 progress_bar = Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - BarColumn(bar_width=40), + BarColumn(bar_width=bar_width), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TextColumn("•"), TextColumn("[cyan]{task.completed:>10}[/cyan]/[dim]{task.total:>10}[/dim]"), @@ -293,10 +390,11 @@ def create_live_dashboard(self) -> Layout: completed = self.status.bytes_transferred else: # Item-based progress (fallback) + bar_width = max(8, min(40, self.console.size.width - 48)) if compact_width else 40 progress_bar = Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - BarColumn(bar_width=40), + BarColumn(bar_width=bar_width), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TextColumn("•"), MofNCompleteColumn(), @@ -309,7 +407,7 @@ def create_live_dashboard(self) -> Layout: progress_bar.columns = ( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - BarColumn(bar_width=40), + BarColumn(bar_width=bar_width), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TextColumn("•"), MofNCompleteColumn(), @@ -322,115 +420,75 @@ def create_live_dashboard(self) -> Layout: completed = self.status.completed_items progress_bar.add_task( - self.status.current_action[:50] if self.status.current_action else "Processing...", + self.status.current_action[:max(12, self.console.size.width - 36)] if self.status.current_action else "Processing...", total=total, completed=completed, ) - - layout["progress"].update( - Panel(progress_bar, title="Progress", border_style="blue", box=box.ROUNDED) - ) + + if compact_height: + layout["progress"].update(progress_bar) + else: + layout["progress"].update( + Panel(progress_bar, title="Progress", border_style="blue", box=box.ROUNDED) + ) containers_status = self.status.item_statuses("containers") volumes_status = self.status.item_statuses("volumes") filesystems_status = self.status.item_statuses("filesystems") errors, warnings = self.status.messages() - # Containers panel with enhanced info - containers_table = Table(show_header=True, box=box.SIMPLE, show_edge=False) - containers_table.add_column("Container", style="cyan", width=22) - containers_table.add_column("Status", width=10) - containers_table.add_column("Progress", style="dim", width=12) - - for name, status_info in list(containers_status.items())[:10]: - # Handle both dict and string status - if isinstance(status_info, dict): - status = status_info.get("status", "unknown") - size = status_info.get("size", "-") - speed = status_info.get("speed", "") - else: - status = status_info - size = "-" - speed = "" - - status_color = "green" if status == "success" else "red" if status == "failed" else "yellow" - progress_display = size if size != "-" else speed if speed else status - containers_table.add_row( - name[:22], - f"[{status_color}]{status[:8]}[/{status_color}]", - progress_display[:12], - ) - - if len(containers_status) == 0: - containers_table.add_row("[dim]No containers backed up yet[/dim]", "", "") - - layout["containers"].update( - Panel(containers_table, title="Containers", border_style="green", box=box.ROUNDED) - ) - - # Volumes panel with enhanced info - volumes_table = Table(show_header=True, box=box.SIMPLE, show_edge=False) - volumes_table.add_column("Volume", style="cyan", width=22) - volumes_table.add_column("Status", width=10) - volumes_table.add_column("Progress", style="dim", width=12) - - for name, status_info in list(volumes_status.items())[:10]: - # Handle both dict and string status - if isinstance(status_info, dict): - status = status_info.get("status", "unknown") - size = status_info.get("size", "-") - speed = status_info.get("speed", "") + resources = [("Containers", containers_status), ("Volumes", volumes_status), + ("Filesystems", filesystems_status)] + if compact_width: + layout["resources"].update(self._compact_resources_panel(resources)) + else: + layout["containers"].update(self._resource_panel("Containers", "Container", containers_status)) + layout["volumes"].update(self._resource_panel("Volumes", "Volume", volumes_status)) + layout["filesystems"].update(self._resource_panel("Filesystems", "Path", filesystems_status)) + + if compact_height: + priority = [] + if errors: + priority.append(f"[red]Errors: {len(errors)}[/red]") + if self.status.encryption_status == "failed": + priority.append("[red]Encryption failed[/red]") + if warnings: + priority.append(f"[yellow]Warnings: {len(warnings)}[/yellow]") + if not priority: + priority.append("[green]No errors or warnings[/green]") + + if errors: + detail = errors[-1] + elif self.status.encryption_status == "failed": + detail = "Check the retained operation result before retrying." + elif warnings: + detail = warnings[-1] + elif self.status.encryption_status == "encrypting": + detail = "Encryption in progress" + elif self.status.encryption_status == "encrypted": + detail = "Backup encrypted" else: - status = status_info - size = "-" - speed = "" - - status_color = "green" if status == "success" else "red" if status == "failed" else "yellow" - progress_display = size if size != "-" else speed if speed else status - volumes_table.add_row( - name[:22], - f"[{status_color}]{status[:8]}[/{status_color}]", - progress_display[:12], - ) - - if len(volumes_status) == 0: - volumes_table.add_row("[dim]No volumes backed up yet[/dim]", "", "") - - layout["volumes"].update( - Panel(volumes_table, title="Volumes", border_style="yellow", box=box.ROUNDED) - ) + detail = f"{self.status.transfer_speed:.2f} MB/s" if self.status.transfer_speed else "Ready" + detail = detail[:max(12, self.console.size.width - 4)] + layout["status"].update(" Ā· ".join(priority) + "\n" + detail) + else: + self._update_expanded_status(layout, errors, warnings) - # Filesystems panel - filesystems_table = Table(show_header=True, box=box.SIMPLE, show_edge=False) - filesystems_table.add_column("Path", style="cyan", width=22) - filesystems_table.add_column("Status", width=10) - filesystems_table.add_column("Progress", style="dim", width=12) - - for name, status_info in list(filesystems_status.items())[:10]: - if isinstance(status_info, dict): - fs_status = status_info.get("status", "unknown") - size = status_info.get("size", "-") - speed = status_info.get("speed", "") - else: - fs_status = status_info - size = "-" - speed = "" - color = "green" if fs_status == "success" else "red" if fs_status == "failed" else "yellow" - progress_display = size if size != "-" else speed if speed else fs_status - filesystems_table.add_row( - name[:22], - f"[{color}]{fs_status[:8]}[/{color}]", - progress_display[:12], + # Footer with controls + if compact_height: + layout["footer"].update("[dim]Q quit/cancel Ā· P pause Ā· S skip Ā· H help[/dim]") + else: + footer_content = """ +[dim]Controls:[/dim] [bold]Q[/bold] = Quit/Cancel [bold]P[/bold] = Pause [bold]S[/bold] = Skip Current [bold]H[/bold] = Help +""" + layout["footer"].update( + Panel(footer_content.strip(), border_style="dim", box=box.SIMPLE) ) - if not filesystems_status: - filesystems_table.add_row("[dim]No paths backed up yet[/dim]", "", "") - - layout["filesystems"].update( - Panel(filesystems_table, title="Filesystems", border_style="cyan", box=box.ROUNDED) - ) + return layout - # Status panel with encryption info and metrics + def _update_expanded_status(self, layout: Layout, errors: list[str], warnings: list[str]) -> None: + """Render the full status detail when the terminal has vertical space.""" status_lines = [] # Show transfer metrics if available @@ -478,16 +536,6 @@ def create_live_dashboard(self) -> Layout: layout["status"].update( Panel("\n".join(status_lines), title="Status", border_style="magenta", box=box.ROUNDED) ) - - # Footer with controls - footer_content = """ -[dim]Controls:[/dim] [bold]Q[/bold] = Quit/Cancel [bold]P[/bold] = Pause [bold]S[/bold] = Skip Current [bold]H[/bold] = Help -""" - layout["footer"].update( - Panel(footer_content.strip(), border_style="dim", box=box.SIMPLE) - ) - - return layout @staticmethod def _set_terminal_cbreak(stream: Any) -> Any: diff --git a/docs/README.md b/docs/README.md index a66ad5d..ec66584 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,45 +2,54 @@ bbackup is for Linux administrators, homelab and self-hosted operators, and small infrastructure teams who need encrypted local backups, verified file restores, and visible scheduled jobs. Choose a guide by the work you are doing. -These guides describe v2.0.5. The [qualification record](development/version-2.md) is the authority for the exact qualified scope, version-specific evidence, and remaining limits; an implemented path alone does not establish support. The v2.0.5 release retains the v2.0.4 tested core scope, while new cloud and Linux workflows are experimental and unqualified. Operators still using v2.0.2 can follow its [versioned documentation](https://github.com/CruxExperts/BestBackup/tree/v2.0.2/docs) until they migrate. - -The [release dependency evidence](development/release-dependencies.json) records the reviewed versions, source timestamps, lock hash, and audit limits. - -The current source also includes [unreleased maintenance changes](development/version-2.md#unreleased-maintenance-issue-30) -for reviewed stale locks, local retention counts, Restic keep-all parsing, and -B2 lifecycle-read compatibility. They are separate from published v2.0.5. -See the [maintenance quick start](../QUICKSTART.md#review-local-retention-and-stale-locks-unreleased) -before using the new commands. +These guides describe published v2.0.5 and clearly marked unreleased source +changes. Check the [qualification record](development/version-2.md) for the exact +supported scope before selecting a workflow. ## Choose a starting point -| If you need to... | Start here | -|:--|:--| -| Install bbackup and run your first backup | [Installation](../INSTALL.md) and the [quick start](../QUICKSTART.md) | -| Migrate an existing schema-2 setup | [Quick start: migrate an existing configuration](../QUICKSTART.md#migrate-an-existing-schema-2-configuration) | -| Maintain an older Docker/YAML workflow | [Legacy management](management.md) and [encryption compatibility](encryption.md) | -| Schedule jobs or integrate with scripts and agents | [Quick start: apply a schedule](../QUICKSTART.md#apply-a-schedule-deliberately), [agent integration](AGENT_INTEGRATION.md), and the [production command reference](production-cli.md) | -| Check v2.0.5 scope and issue status | [Version 2 qualification record](development/version-2.md) (issues #23–#26 were canceled and closed as not planned; they remain unqualified) | -| Evaluate experimental cloud recovery, retention or Linux coverage | [Experimental program and limits](development/version-2.md#experimental-cloud-and-machine-recovery-features) and [cloud storage](cloud-storage.md) | +- **Install and run a first backup:** [installation](../INSTALL.md) and the [quick start](../QUICKSTART.md). +- **Migrate a schema-2 setup:** [migration steps](../QUICKSTART.md#migrate-an-existing-schema-2-configuration). +- **Schedule jobs:** [apply a schedule deliberately](../QUICKSTART.md#apply-a-schedule-deliberately). +- **Automate commands:** [agent integration](AGENT_INTEGRATION.md) and the [production command reference](production-cli.md). +- **Maintain older Docker/YAML workflows:** [legacy management](management.md) and [encryption compatibility](encryption.md). +- **Evaluate cloud recovery or Linux coverage:** [experimental limits](development/version-2.md#experimental-cloud-and-machine-recovery-features) and [cloud storage](cloud-storage.md). ## Operator references -| Reference | Use it for | -|:--|:--| -| [architecture.md](architecture.md) | Shared operations, durable state, and safety boundaries | -| [cloud-storage.md](cloud-storage.md) | Implemented B2/AWS paths and their qualification limits | -| [encryption.md](encryption.md) | Restic password custody, deferred off-host custody, and legacy file encryption | -| [cli-skills.md](cli-skills.md) | Generated command and agent-skills catalog | +- [Architecture](architecture.md): shared operations, durable state and safety boundaries. +- [Cloud storage](cloud-storage.md): B2/AWS paths and their qualification limits. +- [Encryption](encryption.md): Restic password custody and legacy file encryption. +- [CLI skills](cli-skills.md): generated command and agent-skills catalog. + +## Version and support boundaries + +v2.0.5 retains the v2.0.4 tested core scope. Experimental cloud and Linux +workflows remain unqualified; issues #23–#26 were canceled and closed as not +planned. An implemented path or a closed issue does not establish support. +The [qualification record](development/version-2.md) retains the dated evidence. + +Current source includes [unreleased issue #30 maintenance](development/version-2.md#unreleased-maintenance-issue-30) +for reviewed stale locks, local retention counts, strict Restic parsing, B2 +lifecycle-read compatibility and nested cache policy. Follow the +[maintenance quick start](../QUICKSTART.md#review-local-retention-and-stale-locks-unreleased) +before using those commands; they are separate from published v2.0.5. + +The [current layout and dependency batch](development/version-2.md#unreleased-layout-and-dependency-maintenance) +is also unreleased. It covers compact terminal forms, documentation navigation, +and reviewed Python and GitHub Actions maintenance. + +Operators still using v2.0.2 can follow its +[versioned documentation](https://github.com/CruxExperts/BestBackup/tree/v2.0.2/docs) +until they migrate. ## Maintainer references -| Reference | Use it for | -|:--|:--| -| [VERSIONING.md](VERSIONING.md) | Canonical package version, release tags, and validation | -| [PUBLISHING_CHECKLIST.md](PUBLISHING_CHECKLIST.md) | Community files and release readiness checks | -| [development/version-2.md](development/version-2.md) | Qualification evidence, candidate results, and deferred assurance | -| [development/release-dependencies.json](development/release-dependencies.json) | Reviewed package versions and dependency audit evidence | -| [assets/README.md](assets/README.md) | Documentation asset inventory and visual standards | +- [Versioning](VERSIONING.md): canonical package version, release tags and validation. +- [Publishing checklist](PUBLISHING_CHECKLIST.md): community files and release readiness. +- [Qualification record](development/version-2.md): candidate results and deferred assurance. +- [Release dependency evidence](development/release-dependencies.json): reviewed versions, source timestamps, lock hash and audit limits. +- [Documentation assets](assets/README.md): asset inventory and visual standards. ## Documentation standards diff --git a/docs/architecture.md b/docs/architecture.md index 20763be..d0e8420 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,15 +52,18 @@ The remaining module and configuration sections below describe the retained 1.x ## Technology stack +These minimums describe the current source checkout. Published v2.0.5 retains +its [versioned requirements](https://github.com/CruxExperts/BestBackup/blob/v2.0.5/pyproject.toml). + | Component | Library / tool | Version | |---|---|---| | Language | Python | 3.12+ | -| CLI framework | Click | 8.1.7+ | +| CLI framework | Click | 8.5.0+ | | Terminal UI | Rich | 13.7.0+ | -| Docker integration | docker-py SDK | 7.0.0+ | +| Docker integration | docker-py SDK | 7.2.0+ | | Config format | PyYAML | 6.0.1+ | -| SFTP | paramiko | 3.4.0+ | -| Encryption | cryptography | 50.0.0+ | +| SFTP | paramiko | 5.1.0+ | +| Encryption | cryptography | 50.0.2+ | | HTTP (key fetching) | requests | 2.31.0+ | | Volume backup | rsync | system | | Metadata/archive handling | tar | system | diff --git a/docs/assets/README.md b/docs/assets/README.md index c4236a9..1ef838f 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -8,6 +8,9 @@ Active README artwork follows the bbackup forest-green identity and keeps all pr - `bbackup-social-v3.jpg`: current GitHub social upload, generated with OpenAI's built-in image generation tool on 2026-10-19 using the README banner as a brand reference. Center-cropped and resized to GitHub's recommended 1280Ɨ640 dimensions; 154,877 bytes, under 1 MB. EXIF and XMP retain ownership and usage terms. - `bbackup-dashboard-preview.svg`: Textual headless dashboard capture, using fixture data only and illustrative capacity. Regenerate with `uv run python scripts/generate_dashboard_preview.py` (requires restic). + The current capture uses a 112-column by 36-row terminal. Compact-layout + checks use separate disposable fixtures; this overview image is a readable + example rather than proof of every screen or a live host's recovery status. [provenance.json](provenance.json) records generation briefs, references, dimensions, original hashes, final hashes, and ownership for the generated images and delivery encoding. Copyright, company URL, creator, usage terms, and generator are embedded in PNG metadata. Metadata insertion preserves the generated pixel data. diff --git a/docs/assets/bbackup-dashboard-preview.svg b/docs/assets/bbackup-dashboard-preview.svg index 8f5743a..c522a09 100644 --- a/docs/assets/bbackup-dashboard-preview.svg +++ b/docs/assets/bbackup-dashboard-preview.svg @@ -20,192 +20,192 @@ font-weight: 700; } - .terminal-1427695253-matrix { + .terminal-3556323382-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1427695253-title { + .terminal-3556323382-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1427695253-r1 { fill: #c5c8c6 } -.terminal-1427695253-r2 { fill: #e0e0e0 } -.terminal-1427695253-r3 { fill: #a3a3a3 } -.terminal-1427695253-r4 { fill: #eaeaea;font-weight: bold } -.terminal-1427695253-r5 { fill: #7f7f7f } -.terminal-1427695253-r6 { fill: #585858 } -.terminal-1427695253-r7 { fill: #656565 } -.terminal-1427695253-r8 { fill: #2d2d2d } -.terminal-1427695253-r9 { fill: #e0e0e0;font-weight: bold } -.terminal-1427695253-r10 { fill: #0d0d0d } -.terminal-1427695253-r11 { fill: #a9a9a9 } -.terminal-1427695253-r12 { fill: #3a3a3a } -.terminal-1427695253-r13 { fill: #b0b0b0;font-weight: bold } -.terminal-1427695253-r14 { fill: #515151 } + .terminal-3556323382-r1 { fill: #c5c8c6 } +.terminal-3556323382-r2 { fill: #e0e0e0 } +.terminal-3556323382-r3 { fill: #a3a3a3 } +.terminal-3556323382-r4 { fill: #eaeaea;font-weight: bold } +.terminal-3556323382-r5 { fill: #7f7f7f } +.terminal-3556323382-r6 { fill: #585858 } +.terminal-3556323382-r7 { fill: #656565 } +.terminal-3556323382-r8 { fill: #2d2d2d } +.terminal-3556323382-r9 { fill: #e0e0e0;font-weight: bold } +.terminal-3556323382-r10 { fill: #0d0d0d } +.terminal-3556323382-r11 { fill: #a9a9a9 } +.terminal-3556323382-r12 { fill: #3a3a3a } +.terminal-3556323382-r13 { fill: #b0b0b0;font-weight: bold } +.terminal-3556323382-r14 { fill: #515151 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - bbackup ā€¢ disposable example data + bbackup ā€¢ disposable example data - - - - ⭘bbackup ā€” Encrypted backups ā€¢ verified file restores -OverviewJobsRepositoriesSnapshotsRestoreRecoveryActivitySettings -╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Recovery status - -Start here: initialize each repository, run a backup, then verify a restore. A second copy is useful only -when you can recover it independently. - -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Set up storage  Run first backup  Verify a restore  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - -Configured jobs: 1 -Last local capture: 2026-10-17T18:36:14.811+00:00 -Last additional copy: 2026-10-17T18:36:17.074+00:00 -Last full repository check: 2026-10-17T18:36:17.802+00:00 -Last verified file restore: 2026-10-17T18:36:18.566+00:00 -Operation outcomes: succeeded=7 -Repository local (local): 384.00 GiB free of 512.00 GiB -Repository additional-copy (replica): 384.00 GiB free of 512.00 GiB -Job daily: next no observed active timer; active timer none observed; configured daily -Recovery checkpoint for current repository bindings: not recorded -Application recovery: not recorded; native and file checks are not application validation -not qualified; checkpoint expiry alone does not prove off-host custody or current provider protection -Keep testing restores after changes to your applications. -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Refresh status  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - -Ready. Closing cancels interactive work; installed systemd timers continue. - q Close  r Refresh  s Start job  x Cancel job  f1 Field help ā–^p palette + + + + ⭘bbackup ā€” Encrypted backups ā€¢ verified file restores +OverviewJobsRepositoriesSnapshotsRestoreRecoveryActivitySettings +╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Recovery status + +Start here: initialize each repository, run a backup, then verify a restore. A second copy is useful only +when you can recover it independently. + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Set up storage  Run first backup  Verify a restore  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +Configured jobs: 1 +Last local capture: 2026-10-23T19:30:49.589+00:00 +Last additional copy: 2026-10-23T19:30:51.853+00:00 +Last full repository check: 2026-10-23T19:30:52.553+00:00 +Last verified file restore: 2026-10-23T19:30:53.238+00:00 +Operation outcomes: succeeded=7 +Repository local (local): 384.00 GiB free of 512.00 GiB +Repository additional-copy (replica): 384.00 GiB free of 512.00 GiB +Job daily: next no observed active timer; active timer none observed; configured daily +Recovery checkpoint for current repository bindings: not recorded +Application recovery: not recorded; native and file checks are not application validation +not qualified; checkpoint expiry alone does not prove off-host custody or current provider protection +Keep testing restores after changes to your applications. +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Refresh status  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + +Ready. Closing cancels interactive work; installed systemd timers continue. + q Close  r Refresh  s Start job  x Cancel job  f1 Field help ā–^p palette diff --git a/docs/development/release-dependencies.json b/docs/development/release-dependencies.json index 763e6df..44af216 100644 --- a/docs/development/release-dependencies.json +++ b/docs/development/release-dependencies.json @@ -16,58 +16,58 @@ ], "package_version_coverage_verified_against_lockfile": true, "query_failures": 0, - "raw_evidence_sha256": "d976f5d4c39ab2d1dbfc55448ced4f2bb8696394500f166c0f27982b49803846", - "result": "No advisory records were returned for these exact package versions by OSV, GitHub Advisory Database, deps.dev, or PyPI at retrieval time.", - "retrieved_at_utc": "2026-10-22T03:13:27.879942+00:00", - "scope": "All 51 exact PyPI registry package versions in uv.lock, plus the exact setuptools and wheel versions resolved for the build-system requirements.", + "raw_evidence_sha256": "e5a35a6ecc802af8cecccfee57ce55bac00a0d8df0021d3748749a7b3a11f4d2", + "result": "No advisory records were returned for these exact PyPI versions by OSV, GitHub Advisory Database, deps.dev or PyPI at retrieval time. GitHub reviewed and malware queries were fully paginated.", + "retrieved_at_utc": "2026-10-23T18:47:52.716390+00:00", + "scope": "All 51 exact PyPI registry package versions in the unreleased source uv.lock, plus the exact setuptools and wheel build requirements.", "sources": [ { - "advisory_hits": 0, + "name": "OSV", + "independence_class": "A", "endpoint": "https://api.osv.dev/v1/querybatch", + "requests": 1, + "package_version_queries": 53, "http_status_counts": { "200": 1 }, - "independence_class": "A", - "name": "OSV", - "package_version_queries": 53, - "requests": 1, - "retrieved_at_utc": "2026-10-22T03:13:27.879942+00:00" + "advisory_hits": 0, + "retrieved_at_utc": "2026-10-23T18:47:52.716390+00:00" }, { - "advisory_hits": 0, - "endpoint": "https://api.github.com/advisories?ecosystem=pip&affects=PACKAGE%40VERSION", - "http_status_counts": { - "200": 6 - }, - "independence_class": "B", "name": "GitHub Advisory Database", + "independence_class": "B", + "endpoint": "https://api.github.com/advisories", + "requests": 2, "package_version_queries": 53, - "retrieved_at_utc": "2026-10-22T03:13:27.879942+00:00", - "requests": 6 + "http_status_counts": { + "200": 2 + }, + "advisory_hits": 0, + "retrieved_at_utc": "2026-10-23T18:48:17.592617+00:00" }, { - "advisory_hits": 0, + "name": "deps.dev", + "independence_class": "E", "endpoint": "https://api.deps.dev/v3/systems/pypi/packages/PACKAGE/versions/VERSION", + "requests": 53, + "package_version_queries": 53, "http_status_counts": { "200": 53 }, - "independence_class": "E", - "name": "deps.dev", - "package_version_queries": 53, - "retrieved_at_utc": "2026-10-22T03:13:27.879942+00:00", - "requests": 53 + "advisory_hits": 0, + "retrieved_at_utc": "2026-10-23T18:47:52.716390+00:00" }, { - "advisory_hits": 0, + "name": "PyPI", + "independence_class": "E", "endpoint": "https://pypi.org/pypi/PACKAGE/VERSION/json", + "requests": 53, + "package_version_queries": 53, "http_status_counts": { "200": 53 }, - "independence_class": "E", - "name": "PyPI JSON API", - "package_version_queries": 53, - "retrieved_at_utc": "2026-10-22T03:13:27.879942+00:00", - "requests": 53 + "advisory_hits": 0, + "retrieved_at_utc": "2026-10-23T18:47:52.716390+00:00" } ], "remediation": { @@ -76,14 +76,52 @@ "previous_version": "2026.5.20", "resolved_version": "2026.10.19", "source": "https://github.com/advisories/GHSA-6q48-6c7w-9gw2", - "note": "The initial lookup identified the affected version; the fixed version was separately queried across the same four sources. Other exact package versions are unchanged." + "note": "Historical v2.0.5 certifi remediation retained. Its original exact-version lookup and unchanged-package statement apply to the published versioned report, not this later source maintenance batch." + }, + "registry_artifact_hashes_verified_against_lockfile": true, + "known_advisory_review": { + "sources": [ + { + "name": "NVD", + "independence_class": "C", + "http_status": 200, + "records_returned": 6, + "checked_ids": [ + "CVE-2026-69247", + "CVE-2026-69249", + "CVE-2026-69248", + "CVE-2026-44405", + "CVE-2025-71176", + "CVE-2024-42471" + ] + }, + { + "name": "CISA KEV", + "independence_class": "D", + "http_status": 200, + "source": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json", + "catalog_version": "2026.10.23", + "checked_ids": [ + "CVE-2026-69247", + "CVE-2026-69249", + "CVE-2026-69248", + "CVE-2026-44405", + "CVE-2025-71176", + "CVE-2024-42471" + ], + "matches": [] + } + ], + "disposition": "informational", + "affects_resolved_versions": false, + "note": "Known cryptography, Paramiko, pytest and artifact-download advisories were reviewed. Candidate versions are beyond the recorded affected ranges; none of the checked CVE aliases matched the retrieved KEV catalog. This does not prove the absence of unknown vulnerabilities." } }, "lockfile": { "local_project_version": "2.0.5", "locked_pypi_packages": 51, "path": "uv.lock", - "sha256": "b96e18305ab40567f2df3894c29d05b32809ed3b5f344fb2a7f72d03ca2e2269" + "sha256": "f7aab4b971fb2bcc92889928d61f83fbe65f315fb590910b4a9cbbb89df8a4a4" }, "package_versions": [ { @@ -92,7 +130,7 @@ }, { "name": "b2sdk", - "version": "2.13.0" + "version": "2.13.1" }, { "name": "bcrypt", @@ -100,11 +138,11 @@ }, { "name": "boto3", - "version": "1.43.108" + "version": "1.43.114" }, { "name": "botocore", - "version": "1.43.108" + "version": "1.43.117" }, { "name": "certifi", @@ -120,7 +158,7 @@ }, { "name": "click", - "version": "8.4.1" + "version": "8.5.0" }, { "name": "colorama", @@ -132,11 +170,11 @@ }, { "name": "cryptography", - "version": "50.0.0" + "version": "50.0.2" }, { "name": "docker", - "version": "7.1.0" + "version": "7.2.0" }, { "name": "execnet", @@ -148,7 +186,7 @@ }, { "name": "google-auth-oauthlib", - "version": "1.4.0" + "version": "1.5.0" }, { "name": "idna", @@ -196,7 +234,7 @@ }, { "name": "paramiko", - "version": "5.0.0" + "version": "5.1.0" }, { "name": "platformdirs", @@ -228,7 +266,7 @@ }, { "name": "pytest", - "version": "9.0.3" + "version": "9.1.1" }, { "name": "pytest-cov", @@ -236,7 +274,7 @@ }, { "name": "pytest-mock", - "version": "3.15.1" + "version": "3.17.0" }, { "name": "pytest-xdist", @@ -268,7 +306,7 @@ }, { "name": "ruff", - "version": "0.15.15" + "version": "0.17.1" }, { "name": "s3transfer", @@ -289,14 +327,6 @@ { "name": "urllib3", "version": "2.8.0" - }, - { - "name": "setuptools", - "version": "84.0.0" - }, - { - "name": "wheel", - "version": "0.48.0" } ], "release": { @@ -344,5 +374,98 @@ "upstream_fix_pull_request": "https://github.com/restic/restic/pull/21997" } } + }, + "source_checkout": { + "status": "unreleased", + "maintenance_pull_requests": [ + 20, + 28 + ], + "audited_python_candidate": "f10d7e3bd2a47db2edf217b2fbd2709e3ee204e4", + "published_release_evidence": { + "tag": "v2.0.5", + "sha256": "3e3b833f1882a21365c286ddac2961cc11cb279e6c8a3eb1362be6f415a55739", + "url": "https://github.com/CruxExperts/BestBackup/blob/v2.0.5/docs/development/release-dependencies.json" + } + }, + "github_actions_maintenance": { + "status": "unreleased", + "retrieved_at_utc": "2026-10-23T18:53:25.163180+00:00", + "refs": [ + { + "action": "actions/setup-python", + "tag": "v7.0.0", + "commit": "5fda3b95a4ea91299a34e894583c3862153e4b97", + "expected_commit": "5fda3b95a4ea91299a34e894583c3862153e4b97", + "matches": true + }, + { + "action": "actions/download-artifact", + "tag": "v8.0.2", + "commit": "9000827ccba6bdab643e8b6fd33ac0654aef8333", + "expected_commit": "9000827ccba6bdab643e8b6fd33ac0654aef8333", + "matches": true + }, + { + "action": "actions/stale", + "tag": "v11.0.0", + "commit": "4391f3da665fdf50b6810c1a66712fb9ba21aa93", + "expected_commit": "4391f3da665fdf50b6810c1a66712fb9ba21aa93", + "matches": true + } + ], + "runtime": "node24", + "runner_requirement": "GitHub Actions runner >= 2.327.1; existing workflows use hosted Ubuntu runners.", + "advisory_evidence": { + "OSV": { + "independence_class": "A", + "http_status": 200, + "exact_version_queries": 3, + "advisory_hits": 0 + }, + "GitHub": { + "independence_class": "B", + "exact_version_queries": 3, + "reviewed_advisory_hits": 0, + "malware_advisory_hits": 0, + "pagination_exhausted": true + }, + "NVD": { + "independence_class": "C", + "reviewed_alias": "CVE-2024-42471", + "affects_candidate": false + } + }, + "limits": [ + "Immutable action tags and declared inputs/runtime were inspected; release and stale workflows were not dispatched.", + "The historical action GHSA API has a null CVE field; its linked toolkit advisory carries CVE-2024-42471. The alias was checked without treating the metadata discrepancy as proof of no CVE." + ] + }, + "source_maintenance_policy": { + "applies_to": [ + "package_versions", + "dependency_audit.audited_build_requirements", + "github_actions_maintenance.refs" + ], + "owners": [ + "pyproject.toml", + "uv.lock", + ".github/workflows/ci.yml", + ".github/workflows/release-notes.yml", + ".github/workflows/stale.yml" + ], + "sources": [ + "https://pypi.org/simple", + "https://github.com/actions" + ], + "version_selection": "Adopt the exact reviewed PR20 and PR28 targets; retain all other package versions and SHA-pin action tags. This is source maintenance, not a release or deployment.", + "consumers": [ + "bbackup CLI and dashboard", + "legacy backup/restore and optional Google Drive authentication", + "tests and lint", + "hosted CI, release preparation and stale-item workflows" + ], + "freshness_and_verification": "Use the timestamped exact-version advisory, registry hash, lockfile and immutable action-reference evidence in this report; no latest-version or risk-free claim is made.", + "rollback": "Revert the scoped maintenance commit with an OpenPGP-signed commit and rerun uv sync --locked. Preserve published branch history." } } diff --git a/docs/development/version-2.md b/docs/development/version-2.md index 355c8d3..fd6304d 100644 --- a/docs/development/version-2.md +++ b/docs/development/version-2.md @@ -4,6 +4,45 @@ > below. Experimental additions remain unqualified. The dated records retain > historical evidence and limits without expanding that scope. +## Unreleased layout and dependency maintenance + +The source checkout includes compact-terminal layout repairs for the production +dashboard, guided setup and retained Rich dashboard. Forms scroll within the +available viewport, setup navigation stays reachable, and compact status gives +failures and cancel hints priority. Resource summaries retain each resource +category when individual names cannot all fit. The README and documentation +index put task navigation ahead of detailed support history. + +The maintenance batch also reviews the Python updates proposed in +[#28](https://github.com/CruxExperts/BestBackup/pull/28) and pinned GitHub Actions +updates in [#20](https://github.com/CruxExperts/BestBackup/pull/20). Ruff's prior +rule profile is explicit so a tool upgrade does not silently change the +repository's lint policy. The [dependency evidence](release-dependencies.json) +binds the reviewed source dependency graph and records advisory-query limits. +The published v2.0.5 report remains available in its +[versioned source](https://github.com/CruxExperts/BestBackup/blob/v2.0.5/docs/development/release-dependencies.json). + +The B2 SDK update honors a server's `Retry-After` delay on 5xx service errors. +The retry regression covers both 429 and 503 responses, including the fallback +when that header is absent. Inspection deadlines and cancellation still bound +the SDK subprocess; this offline check adds no live provider qualification. + +These are unreleased source changes. They do not publish new artifacts or +expand Docker, cloud-provider, off-host recovery or complete-machine support. + +On 2026-10-23, the final local maintenance candidate passed 1,295 tests with +one skipped test in 114.24 seconds using three workers. This includes the +47 dashboard and TUI cases and the 45 B2 cases covering the upgraded SDK. +Headless captures confirmed the populated/error-state Rich layout at 60x20 +and 80x24, narrow dashboard navigation and restore controls at 60x20, and the +normal 112x36 dashboard preview. Documentation previews were inspected at +narrow and wide widths. Independent review found no remaining blocking issues. +The skipped legacy Docker volume roundtrip still does not qualify that path. +Ruff, Python compilation, version synchronization, generated CLI/schema checks, +Markdown, artwork provenance, publishing readiness and whitespace checks passed. +The wheel and source archive built and passed isolated installed-artifact smoke +checks. These checks validate the source candidate without publishing a release. + ## Unreleased maintenance (issue #30) The source checkout implements the reviewed portions of @@ -1753,6 +1792,11 @@ recovery limits rather than treating discoverability as additional qualification ## Dependency evidence +The entries below record earlier candidates and released 2.0.x inputs. They +remain historical evidence. For the current unreleased dependency graph, use +the [source maintenance record](#unreleased-layout-and-dependency-maintenance) +and [exact lockfile review](release-dependencies.json). + The optional Google Drive client uses oauthlib 4.0.0 to incorporate upstream fixes for CVE-2026-49264 and CVE-2026-49265. Exact-version OSV, GitHub advisory, deps.dev, and PyPI checks found no affecting advisories; neither CVE appeared in diff --git a/pyproject.toml b/pyproject.toml index 3a4fca0..e5c7fe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,20 +34,20 @@ classifiers = [ dependencies = [ "rich>=13.7.0", "pyyaml>=6.0.1", - "docker>=7.0.0", - "click>=8.1.7", - "paramiko>=3.4.0", - "cryptography>=50.0.0", + "docker>=7.2.0", + "click>=8.5.0", + "paramiko>=5.1.0", + "cryptography>=50.0.2", "requests>=2.31.0", "textual==8.2.8", - "boto3==1.43.108", + "boto3==1.43.114", "urllib3>=2.8.0", - "b2sdk==2.13.0", + "b2sdk==2.13.1", ] [project.optional-dependencies] gdrive-auth = [ - "google-auth-oauthlib>=1.4.0,<2", + "google-auth-oauthlib>=1.5.0,<2", "oauthlib>=4.0.0", "requests-oauthlib>=2.0.0", ] @@ -63,11 +63,11 @@ bbman = "bbackup.bbman_entry:cli" [dependency-groups] dev = [ - "pytest>=8.0", + "pytest>=9.1.1", "pytest-cov>=5.0", - "pytest-mock>=3.14", + "pytest-mock>=3.17.0", "pytest-xdist>=3.8,<4", - "ruff>=0.8.0", + "ruff>=0.17.1", ] [tool.setuptools.packages.find] @@ -85,3 +85,7 @@ markers = [ [tool.ruff] target-version = "py312" + +[tool.ruff.lint] +# Preserve the established rule profile across Ruff's 0.16 default expansion. +select = ["E4", "E7", "E9", "F"] diff --git a/tests/test_b2.py b/tests/test_b2.py index 098ef5c..c076eb0 100644 --- a/tests/test_b2.py +++ b/tests/test_b2.py @@ -347,7 +347,8 @@ def test_real_child_missing_credentials_and_precancel(monkeypatch): @pytest.mark.parametrize("status", [429, 503]) -def test_vendor_http_retry_uses_polite_backoff(monkeypatch, status): +@pytest.mark.parametrize("retry_after", ["3", None]) +def test_vendor_http_retry_uses_polite_backoff(monkeypatch, status, retry_after): from b2sdk._internal.b2http import B2Http from b2sdk.v3 import B2HttpApiConfig import requests @@ -364,7 +365,8 @@ def request(self, *args, **kwargs): if len(attempts) == 1 else {"ok": True} ).encode() - response.headers["Retry-After"] = "3" + if retry_after is not None: + response.headers["Retry-After"] = retry_after return response monkeypatch.setattr("b2sdk._internal.b2http.time.sleep", delays.append) @@ -379,4 +381,4 @@ def request(self, *args, **kwargs): "https://test.invalid", {}, {}, try_count=3 ) == {"ok": True} assert len(attempts) == 2 - assert delays == ([3] if status == 429 else [1.0]) + assert delays == ([3] if retry_after is not None else [1.0]) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 972dfcb..55bd481 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2,16 +2,41 @@ import asyncio import json import shutil +import threading import pytest -from textual.widgets import Button, DataTable, Input +from textual.containers import VerticalScroll +from textual.widgets import Button, DataTable, Input, TabbedContent from bbackup.dashboard import BackupDashboard from bbackup.configuration_workspace import ConfigurationWorkspace +from bbackup.dashboard_operations import OperationPanel from tests.test_service import service # noqa: F401 +async def _tab_to(pilot, app, widget, *, steps=50): + for _ in range(steps): + if app.focused is widget: + return True + await pilot.press("tab") + await pilot.pause(0.1) + if app.focused is widget: + for _ in range(10): + if _fully_visible(widget, app): + return True + await pilot.pause(0.1) + return True + return app.focused is widget + + +def _fully_visible(widget, app): + visible = widget.region.intersection(app.screen.region) + return (widget.region.height > 0 and widget.region.width > 0 + and visible.height == widget.region.height + and visible.width == widget.region.width) + + def test_mouse_keyboard_search_and_small_screen(service): async def exercise(): app = BackupDashboard(service) @@ -36,6 +61,147 @@ async def exercise(): asyncio.run(exercise()) +def test_small_screen_keyboard_reaches_all_overview_actions(service): + async def exercise(): + app = BackupDashboard(service) + async with app.run_test(size=(60, 20)) as pilot: + for widget_id in ("next-storage", "next-backup", "next-restore"): + button = app.query_one(f"#{widget_id}", Button) + assert await _tab_to(pilot, app, button), widget_id + assert _fully_visible(button, app), (widget_id, button.region) + + asyncio.run(exercise()) + + +def test_small_screen_scroll_reaches_dashboard_actions(service, tmp_path): + workspace = ConfigurationWorkspace(tmp_path / "config.json", tmp_path / "bindings.json") + + async def exercise(): + app = BackupDashboard(service, workspace=workspace) + async with app.run_test(size=(60, 20)) as pilot: + tabs = app.query_one(TabbedContent) + tabs.active = "jobs" + await pilot.pause() + jobs = app.query_one("#jobs") + assert jobs.region.bottom <= tabs.region.bottom + jobs.scroll_end(animate=False, immediate=True) + await pilot.pause() + panel = app.query_one("#job-operations", OperationPanel) + panel.scroll_end(animate=False, immediate=True) + await pilot.pause() + run = panel.query_one("#operation-run", Button) + assert run.region.height > 0 + assert run.region.intersection(app.screen.region).height == run.region.height + + tabs.active = "restore" + await pilot.pause() + restore_scroll = app.query_one("#restore").query_one(VerticalScroll) + restore_scroll.scroll_end(animate=False, immediate=True) + await pilot.pause() + restore = app.query_one("#restore-start", Button) + assert restore.region.intersection(app.screen.region).height == restore.region.height + + tabs.active = "settings" + await pilot.pause() + settings = app.query_one("#settings") + settings.scroll_end(animate=False, immediate=True) + await pilot.pause() + save = app.query_one("#settings-save", Button) + assert save.region.intersection(app.screen.region).height == save.region.height + + asyncio.run(exercise()) + + +def test_small_screen_keyboard_reaches_restore_start_and_cancel(service, tmp_path): + started = threading.Event() + cancelled = threading.Event() + + async def exercise(): + app = BackupDashboard(service) + + def fake_restore(repository, snapshot, target): + started.set() + if not app._restore_cancel.wait(5): + raise TimeoutError("test restore was not cancelled") + cancelled.set() + + app._restore_operation = fake_restore + async with app.run_test(size=(60, 20)) as pilot: + await pilot.click("#--content-tab-restore") + app.query_one("#restore-repository", Input).value = "local" + app.query_one("#restore-snapshot", Input).value = "a" * 64 + app.query_one("#restore-target", Input).value = str(tmp_path / "new-restore") + + start = app.query_one("#restore-start", Button) + assert await _tab_to(pilot, app, start), "restore start was not keyboard reachable" + assert _fully_visible(start, app) + await pilot.press("enter") + assert await asyncio.to_thread(started.wait, 2) + + cancel = app.query_one("#restore-cancel", Button) + assert not cancel.disabled + assert await _tab_to(pilot, app, cancel), "restore cancel was not keyboard reachable" + assert _fully_visible(cancel, app) + await pilot.press("enter") + await app.workers.wait_for_complete() + assert cancelled.is_set() + + asyncio.run(exercise()) + + +def test_small_screen_keyboard_reaches_operation_confirmation_and_cancel(service, monkeypatch): + from bbackup import dashboard_operations + + started = threading.Event() + cancelled = threading.Event() + calls = [] + + def fake_execute(command, values, **kwargs): + calls.append((command, values)) + started.set() + if not kwargs["cancel"].wait(5): + raise TimeoutError("test operation was not cancelled") + cancelled.set() + return {"cancelled": True} + + monkeypatch.setattr(dashboard_operations, "execute_operation", fake_execute) + + async def exercise(): + app = BackupDashboard(service) + async with app.run_test(size=(60, 20)) as pilot: + await pilot.click("#--content-tab-jobs") + panel = app.query_one("#job-operations", OperationPanel) + panel.prepare("jobs run", {"name": "daily"}) + await pilot.click("#job-search") + + confirmation = panel.query_one("#value-quiet_administrative_window_confirmed") + assert await _tab_to(pilot, app, confirmation), "quiet-window confirmation was not keyboard reachable" + assert confirmation.value is False + assert _fully_visible(confirmation, app), ( + confirmation.region, confirmation.region.intersection(app.screen.region), + panel.region, panel.scroll_y, panel.max_scroll_y, + app.query_one("#jobs").scroll_y, app.query_one("#jobs").max_scroll_y, + ) + + run = panel.query_one("#operation-run", Button) + assert await _tab_to(pilot, app, run), "execute button was not keyboard reachable" + assert _fully_visible(run, app) + await pilot.press("enter") + assert await asyncio.to_thread(started.wait, 2) + assert calls[0][0] == "jobs run" + assert calls[0][1]["quiet_administrative_window_confirmed"] is False + + cancel = panel.query_one("#operation-cancel", Button) + assert not cancel.disabled + assert await _tab_to(pilot, app, cancel), "operation cancel was not keyboard reachable" + assert _fully_visible(cancel, app) + await pilot.press("enter") + await app.workers.wait_for_complete() + assert cancelled.is_set() + + asyncio.run(exercise()) + + @pytest.mark.integration @pytest.mark.skipif(shutil.which("restic") is None, reason="restic is required") def test_dashboard_restore_verifies_files_and_refuses_existing_target(service, tmp_path): @@ -98,7 +264,7 @@ async def exercise(): asyncio.run(exercise()) -@pytest.mark.parametrize("size", [(80, 24), (112, 32)]) +@pytest.mark.parametrize("size", [(80, 24), (112, 32), (60, 20)]) def test_guided_setup_preserves_invalid_input_and_creates_enrolled_policy(tmp_path, monkeypatch, size): from pathlib import Path from textual.widgets import Static @@ -114,6 +280,8 @@ async def exercise(): app = BackupSetup(workspace) async with app.run_test(size=size) as pilot: await pilot.click("#setup-next") + navigation = app.query_one("#setup-next", Button) + assert navigation.region.intersection(app.screen.region).height == navigation.region.height assert app._step == 0 assert "existing absolute source" in str(app.query_one("#setup-status", Static).render()) app.query_one("#setup-source", Input).value = str(source) @@ -124,6 +292,9 @@ async def exercise(): app.query_one("#setup-next", Button).focus() await pilot.press("enter") await pilot.pause(0.35) + navigation = app.query_one("#setup-next", Button) + if navigation.display: + assert navigation.region.intersection(app.screen.region).height == navigation.region.height assert app._step == 3, str(app.query_one("#setup-status", Static).render()) assert str(source) in str(app.query_one("#setup-review", Static).render()) app.query_one("#setup-save", Button).focus() diff --git a/tests/test_tui.py b/tests/test_tui.py index 3a32e16..6925396 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -290,6 +290,45 @@ def test_live_dashboard_header_uses_package_version(self): assert f"v{bbackup.__version__}" in output.getvalue() + def test_live_dashboard_adapts_resource_panels_to_terminal_size(self): + from rich.console import Console + + cases = ((120, 40), (80, 24), (60, 20)) + for width, height in cases: + tui = BackupTUI(Config(config_path=None)) + tui.console = Console(file=StringIO(), width=width, height=height, force_terminal=False) + for number in range(12): + tui.status.set_item_status( + "containers", f"api-{number}", {"status": "success", "size": "1.2 GiB"} + ) + tui.status.set_item_status("volumes", "db", {"status": "failed", "speed": "retrying"}) + tui.status.set_item_status("filesystems", "/srv/data", {"status": "success", "size": "2.0 GiB"}) + tui.status.update(action="Verifying restore", item="daily-job", completed=2, total=4) + tui.status.add_error("Restic check failed") + tui.status.add_warning("Repository capacity is low") + tui.status.set_encryption_status("failed") + + tui.console.print(tui.create_live_dashboard()) + rendered = tui.console.file.getvalue() + + assert all(value in rendered for value in ("api-0", "db", "/srv/data")) + if width >= 120: + assert "Container" in rendered + assert "Progress" in rendered + assert "Progre…" not in rendered + else: + assert "Backup resources" in rendered + assert "Containers (12)" in rendered + assert "Volumes (1)" in rendered + assert "Filesystems (1)" in rendered + assert "Verifying restore" in rendered + assert "daily-job" in rendered + assert "Errors: 1" in rendered + assert "Encryption failed" in rendered + assert "Warnings: 1" in rendered + assert "Restic check failed" in rendered + assert "Q quit/cancel" in rendered + def test_live_dashboard_single_key_cancel_restores_terminal(self, monkeypatch): """A raw Q cancels the operation and restores the PTY settings.""" from rich.console import Console @@ -320,4 +359,4 @@ def operation(): assert termios.tcgetattr(stdin) == before finally: stdin.close() - os.close(master_fd) \ No newline at end of file + os.close(master_fd) diff --git a/uv.lock b/uv.lock index 7ea5165..f2f1c42 100644 --- a/uv.lock +++ b/uv.lock @@ -17,16 +17,16 @@ wheels = [ [[package]] name = "b2sdk" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "logfury" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/9c/63c08bc3d737893689f62a1fb7a6a61cd60f73cb0e9ac07c62de65dbe9a1/b2sdk-2.13.0.tar.gz", hash = "sha256:edb586be7698b4b868194506cd81d594364e445817461c7e7b43df47378f6827", size = 210181, upload-time = "2026-09-14T19:05:18.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/1d/18ad9b5ed6408b74d159a65544a786813149aaf34128e888c6c42ed49595/b2sdk-2.13.1.tar.gz", hash = "sha256:45f91907bb677f916c920e9ccc3d120fdbd2b11d0059ff2cd06360973c281e6f", size = 210410, upload-time = "2026-10-04T16:19:26.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/f1/d0dec920f2af7fc8a67640a08c5ec6397d92922db51b91ff488520915f7b/b2sdk-2.13.0-py3-none-any.whl", hash = "sha256:2063cd1c1c202f2dbc0fc517e9ad0c50980939355f565704e0183c4817f18524", size = 301298, upload-time = "2026-09-14T19:05:17.381Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/5f3b9a75835f4dd897ee65b3a4d719700e7583989c38a01dba76280ca18e/b2sdk-2.13.1-py3-none-any.whl", hash = "sha256:ce6533be17f75785ce9511f063b9dd1142adbe71fa124417005b65bad69e8abe", size = 301509, upload-time = "2026-10-04T16:19:24.848Z" }, ] [[package]] @@ -65,14 +65,14 @@ dev = [ [package.metadata] requires-dist = [ - { name = "b2sdk", specifier = "==2.13.0" }, - { name = "boto3", specifier = "==1.43.108" }, - { name = "click", specifier = ">=8.1.7" }, - { name = "cryptography", specifier = ">=50.0.0" }, - { name = "docker", specifier = ">=7.0.0" }, - { name = "google-auth-oauthlib", marker = "extra == 'gdrive-auth'", specifier = ">=1.4.0,<2" }, + { name = "b2sdk", specifier = "==2.13.1" }, + { name = "boto3", specifier = "==1.43.114" }, + { name = "click", specifier = ">=8.5.0" }, + { name = "cryptography", specifier = ">=50.0.2" }, + { name = "docker", specifier = ">=7.2.0" }, + { name = "google-auth-oauthlib", marker = "extra == 'gdrive-auth'", specifier = ">=1.5.0,<2" }, { name = "oauthlib", marker = "extra == 'gdrive-auth'", specifier = ">=4.0.0" }, - { name = "paramiko", specifier = ">=3.4.0" }, + { name = "paramiko", specifier = ">=5.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "requests", specifier = ">=2.31.0" }, { name = "requests-oauthlib", marker = "extra == 'gdrive-auth'", specifier = ">=2.0.0" }, @@ -84,11 +84,11 @@ provides-extras = ["gdrive-auth"] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=8.0" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=5.0" }, - { name = "pytest-mock", specifier = ">=3.14" }, + { name = "pytest-mock", specifier = ">=3.17.0" }, { name = "pytest-xdist", specifier = ">=3.8,<4" }, - { name = "ruff", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.17.1" }, ] [[package]] @@ -159,30 +159,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.108" +version = "1.43.114" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/59/fb93b6ebd9ad43eb9a58c7a6da51a0fe24ab0c04bc4d534a0bfc5eba7f59/boto3-1.43.108.tar.gz", hash = "sha256:03341f089158368acf83e921aca98b706095322ca52bc4c039a616940aa5ad41", size = 112682, upload-time = "2026-10-02T19:32:25.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/8c/f6f884dc947789317e73ed6fce85e18580d22e9f90e48d67c2367b02667e/boto3-1.43.114.tar.gz", hash = "sha256:be704857751564a5cf69c5bbaadbfa01c22806409815c73563db42fbffe583a2", size = 112653, upload-time = "2026-10-14T19:24:22.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/e4/7e88c40e9f61888e12dac0de41a5fddc2bcd1c3992d9b28e17d915cce0df/boto3-1.43.108-py3-none-any.whl", hash = "sha256:19e9da95ef0c494e27052049a42137550e66730509bb613e76eaa30ddf9a7170", size = 140045, upload-time = "2026-10-02T19:32:23.826Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f8/0799a101e6f65c8b687f50c218654cef1e44658e946c7d33d362e2572621/boto3-1.43.114-py3-none-any.whl", hash = "sha256:d9cac2eb921ce674970cef1c9ad750f85ee3a846aedcf188d18368fb9eb6da23", size = 140043, upload-time = "2026-10-14T19:24:21.038Z" }, ] [[package]] name = "botocore" -version = "1.43.108" +version = "1.43.117" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/16/6b4477f433da2c11193802f538330ce080076c2f38d817ad437ed3cd1465/botocore-1.43.108.tar.gz", hash = "sha256:ee4f75cf3bdbb0da7912e089950e8112f692016539d939312c771499958e6cfd", size = 16312879, upload-time = "2026-10-02T19:32:20.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/a0/d0a5c44ad6a01f195681ddcb4ce203f06c712811daf1d15f8f8785e8a9f8/botocore-1.43.117.tar.gz", hash = "sha256:71993702832cc37c2495048b9945f2929d306fccb3fb767e9964e437e637fa6c", size = 16366888, upload-time = "2026-10-21T19:29:59.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/0b/4670b7e23914b5cc357be5ca45fae503b57d98ecff4b3eabea70412809fd/botocore-1.43.108-py3-none-any.whl", hash = "sha256:ab9d16c6b4350aaa54ed28202dfa2998b2d735fdbf3247eb60af469d8d48a5b8", size = 16008194, upload-time = "2026-10-02T19:32:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/10/94/050e35c9febf6dd2fce4a76be587ae4a323964865700467d9d7b082f7133/botocore-1.43.117-py3-none-any.whl", hash = "sha256:5920ef9b3002e1361846549078549d9154a6e0004c22853c01ce7b279d05d495", size = 16061765, upload-time = "2026-10-21T19:29:56.001Z" }, ] [[package]] @@ -326,14 +326,11 @@ wheels = [ [[package]] name = "click" -version = "8.4.1" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -431,66 +428,79 @@ wheels = [ [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, - { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, - { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, - { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, - { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, - { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, - { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] [[package]] name = "docker" -version = "7.1.0" +version = "7.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, ] [[package]] @@ -517,15 +527,15 @@ wheels = [ [[package]] name = "google-auth-oauthlib" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "requests-oauthlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/40/1d7901e454831247e377ef3f642cb857cc0e82ec4d2380b7a148a48f20d1/google_auth_oauthlib-1.5.0.tar.gz", hash = "sha256:b351107c7dd9017f426cbb0272ea1bc04f469020fd18f4443c7d40362e0b1510", size = 23926, upload-time = "2026-09-29T19:25:56.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bb/b5cc0757ad2f8068e7eb7180c37f82eb488627624c1b1214aa47ddbc032c/google_auth_oauthlib-1.5.0-py3-none-any.whl", hash = "sha256:71625fdea21c6c03217eb9ff13741c3096e6d257ebca0ad084dbd6e9f4aa4ef4", size = 20348, upload-time = "2026-09-29T19:25:31.842Z" }, ] [[package]] @@ -640,7 +650,7 @@ wheels = [ [[package]] name = "paramiko" -version = "5.0.0" +version = "5.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -648,9 +658,9 @@ dependencies = [ { name = "invoke" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/cb/b1fde76618d84b1acf54d3d03d2b833ba5878afd12b9899346cc080f209d/paramiko-5.1.0.tar.gz", hash = "sha256:239b5f9b2a53006cd54f83cf8495ee742083708ead947882c8dd2786de79f39b", size = 1579549, upload-time = "2026-10-18T02:38:51.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" }, + { url = "https://files.pythonhosted.org/packages/13/45/fa0e8d70cb25d2dae06bc238e580a84834f6ca53a4e2408cccc19a78db7b/paramiko-5.1.0-py3-none-any.whl", hash = "sha256:d9fe6d67a9d0bbf3eb85b051d26ab850b678c02d060f32915b944e5ca6d0d9e1", size = 211623, upload-time = "2026-10-18T02:38:49.903Z" }, ] [[package]] @@ -747,7 +757,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -756,9 +766,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -777,14 +787,14 @@ wheels = [ [[package]] name = "pytest-mock" -version = "3.15.1" +version = "3.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz", hash = "sha256:e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4", size = 43064, upload-time = "2026-10-17T16:21:42.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl", hash = "sha256:1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b", size = 11248, upload-time = "2026-10-17T16:21:41.265Z" }, ] [[package]] @@ -917,27 +927,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, ] [[package]]