diff --git a/src/borg/archiver.py b/src/borg/archiver.py index b50aeb6ba..93ba4b43b 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -124,15 +124,17 @@ def with_repository(fake=False, invert_fake=False, create=False, lock=True, location = args.location # note: 'location' must be always present in args append_only = getattr(args, 'append_only', False) storage_quota = getattr(args, 'storage_quota', None) + make_parent_dirs = getattr(args, 'make_parent_dirs', False) if argument(args, fake) ^ invert_fake: return method(self, args, repository=None, **kwargs) elif location.proto == 'ssh': repository = RemoteRepository(location, create=create, exclusive=argument(args, exclusive), - lock_wait=self.lock_wait, lock=lock, append_only=append_only, args=args) + lock_wait=self.lock_wait, lock=lock, append_only=append_only, + make_parent_dirs=make_parent_dirs, args=args) else: repository = Repository(location.path, create=create, exclusive=argument(args, exclusive), lock_wait=self.lock_wait, lock=lock, append_only=append_only, - storage_quota=storage_quota) + storage_quota=storage_quota, make_parent_dirs=make_parent_dirs) with repository: if manifest or cache: kwargs['manifest'], kwargs['key'] = Manifest.load(repository, compatibility) @@ -2773,6 +2775,8 @@ class Archiver: subparser.add_argument('--storage-quota', metavar='QUOTA', dest='storage_quota', default=None, type=parse_storage_quota, help='Set storage quota of the new repository (e.g. 5G, 1.5T). Default: no quota.') + subparser.add_argument('--make-parent-dirs', dest='make_parent_dirs', action='store_true', + help='create the parent directories of the repository directory, if they are missing.') check_epilog = process_epilog(""" The check command verifies the consistency of a repository and the corresponding archives. diff --git a/src/borg/remote.py b/src/borg/remote.py index 272b86036..f8db4cec0 100644 --- a/src/borg/remote.py +++ b/src/borg/remote.py @@ -333,7 +333,8 @@ class RepositoryServer: # pragma: no cover path = path[3:] return os.path.realpath(path) - def open(self, path, create=False, lock_wait=None, lock=True, exclusive=None, append_only=False): + def open(self, path, create=False, lock_wait=None, lock=True, exclusive=None, append_only=False, + make_parent_dirs=False): logging.debug('Resolving repository path %r', path) path = self._resolve_path(path) logging.debug('Resolved repository path to %r', path) @@ -362,7 +363,8 @@ class RepositoryServer: # pragma: no cover self.repository = Repository(path, create, lock_wait=lock_wait, lock=lock, append_only=append_only, storage_quota=self.storage_quota, - exclusive=exclusive) + exclusive=exclusive, + make_parent_dirs=make_parent_dirs) self.repository.__enter__() # clean exit handled by serve() method return self.repository.id @@ -523,7 +525,8 @@ class RemoteRepository: # If compatibility with 1.0.x is not longer needed, replace all checks of this with True and simplify the code dictFormat = False # outside of __init__ for testing of legacy free protocol - def __init__(self, location, create=False, exclusive=False, lock_wait=None, lock=True, append_only=False, args=None): + def __init__(self, location, create=False, exclusive=False, lock_wait=None, lock=True, append_only=False, + make_parent_dirs=False, args=None): self.location = self._location = location self.preload_ids = [] self.msgid = 0 @@ -576,7 +579,8 @@ class RemoteRepository: def do_open(): self.id = self.open(path=self.location.path, create=create, lock_wait=lock_wait, - lock=lock, exclusive=exclusive, append_only=append_only) + lock=lock, exclusive=exclusive, append_only=append_only, + make_parent_dirs=make_parent_dirs) if self.dictFormat: do_open() @@ -739,6 +743,8 @@ This problem will go away as soon as the server has been upgraded to 1.0.7+. raise PathNotAllowed('(unknown)') else: raise PathNotAllowed(args[0].decode()) + elif error == 'ParentPathDoesNotExist': + raise Repository.ParentPathDoesNotExist(args[0].decode()) elif error == 'ObjectNotFound': if old_server: raise Repository.ObjectNotFound('(not available)', self.location.orig) @@ -884,8 +890,10 @@ This problem will go away as soon as the server has been upgraded to 1.0.7+. self.ignore_responses |= set(waiting_for) # we lose order here @api(since=parse_version('1.0.0'), - append_only={'since': parse_version('1.0.7'), 'previously': False}) - def open(self, path, create=False, lock_wait=None, lock=True, exclusive=False, append_only=False): + append_only={'since': parse_version('1.0.7'), 'previously': False}, + make_parent_dirs={'since': parse_version('1.1.9'), 'previously': False}) + def open(self, path, create=False, lock_wait=None, lock=True, exclusive=False, append_only=False, + make_parent_dirs=False): """actual remoting is done via self.call in the @api decorator""" @api(since=parse_version('1.0.0')) diff --git a/src/borg/repository.py b/src/borg/repository.py index 2563d5219..3971a69c2 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -120,6 +120,9 @@ class Repository: class PathAlreadyExists(Error): """There is already something at {}.""" + class ParentPathDoesNotExist(Error): + """The parent path of the repo directory [{}] does not exist.""" + class InvalidRepository(Error): """{} is not a valid repository. Check repo config.""" @@ -147,7 +150,8 @@ class Repository: """The storage quota ({}) has been exceeded ({}). Try deleting some archives.""" def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True, - append_only=False, storage_quota=None, check_segment_magic=True): + append_only=False, storage_quota=None, check_segment_magic=True, + make_parent_dirs=False): self.path = os.path.abspath(path) self._location = Location('file://%s' % self.path) self.io = None # type: LoggedIO @@ -168,6 +172,7 @@ class Repository: self.storage_quota_use = 0 self.transaction_doomed = None self.check_segment_magic = check_segment_magic + self.make_parent_dirs = make_parent_dirs def __del__(self): if self.lock: @@ -250,8 +255,14 @@ class Repository: """Create a new empty repository at `path` """ self.check_can_create_repository(path) + if self.make_parent_dirs: + parent_path = os.path.join(path, os.pardir) + os.makedirs(parent_path, exist_ok=True) if not os.path.exists(path): - os.mkdir(path) + try: + os.mkdir(path) + except FileNotFoundError as err: + raise self.ParentPathDoesNotExist(path) from err with open(os.path.join(path, 'README'), 'w') as fd: fd.write(REPOSITORY_README) os.mkdir(os.path.join(path, 'data')) diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 0142e78f5..9b5cd9480 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -438,6 +438,17 @@ class ArchiverTestCase(ArchiverTestCaseBase): # the interesting parts of info_output2 and info_output should be same self.assert_equal(filter(info_output), filter(info_output2)) + def test_init_parent_dirs(self): + parent_path = os.path.join(self.tmpdir, 'parent1', 'parent2') + repository_path = os.path.join(parent_path, 'repository') + repository_location = self.prefix + repository_path + with pytest.raises(Repository.ParentPathDoesNotExist): + # normal borg init does NOT create missing parent dirs + self.cmd('init', '--encryption=none', repository_location) + # but if told so, it does: + self.cmd('init', '--encryption=none', '--make-parent-dirs', repository_location) + assert os.path.exists(parent_path) + def test_unix_socket(self): self.cmd('init', '--encryption=repokey', self.repository_location) try: @@ -2893,6 +2904,10 @@ class ArchiverTestCaseBinary(ArchiverTestCase): EXE = 'borg.exe' FORK_DEFAULT = True + @unittest.skip('does not raise Exception, but sets rc==2') + def test_init_parent_dirs(self): + pass + @unittest.skip('patches objects') def test_init_interrupt(self): pass