feat: rustic support
This commit is contained in:
@@ -1,24 +1,29 @@
|
|||||||
[settings]
|
[settings]
|
||||||
backup-base-dir = "/mnt/snap1"
|
backup-base-dir = "/mnt/snap1"
|
||||||
|
tmp-dir = "/tmp/rustic"
|
||||||
|
# backup engine to use, "restic" (default) or "rustic"
|
||||||
|
subsystem-name = "rustic"
|
||||||
|
|
||||||
[settings.globalvars]
|
[settings.globalvars]
|
||||||
|
# also built-in
|
||||||
BTRVOL = "snap1"
|
BTRVOL = "snap1"
|
||||||
CONF_BASE = "/home/osmium/backup"
|
CONF_BASE = "/home/osmium/backup"
|
||||||
|
|
||||||
|
|
||||||
[repo.cloud]
|
[repo.cloud]
|
||||||
kind = "s3"
|
kind = "s3"
|
||||||
# restic by default expects path-style bucket URL
|
endpoint = "https://s3.some-storage.localdomain"
|
||||||
url = "https://s3.some-storage.localdomain//my-bucket/restic-backups/"
|
|
||||||
region = "us-east-1"
|
region = "us-east-1"
|
||||||
access-key-file = "$CONF_BASE/.s3/access.key"
|
access-key-file = "$CONF_BASE/.s3/access.key"
|
||||||
secret-key-file = "$CONF_BASE/.s3/secret.key"
|
secret-key-file = "$CONF_BASE/.s3/secret.key"
|
||||||
repo-key-file = "$CONF_BASE/repo-s3.password"
|
repo-key-file = "$CONF_BASE/repo-s3.password"
|
||||||
|
path = "/restic-backups"
|
||||||
|
bucket = "my-bucket"
|
||||||
|
|
||||||
[repo.localdir]
|
[repo.localdir]
|
||||||
kind = "local"
|
kind = "local"
|
||||||
path = "$CONF_BASE/local-repo"
|
path = "$CONF_BASE/local-repo"
|
||||||
restic-key-file = "$CONF_BASE/repo-local.password"
|
repo-key-file = "$CONF_BASE/repo-local.password"
|
||||||
default = true
|
default = true
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ maintainers = [
|
|||||||
{name = "Igor Ovsyannikov"}
|
{name = "Igor Ovsyannikov"}
|
||||||
]
|
]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
keywords = ["restic", "backups"]
|
keywords = ["restic", "rustic", "backups"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
@@ -20,6 +20,7 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"click>=8.2.1",
|
"click>=8.2.1",
|
||||||
"humanize>=4.13.0",
|
"humanize>=4.13.0",
|
||||||
|
"tomlkit>=0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
from lohup.app import Lohup
|
from lohup.app import Lohup
|
||||||
|
|
||||||
|
__all__ = ("Lohup",)
|
||||||
|
|||||||
+39
-32
@@ -1,28 +1,31 @@
|
|||||||
import subprocess as procs
|
import subprocess as procs
|
||||||
|
|
||||||
from lohup import config
|
from lohup import config
|
||||||
from lohup.logger import BasicLogger, LogLevel
|
from lohup.logger import BasicLogger, LogLevel, LoggerProto
|
||||||
from lohup.restic import Restic
|
from lohup.restic import Restic
|
||||||
|
from lohup.rustic import Rustic
|
||||||
|
|
||||||
|
|
||||||
class Lohup:
|
class Lohup:
|
||||||
def __init__(self, config_path: str | None, logger=None):
|
def __init__(self, config_path: str | None, logger: LoggerProto = None):
|
||||||
self._config_path = config_path or "lohup.toml"
|
self._config_path = config_path or "lohup.toml"
|
||||||
self.config = None
|
self.config = None
|
||||||
|
self.subsystem = None
|
||||||
self.log = logger or BasicLogger(level=LogLevel.INFO)
|
self.log = logger or BasicLogger(level=LogLevel.INFO)
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
self.config = config.TomlConfig.from_file(self._config_path, logger=self.log)
|
self.config = config.TomlConfig.from_file(self._config_path, logger=self.log)
|
||||||
|
self.subsystem = self.config.settings.subsystem
|
||||||
|
if self.subsystem not in ("restic", "rustic"):
|
||||||
|
raise KeyError(f"Invalid subsystem: {self.subsystem}")
|
||||||
|
|
||||||
def invoke_restic(self, repo: str, args: tuple[str, ...]):
|
def invoke_direct(self, repo: str, args: tuple[str, ...]):
|
||||||
spec = self.config.repos.get(repo)
|
spec = self.config.repos.get(repo) if repo else self._default_repo
|
||||||
if spec is None:
|
if spec is None:
|
||||||
raise KeyError(f"Unknown repo: {repo}")
|
raise KeyError(f"Unknown repo: {repo}")
|
||||||
restic = Restic(spec, self.log)
|
engine = self._engine_for(spec)
|
||||||
try:
|
with engine:
|
||||||
restic.run(args)
|
engine.run(args)
|
||||||
except procs.CalledProcessError as e:
|
|
||||||
self.log.error(e)
|
|
||||||
|
|
||||||
def _exechooks(self, hooks: list):
|
def _exechooks(self, hooks: list):
|
||||||
for h in hooks:
|
for h in hooks:
|
||||||
@@ -43,9 +46,13 @@ class Lohup:
|
|||||||
raise ValueError(f"Unknown btrfs action: {spec.action}")
|
raise ValueError(f"Unknown btrfs action: {spec.action}")
|
||||||
procs.check_call(cmd)
|
procs.check_call(cmd)
|
||||||
|
|
||||||
def _repo_for(self, profile: config.Profile):
|
@property
|
||||||
|
def _default_repo(self):
|
||||||
default_list = list(filter(lambda x: x.default, self.config.repos.values()))
|
default_list = list(filter(lambda x: x.default, self.config.repos.values()))
|
||||||
default = default_list.pop() if default_list else None
|
return default_list.pop() if default_list else None
|
||||||
|
|
||||||
|
def _repo_for(self, profile: config.Profile):
|
||||||
|
default = self._default_repo
|
||||||
if profile.repo is not None:
|
if profile.repo is not None:
|
||||||
if repo := self.config.repos.get(profile.repo):
|
if repo := self.config.repos.get(profile.repo):
|
||||||
return repo
|
return repo
|
||||||
@@ -55,52 +62,52 @@ class Lohup:
|
|||||||
raise KeyError(f"No repo attached to profile: {profile.name!r}")
|
raise KeyError(f"No repo attached to profile: {profile.name!r}")
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
def _engine_for(self, repo: config.Repository):
|
||||||
|
match self.subsystem:
|
||||||
|
case "rustic":
|
||||||
|
return Rustic(
|
||||||
|
repo, log=self.log, conf_dir=self.config.settings.build_dir
|
||||||
|
)
|
||||||
|
case "restic":
|
||||||
|
return Restic(repo, log=self.log)
|
||||||
|
case x:
|
||||||
|
raise KeyError(f"Unknown subsystem: {x}")
|
||||||
|
|
||||||
def backup(self, profile: str):
|
def backup(self, profile: str):
|
||||||
spec = self._profile_for(profile)
|
spec = self._profile_for(profile)
|
||||||
repo = self._repo_for(spec)
|
repo = self._repo_for(spec)
|
||||||
restic = Restic(repo, log=self.log)
|
engine = self._engine_for(repo)
|
||||||
self._exechooks(self.config.hooks.before_all)
|
self._exechooks(self.config.hooks.before_all)
|
||||||
try:
|
try:
|
||||||
self._invoke_profile(restic, profile=profile)
|
self._invoke_profile(engine, profile=spec)
|
||||||
finally:
|
finally:
|
||||||
self._exechooks(self.config.hooks.after_all)
|
self._exechooks(self.config.hooks.after_all)
|
||||||
self.log.info("Finished!")
|
self.log.info("Finished!")
|
||||||
|
|
||||||
def backup_all(self):
|
def backup_all(self):
|
||||||
restics = {}
|
engines = {}
|
||||||
for name, spec in self.config.profiles.items():
|
for name, spec in self.config.profiles.items():
|
||||||
restic = Restic(self._repo_for(spec), log=self.log)
|
engine = self._engine_for(self._repo_for(spec))
|
||||||
restics[name] = restic
|
engines[name] = engine
|
||||||
|
|
||||||
self._exechooks(self.config.hooks.before_all)
|
self._exechooks(self.config.hooks.before_all)
|
||||||
try:
|
try:
|
||||||
for name, spec in self.config.profiles.items():
|
for name, spec in self.config.profiles.items():
|
||||||
restic = restics[name]
|
self._invoke_profile(engines[name], profile=spec)
|
||||||
self._invoke_profile(restic, profile=spec)
|
|
||||||
finally:
|
finally:
|
||||||
self._exechooks(self.config.hooks.after_all)
|
self._exechooks(self.config.hooks.after_all)
|
||||||
self.log.info("Finished!")
|
self.log.info("Finished!")
|
||||||
|
|
||||||
def snapshots(self, repo: str, is_json=False):
|
def snapshots(self, repo: str, is_json=False):
|
||||||
spec = self.config.repos.get(repo)
|
spec = self.config.repos.get(repo) if repo else self._default_repo
|
||||||
if spec is None:
|
if spec is None:
|
||||||
raise KeyError(f"Unknown repo: {repo}")
|
raise KeyError(f"Unknown repo: {repo}")
|
||||||
restic = Restic(spec, log=self.log)
|
restic = Restic(spec, log=self.log)
|
||||||
format = "json" if is_json else "text"
|
format = "json" if is_json else "text"
|
||||||
return restic.snapshots(format=format)
|
return restic.snapshots(format=format)
|
||||||
|
|
||||||
def _invoke_profile(self, restic: Restic, profile: config.Profile):
|
def _invoke_profile(self, restic, profile: config.Profile):
|
||||||
args = ["backup", "--tag", profile.name]
|
with restic as engine:
|
||||||
match profile:
|
engine.backup(profile)
|
||||||
case config.PathsProfile():
|
|
||||||
args.append("--read-concurrency=6")
|
|
||||||
for pth in profile.exclude_paths:
|
|
||||||
args.extend(["-e", pth])
|
|
||||||
args.extend(profile.paths)
|
|
||||||
restic.run(args)
|
|
||||||
case config.CommandProfile():
|
|
||||||
args.append("--stdin")
|
|
||||||
restic.pipe_stdout(args, src_cmd=profile.command.split())
|
|
||||||
self.log.info("Backup created successfully.")
|
self.log.info("Backup created successfully.")
|
||||||
|
|
||||||
def _profile_for(self, name: str):
|
def _profile_for(self, name: str):
|
||||||
|
|||||||
+6
-3
@@ -28,7 +28,7 @@ def restic(obj: Lohup, repo, args: tuple[str, ...]):
|
|||||||
"""
|
"""
|
||||||
Pass command to restic
|
Pass command to restic
|
||||||
"""
|
"""
|
||||||
obj.invoke_restic(repo, args)
|
obj.invoke_direct(repo, args)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@@ -62,6 +62,8 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
|
|||||||
summary = snap["summary"]
|
summary = snap["summary"]
|
||||||
end = datetime.fromisoformat(summary["backup_end"])
|
end = datetime.fromisoformat(summary["backup_end"])
|
||||||
changes = summary["files_new"] + summary["files_changed"]
|
changes = summary["files_new"] + summary["files_changed"]
|
||||||
|
total_files = summary["total_files_processed"]
|
||||||
|
changes_ratio = changes / total_files if total_files else 0
|
||||||
snapshots.append(
|
snapshots.append(
|
||||||
dict(
|
dict(
|
||||||
id=snap["short_id"],
|
id=snap["short_id"],
|
||||||
@@ -71,7 +73,7 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
|
|||||||
hostname=snap["hostname"],
|
hostname=snap["hostname"],
|
||||||
duration=end - dt,
|
duration=end - dt,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
changes_ratio=changes / summary["total_files_processed"],
|
changes_ratio=changes_ratio,
|
||||||
size_comp=summary["data_added_packed"],
|
size_comp=summary["data_added_packed"],
|
||||||
size_raw=summary["data_added"],
|
size_raw=summary["data_added"],
|
||||||
processed=summary["total_bytes_processed"],
|
processed=summary["total_bytes_processed"],
|
||||||
@@ -90,7 +92,8 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
|
|||||||
click.echo(f"\tFiles changed (since previous): {snap['changes']} ({ratio})")
|
click.echo(f"\tFiles changed (since previous): {snap['changes']} ({ratio})")
|
||||||
size = humanize.naturalsize(snap["size_raw"], binary=True)
|
size = humanize.naturalsize(snap["size_raw"], binary=True)
|
||||||
compressed = humanize.naturalsize(snap["size_comp"], binary=True)
|
compressed = humanize.naturalsize(snap["size_comp"], binary=True)
|
||||||
ratio = snap["size_raw"] / snap["processed"]
|
total_bytes = snap["processed"]
|
||||||
|
ratio = snap["size_raw"] / total_bytes if total_bytes else 0
|
||||||
ratio = click.style(f"{ratio:.1f}%", fg="blue")
|
ratio = click.style(f"{ratio:.1f}%", fg="blue")
|
||||||
click.echo(f"\tDiff size: {compressed}, unpacked: {size} ({ratio})")
|
click.echo(f"\tDiff size: {compressed}, unpacked: {size} ({ratio})")
|
||||||
click.echo(f"\tHost: {snap['hostname']}")
|
click.echo(f"\tHost: {snap['hostname']}")
|
||||||
|
|||||||
+32
-8
@@ -1,3 +1,4 @@
|
|||||||
|
import platform
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -15,16 +16,29 @@ class ConfigError(ValueError):
|
|||||||
class Settings:
|
class Settings:
|
||||||
backup_base_dir: Path
|
backup_base_dir: Path
|
||||||
globalvars: dict[str, str]
|
globalvars: dict[str, str]
|
||||||
|
build_dir: Path
|
||||||
|
subsystem: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(conf: dict):
|
def load(conf: dict):
|
||||||
with catch_errors() as catch:
|
with catch_errors() as catch:
|
||||||
basedir = conf.get("backup-base-dir")
|
basedir = conf.get("backup-base-dir")
|
||||||
basepath = Path(basedir) if basedir else Path.cwd()
|
basepath = Path(basedir) if basedir else Path.cwd()
|
||||||
|
tmpdir = conf.get("tmp-dir")
|
||||||
|
tmp_path = Path("/tmp/lohup")
|
||||||
|
if not tmpdir:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
tmp_path = Path.home().joinpath("AppData", "Local", "Temp", "lohup")
|
||||||
|
else:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
backup_base_dir=basepath, globalvars=conf.get("globalvars") or {}
|
backup_base_dir=basepath,
|
||||||
|
globalvars=conf.get("globalvars") or {},
|
||||||
|
build_dir=tmp_path,
|
||||||
|
subsystem=conf.get("subsystem-name", "restic"),
|
||||||
)
|
)
|
||||||
settings.globalvars["BDIR"] = str(basepath)
|
settings.globalvars["BDIR"] = str(basepath)
|
||||||
|
settings.globalvars["BUILDDIR"] = str(settings.build_dir)
|
||||||
for key, value in settings.globalvars.items():
|
for key, value in settings.globalvars.items():
|
||||||
if "$" in value:
|
if "$" in value:
|
||||||
catch.error(
|
catch.error(
|
||||||
@@ -59,27 +73,33 @@ class LocalRepository:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class S3Repository:
|
class S3Repository:
|
||||||
name: str
|
name: str
|
||||||
url: str
|
endpoint: str
|
||||||
region: str
|
region: str
|
||||||
access_key_file: Masked
|
access_key_file: Masked
|
||||||
secret_key_file: Masked
|
secret_key_file: Masked
|
||||||
repo_key_file: Masked
|
repo_key_file: Masked
|
||||||
|
bucket: str
|
||||||
|
path: str
|
||||||
default: bool
|
default: bool
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(name: str, conf: dict, expander: VarExpander):
|
def load(name: str, conf: dict, expander: VarExpander):
|
||||||
repo = S3Repository(
|
repo = S3Repository(
|
||||||
name=name,
|
name=name,
|
||||||
url=expander.expand(conf.get("url", "")),
|
endpoint=expander.expand(conf.get("endpoint", "https://s3.amazonaws.com")),
|
||||||
region=conf.get("region", ""),
|
region=conf.get("region", ""),
|
||||||
access_key_file=Masked(expander.expand(conf.get("access-key-file", ""))),
|
access_key_file=Masked(expander.expand(conf.get("access-key-file", ""))),
|
||||||
secret_key_file=Masked(expander.expand(conf.get("secret-key-file", ""))),
|
secret_key_file=Masked(expander.expand(conf.get("secret-key-file", ""))),
|
||||||
repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))),
|
repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))),
|
||||||
|
bucket=expander.expand(conf.get("bucket", "")),
|
||||||
|
path=expander.expand(conf.get("path", "/")),
|
||||||
default=conf.get("default", False),
|
default=conf.get("default", False),
|
||||||
)
|
)
|
||||||
with catch_errors() as catcher:
|
with catch_errors() as catcher:
|
||||||
if not repo.url:
|
if not repo.endpoint:
|
||||||
catcher.error("field 'url': not set")
|
catcher.error("field 'endpoint': not set")
|
||||||
|
if not repo.bucket:
|
||||||
|
catcher.error("field 'bucket': not set")
|
||||||
if value := repo.access_key_file.value:
|
if value := repo.access_key_file.value:
|
||||||
if msg := ensure_exists(value, field="access-key-file"):
|
if msg := ensure_exists(value, field="access-key-file"):
|
||||||
catcher.error(msg)
|
catcher.error(msg)
|
||||||
@@ -178,13 +198,15 @@ class PathsProfile:
|
|||||||
repo: str | None
|
repo: str | None
|
||||||
paths: list[str]
|
paths: list[str]
|
||||||
exclude_paths: list[str]
|
exclude_paths: list[str]
|
||||||
|
cli_args: list[str]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandProfile:
|
class CommandProfile:
|
||||||
name: str
|
name: str
|
||||||
repo: str | None
|
repo: str | None
|
||||||
command: str
|
command: str | list[str]
|
||||||
|
cli_args: list[str]
|
||||||
|
|
||||||
|
|
||||||
Profile = PathsProfile | CommandProfile
|
Profile = PathsProfile | CommandProfile
|
||||||
@@ -205,7 +227,7 @@ class TomlConfig:
|
|||||||
with catch_errors(failfast=logger.accepts(LogLevel.VERBOSE)) as catcher:
|
with catch_errors(failfast=logger.accepts(LogLevel.VERBOSE)) as catcher:
|
||||||
return TomlConfig._ffile_impl(name, path, catcher)
|
return TomlConfig._ffile_impl(name, path, catcher)
|
||||||
except CatcherError as e:
|
except CatcherError as e:
|
||||||
raise ConfigError(f"Failed to parse TOML config") from e
|
raise ConfigError("Failed to parse TOML config") from e
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ffile_impl(name: str, path: Path, catcher):
|
def _ffile_impl(name: str, path: Path, catcher):
|
||||||
@@ -247,9 +269,10 @@ class TomlConfig:
|
|||||||
)
|
)
|
||||||
profiles = {}
|
profiles = {}
|
||||||
for name, opts in conf.get("profiles", {}).items():
|
for name, opts in conf.get("profiles", {}).items():
|
||||||
|
args = opts.get("cli-args", [])
|
||||||
if cmd := opts.get("command"):
|
if cmd := opts.get("command"):
|
||||||
profiles[name] = CommandProfile(
|
profiles[name] = CommandProfile(
|
||||||
name, repo=opts.get("repo"), command=cmd
|
name, repo=opts.get("repo"), command=cmd, cli_args=args
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
paths = opts.get("paths")
|
paths = opts.get("paths")
|
||||||
@@ -263,6 +286,7 @@ class TomlConfig:
|
|||||||
exclude_paths=[
|
exclude_paths=[
|
||||||
expander.expand(x) for x in opts.get("exclude-paths", [])
|
expander.expand(x) for x in opts.get("exclude-paths", [])
|
||||||
],
|
],
|
||||||
|
cli_args=args,
|
||||||
)
|
)
|
||||||
toml = TomlConfig(
|
toml = TomlConfig(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
|||||||
+18
-3
@@ -6,6 +6,7 @@ import enum
|
|||||||
|
|
||||||
class LogLevel(enum.IntEnum):
|
class LogLevel(enum.IntEnum):
|
||||||
ERROR = 40
|
ERROR = 40
|
||||||
|
WARNING = 30
|
||||||
INFO = 20
|
INFO = 20
|
||||||
VERBOSE = 15
|
VERBOSE = 15
|
||||||
DEBUG = 10
|
DEBUG = 10
|
||||||
@@ -22,15 +23,23 @@ class CliLogger:
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
msg = "".join(traceback.format_exception(msg))
|
msg = "".join(traceback.format_exception(msg))
|
||||||
click.echo(click.style(msg, fg="red"))
|
msg = (click.style("[error]", fg="red"), msg)
|
||||||
|
click.echo(" ".join(msg))
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
if self.level <= LogLevel.WARNING:
|
||||||
|
msg = (click.style("[warn]", fg="orange"), msg)
|
||||||
|
click.echo(" ".join(msg))
|
||||||
|
|
||||||
def info(self, msg):
|
def info(self, msg):
|
||||||
if self.level <= LogLevel.INFO:
|
if self.level <= LogLevel.INFO:
|
||||||
click.echo(click.style(msg, fg="blue"))
|
msg = (click.style("[info]", fg="blue"), msg)
|
||||||
|
click.echo(" ".join(msg))
|
||||||
|
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
if self.level <= LogLevel.DEBUG:
|
if self.level <= LogLevel.DEBUG:
|
||||||
click.echo(click.style(msg, fg="white"))
|
msg = (click.style("[debug]", fg="white"), msg)
|
||||||
|
click.echo(" ".join(msg))
|
||||||
|
|
||||||
def accepts(self, level):
|
def accepts(self, level):
|
||||||
return self.level <= level
|
return self.level <= level
|
||||||
@@ -51,6 +60,9 @@ class BasicLogger:
|
|||||||
else:
|
else:
|
||||||
self.logger.error(msg)
|
self.logger.error(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
self.logger.warning(msg)
|
||||||
|
|
||||||
def info(self, msg):
|
def info(self, msg):
|
||||||
self.logger.info(msg)
|
self.logger.info(msg)
|
||||||
|
|
||||||
@@ -59,3 +71,6 @@ class BasicLogger:
|
|||||||
|
|
||||||
def accepts(self, level):
|
def accepts(self, level):
|
||||||
return self.level <= level
|
return self.level <= level
|
||||||
|
|
||||||
|
|
||||||
|
LoggerProto = CliLogger | BasicLogger
|
||||||
|
|||||||
+28
-1
@@ -24,7 +24,9 @@ class Restic:
|
|||||||
if masked := self.repo.access_key_file:
|
if masked := self.repo.access_key_file:
|
||||||
env["AWS_ACCESS_KEY_ID"] = Path(masked.value).read_text().strip()
|
env["AWS_ACCESS_KEY_ID"] = Path(masked.value).read_text().strip()
|
||||||
if masked := self.repo.secret_key_file:
|
if masked := self.repo.secret_key_file:
|
||||||
env["AWS_SECRET_ACCESS_KEY"] = Path(masked.value).read_text().strip()
|
env["AWS_SECRET_ACCESS_KEY"] = (
|
||||||
|
Path(masked.value).read_text().strip()
|
||||||
|
)
|
||||||
env["RESTIC_REPOSITORY"] = f"s3:{self.repo.url}"
|
env["RESTIC_REPOSITORY"] = f"s3:{self.repo.url}"
|
||||||
case config.LocalRepository():
|
case config.LocalRepository():
|
||||||
env["RESTIC_REPOSITORY"] = self.repo.path
|
env["RESTIC_REPOSITORY"] = self.repo.path
|
||||||
@@ -34,6 +36,25 @@ class Restic:
|
|||||||
cmd, env = self._prepare(args)
|
cmd, env = self._prepare(args)
|
||||||
procs.check_call(cmd, env=env)
|
procs.check_call(cmd, env=env)
|
||||||
|
|
||||||
|
def backup(self, profile: config.Profile):
|
||||||
|
args = ["backup", "--tag", profile.name]
|
||||||
|
args.extend(profile.cli_args)
|
||||||
|
match profile:
|
||||||
|
case config.PathsProfile():
|
||||||
|
for pth in profile.exclude_paths:
|
||||||
|
args.extend(["-e", pth])
|
||||||
|
args.extend(profile.paths)
|
||||||
|
self.run(args)
|
||||||
|
case config.CommandProfile():
|
||||||
|
args.append("--stdin")
|
||||||
|
match profile.command:
|
||||||
|
case str(x):
|
||||||
|
self.pipe_stdout(args, src_cmd=x.split())
|
||||||
|
case list(x):
|
||||||
|
self.pipe_stdout(args, src_cmd=x)
|
||||||
|
case _:
|
||||||
|
raise NotImplementedError(profile.command)
|
||||||
|
|
||||||
def pipe_stdout(self, args: list[str], src_cmd: list[str]):
|
def pipe_stdout(self, args: list[str], src_cmd: list[str]):
|
||||||
cmd = [self.binary] + args
|
cmd = [self.binary] + args
|
||||||
env = self.environ()
|
env = self.environ()
|
||||||
@@ -56,3 +77,9 @@ class Restic:
|
|||||||
cmd.extend(args)
|
cmd.extend(args)
|
||||||
env = self.environ()
|
env = self.environ()
|
||||||
return cmd, env
|
return cmd, env
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
return self
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import subprocess as procs
|
||||||
|
import json
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from lohup import config
|
||||||
|
from lohup.logger import LoggerProto
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rustic:
|
||||||
|
repo: config.Repository
|
||||||
|
log: LoggerProto
|
||||||
|
conf_dir: Path
|
||||||
|
binary: str = field(default="rustic")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conf_file(self) -> Path:
|
||||||
|
return self.conf_dir / "rustic.toml"
|
||||||
|
|
||||||
|
def write_config(self) -> Path:
|
||||||
|
out = {}
|
||||||
|
repo_pass = Path(self.repo.repo_key_file.value).read_text().strip()
|
||||||
|
out["repository"] = {"password": repo_pass}
|
||||||
|
match self.repo:
|
||||||
|
case config.S3Repository():
|
||||||
|
out["repository"]["repository"] = "opendal:s3"
|
||||||
|
opts = {}
|
||||||
|
if masked := self.repo.access_key_file:
|
||||||
|
opts["access_key_id"] = Path(masked.value).read_text().strip()
|
||||||
|
if masked := self.repo.secret_key_file:
|
||||||
|
opts["secret_access_key"] = Path(masked.value).read_text().strip()
|
||||||
|
opts["endpoint"] = self.repo.endpoint
|
||||||
|
opts["bucket"] = self.repo.bucket
|
||||||
|
opts["root"] = self.repo.path
|
||||||
|
opts["region"] = self.repo.region
|
||||||
|
out["repository"]["options"] = opts
|
||||||
|
case config.LocalRepository():
|
||||||
|
out["repository"]["repository"] = self.repo.path
|
||||||
|
self.conf_dir.mkdir(exist_ok=True)
|
||||||
|
with self.conf_file.open("w") as f:
|
||||||
|
tomlkit.dump(out, f)
|
||||||
|
|
||||||
|
def _cmdline(self):
|
||||||
|
out = [self.binary, "--log-level=warn", "-P", str(self.conf_dir / "rustic")]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def run(self, args):
|
||||||
|
cmd = self._cmdline()
|
||||||
|
cmd.extend(args)
|
||||||
|
procs.check_call(cmd)
|
||||||
|
|
||||||
|
def backup(self, profile: config.Profile):
|
||||||
|
args = ["backup", "--tag", profile.name]
|
||||||
|
args.extend(profile.cli_args)
|
||||||
|
match profile:
|
||||||
|
case config.PathsProfile():
|
||||||
|
for pth in profile.exclude_paths:
|
||||||
|
args.extend(["--glob", f"!{pth}"])
|
||||||
|
args.extend(profile.paths)
|
||||||
|
self.run(args)
|
||||||
|
case config.CommandProfile():
|
||||||
|
args.append("-")
|
||||||
|
match profile.command:
|
||||||
|
case str(x):
|
||||||
|
self.pipe_stdout(args, src_cmd=x.split())
|
||||||
|
case list(x):
|
||||||
|
self.pipe_stdout(args, src_cmd=x)
|
||||||
|
|
||||||
|
def pipe_stdout(self, args: list[str], src_cmd: list[str]):
|
||||||
|
cmd = self._cmdline()
|
||||||
|
cmd.extend(args)
|
||||||
|
with procs.Popen(cmd, stdin=procs.PIPE) as rustic:
|
||||||
|
procs.check_call(src_cmd, stdout=rustic.stdin)
|
||||||
|
|
||||||
|
def snapshots(self, format="text"):
|
||||||
|
cmd = self._cmdline()
|
||||||
|
cmd.extend(["snapshots", "--compact"])
|
||||||
|
if format == "json":
|
||||||
|
cmd.append("--json")
|
||||||
|
result = procs.check_output(cmd, encoding="utf-8")
|
||||||
|
if format == "json":
|
||||||
|
return json.loads(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.write_config()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
self.conf_file.unlink(True)
|
||||||
@@ -57,6 +57,7 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "humanize" },
|
{ name = "humanize" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -69,6 +70,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.2.1" },
|
{ name = "click", specifier = ">=8.2.1" },
|
||||||
{ name = "humanize", specifier = ">=4.13.0" },
|
{ name = "humanize", specifier = ">=4.13.0" },
|
||||||
|
{ name = "tomlkit", specifier = ">=0.13.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -199,3 +201,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user