diff --git a/CHANGES b/CHANGES index c3a9a0492..993eef2b4 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,7 @@ Version 1.5.2-dev (development of upcoming release) * .. Version 1.5.1 (2024-07-27) +* Fix bug: Check if Include folders/files do exists (in case they are removed) (#1586) (@rafaelhdr) * Fix: Use correct port to ping SSH Proxy (#1815) Version 1.5.0 (2024-07-26) diff --git a/common/backintime.py b/common/backintime.py index ed9fdab77..d78773703 100644 --- a/common/backintime.py +++ b/common/backintime.py @@ -48,6 +48,7 @@ parsers = {} + def takeSnapshotAsync(cfg, checksum = False): """ Fork a new backintime process with 'backup' command which will @@ -82,6 +83,7 @@ def takeSnapshotAsync(cfg, checksum = False): pass subprocess.Popen(cmd, env = env) + def takeSnapshot(cfg, force = True): """ Take a new snapshot. @@ -98,6 +100,7 @@ def takeSnapshot(cfg, force = True): ret = snapshots.Snapshots(cfg).backup(force) return ret + def _mount(cfg): """ Mount external filesystems. @@ -113,6 +116,7 @@ def _mount(cfg): else: cfg.setCurrentHashId(hash_id) + def _umount(cfg): """ Unmount external filesystems. @@ -125,6 +129,7 @@ def _umount(cfg): except MountException as ex: logger.error(str(ex)) + def createParsers(app_name = 'backintime'): """ Define parsers for commandline arguments. @@ -620,6 +625,7 @@ def join(args, subArgs): return args + def printHeader(): """ Print application name, version and legal notes. @@ -633,6 +639,7 @@ def printHeader(): print("under certain conditions; type `backintime --license' for details.") print('') + class PseudoAliasAction(argparse.Action): """ Translate '--COMMAND' into 'COMMAND' for backwards compatibility. @@ -659,6 +666,7 @@ def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, 'replace', replace) setattr(namespace, 'alias', alias) + def aliasParser(args): """ Call commands which where given with leading -- for backwards @@ -676,6 +684,7 @@ def aliasParser(args): if 'func' in dir(newArgs): newArgs.func(newArgs) + def getConfig(args, check = True): """ Load config and change to profile selected on commandline. @@ -713,6 +722,7 @@ def getConfig(args, check = True): cfg.forceUseChecksum = args.checksum return cfg + def setQuiet(args): """ Redirect :py:data:`sys.stdout` to ``/dev/null`` if ``--quiet`` was set on @@ -734,6 +744,7 @@ def setQuiet(args): atexit.register(force_stdout.close) return force_stdout + class printLicense(argparse.Action): """ Print custom license @@ -746,6 +757,7 @@ def __call__(self, *args, **kwargs): print(license_path.read_text('utf-8')) sys.exit(RETURN_OK) + class printDiagnostics(argparse.Action): """ Print information that is helpful for the support team @@ -764,6 +776,7 @@ def __call__(self, *args, **kwargs): sys.exit(RETURN_OK) + def backup(args, force = True): """ Command for force taking a new snapshot. @@ -783,6 +796,7 @@ def backup(args, force = True): ret = takeSnapshot(cfg, force) sys.exit(int(ret)) + def backupJob(args): """ Command for taking a new snapshot in background. Mainly used for cronjobs. @@ -798,6 +812,7 @@ def backupJob(args): """ cli.BackupJobDaemon(backup, args).start() + def shutdown(args): """ Command for shutting down the computer after the current snapshot has @@ -842,6 +857,7 @@ def shutdown(args): sd.shutdown() sys.exit(RETURN_OK) + def snapshotsPath(args): """ Command for printing the full snapshot path of current profile. @@ -864,6 +880,7 @@ def snapshotsPath(args): print(msg.format(cfg.snapshotsFullPath()), file=force_stdout) sys.exit(RETURN_OK) + def snapshotsList(args): """ Command for printing a list of all snapshots in current profile. @@ -894,6 +911,7 @@ def snapshotsList(args): _umount(cfg) sys.exit(RETURN_OK) + def snapshotsListPath(args): """ Command for printing a list of all snapshots paths in current profile. @@ -924,6 +942,7 @@ def snapshotsListPath(args): _umount(cfg) sys.exit(RETURN_OK) + def lastSnapshot(args): """ Command for printing the very last snapshot in current profile. @@ -950,6 +969,7 @@ def lastSnapshot(args): _umount(cfg) sys.exit(RETURN_OK) + def lastSnapshotPath(args): """ Command for printing the path of the very last snapshot in @@ -978,6 +998,7 @@ def lastSnapshotPath(args): _umount(cfg) sys.exit(RETURN_OK) + def unmount(args): """ Command for unmounting all filesystems. @@ -995,6 +1016,7 @@ def unmount(args): _umount(cfg) sys.exit(RETURN_OK) + def benchmarkCipher(args): """ Command for transferring a file with scp to remote host with all @@ -1018,6 +1040,7 @@ def benchmarkCipher(args): logger.error("SSH is not configured for profile '%s'!" % cfg.profileName()) sys.exit(RETURN_ERR) + def pwCache(args): """ Command for starting password cache daemon. @@ -1048,6 +1071,7 @@ def pwCache(args): daemon.run() sys.exit(ret) + def decode(args): """ Command for decoding paths given paths with 'encfsctl'. @@ -1082,6 +1106,7 @@ def decode(args): _umount(cfg) sys.exit(RETURN_OK) + def remove(args, force = False): """ Command for removing snapshots. @@ -1102,6 +1127,7 @@ def remove(args, force = False): _umount(cfg) sys.exit(RETURN_OK) + def removeAndDoNotAskAgain(args): """ Command for removing snapshots without asking before remove @@ -1116,6 +1142,7 @@ def removeAndDoNotAskAgain(args): """ remove(args, True) + def smartRemove(args): """ Command for running Smart-Removal from Terminal. @@ -1149,6 +1176,7 @@ def smartRemove(args): logger.error('Smart Removal is not configured.') sys.exit(RETURN_NO_CFG) + def restore(args): """ Command for restoring files from snapshots. @@ -1178,6 +1206,7 @@ def restore(args): _umount(cfg) sys.exit(RETURN_OK) + def checkConfig(args): """ Command for checking the config file. @@ -1205,5 +1234,6 @@ def checkConfig(args): file = force_stdout) sys.exit(RETURN_ERR) + if __name__ == '__main__': startApp() diff --git a/common/snapshots.py b/common/snapshots.py index 5ecddd6e1..f3732c98e 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -import json import os from pathlib import Path import stat @@ -698,6 +697,23 @@ def remove(self, sid): return True + def _check_included_sources_exist_on_take_snapshot(self, config): + """ + Check if files and/or folders in the include list exist on the source. + + If a file or folder does not exist, a warning message is logged. + + Args: + cfg (config.Config): config that should be used + """ + missing = has_missing_includes(config.include()) + + if missing: + msg = ', '.join(missing) + msg = f'The following **files/**folders are missing: {msg}' + logger.warning(msg) + self.setTakeSnapshotMessage(1, msg) + # TODO Refactor: This functions is extremely difficult to understand: # - Nested "if"s # - Fuzzy names of classes, attributes and methods @@ -807,6 +823,7 @@ def backup(self, force=False): else: self.config.setCurrentHashId(hash_id) + self._check_included_sources_exist_on_take_snapshot(self.config) include_folders = self.config.include() if not include_folders: @@ -3088,6 +3105,25 @@ def lastSnapshot(cfg): return sids[0] +def has_missing_includes(included): + """ + Check if there are missing files or folders in a snapshot. + + Args: + included (list): list of tuples (item, info) + + Returns: + tuple: (bool, str) where bool is ``True`` if there are + missing files or folders and str is a message + describing the missing files or folders + """ + not_found = [] + for path, info in included: + if not os.path.exists(path): + not_found.append(path) + return not_found + + if __name__ == '__main__': config = config.Config() snapshots = Snapshots(config) diff --git a/qt/app.py b/qt/app.py index 8936483e0..35825dc2c 100644 --- a/qt/app.py +++ b/qt/app.py @@ -1242,12 +1242,27 @@ def updateTimeLine(self, refreshSnapshotsList=True): item = self.timeLine.addSnapshot(sid) self.timeLine.checkSelection() + def validate_on_take_snapshot(self): + missing = snapshots.has_missing_includes(self.config.include()) + if missing: + msg_missing = '\n'.join(missing) + msg = _('The following folders are missing: {folders} Do you want to proceed?'.format( + folders=f'\n{msg_missing}\n\n')) + answer = messagebox.warningYesNo(self, msg) + return answer == QMessageBox.StandardButton.Yes + return True + def btnTakeSnapshotClicked(self): - backintime.takeSnapshotAsync(self.config) - self.updateTakeSnapshot(True) + self._take_snapshot_clicked(checksum=False) def btnTakeSnapshotChecksumClicked(self): - backintime.takeSnapshotAsync(self.config, checksum = True) + self._take_snapshot_clicked(checksum=True) + + def _take_snapshot_clicked(self, checksum): + if not self.validate_on_take_snapshot(): + return + + backintime.takeSnapshotAsync(self.config, checksum=checksum) self.updateTakeSnapshot(True) def btnStopTakeSnapshotClicked(self): @@ -1993,6 +2008,7 @@ def eventFilter(self, receiver, event): return super(ExtraMouseButtonEventFilter, self) \ .eventFilter(receiver, event) + class RemoveSnapshotThread(QThread): """ remove snapshots in background thread so GUI will not freeze @@ -2028,6 +2044,7 @@ def run(self): if self.config.inhibitCookie: self.config.inhibitCookie = tools.unInhibitSuspend(*self.config.inhibitCookie) + class FillTimeLineThread(QThread): """ add snapshot IDs to timeline in background @@ -2045,6 +2062,7 @@ def run(self): self.parent.snapshotsList.sort() + class SetupCron(QThread): """ Check crontab entries on startup.