Source code for dccd.daemon.storage
#!/usr/bin/env python3
# coding: utf-8
""" Remote storage abstraction for the dccd daemon.
Wraps rclone to push local data directories to any rclone-supported
destination (NAS, SFTP, S3, Google Drive, …).
"""
from __future__ import annotations
import logging
import pathlib
import shutil
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from dccd.daemon.config import StorageConfig
__all__ = ['RemoteStorage']
logger = logging.getLogger(__name__)
[docs]
class RemoteStorage:
""" Push local data directories to remote destinations via rclone.
If no remotes are configured, :meth:`push` is a silent no-op and data
is kept on the daemon host only.
Parameters
----------
config : StorageConfig
Storage configuration (local path + optional rclone remotes).
"""
def __init__(self, config: StorageConfig) -> None:
self.config = config
self._rclone_available: bool | None = None
[docs]
def check_rclone(self) -> bool:
""" Check whether rclone is available in PATH (result is cached).
Returns
-------
bool
``True`` if rclone is found, ``False`` otherwise.
"""
if not self.config.remotes:
return False
if self._rclone_available is None:
self._rclone_available = shutil.which('rclone') is not None
if not self._rclone_available and self.config.remotes:
logger.warning(
'rclone not found in PATH — remote sync disabled. '
'Install rclone and ensure it is on your PATH.'
)
return self._rclone_available
[docs]
def push(self, local_path: str | pathlib.Path) -> None:
""" Copy *local_path* to all configured remote destinations.
The remote target mirrors the relative directory structure under
``config.local_path``. For example, if ``local_path`` is
``/data/crypto/Binance`` and ``config.local_path`` is ``/data/crypto``,
the target will be ``<remote>/Binance``. If ``local_path`` equals
``config.local_path`` (root sync), the contents are copied directly
into each remote root.
Parameters
----------
local_path : str or pathlib.Path
Absolute path of the directory to synchronise.
Notes
-----
This method never raises — failures are logged as errors so that
a remote-push failure does not interrupt the local collection loop.
"""
if not self.config.remotes:
return
if not self.check_rclone():
return
local_abs = pathlib.Path(local_path).resolve()
base_abs = pathlib.Path(self.config.local_path).resolve()
try:
rel = local_abs.relative_to(base_abs)
except ValueError:
logger.error(
'push() called with path %s that is not under base %s',
local_abs, base_abs,
)
return
for remote_cfg in self.config.remotes:
root = remote_cfg.remote.rstrip('/')
remote_target = root if str(rel) == '.' else f'{root}/{rel}'
result = subprocess.run(
['rclone', 'copy', str(local_abs), remote_target],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
logger.error(
'rclone copy failed (%s → %s): %s',
local_abs, remote_target, result.stderr.strip(),
)