From f985285d8f8e523db668391875de4700be8af946 Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Sun, 21 Sep 2025 23:28:37 +0200 Subject: [PATCH 1/7] minor: fixed act workflow file path --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9d1bf86..12c93e6 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ test: uv run bash tests/tests.sh ci: - act --workflows ".github/workflows/test.yml" + act --workflows ".github/workflows/from_commit_to_build_test.yml" toc: find * -type f ! -name 'CHANGELOG.md' -exec toc -f {} \; 2>/dev/null From 5522aa966b74420182700911258bdbd8f15dc0a3 Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Sun, 21 Sep 2025 23:30:46 +0200 Subject: [PATCH 2/7] feat: automatically ignoring invalid rustic profiles --- README.md | 13 +-- example/systemd/rusticlone.service | 4 +- images/coverage.svg | 2 +- rusticlone/cli.py | 6 +- rusticlone/helpers/custom.py | 45 +++++--- rusticlone/processing/profile.py | 166 ++++++++++++++++------------- tests/tests.sh | 28 +++-- 7 files changed, 154 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index e045302..0c2c367 100644 --- a/README.md +++ b/README.md @@ -242,20 +242,17 @@ keep-quarter-yearly = 4 keep-yearly = 1 ``` +As it doesn't contain a "\[repository]" section, it will not be treated as a standalone profile by Rusticlone. + This "common.toml" profile can be referenced from our documents by adding to "Documents.toml" the following: ```toml [global] use-profile = ["common"] -``` -To exclude "common.toml" from Rusticlone (since it cannot be used alone), add the `--ignore` argument followed by "common": - -```bash -rusticlone --ignore "common" -r "gdrive:/PC" backup +# [...] ``` -All the profiles containing "common" in their name will be excluded, but will still be sourced from other profiles when needed. ### Custom log file @@ -296,10 +293,10 @@ Description=Rusticlone service [Service] Type=oneshot -ExecStart=rusticlone --ignore "common" --remote "gdrive:/PC" backup +ExecStart=rusticlone --remote "gdrive:/PC" backup ``` -Adjust your `--ignore` and `--remote` as needed. +Adjust your `--remote` as needed. Apply your changes and enable the timer: diff --git a/example/systemd/rusticlone.service b/example/systemd/rusticlone.service index e5adfde..5ed7d75 100644 --- a/example/systemd/rusticlone.service +++ b/example/systemd/rusticlone.service @@ -3,5 +3,5 @@ Description=Rusticlone Backup - service [Service] Type=oneshot -# replace the remote and ignore pattern -ExecStart=rusticlone --remote "gdrive:/PC" --ignore "common" backup +# replace the remote +ExecStart=rusticlone --remote "gdrive:/PC" backup diff --git a/images/coverage.svg b/images/coverage.svg index 70b3ad8..8c7994e 100644 --- a/images/coverage.svg +++ b/images/coverage.svg @@ -1 +1 @@ -coverage: 98.36%coverage98.36% \ No newline at end of file +coverage: 91.25%coverage91.25% \ No newline at end of file diff --git a/rusticlone/cli.py b/rusticlone/cli.py index eba63c0..42400b8 100755 --- a/rusticlone/cli.py +++ b/rusticlone/cli.py @@ -58,9 +58,9 @@ def parse_args(): "-i", "--ignore", type=str, - default="🫣🫣🫣", - env_var="IGNORE", - help="Ignore rustic profiles containing this pattern", + default="", + help="Deprecated argument, does nothing. Will be removed in a future release", + deprecated=True, ) parser.add_argument( "-l", diff --git a/rusticlone/helpers/custom.py b/rusticlone/helpers/custom.py index 29e8773..21ee13f 100644 --- a/rusticlone/helpers/custom.py +++ b/rusticlone/helpers/custom.py @@ -26,8 +26,12 @@ # exit import sys +# toml parsing +import tomllib + # rusticlone from rusticlone.helpers.action import Action +from rusticlone.helpers.rustic import Rustic from rusticlone.helpers.notification import notify_user from rusticlone.processing.parallel import ( system_backup_parallel, @@ -45,6 +49,7 @@ system_download_sequential, system_extract_sequential, ) +from rusticlone.processing.profile import parse_repo, parse_sources # ################################################################ CLASSES @@ -75,24 +80,25 @@ def __init__(self, args) -> None: self.profiles_dirs = [ Path.home() / "AppData/Roaming/rustic/config", Path("C:/ProgramData/rustic/config"), + Path.cwd(), ] elif self.operating_system == "Darwin": self.profiles_dirs = [ Path.home() / "Library/Application Support/rustic", Path("/etc/rustic"), + Path.cwd(), ] else: self.profiles_dirs = [ Path.home() / ".config/rustic", Path("/etc/rustic"), + Path.cwd(), ] # remote prefix: rclone remote + subdirectory without trailing slash if args.remote is not None: self.remote_prefix = args.remote.rstrip("/") else: self.remote_prefix = "None:/" - # ignore pattern for profiles - self.ignore_pattern = args.ignore # test profile if args.profile: self.provided_profile = args.profile @@ -141,15 +147,29 @@ def check_profiles_dirs(self) -> None: # ################################################################ FUNCTIONS -def list_profiles( - profiles_dirs: list, provided_profile: str = "*", ignore_pattern: str = "🫣🫣🫣" -) -> list: +def has_needed_config_components(profile_path: Path) -> bool: + """ + Check if a profile should be processed by running rustic show-config + """ + try: + profile_name = profile_path.stem + rustic = Rustic(profile_name, "show-config") + config = tomllib.loads(rustic.stdout) + has_repo = parse_repo(config) is not None + has_sources = parse_sources(config) is not None + return has_repo and has_sources + except (AttributeError, tomllib.TOMLDecodeError, KeyError, IndexError): + return False + + +def list_profiles(profiles_dirs: list, provided_profile: str = "*") -> list: """ Scan profiles from directories if none have been provided explicitely Don't scan from /etc/rustic if ~/.config/rustic has some profiles' """ action = Action("Reading profiles") profiles: list[str] = [] + opened_files = 0 if not provided_profile: provided_profile = "*" for profiles_dir in profiles_dirs: @@ -157,10 +177,11 @@ def list_profiles( action.stop(f'Scanning "{profiles_dir}"', "") files = sorted(list(profiles_dir.glob(f"{provided_profile}.toml"))) for file in files: + opened_files += 1 if ( file.is_file() - and ignore_pattern not in file.stem and file.stem not in profiles + and has_needed_config_components(file) ): profiles.append(file.stem) # remove duplicates @@ -169,9 +190,10 @@ def list_profiles( action.stop(f"Profiles: {str(profiles)}", "") return profiles else: - print(provided_profile) - print(provided_profile) - action.abort("Could not find any rustic profile") + print("") + print(f"Glob pattern: {provided_profile}") + print(f"Files tried: {opened_files}") + action.abort("Could not find any valid profile") sys.exit(1) @@ -243,6 +265,7 @@ def process_profiles( ) case _: print(f"Invalid command '{command}'") + sys.exit(1) if apprise_url and results: notify_user(results, apprise_url) @@ -254,9 +277,7 @@ def load_customizations(args: Namespace): custom = Custom(args) custom.check_log_file() custom.check_profiles_dirs() - profiles = list_profiles( - custom.profiles_dirs, custom.provided_profile, custom.ignore_pattern - ) + profiles = list_profiles(custom.profiles_dirs, custom.provided_profile) process_profiles( profiles, custom.parallel, diff --git a/rusticlone/processing/profile.py b/rusticlone/processing/profile.py index 5689425..bc96bf8 100644 --- a/rusticlone/processing/profile.py +++ b/rusticlone/processing/profile.py @@ -8,6 +8,7 @@ # │ # ├── IMPORTS # ├── CLASSES +# ├── FUNCTIONS # │ # └─────────────────────────────────────────────────────────────── @@ -50,16 +51,17 @@ def __init__(self, profile: str, parallel: bool = False) -> None: """ self.profile_name = profile self.parallel = parallel - self.repo = "" + self.repo = Path("") self.log_file = Path("rusticlone.log") self.env: dict[str, str] = {} + self.does_forget = False self.password_provided = "" # json objects self.backup_output: list[dict[Any, Any]] = [] - self.sources: list[str] = [] + self.sources: list[Path] = [] self.sources_number = 0 - self.sources_exist: dict[str, bool] = {} - self.sources_type: dict[str, str] = {} + self.sources_exist: dict[Path, bool] = {} + self.sources_type: dict[Path, str] = {} self.local_repo_exists = False self.snapshot_exists = False self.result = True @@ -80,76 +82,40 @@ def parse_rustic_config(self) -> None: except (AttributeError, tomllib.TOMLDecodeError): self.result = action.abort("Could not parse rustic configuration") else: - self.result = self.parse_rustic_config_source(action) - self.result = self.parse_rustic_config_repo(action) - self.result = self.parse_rustic_config_log(action) - self.result = self.parse_rustic_config_env(action) + self.result = self.parse_rustic_config_component(action, "sources") + self.result = self.parse_rustic_config_component(action, "repo") + self.result = self.parse_rustic_config_component(action, "log") + self.result = self.parse_rustic_config_component(action, "env") + self.result = self.parse_rustic_config_component(action, "forget") if self.result: action.stop("Parsed rustic configuration") - def parse_rustic_config_source(self, action) -> bool: + def parse_rustic_config_component(self, action, component: str) -> bool: """ - Read sources from Rustic profile configuration + Store values and return True if successful """ try: - # self.source = self.config["backup"]["sources"][0]["source"] - # they can be either string or list of strings: - # https://github.com/rustic-rs/rustic/blob/a88afdd4af295c16e5de50de91ec430920f81f56/config/full.toml - config_sources = [ - section["sources"] for section in self.config["backup"]["snapshots"] - ] - for config_source in config_sources: - if config_source and isinstance(config_source, list): - self.sources.extend(config_source) - elif config_source: - self.sources.append(config_source) - # remove eventual duplicates - self.sources = list(set(self.sources)) + match component: + case "sources": + self.sources = parse_sources(self.config) + case "repo": + self.repo = parse_repo(self.config) + case "log": + self.log_file = parse_log(self.config) + case "env": + self.env = parse_env(self.config) + case "forget": + self.does_forget = parse_forget(self.config) except KeyError: - return action.abort("Could not parse source in config:\n", self.config) - return True - - def parse_rustic_config_repo(self, action) -> bool: - """ - Read repo from Rustic profile configuration - """ - try: - self.repo = self.config["repository"]["repository"] - except KeyError: - return action.abort("Could not parse repo in config:\n", self.config) - return True - - def parse_rustic_config_log(self, action) -> bool: - """ - Read log file from Rustic profile configuration - """ - try: - self.log_file = Path(self.config["global"]["log-file"]) - except KeyError: - return action.abort(f'Invalid log file: "{str(self.log_file)}"') - return True - - def parse_rustic_config_env(self, action) -> bool: - """ - Read environment variables for Rustic and Rclone - """ - try: - self.env = self.config["global"]["env"] - except KeyError: - return True + match component: + case "env": + pass + case _: + action.abort( + f"Could not parse {component} in config:\n", str(self.config) + ) return True - def parse_rustic_config_forget(self) -> bool: - """ - Check if the Rustic config has any "keep-*" keys inside [forget] section - Returns True if any keep-* keys are found, False otherwise - """ - try: - forget_section = self.config["forget"] - return any(key.startswith("keep-") for key in forget_section.keys()) - except KeyError: - return False - def check_rclone_config_exists(self) -> None: """ Parse Rustic configuration and extract rclone config, and rclone config password command. @@ -201,13 +167,13 @@ def check_sources_exist(self) -> None: action = Action("Checking if sources exists", self.parallel) # print(self.source) for source in self.sources: - source_path = Path(source) - if source_path.exists(): + if source.exists(): self.sources_exist[source] = True else: self.sources_exist[source] = False if all(self.sources_exist.values()): self.sources_number = len(self.sources) + action.stop("All sources exist") else: self.result = action.abort("Some sources do not exist") @@ -227,8 +193,7 @@ def check_local_repo_exists(self) -> None: """ if self.result: action = Action("Checking if local repo exists", self.parallel) - # self.repo_type = "local" - repo_config_file = Path(self.repo) / "config" + repo_config_file = self.repo / "config" if repo_config_file.exists() and repo_config_file.is_file(): self.local_repo_exists = True action.stop("Local repo already exists") @@ -253,7 +218,7 @@ def check_remote_repo_exists(self, remote_prefix: str) -> None: if self.result: action = Action("Checking if remote repo exists", self.parallel) rclone_log_file = str(self.log_file) - repo_name = str(Path(self.repo).name) + repo_name = str(self.repo.name) rclone_origin = remote_prefix + "/" + repo_name rclone = Rclone( env=self.env, @@ -419,7 +384,7 @@ def forget(self) -> None: Mark snapshots for deletion and evenually prune them. """ if self.result: - if self.parse_rustic_config_forget(): + if self.does_forget: action = Action("Deprecating old snapshots", self.parallel) Rustic( self.profile_name, @@ -439,9 +404,9 @@ def upload(self, remote_prefix: str) -> None: if self.result: action = Action("Uploading repo", self.parallel) rclone_log_file = str(self.log_file) - rclone_origin = self.repo.replace("\\", "/").replace("//", "/") + rclone_origin = str(self.repo).replace("\\", "/").replace("//", "/") # rclone_destination = remote_prefix + "/" + self.profile_name - repo_name = str(Path(self.repo).name) + repo_name = str(self.repo.name) rclone_destination = remote_prefix + "/" + repo_name # print(rclone_destination) rclone = Rclone( @@ -467,12 +432,12 @@ def download(self, remote_prefix: str) -> None: Uploads the remote repository to a local destination using rclone. """ if self.result: - if not self.repo.startswith("rclone:"): + if not str(self.repo).startswith("rclone:"): action = Action("Downloading repo", self.parallel) if not self.local_repo_exists: rclone_log_file = str(self.log_file) # rclone_origin = remote_prefix + "/" + self.profile_name - repo_name = str(Path(self.repo).name) + repo_name = self.repo.name rclone_origin = remote_prefix + "/" + repo_name rclone_destination = self.repo Rclone( @@ -535,7 +500,7 @@ def check_latest_snapshot(self) -> None: else: self.result = action.abort("Repo does not have snapshots") timestamp_pretty = self.latest_snapshot_timestamp.strftime( - "%Y-%m-%d %H:%M:%S" + "%Y-%m-%d %H:%M" ) clear_line(parallel=self.parallel) # self.snapshot_exists = True @@ -612,3 +577,52 @@ def restore(self) -> None: if rustic.returncode != 0: self.result = action.abort(f"Error extracting '{source}'") action.stop("Snapshot extracted") + + +# ################################################################ FUNCTIONS + + +def parse_repo(config: dict[str, Any]) -> Path: + """ + Extract repository folder from Rustic profile configuration + """ + return Path(config["repository"]["repository"]) + + +def parse_sources(config: dict[str, Any]) -> list[Path]: + """ + Extract list of sources from Rustic profile configuration + """ + sources: list[str] = [] + raw_sources = [snapshot["sources"] for snapshot in config["backup"]["snapshots"]] + # raw_sources can be either lists or strings + for source in raw_sources: + if source and isinstance(source, list): + sources.extend(source) + else: + sources.append(source) + # remove eventual duplicates and convert to Path + unique_sources = list(set({Path(source) for source in sources})) + return unique_sources + + +def parse_log(config: dict[str, Any]) -> Path: + """ + Extract log file location from Rustic profile configuration + """ + return Path(config["global"]["log-file"]) + + +def parse_env(config: dict[str, Any]) -> dict[str, Any]: + """ + Extract environment variables from Rustic profile configuration + """ + return config["global"]["env"] + + +def parse_forget(config: dict[str, Any]) -> bool: + """ + Check if the Rustic config has any "keep-*" keys inside [forget] section + Returns True if any keep-* keys are found, False otherwise + """ + return any(key.startswith("keep-") for key in config["forget"].keys()) diff --git a/tests/tests.sh b/tests/tests.sh index 8d3729e..766e9a0 100755 --- a/tests/tests.sh +++ b/tests/tests.sh @@ -35,11 +35,14 @@ set -euo pipefail RUSTIC_PROFILES_DIR="$HOME/.config/rustic" # can be any folder -RUSTICLONE_TEST_DIR="$HOME/rusticlone-tests" +RUSTICLONE_TEST_DIR="$HOME/.rusticlone-tests" # ################################ DERIVED mapfile -d '' profile1Content << CONTENT +[global] +use-profiles = ["common"] + [repository] repository = "$RUSTICLONE_TEST_DIR/local/Documents" cache-dir = "$RUSTICLONE_TEST_DIR/cache" @@ -111,7 +114,7 @@ RCLONE_ENCRYPT_V0: LDDUg4mDyUxDwMtntnCaiUN+o9SexiohA8Y74ZYJmPD9KD8UjVtH9XYCL+3A6OGR7msabjvu0Gj2W8JRande CONTENT -GOOD_APPRISE_URL="dbus://" +GOOD_APPRISE_URL="form://example.org" BAD_APPRISE_URL="moz://a" # ################################################################ FUNCTIONS @@ -173,11 +176,15 @@ print_cleanup(){ echo "[OK] Test completed, feel free to read test coverage and remove \"$RUSTIC_PROFILES_DIR\" and \"$RUSTICLONE_TEST_DIR\"" } -create_dirs(){ - echo "[OK] Creating directories" +remove_profiles_dir(){ if [[ -d "$RUSTIC_PROFILES_DIR" ]]; then rm -r "$RUSTIC_PROFILES_DIR" fi +} + +create_dirs(){ + echo "[OK] Creating directories" + remove_profiles_dir if [[ -d "$RUSTICLONE_TEST_DIR" ]]; then rm -r "$RUSTICLONE_TEST_DIR" fi @@ -189,12 +196,13 @@ create_confs(){ profile1Conf="$RUSTIC_PROFILES_DIR/Documents-test.toml" profile2Conf="$RUSTIC_PROFILES_DIR/Pictures-test.toml" profile3Conf="$RUSTIC_PROFILES_DIR/Passwords-test.toml" + profileCommonConf="$RUSTIC_PROFILES_DIR/common.toml" rcloneConfDecrypted="$RUSTIC_PROFILES_DIR/rclone-decrypted.conf" rcloneConfEncrypted="$RUSTIC_PROFILES_DIR/rclone-encrypted.conf" echo "${profile1Content[0]}" > "$profile1Conf" echo "${profile2Content[0]}" > "$profile2Conf" echo "${profile3Content[0]}" > "$profile3Conf" - echo "${profileCommonContent[0]}" >> "$profile1Conf" + echo "${profileCommonContent[0]}" > "$profileCommonConf" echo "${profileCommonContent[0]}" >> "$profile2Conf" echo "${profileCommonContent[0]}" >> "$profile3Conf" echo "${rcloneConfContentDecrypted[0]}" > "$rcloneConfDecrypted" @@ -246,7 +254,7 @@ rusticlone_backup_flags(){ logecho "[OK] Backing up from Rusticlone" "$RUSTICLONE_TEST_DIR/logs/log-specified-in-args.log" coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Pictures-test" --log-file "$RUSTICLONE_TEST_DIR/logs/log-specified-in-args.log" backup logecho "[OK] Backing up from Rusticlone" - coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Documents-test" --ignore "common" backup + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Documents-test" backup coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Passwords-test" -a "$BAD_APPRISE_URL" backup } @@ -344,7 +352,7 @@ rusticlone_restore_flags(){ logecho "[OK] Restoring from Rusticlone" "$RUSTICLONE_TEST_DIR/logs/log-specified-in-args.log" coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Documents-test" --log-file "$RUSTICLONE_TEST_DIR/logs/log-specified-in-args.log" restore logecho "[OK] Restoring from Rusticlone" - coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Passwords-test" --ignore "common" restore + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Passwords-test" restore coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" -P "Pictures-test" restore } @@ -395,7 +403,10 @@ create_badge(){ } check_coverage(){ - echo "[OK] Read the coverage report by running:" + cd "$RUSTICLONE_TEST_DIR/coverage" + coverage report + echo " " + echo "[OK] Read the coverage report in detail by running:" echo " " echo " firefox \"$RUSTICLONE_TEST_DIR/coverage/index.html\"" echo " " @@ -485,6 +496,7 @@ main(){ print_space # results + remove_profiles_dir create_coverage create_badge check_coverage From 89c6febe55885fd111a8bd387588b5c3b028b03d Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Mon, 22 Sep 2025 23:26:55 +0200 Subject: [PATCH 3/7] feat: added lockfile mechanism --- images/coverage.svg | 2 +- rusticlone/processing/atomic.py | 8 ++++ rusticlone/processing/profile.py | 48 +++++++++++++++++++++-- tests/tests.sh | 65 +++++++++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/images/coverage.svg b/images/coverage.svg index 8c7994e..ae696e9 100644 --- a/images/coverage.svg +++ b/images/coverage.svg @@ -1 +1 @@ -coverage: 91.25%coverage91.25% \ No newline at end of file +coverage: 96.08%coverage96.08% \ No newline at end of file diff --git a/rusticlone/processing/atomic.py b/rusticlone/processing/atomic.py index 3d154e2..16ad446 100644 --- a/rusticlone/processing/atomic.py +++ b/rusticlone/processing/atomic.py @@ -40,12 +40,14 @@ def profile_archive( profile.set_log_file(log_file) profile.check_sources_exist() profile.check_local_repo_exists() + profile.create_lockfile("archive") profile.check_local_repo_health() profile.init() profile.backup() profile.forget() profile.source_stats() profile.repo_stats() + profile.delete_lockfile() timer.stop() # action.stop(" ", "") return profile.result, timer.duration @@ -64,7 +66,9 @@ def profile_upload( profile.set_log_file(log_file) profile.check_rclone_config_exists() profile.check_local_repo_exists() + profile.create_lockfile("upload") profile.upload(remote_prefix) + profile.delete_lockfile() timer.stop() # action.stop(" ", "") return profile.result, timer.duration @@ -87,7 +91,9 @@ def profile_download( profile.check_rclone_config_exists() profile.check_remote_repo_exists(remote_prefix) profile.check_local_repo_exists() + profile.create_lockfile("download") profile.download(remote_prefix) + profile.delete_lockfile() timer.stop() # print_stats("", "") return profile.result, timer.duration @@ -105,9 +111,11 @@ def profile_extract( profile.parse_rustic_config() profile.set_log_file(log_file) profile.check_local_repo_exists() + profile.create_lockfile("extract") profile.check_latest_snapshot() profile.check_sources_type() profile.restore() + profile.delete_lockfile() timer.stop() # print_stats("", "") return profile.result, timer.duration diff --git a/rusticlone/processing/profile.py b/rusticlone/processing/profile.py index bc96bf8..8865ad7 100644 --- a/rusticlone/processing/profile.py +++ b/rusticlone/processing/profile.py @@ -52,6 +52,7 @@ def __init__(self, profile: str, parallel: bool = False) -> None: self.profile_name = profile self.parallel = parallel self.repo = Path("") + self.lockfile = Path("rusticlone.lock") self.log_file = Path("rusticlone.log") self.env: dict[str, str] = {} self.does_forget = False @@ -100,6 +101,7 @@ def parse_rustic_config_component(self, action, component: str) -> bool: self.sources = parse_sources(self.config) case "repo": self.repo = parse_repo(self.config) + self.lockfile = self.repo / "rusticlone.lock" case "log": self.log_file = parse_log(self.config) case "env": @@ -137,6 +139,46 @@ def check_rclone_config_exists(self) -> None: f"Rclone configuration file does not exist: {rclone_config_file}" ) + def create_lockfile(self, operation: str) -> None: + """ + Add a lockfile to the repo containing the operation name. + If the lockfile already exists, abort and print its contents. + """ + if self.result: + action = Action("Creating lockfile", self.parallel) + # Create repo directory if it doesn't exist (for new repos) + if not self.local_repo_exists: + self.repo.mkdir(parents=True, exist_ok=True) + + if self.lockfile.exists(): + try: + with open(self.lockfile, "r") as f: + existing_operation = f.read() + except Exception: + self.result = action.abort("Lockfile already exists") + else: + self.result = action.abort( + f'Found another "{existing_operation}" lockfile' + ) + else: + try: + with open(self.lockfile, "w") as f: + f.write(operation) + except Exception: + self.result = action.abort("Could not create lockfile") + else: + action.stop("Created lockfile") + + def delete_lockfile(self) -> None: + """ + Delete the lockfile from the repo + """ + if self.result: + action = Action("Deleting lockfile", self.parallel) + if self.lockfile.exists(): + self.lockfile.unlink() + action.stop("Deleted lockfile") + def set_log_file(self, passed_log_file: Path) -> None: """ set rclone log file @@ -437,9 +479,9 @@ def download(self, remote_prefix: str) -> None: if not self.local_repo_exists: rclone_log_file = str(self.log_file) # rclone_origin = remote_prefix + "/" + self.profile_name - repo_name = self.repo.name + repo_name = str(self.repo.name) rclone_origin = remote_prefix + "/" + repo_name - rclone_destination = self.repo + rclone_destination = str(self.repo) Rclone( env=self.env, log_file=rclone_log_file, @@ -506,7 +548,7 @@ def check_latest_snapshot(self) -> None: # self.snapshot_exists = True print_stats( "Restoring from:", - f"[{timestamp_pretty}]", + timestamp_pretty, 19, 21, parallel=self.parallel, diff --git a/tests/tests.sh b/tests/tests.sh index 766e9a0..cd52c3c 100755 --- a/tests/tests.sh +++ b/tests/tests.sh @@ -231,6 +231,10 @@ create_check_source(){ find "$RUSTICLONE_TEST_DIR/source" -type f -exec b2sum {} \; > "$RUSTICLONE_TEST_DIR/check/source.txt" } +wait_background(){ + while wait -n; do : ; done; +} + # ################################ RUSTICLONE BACKUP # ################ SEQUENTIAL @@ -275,6 +279,23 @@ rusticlone_upload_parallel(){ coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" --parallel upload } +# ################ BACKGROUND + +rusticlone_backup_background(){ + echo "[OK] Backing up with Rusticlone in background" + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" --parallel backup >/dev/null & +} + +rusticlone_archive_background(){ + echo "[OK] Archiving with Rusticlone in background" + coverage run --append --module rusticlone.cli archive >/dev/null & +} + +rusticlone_upload_background(){ + echo "[OK] Uploading with Rusticlone in background" + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" upload >/dev/null & +} + # ################################ DISASTER SIMULATION # ################ LOSING SOURCE FILES @@ -383,6 +404,23 @@ rusticlone_extract_parallel(){ coverage run --append --module rusticlone.cli --parallel extract } +# ################ BACKGROUND + +rusticlone_restore_background(){ + echo "[OK] Restoring with Rusticlone in background" + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" --parallel restore >/dev/null & +} + +rusticlone_download_background(){ + echo "[OK] Downloading with Rusticlone in background" + coverage run --append --module rusticlone.cli --remote "gdrive:/$RUSTICLONE_TEST_DIR/remote" download >/dev/null & +} + +rusticlone_extract_background(){ + echo "[OK] Extracting with Rusticlone in background" + coverage run --append --module rusticlone.cli --parallel extract >/dev/null & +} + # ################################ RESULT check_source(){ @@ -393,6 +431,7 @@ check_source(){ create_coverage(){ coverage html coverage xml + coverage report rm -rf "tests/coverage" mv "htmlcov" "$RUSTICLONE_TEST_DIR/coverage" mv "coverage.xml" "$RUSTICLONE_TEST_DIR/coverage" @@ -403,8 +442,6 @@ create_badge(){ } check_coverage(){ - cd "$RUSTICLONE_TEST_DIR/coverage" - coverage report echo " " echo "[OK] Read the coverage report in detail by running:" echo " " @@ -436,7 +473,16 @@ main(){ destroy_remote1 destroy_local2 print_space + rusticlone_backup_background + rusticlone_backup_background + rusticlone_backup_parallel + rusticlone_upload_background + rusticlone_archive_background + rusticlone_upload_background + rusticlone_archive_background rusticlone_archive + rusticlone_backup_background + wait_background print_space destroy_cache print_space @@ -459,20 +505,32 @@ main(){ destroy_source1 destroy_local2 print_space + rusticlone_restore_background rusticlone_restore_parallel + wait_background print_space check_source destroy_local1 destroy_source2 print_space + rusticlone_restore_background + rusticlone_download_background + print_space rusticlone_download_parallel + rusticlone_archive_parallel rusticlone_extract + rusticlone_extract + wait_background print_space check_source destroy_cache print_space rusticlone_download + rusticlone_extract_background + rusticlone_extract_parallel + rusticlone_extract_background rusticlone_extract_parallel + wait_background print_space check_source print_space @@ -480,7 +538,10 @@ main(){ destroy_source1 destroy_local2 print_space + rusticlone_restore_background + print_space rusticlone_restore + wait_background print_space destroy_source2 destroy_local1 From 8c42bbb3d8282a9ec265ca0958240d875b0ed573 Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Mon, 22 Sep 2025 23:28:55 +0200 Subject: [PATCH 4/7] feat: added rclone flags to speed up upload process --- rusticlone/helpers/rclone.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rusticlone/helpers/rclone.py b/rusticlone/helpers/rclone.py index 12a2997..5f50fe8 100644 --- a/rusticlone/helpers/rclone.py +++ b/rusticlone/helpers/rclone.py @@ -48,6 +48,8 @@ def __init__(self, **kwargs): "--drive-chunk-size=128M", "--drive-acknowledge-abuse", "--drive-stop-on-upload-limit", + "--no-update-modtime", + "--no-update-dir-modtime", ] default_kwargs: dict[str, Any] = { "env": {}, From f6a5f7e138ebaf781792a3600fd377f3917d84e3 Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Tue, 23 Sep 2025 00:13:47 +0200 Subject: [PATCH 5/7] ci: added new files to test suite --- images/coverage.svg | 2 +- rusticlone/helpers/custom.py | 8 +------- rusticlone/processing/profile.py | 2 +- tests/tests.sh | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/images/coverage.svg b/images/coverage.svg index ae696e9..683122a 100644 --- a/images/coverage.svg +++ b/images/coverage.svg @@ -1 +1 @@ -coverage: 96.08%coverage96.08% \ No newline at end of file +coverage: 96.43%coverage96.43% \ No newline at end of file diff --git a/rusticlone/helpers/custom.py b/rusticlone/helpers/custom.py index 21ee13f..af48450 100644 --- a/rusticlone/helpers/custom.py +++ b/rusticlone/helpers/custom.py @@ -80,20 +80,14 @@ def __init__(self, args) -> None: self.profiles_dirs = [ Path.home() / "AppData/Roaming/rustic/config", Path("C:/ProgramData/rustic/config"), - Path.cwd(), ] elif self.operating_system == "Darwin": self.profiles_dirs = [ Path.home() / "Library/Application Support/rustic", Path("/etc/rustic"), - Path.cwd(), ] else: - self.profiles_dirs = [ - Path.home() / ".config/rustic", - Path("/etc/rustic"), - Path.cwd(), - ] + self.profiles_dirs = [Path.home() / ".config/rustic", Path("/etc/rustic")] # remote prefix: rclone remote + subdirectory without trailing slash if args.remote is not None: self.remote_prefix = args.remote.rstrip("/") diff --git a/rusticlone/processing/profile.py b/rusticlone/processing/profile.py index 8865ad7..c1b2b79 100644 --- a/rusticlone/processing/profile.py +++ b/rusticlone/processing/profile.py @@ -158,7 +158,7 @@ def create_lockfile(self, operation: str) -> None: self.result = action.abort("Lockfile already exists") else: self.result = action.abort( - f'Found another "{existing_operation}" lockfile' + f"Found another {existing_operation} lockfile" ) else: try: diff --git a/tests/tests.sh b/tests/tests.sh index cd52c3c..dc1375b 100755 --- a/tests/tests.sh +++ b/tests/tests.sh @@ -226,6 +226,19 @@ create_files(){ chmod 0600 "$RUSTICLONE_TEST_DIR/source/passwords.kdbx" } +create_new_files(){ + # 10MB each + echo "[OK] Creating files" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/docs/important2.pdf" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/docs/veryimportant2.pdf" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/docs/notsoimportant2.docx" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/pics/screenshot2.png" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/pics/opengraph2.webp" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/pics/funny2.gif" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/photos/photo2.jpeg" + head -c 10000000 /dev/urandom > "$RUSTICLONE_TEST_DIR/source/photos/deeply/nested/memory2.avif" +} + create_check_source(){ echo "[OK] Creating checksums for source files" find "$RUSTICLONE_TEST_DIR/source" -type f -exec b2sum {} \; > "$RUSTICLONE_TEST_DIR/check/source.txt" @@ -551,6 +564,7 @@ main(){ # further run check_source + create_new_files rusticlone_backup rusticlone_restore check_source From 301e2a6989105317111953cf414ff9e614dd2c7a Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Tue, 23 Sep 2025 00:21:01 +0200 Subject: [PATCH 6/7] minor: removed deprecated flag for ignore argument, as it's only available from Python 3.13 --- rusticlone/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rusticlone/cli.py b/rusticlone/cli.py index 42400b8..a8c5058 100755 --- a/rusticlone/cli.py +++ b/rusticlone/cli.py @@ -60,7 +60,6 @@ def parse_args(): type=str, default="", help="Deprecated argument, does nothing. Will be removed in a future release", - deprecated=True, ) parser.add_argument( "-l", From a1a1ec11a33b6150f9d533e2c1de80d212ae8026 Mon Sep 17 00:00:00 2001 From: AlphaJack Date: Tue, 23 Sep 2025 00:25:44 +0200 Subject: [PATCH 7/7] minor: moving test results under ~/.cache --- .github/workflows/from_commit_to_build_test.yml | 2 +- tests/tests.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/from_commit_to_build_test.yml b/.github/workflows/from_commit_to_build_test.yml index d9d530e..082981b 100644 --- a/.github/workflows/from_commit_to_build_test.yml +++ b/.github/workflows/from_commit_to_build_test.yml @@ -79,5 +79,5 @@ jobs: if: ${{ env.ACT == '' }} with: name: coverage-reports - path: /home/runner/rusticlone-tests/coverage/ + path: /home/runner/.cache/rusticlone-tests/coverage/ retention-days: 30 diff --git a/tests/tests.sh b/tests/tests.sh index dc1375b..3c2ce07 100755 --- a/tests/tests.sh +++ b/tests/tests.sh @@ -35,7 +35,7 @@ set -euo pipefail RUSTIC_PROFILES_DIR="$HOME/.config/rustic" # can be any folder -RUSTICLONE_TEST_DIR="$HOME/.rusticlone-tests" +RUSTICLONE_TEST_DIR="$HOME/.cache/rusticlone-tests" # ################################ DERIVED