Mini Shell
"""Restic dataclasses"""
from typing import Literal, Union, TypedDict, TYPE_CHECKING
from functools import partial
import os
import shlex
from dataclasses import dataclass
from subprocess import CompletedProcess, run as s_run
import arrow
from cproc import Proc
if TYPE_CHECKING:
from . import Restic
else:
Restic = 'Restic' # pylint: disable=invalid-name
class SQLBackupGroupDict(TypedDict):
"""Return format of SQLBackupGroup.serialize()"""
time: int # unix time
user: str
type: Literal["mysql", "pgsql"]
failover: bool
fin: str # snapshot ID
dbs: dict[str, str] # dbname -> snapshot ID
class BackupDict(TypedDict):
"""Return format of Backup.serialize()"""
failover: bool
snap: str # snapshot ID
time: int # unix time
type: Literal["homedir", "dirs", "pkgacct"]
user: str
@dataclass(init=True)
class ResticRepo:
"""Dataclass holding restic/S3 keys and bucket name
Args:
bucket(str): bucket name
restic_pass(str): restic password
access_key(str): S3 access key
secret_key(str): S3 access key
"""
__module__ = 'restic'
bucket: str
restic_pass: str
access_key: str
secret_key: str
class Snapshot:
"""Represents a restic snapshot
Attributes:
restic (Restic): restic instance this snapshot was found in
id (str): short snapshot ID
tags (list[str]): tags supplied when the snapshot was created
datetime (datetime.datetime): backup creation time
timestamp (int): backup creation time
paths (list[str]): top level paths the snapshot contains
listdir (callable): functools.partial to Restic.listdir which
automatically includes the snap= kwarg
scandir (callable): functools.partial to Restic.scandir which
automatically includes the snap= kwarg
restore (callable): functools.partial to Restic.restore which
automatically includes the snap= kwarg
dump (callable): functools.partial to Restic.dump which
automatically includes the snap= kwarg
forget (callable): lambda to call Restic.forget with this snap's ID
"""
__module__ = 'restic'
def __init__(self, *, restic: Restic, data: dict):
self.restic = restic
self.id = str(data['id'])
self.tags: list[str] = list(data.get('tags', []))
arw = arrow.get(data['time'])
self.datetime = arw.datetime
self.timestamp = arw.int_timestamp
self.paths: list[str] = list(data['paths'])
self.listdir = partial(self.restic.listdir, snap=self)
self.scandir = partial(self.restic.scandir, snap=self)
self.restore = partial(self.restic.restore, snap=self)
self.dump = partial(self.restic.dump, snap=self)
self.forget = lambda: self.restic.forget(self.id)
def __repr__(self):
return f'Snapshot<{self.id}>'
class ResticCmd:
"""Return type of Restic's build() function. Can be cast to a str() to
get a shell-escaped representation of the command.
Attributes:
.cmd (list[str]): Popen arguments
"""
__module__ = 'restic'
def __init__(
self,
cmd,
restic: Restic,
):
self.cmd = cmd
self.restic = restic
def __str__(self) -> str:
return shlex.join(self.cmd)
def run(
self,
*,
stdout: Union[int, None] = Proc.DEVNULL,
stderr: Union[int, None] = Proc.PIPE,
stdin: Union[int, None] = None,
input: Union[str, None] = None, # pylint: disable=redefined-builtin
check: bool = False,
timeout: Union[int, float, None] = None,
no_lim: bool = False,
**kwargs,
) -> CompletedProcess:
"""Execute the restic command and return a CompletedProcess
Args:
stdout (int | None): stdout redirection. Defaults to DEVNULL
stderr (int | None): stderr redirection. Defaults to PIPE (because
ResticError will need this if you raise it using the result)
stdin (int | None): stdin redirection. Defaults to None
input (bytes | str | None): text to send to stdin. Do not use the
stdin= kwarg if you use input=
timeout (int | float | None): optional command timeout
check (bool): if set True, raise CalledProcessError on non-zero
exit codes. Defaults to False
no_lim (bool): do not CPU limit the command as it runs regardless
of the lim arg in Restic
Raises:
CalledProcessError: program exited with a non-zero exit code
and check=True was specified
TimeoutExpired: program took too long to execute and timeout=
was specified
Returns:
CompletedProcess: process results
"""
kwargs.update(
{
'encoding': 'UTF-8',
'env': self.restic.env,
'stdout': stdout,
'stderr': stderr,
'check': check,
'shell': False,
'timeout': timeout,
}
)
if input is None:
kwargs['stdin'] = stdin
else:
kwargs['input'] = input
if no_lim or self.restic.lim is None:
# pylint: disable=subprocess-run-check
return s_run(self.cmd, **kwargs)
return Proc.run(self.cmd, lim=self.restic.lim, **kwargs)
def execv(self):
"""runs os.execv with the given restic args/env, replacing the
current process with restic"""
os.environ.update(self.restic.env)
os.execv(self.cmd[0], self.cmd)
def proc(
self,
*,
stdout: Union[int, None] = Proc.DEVNULL,
stderr: Union[int, None] = Proc.PIPE,
stdin: Union[int, None] = None,
no_lim: bool = False,
**kwargs,
) -> Proc:
"""Start and return the command
Args:
stdout (int | None): stdout redirection. Defaults to DEVNULL
stderr (int | None): stderr redirection. Defaults to PIPE (because
ResticError will need this if you raise it using the result)
stdin (int | None): stdin redirection. Defaults to None
no_lim (bool): do not CPU limit the command as it runs regardless
of the lim arg in Restic
Returns:
cproc.Proc: Running process
"""
kwargs.update(
{
'encoding': 'UTF-8',
'env': self.restic.env,
'stdout': stdout,
'stderr': stderr,
'stdin': stdin,
'shell': False,
}
)
if no_lim or self.restic.lim is None:
# pylint: disable=subprocess-run-check,consider-using-with
return Proc(self.cmd, lim=None, **kwargs)
return Proc(self.cmd, lim=self.restic.lim, **kwargs)
@dataclass(init=True)
class SnapPath:
"""Base class for a remote path in a restic snapshot. When Restic
instantiates this object, it'll be returned as one of its subclasses,
``SnapDir`` or ``SnapFile``"""
__module__ = 'restic'
# SnapPath intentionally does not subclass os.PathLike or os.DirEntry
# because the path isn't mounted anywhere that normal filesystem ops
# will work against it
snapshot: Snapshot
restic: Restic
name: str
type: str
path: str
uid: int
gid: int
mode: Union[int, None]
permissions: Union[str, None]
def __new__(cls, type: str, **_): # pylint: disable=redefined-builtin
if type == 'dir':
return object.__new__(SnapDir)
return object.__new__(SnapFile)
def __str__(self):
return self.path
class SnapFile(SnapPath):
"""A remote file in a restic snapshot
Attributes:
snapshot (Snapshot): snapshot instance this file was found in
restic (Restic): restic instance this file was found in
name (str): base filename
type (str): "file"
path (str): full path
uid (int): original UID of the file when backed up
gid (int): original GID of the file when backed up
mode (int | None): original file mode when backed up
permissions (str | None): original file permissions when backed up
dump (callable): convenience functools.partial function that returns
a ResticCmd which can be used to fetch the file's contents
"""
__module__ = 'restic'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dump = partial(
self.restic.dump, snap=self.snapshot, filename=self.path
)
class SnapDir(SnapPath):
"""A remote directory in a restic snapshot
Attributes:
snapshot (Snapshot): snapshot instance this directory was found in
restic (Restic): restic instance this directory was found in
name (str): base directory name
type (str): "dir"
path (str): full path
uid (int): original UID of the directory when backed up
gid (int): original GID of the directory when backed up
mode (int): original directory mode when backed up
permissions (str | None): original directory permissions when backed up
listdir (callable): convenience functools.partial function that calls
Restic.listdir inside this directory's path
scandir (callable): convenience functools.partial function that calls
Restic.scandir inside this directory's path
"""
__module__ = 'restic'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.listdir = partial(
self.restic.listdir, snap=self.snapshot, path=self.path
)
self.scandir = partial(
self.restic.scandir, snap=self.snapshot, path=self.path
)
class Backup:
"""Object representing a restic snapshot formatted in a way specific to
backups3.x's backup-runner.
Args:
snap (Snapshot): snapshot from Restic.snapshots()
Raises:
KeyError: snapshot did not have required tags (created manually?)
ValueError: __new__() tried to return a sql subclass, but its start:
timestamp tag was invalid
Returns:
(Backup, SQLBackupGroup, SQLBackupItem): depending on the type of
backups 3.x snapshot
Attributes:
snap (Snapshot): snapshot object
failover (bool): whether this is a failover copy
user (str): cPanel username or root
type (str): mysql or pgsql
time (int): creation timestamp
"""
__module__ = 'restic'
def __new__(cls, snap: Snapshot):
if Backup.get_label(snap, 'type') in ('mysql', 'pgsql'):
if 'finished' in snap.tags:
return object.__new__(SQLBackupGroup)
return object.__new__(SQLBackupItem)
return object.__new__(cls) # normal Backup()
def __init__(self, snap: Snapshot):
self.snap = snap
self.failover = 'failover' in snap.tags
self.user = self.get_label(snap, 'user')
self.type = self.get_label(snap, 'type')
if self.type in ('mysql', 'pgsql'):
self.time = int(self.get_label(snap, 'start'))
else:
self.time = snap.timestamp
@staticmethod
def get_label(snap: Snapshot, name: str) -> str:
"""Search for the value of a tag if read in the format 'name:value'
Args:
snap (Snapshot): snapshot object
name (str): name to search for
Raises:
KeyError: if the label was not found
Returns:
str: the value portion of the 'name:value' tag
"""
prefix = f'{name}:'
for tag in snap.tags:
if tag.startswith(prefix):
return tag.split(':', 1)[1]
raise KeyError(name)
def serialize(self) -> dict:
"""Used internally by Restic.get_backups() if serialize=True"""
ret = {
'snap': self.snap.id,
'time': self.time,
'user': self.user,
'type': self.type,
'failover': self.failover,
}
return ret
class SQLBackupGroup(Backup):
"""Holds a group of SQL snapshots created by imh-backup-client, representing
one sql backup run. Backups 3.x stores each database in its own snapshot,
then artificially groups them together as one.
Attributes:
snap (Snapshot): snapshot object for the snapshot signifying the
backup's completion. This snapshot contains no SQL data. See the
``.dbs`` attribute instead for that
failover (bool): whether this is a failover copy
user (str): cPanel username or root
type (str): mysql or pgsql
time (int): creation timestamp
dbs (dict[str, SQLBackupItem]): database names mapped to SQLBackupItems
"""
__module__ = 'restic'
def __init__(self, snap: Snapshot):
super().__init__(snap)
self.dbs: dict[str, SQLBackupItem] = {}
def serialize(self) -> SQLBackupGroupDict:
"""Used internally by Restic.get_backups(serialize=True)"""
ret = super().serialize()
ret['fin'] = ret.pop('snap')
ret['dbs'] = {k: v.serialize() for k, v in self.dbs.items()}
return ret
class SQLBackupItem(Backup):
"""Represents one SQL snapshot in a ``SQLBackupGroup``.
Attributes:
snap (Snapshot): snapshot object
failover (bool): whether this is a failover copy
user (str): cPanel username or root
type (str): mysql or pgsql
time (int): creation timestamp
dbname (str): database name
dump (callable): convenience functools.partial function that returns
a ResticCmd which can be used to fetch the SQL data
"""
__module__ = 'restic'
def __init__(self, snap: Snapshot):
super().__init__(snap)
self.dbname = Backup.get_label(snap, 'dbname')
self.dump = partial(
self.snap.restic.dump,
snap=self.snap,
filename=self.dbname,
)
def serialize(self) -> dict:
"""Used internally by Restic.get_backups(serialize=True)"""
return self.snap.id
BakTypeStr = Literal["mysql", "pgsql", "homedir", "dirs", "pkgacct"]
UserBackupDicts = dict[
BakTypeStr, Union[list[BackupDict], list[SQLBackupGroupDict]]
]
UserBackups = dict[BakTypeStr, Union[list[Backup], list[SQLBackupGroup]]]
Zerion Mini Shell 1.0