forest-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
forest/rclone.py ADDED
@@ -0,0 +1,561 @@
1
+ """Thin active transport wrapper around the external ``rclone`` binary.
2
+
3
+ This module provides the low-level transport primitives (connection-string
4
+ builder + ``copy``/``copyto``/``lsjson``/``check``) plus the duck-typed
5
+ :class:`RemoteFile` / :class:`TransferResult` data shapes, and the
6
+ :class:`RcloneTransport` adapter + :func:`get_transport` factory used by
7
+ ``cli.py``.
8
+
9
+ forest.yaml is the single source of truth for remotes: every connstring is
10
+ built on the fly and passed as ONE argv element (no shell, no ``rclone.conf``).
11
+ Real-name files at rest on every backend; no md5/content-addressing anywhere.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from contextlib import suppress
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from urllib.parse import urlsplit
28
+
29
+ from forest import metrics
30
+ from forest.config import RemoteConfig
31
+ from forest.logger import get_logger
32
+ from forest.resilience import CircuitBreaker, CircuitOpenError, backoff_delay, is_transient, transfer_retries
33
+
34
+ _log = get_logger("forest.rclone")
35
+
36
+ RCLONE_INSTALL_MSG = "rclone is required. Install with: conda install -c conda-forge rclone"
37
+
38
+ _LOCAL_METADATA_EXCLUDES = ["--exclude", "._*", "--exclude", ".DS_Store"]
39
+ _APPLEDOUBLE_MAGIC = b"\x00\x05\x16\x07"
40
+
41
+
42
+ class RcloneError(Exception):
43
+ """Raised when an rclone subprocess exits non-zero.
44
+
45
+ Carries the captured ``stderr`` text and the process exit ``code`` so
46
+ callers can surface the failure (e.g. as ``TransferResult.errors``).
47
+ """
48
+
49
+ def __init__(self, stderr: str, code: int) -> None:
50
+ self.stderr = stderr
51
+ self.code = code
52
+ super().__init__(f"rclone failed (exit {code}): {stderr}".rstrip())
53
+
54
+
55
+ @dataclass
56
+ class TransferResult:
57
+ """Outcome of a push or pull operation (duck-typed across cli.py)."""
58
+
59
+ files_transferred: int
60
+ bytes_transferred: int
61
+ errors: list[str] = field(default_factory=list)
62
+
63
+ @property
64
+ def success(self) -> bool:
65
+ return len(self.errors) == 0
66
+
67
+
68
+ @dataclass
69
+ class RemoteFile:
70
+ """A file listing entry on a remote.
71
+
72
+ ``key`` is PREFIX-RELATIVE and may contain ``/`` (nested dirs preserved).
73
+ """
74
+
75
+ key: str
76
+ size: int
77
+ last_modified: str | None = None
78
+
79
+
80
+ def is_available() -> bool:
81
+ """Return True when the ``rclone`` binary is resolvable on ``$PATH``."""
82
+ return shutil.which("rclone") is not None
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # Connection-string builder
87
+ # --------------------------------------------------------------------------- #
88
+
89
+
90
+ def build_remote(remote_cfg: RemoteConfig) -> str:
91
+ """Build an on-the-fly rclone connection-string ROOT from a RemoteConfig.
92
+
93
+ Dispatches on ``remote_cfg.remote_type`` (``s3`` / ``local`` / ``sftp``).
94
+ The returned string is a single argv element; never shell-quote it here.
95
+ """
96
+ remote_type = remote_cfg.remote_type
97
+ if remote_type == "local":
98
+ return _build_local(remote_cfg)
99
+ if remote_type == "s3":
100
+ return _build_s3(remote_cfg)
101
+ if remote_type == "sftp":
102
+ return _build_sftp(remote_cfg)
103
+ raise ValueError(f"Unsupported remote type '{remote_type}' for URL: {remote_cfg.url}")
104
+
105
+
106
+ def _build_local(remote_cfg: RemoteConfig) -> str:
107
+ # Plain absolute path, used as-is. Drop trailing slashes so joining
108
+ # ``{root}/{key}`` never yields ``//``; spaces are preserved verbatim.
109
+ stripped = remote_cfg.url.rstrip("/")
110
+ return stripped or "/"
111
+
112
+
113
+ def _build_s3(remote_cfg: RemoteConfig) -> str:
114
+ rest = remote_cfg.url[len("s3://") :].rstrip("/") # "bucket" or "bucket/prefix"
115
+ opts = ["provider=AWS", "env_auth=true"]
116
+ if remote_cfg.profile is not None:
117
+ opts.append(f"profile={remote_cfg.profile}")
118
+ if remote_cfg.region is not None:
119
+ opts.append(f"region={remote_cfg.region}")
120
+ if remote_cfg.endpoint is not None:
121
+ opts.append(f"endpoint={remote_cfg.endpoint}")
122
+ opts.append("no_check_bucket=true")
123
+ return ":s3," + ",".join(opts) + ":" + rest
124
+
125
+
126
+ def _parse_sftp_url(url: str) -> tuple[str | None, str | None, int, str | None]:
127
+ """Parse an sftp URL into ``(user, host, port, path)``.
128
+
129
+ Accepts ``sftp://host[:port]/path`` (credentials rejected) and scp-style
130
+ ``user@host:[port:]/path`` forms.
131
+ """
132
+ if url.startswith("sftp://"):
133
+ parsed = urlsplit(url)
134
+ if parsed.username or parsed.password:
135
+ raise ValueError("Cannot parse sftp url with embedded credentials; use key_file and known_hosts.")
136
+ return None, parsed.hostname, parsed.port or 22, parsed.path
137
+
138
+ match = re.match(r"^(?P<user>[^@]+)@(?P<rest>.+)$", url)
139
+ if not match:
140
+ raise ValueError(f"Cannot parse sftp url (expected user@host:/path or sftp://host/path): {url}")
141
+ user = match.group("user")
142
+ host_part, _, path_part = match.group("rest").partition(":")
143
+ if not path_part:
144
+ raise ValueError(f"Cannot parse sftp url (missing remote path): {url}")
145
+ port = 22
146
+ if not path_part.startswith("/") and ":" in path_part:
147
+ maybe_port, _, rest_path = path_part.partition(":")
148
+ if maybe_port.isdigit():
149
+ port = int(maybe_port)
150
+ path_part = rest_path
151
+ return user, host_part, port, path_part
152
+
153
+
154
+ def _build_sftp(remote_cfg: RemoteConfig) -> str:
155
+ missing = [
156
+ name
157
+ for name, value in (("key_file", remote_cfg.key_file), ("known_hosts", remote_cfg.known_hosts))
158
+ if not value
159
+ ]
160
+ if missing:
161
+ missing_text = ", ".join(missing)
162
+ raise ValueError(
163
+ "sftp remote requires key_file and known_hosts (set them in forest.yaml); "
164
+ f"missing: {missing_text}; "
165
+ "refusing to build a connstring without them (no agent/default-key fallback, "
166
+ "no host-key-check bypass)."
167
+ )
168
+
169
+ user, host_part, port, path_part = _parse_sftp_url(remote_cfg.url)
170
+
171
+ if not host_part:
172
+ raise ValueError(f"Cannot parse sftp url (missing host): {remote_cfg.url}")
173
+ if not path_part or not path_part.startswith("/"):
174
+ raise ValueError(f"Cannot parse sftp url (missing remote path): {remote_cfg.url}")
175
+
176
+ opts = [
177
+ f"host={host_part}",
178
+ f"key_file={remote_cfg.key_file}",
179
+ f"known_hosts_file={remote_cfg.known_hosts}",
180
+ f"port={port}",
181
+ ]
182
+ if user is not None:
183
+ opts.insert(1, f"user={user}")
184
+ return f":sftp,{','.join(opts)}:{path_part}"
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # Subprocess helpers
189
+ # --------------------------------------------------------------------------- #
190
+
191
+
192
+ def _progress_flags(quiet: bool) -> list[str]:
193
+ if quiet:
194
+ return ["-q"]
195
+ # --stats-log-level NOTICE forces rclone to emit the final transfer stats
196
+ # line to stderr even for fast/sub-second transfers (the default INFO level
197
+ # is below rclone's console threshold, so small uploads would otherwise show
198
+ # no stats at all). `-q` in quiet mode suppresses these entirely.
199
+ return ["--stats", "1s", "--stats-one-line", "--stats-log-level", "NOTICE"]
200
+
201
+
202
+ def _run(args: list[str], *, echo_stderr: bool) -> subprocess.CompletedProcess[str]:
203
+ """Run ``rclone <args>``, raising :class:`RcloneError` on non-zero exit.
204
+
205
+ stderr is always captured (so errors carry text); when ``echo_stderr`` it
206
+ is also forwarded to the real stderr so rclone's stats/errors stay visible.
207
+ Transient failures (exit code 5 / network-blip stderr) are retried with
208
+ exponential backoff up to ``FOREST_TRANSFER_RETRIES`` extra attempts; only
209
+ the final attempt's stderr is echoed so retries do not duplicate output.
210
+ """
211
+ op = args[0] if args else "rclone"
212
+ max_retries = transfer_retries()
213
+ attempt = 0
214
+ start = time.monotonic()
215
+ while True:
216
+ proc = subprocess.run(["rclone", *args], capture_output=True, text=True)
217
+ if proc.returncode == 0:
218
+ break
219
+ if attempt < max_retries and is_transient(proc.returncode, proc.stderr or ""):
220
+ attempt += 1
221
+ delay = backoff_delay(attempt)
222
+ _log.warning(
223
+ "transient rclone failure; retrying",
224
+ extra={"op": op, "exit_code": proc.returncode, "attempt": attempt, "delay_s": delay},
225
+ )
226
+ metrics.emit("rclone.retries", 1, tags={"op": op})
227
+ time.sleep(delay)
228
+ continue
229
+ if echo_stderr and proc.stderr:
230
+ sys.stderr.write(proc.stderr)
231
+ _log.error("rclone failed", extra={"op": op, "exit_code": proc.returncode, "attempts": attempt + 1})
232
+ metrics.emit("rclone.failures", 1, tags={"op": op})
233
+ raise RcloneError(proc.stderr or "", proc.returncode)
234
+ if echo_stderr and proc.stderr:
235
+ sys.stderr.write(proc.stderr)
236
+ duration = round(time.monotonic() - start, 3)
237
+ _log.debug("rclone ok", extra={"op": op, "attempts": attempt + 1, "duration_s": duration})
238
+ metrics.emit("rclone.duration_seconds", duration, unit="s", tags={"op": op})
239
+ return proc
240
+
241
+
242
+ def _is_local_dst(dst: str) -> bool:
243
+ # rclone connstrings for non-local backends start with ':' (e.g. ':s3,', ':sftp,').
244
+ return not dst.startswith(":")
245
+
246
+
247
+ def _remove_local_metadata_sidecars(written_files: list[Path], *, root: Path | None = None) -> None:
248
+ """Remove local macOS metadata sidecars scoped to written file paths.
249
+
250
+ AppleDouble ``._*`` files are deleted only when the 4-byte AppleDouble magic
251
+ matches, preserving real data files whose names happen to start with ``._``.
252
+ ``.DS_Store`` has no data payload in forest outputs, so it is deleted
253
+ unconditionally, but only from directories touched by this transfer.
254
+ """
255
+ candidates: set[Path] = set()
256
+ for path in written_files:
257
+ path = Path(path)
258
+ candidates.add(path.parent / f"._{path.name}")
259
+ candidates.add(path.parent / ".DS_Store")
260
+ if root is not None:
261
+ candidates.update(_ancestor_metadata_candidates(path, root))
262
+
263
+ for path in candidates:
264
+ if _is_metadata_sidecar(path):
265
+ with suppress(OSError):
266
+ path.unlink()
267
+
268
+
269
+ def _ancestor_metadata_candidates(path: Path, root: Path) -> set[Path]:
270
+ """Return metadata candidates for ancestor dirs from ``root`` to ``path``."""
271
+ candidates: set[Path] = set()
272
+ with suppress(ValueError):
273
+ rel = path.relative_to(root)
274
+ current = root
275
+ candidates.add(current / ".DS_Store")
276
+ for part in rel.parts[:-1]:
277
+ child = current / part
278
+ candidates.add(current / f"._{part}")
279
+ candidates.add(child / ".DS_Store")
280
+ current = child
281
+ return candidates
282
+
283
+
284
+ def _is_metadata_sidecar(path: Path) -> bool:
285
+ if not path.is_file():
286
+ return False
287
+ if path.name == ".DS_Store":
288
+ return True
289
+ if not path.name.startswith("._"):
290
+ return False
291
+ with suppress(OSError):
292
+ return path.read_bytes()[:4] == _APPLEDOUBLE_MAGIC
293
+ return False
294
+
295
+
296
+ # --------------------------------------------------------------------------- #
297
+ # Transfer primitives
298
+ # --------------------------------------------------------------------------- #
299
+
300
+
301
+ def copy(files: list[Path], local_base: Path, dst: str, *, quiet: bool) -> None:
302
+ """Copy ``files`` to ``dst`` preserving paths RELATIVE to ``local_base``.
303
+
304
+ Runs ``rclone copy --files-from-raw <tmp list> <local_base> <dst>``. The
305
+ list entries are each file's path relative to ``local_base``; rclone
306
+ preserves that relative layout under ``dst`` and CANNOT rename (use
307
+ :func:`copyto`). ``--files-from-raw`` (not ``--files-from``) is required so
308
+ real filenames with leading ``#``/``;`` or surrounding whitespace are taken
309
+ verbatim instead of being parsed as comments/trimmed and silently dropped.
310
+ """
311
+ if not files:
312
+ return
313
+
314
+ local_base = Path(local_base)
315
+ rel_lines = [str(Path(f).relative_to(local_base)) for f in files]
316
+
317
+ fd, list_path = tempfile.mkstemp(suffix=".lst")
318
+ try:
319
+ with os.fdopen(fd, "w") as fh:
320
+ fh.write("\n".join(rel_lines) + "\n")
321
+ args = ["copy", "--files-from-raw", list_path, str(local_base), dst, *_progress_flags(quiet)]
322
+ _run(args, echo_stderr=not quiet)
323
+ if _is_local_dst(dst):
324
+ dst_root = Path(dst)
325
+ _remove_local_metadata_sidecars([dst_root / rel for rel in rel_lines], root=dst_root)
326
+ finally:
327
+ os.unlink(list_path)
328
+
329
+
330
+ def copyto(src_file: Path | str, dst: str, *, quiet: bool) -> None:
331
+ """Copy a single file to an exact destination (rename semantics).
332
+
333
+ Runs ``rclone copyto <src> <dst>`` — used for remote_path remaps and
334
+ ``sync_by=file`` renames where the destination key differs from the source.
335
+ """
336
+ args = ["copyto", str(src_file), dst, *_progress_flags(quiet)]
337
+ _run(args, echo_stderr=not quiet)
338
+ if _is_local_dst(dst):
339
+ _remove_local_metadata_sidecars([Path(dst)])
340
+
341
+
342
+ # --------------------------------------------------------------------------- #
343
+ # Listing
344
+ # --------------------------------------------------------------------------- #
345
+
346
+
347
+ def lsjson(dst: str, prefix: str = "") -> list[RemoteFile]:
348
+ """List files under ``dst``/``prefix`` recursively as RemoteFile objects.
349
+
350
+ Runs ``rclone lsjson --recursive --files-only [--exclude '._*' ...] <target>``
351
+ and maps each object: ``Path -> key`` (prefix-relative, nested preserved),
352
+ ``Size -> size``, ``ModTime -> last_modified``. The ModTime parse is tolerant
353
+ (RFC3339 with variable fractional seconds / tz, or odd/missing values): the
354
+ raw string is kept and an empty/absent value becomes None. Never crashes.
355
+ """
356
+ target = f"{dst.rstrip('/')}/{prefix.strip('/')}" if prefix else dst
357
+
358
+ args = ["lsjson", "--recursive", "--files-only"]
359
+ if _is_local_dst(dst):
360
+ args += _LOCAL_METADATA_EXCLUDES
361
+ args.append(target)
362
+
363
+ try:
364
+ proc = _run(args, echo_stderr=False)
365
+ except RcloneError as exc:
366
+ # A missing remote dir/prefix is an empty listing, not a failure: the
367
+ # duck-typed ls contract returns [] so callers (pull --all, status
368
+ # --remote) skip stages that have nothing on the remote yet.
369
+ if _is_missing_prefix_error(exc):
370
+ return []
371
+ raise
372
+ stdout = proc.stdout.strip()
373
+ if not stdout:
374
+ return []
375
+
376
+ files: list[RemoteFile] = []
377
+ for obj in json.loads(stdout):
378
+ key = obj.get("Path")
379
+ if key is None:
380
+ continue
381
+ size_raw = obj.get("Size", 0)
382
+ try:
383
+ size = int(size_raw)
384
+ except (TypeError, ValueError):
385
+ size = 0
386
+ files.append(RemoteFile(key=key, size=size, last_modified=_coerce_modtime(obj.get("ModTime"))))
387
+ return files
388
+
389
+
390
+ def _coerce_modtime(value: object) -> str | None:
391
+ if isinstance(value, str):
392
+ stripped = value.strip()
393
+ return stripped or None
394
+ if value is None:
395
+ return None
396
+ return str(value)
397
+
398
+
399
+ def _is_missing_prefix_error(exc: RcloneError) -> bool:
400
+ """Return True for rclone's absent remote dir/prefix listing error."""
401
+ if exc.code == 3:
402
+ return True
403
+ stderr = (exc.stderr or "").lower()
404
+ return "directory not found" in stderr
405
+
406
+
407
+ # --------------------------------------------------------------------------- #
408
+ # Verify
409
+ # --------------------------------------------------------------------------- #
410
+
411
+
412
+ def check(local_base: Path, dst: str) -> bool:
413
+ """Return True when ``local_base`` and ``dst`` match (rclone exit 0).
414
+
415
+ Runs ``rclone check [--exclude '._*' ...] <local_base> <dst>``. AppleDouble
416
+ sidecars are always excluded because ``local_base`` is always a local path
417
+ (exFAT/macOS would otherwise surface spurious ``._*`` differences).
418
+ """
419
+ args = ["check", *_LOCAL_METADATA_EXCLUDES, str(local_base), dst]
420
+ proc = subprocess.run(["rclone", *args], capture_output=True, text=True)
421
+ return proc.returncode == 0
422
+
423
+
424
+ # --------------------------------------------------------------------------- #
425
+ # RcloneTransport adapter + factory
426
+ # --------------------------------------------------------------------------- #
427
+
428
+
429
+ def _batchable_base(local_path: Path, remote_key: str) -> Path | None:
430
+ """Return the directory ``base`` for which ``local_path == base / remote_key``.
431
+
432
+ A pair is batchable iff ``remote_key`` is exactly the tail of ``local_path``
433
+ relative to some real directory boundary; ``rclone copy --files-from-raw``
434
+ then places the file at ``dst_root/remote_key`` with no rename. Returns
435
+ None for remote_path remaps and sync_by=file renames (key diverges from the
436
+ local layout), which must go through :func:`copyto`.
437
+ """
438
+ rk = remote_key.strip("/")
439
+ if not rk:
440
+ return None
441
+ lp = local_path.as_posix()
442
+ suffix = "/" + rk
443
+ if not lp.endswith(suffix):
444
+ return None
445
+ base = Path(lp[: -len(suffix)])
446
+ try:
447
+ if local_path.relative_to(base).as_posix() == rk:
448
+ return base
449
+ except ValueError:
450
+ return None
451
+ return None
452
+
453
+
454
+ class RcloneTransport:
455
+ """Duck-typed transport backed by the rclone binary (local/s3/sftp).
456
+
457
+ Implements the interface ``cli.py`` consumes:
458
+ ``push`` / ``pull`` / ``ls`` / ``is_available`` / ``backend_name``. Each
459
+ invocation builds its connstring root on the fly from ``remote_cfg`` via
460
+ :func:`build_remote`; the ``remote_base`` argument (always ``remote_cfg.url``)
461
+ is accepted for signature compatibility.
462
+ """
463
+
464
+ def __init__(self, remote_cfg: RemoteConfig) -> None:
465
+ self.remote_cfg = remote_cfg
466
+ # Trips after consecutive failed operations so multi-unit push/pull
467
+ # loops fail fast against a dead remote instead of retrying per unit.
468
+ self._breaker = CircuitBreaker()
469
+
470
+ @property
471
+ def backend_name(self) -> str:
472
+ return f"rclone:{self.remote_cfg.remote_type}"
473
+
474
+ def is_available(self) -> bool:
475
+ return is_available()
476
+
477
+ def push(
478
+ self,
479
+ files: list[tuple[Path, str]],
480
+ remote_base: str,
481
+ *,
482
+ quiet: bool = False,
483
+ ) -> TransferResult:
484
+ if not files:
485
+ return TransferResult(files_transferred=0, bytes_transferred=0)
486
+
487
+ dst_root = build_remote(self.remote_cfg)
488
+ groups: dict[Path, list[Path]] = {}
489
+ renames: list[tuple[Path, str]] = []
490
+ for local_path, remote_key in files:
491
+ local_path = Path(local_path)
492
+ base = _batchable_base(local_path, remote_key)
493
+ if base is None:
494
+ renames.append((local_path, remote_key))
495
+ else:
496
+ groups.setdefault(base, []).append(local_path)
497
+
498
+ try:
499
+ self._breaker.guard("push")
500
+ for base, group in groups.items():
501
+ copy(group, base, dst_root, quiet=quiet)
502
+ for local_path, remote_key in renames:
503
+ copyto(str(local_path), f"{dst_root}/{remote_key.strip('/')}", quiet=quiet)
504
+ except CircuitOpenError as exc:
505
+ return TransferResult(0, 0, errors=[str(exc)])
506
+ except RcloneError as exc:
507
+ self._breaker.record_failure()
508
+ return TransferResult(0, 0, errors=[exc.stderr or str(exc)])
509
+ self._breaker.record_success()
510
+
511
+ total_bytes = sum(Path(lp).stat().st_size for lp, _ in files)
512
+ return TransferResult(files_transferred=len(files), bytes_transferred=total_bytes)
513
+
514
+ def pull(
515
+ self,
516
+ files: list[tuple[str, Path]],
517
+ remote_base: str,
518
+ *,
519
+ quiet: bool = False,
520
+ ) -> TransferResult:
521
+ if not files:
522
+ return TransferResult(files_transferred=0, bytes_transferred=0)
523
+
524
+ dst_root = build_remote(self.remote_cfg)
525
+ try:
526
+ self._breaker.guard("pull")
527
+ for remote_key, local_path in files:
528
+ local_path = Path(local_path)
529
+ local_path.parent.mkdir(parents=True, exist_ok=True)
530
+ copyto(f"{dst_root}/{remote_key.strip('/')}", str(local_path), quiet=quiet)
531
+ except CircuitOpenError as exc:
532
+ return TransferResult(0, 0, errors=[str(exc)])
533
+ except RcloneError as exc:
534
+ self._breaker.record_failure()
535
+ return TransferResult(0, 0, errors=[exc.stderr or str(exc)])
536
+ self._breaker.record_success()
537
+
538
+ total_bytes = sum(Path(lp).stat().st_size for _, lp in files if Path(lp).exists())
539
+ return TransferResult(files_transferred=len(files), bytes_transferred=total_bytes)
540
+
541
+ def ls(self, remote_base: str, prefix: str = "") -> list[RemoteFile]:
542
+ self._breaker.guard("ls")
543
+ try:
544
+ listing = lsjson(build_remote(self.remote_cfg), prefix=prefix)
545
+ except RcloneError:
546
+ self._breaker.record_failure()
547
+ raise
548
+ self._breaker.record_success()
549
+ return listing
550
+
551
+
552
+ def get_transport(remote_cfg: RemoteConfig) -> RcloneTransport:
553
+ """Return an :class:`RcloneTransport` for a local/s3/sftp remote.
554
+
555
+ Dispatches on ``remote_cfg.remote_type``; raises for an unsupported type
556
+ (matching the message the old factory produced so callers stay identical).
557
+ """
558
+ remote_type = remote_cfg.remote_type
559
+ if remote_type in ("local", "s3", "sftp"):
560
+ return RcloneTransport(remote_cfg)
561
+ raise ValueError(f"Unsupported remote type '{remote_type}' for URL: {remote_cfg.url}")
forest/resilience.py ADDED
@@ -0,0 +1,122 @@
1
+ """Resilience primitives guarding external rclone transfers.
2
+
3
+ Two layers, both tunable (and disableable) via environment variables:
4
+
5
+ - retry with exponential backoff for *transient* failures only
6
+ (``forest.rclone._run``): rclone exit code 5 ("temporary error") or stderr
7
+ matching timeout/connection patterns. Assertion-class failures (bad remote,
8
+ missing path, auth) never retry.
9
+ Knobs: ``FOREST_TRANSFER_RETRIES`` (default 2 extra attempts, 0 disables),
10
+ ``FOREST_RETRY_BASE_DELAY`` (seconds, default 0.5, doubles per attempt).
11
+
12
+ - a per-transport circuit breaker (``forest.rclone.RcloneTransport``): after
13
+ N *consecutive* failed transfer operations the circuit opens and remaining
14
+ operations fail fast instead of hammering a dead remote (e.g. a bare
15
+ ``forest pull`` over hundreds of units against an unreachable host).
16
+ Knobs: ``FOREST_BREAKER_THRESHOLD`` (default 5, 0 disables),
17
+ ``FOREST_BREAKER_RESET_SECONDS`` (default 60; after this cool-down one probe
18
+ operation is allowed through, closing the circuit again on success).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import time
26
+ from typing import Callable
27
+
28
+ # rclone exit code 5: "Temporary error (one that more retries might fix)".
29
+ TRANSIENT_EXIT_CODES = frozenset({5})
30
+
31
+ _TRANSIENT_STDERR = re.compile(
32
+ r"(timeout|timed out|connection (reset|refused|closed|aborted)"
33
+ r"|temporar(y|ily)|too many requests|rate ?limit|unexpected eof|broken pipe)",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ def is_transient(exit_code: int, stderr: str) -> bool:
39
+ """Classify an rclone failure as retryable (network blip) or permanent."""
40
+ if exit_code in TRANSIENT_EXIT_CODES:
41
+ return True
42
+ return bool(_TRANSIENT_STDERR.search(stderr or ""))
43
+
44
+
45
+ def _env_int(name: str, default: int) -> int:
46
+ raw = os.environ.get(name)
47
+ if raw is None:
48
+ return default
49
+ try:
50
+ return max(0, int(raw))
51
+ except ValueError:
52
+ return default
53
+
54
+
55
+ def _env_float(name: str, default: float) -> float:
56
+ raw = os.environ.get(name)
57
+ if raw is None:
58
+ return default
59
+ try:
60
+ return max(0.0, float(raw))
61
+ except ValueError:
62
+ return default
63
+
64
+
65
+ def transfer_retries() -> int:
66
+ """Extra attempts allowed after the first failure (0 disables retries)."""
67
+ return _env_int("FOREST_TRANSFER_RETRIES", 2)
68
+
69
+
70
+ def retry_base_delay() -> float:
71
+ """Initial backoff delay in seconds; doubles on each subsequent retry."""
72
+ return _env_float("FOREST_RETRY_BASE_DELAY", 0.5)
73
+
74
+
75
+ def backoff_delay(attempt: int) -> float:
76
+ """Delay before retry ``attempt`` (1-based): base * 2**(attempt-1)."""
77
+ return retry_base_delay() * (2.0 ** max(0, attempt - 1))
78
+
79
+
80
+ class CircuitOpenError(Exception):
81
+ """Raised when the circuit is open and an operation is refused fast."""
82
+
83
+
84
+ class CircuitBreaker:
85
+ """Consecutive-failure circuit breaker with time-based half-open reset."""
86
+
87
+ def __init__(
88
+ self,
89
+ *,
90
+ threshold: int = 0,
91
+ reset_seconds: float = 0.0,
92
+ clock: Callable[[], float] = time.monotonic,
93
+ ) -> None:
94
+ self._threshold = threshold or _env_int("FOREST_BREAKER_THRESHOLD", 5)
95
+ self._reset_seconds = reset_seconds or _env_float("FOREST_BREAKER_RESET_SECONDS", 60.0)
96
+ self._clock = clock
97
+ self._consecutive_failures = 0
98
+ self._opened_at: float = 0.0
99
+
100
+ @property
101
+ def is_open(self) -> bool:
102
+ if self._threshold <= 0 or self._consecutive_failures < self._threshold:
103
+ return False
104
+ return (self._clock() - self._opened_at) < self._reset_seconds
105
+
106
+ def guard(self, operation: str) -> None:
107
+ """Refuse ``operation`` fast when the circuit is open."""
108
+ if self.is_open:
109
+ raise CircuitOpenError(
110
+ f"circuit breaker open: {self._consecutive_failures} consecutive transfer failures; "
111
+ f"refusing '{operation}' for {self._reset_seconds:.0f}s to avoid hammering the remote "
112
+ "(set FOREST_BREAKER_THRESHOLD=0 to disable)"
113
+ )
114
+
115
+ def record_success(self) -> None:
116
+ self._consecutive_failures = 0
117
+ self._opened_at = 0.0
118
+
119
+ def record_failure(self) -> None:
120
+ self._consecutive_failures += 1
121
+ if self._threshold > 0 and self._consecutive_failures >= self._threshold:
122
+ self._opened_at = self._clock()