Skip to content

Commit e9588e6

Browse files
Merge pull request #69 from ThomasWaldmann/borg-importer
borg importer
2 parents 2d54c74 + 6f1af4f commit e9588e6

9 files changed

Lines changed: 212 additions & 9 deletions

File tree

README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ Note: we have different importers and some importers may not support all the fea
1818
Currently supported import formats
1919
==================================
2020

21+
`BorgBackup <https://github.com/borgbackup/borg>`_
22+
--------------------------------------------------
23+
24+
Imports archives from an existing Borg repository into a new one.
25+
This is useful when a Borg repository needs to be rebuilt (e.g. if
26+
your borg key and passphrase was compromised).
27+
28+
Usage: ``borg-import borg SOURCE_REPOSITORY DESTINATION_REPOSITORY``
29+
30+
See ``borg-import borg -h`` for help.
31+
2132
`rsnapshot <https://github.com/rsnapshot/rsnapshot>`_
2233
-----------------------------------------------------
2334

docs/usage.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ borg-import rsnapshot
2424
---------------------
2525

2626
.. generate-usage:: rsnapshot
27+
28+
.. _borg:
29+
30+
borg-import borg
31+
----------------
32+
33+
.. generate-usage:: borg

pyproject.toml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,14 @@ write_to = "src/borg_import/_version.py"
4949
python_files = "testsuite/*.py"
5050
testpaths = ["src"]
5151

52-
[tool.pytest.ini_options]
53-
addopts = "-rs --cov=borg_import --cov-config=pyproject.toml"
54-
5552
[tool.flake8]
5653
max-line-length = 120
5754
exclude = "build,dist,.git,.idea,.cache,.tox,docs/conf.py,.eggs"
5855

5956
[tool.coverage.run]
6057
branch = true
6158
source = ["src/borg_import"]
62-
omit = ["*/borg_import/helpers/testsuite/*"]
59+
omit = ["*/borg_import/helpers/testsuite/*", "*/borg_import/testsuite/*"]
6360

6461
[tool.coverage.report]
6562
exclude_lines = [
@@ -77,15 +74,14 @@ env_list = ["py39", "py310", "py311", "py312", "py313", "flake8"]
7774

7875
[tool.tox.env_run_base]
7976
package = "editable-legacy"
77+
commands = [["pytest", "-v", "-rs", "--cov=borg_import", "--cov-config=pyproject.toml", "--pyargs", "{posargs:borg_import}"]]
78+
deps = ["-rrequirements.d/development.txt"]
8079
passenv = ["*"]
8180

8281
[tool.tox.env_pkg_base]
8382
passenv = ["*"]
8483

85-
[tool.tox.env.testenv]
86-
deps = ["-rrequirements.d/development.txt"]
87-
commands = [["pytest", "-rs", "--cov=borg_import", "--cov-config=pyproject.toml", "--pyargs={posargs:borg_import.helpers.testsuite}"]]
84+
[tool.tox.env."py{39,310,311,312,313}"]
8885

8986
[tool.tox.env.flake8]
90-
deps = ["flake8-pyproject"]
9187
commands = [["flake8"]]

requirements.d/development.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ virtualenv
22
tox
33
pytest
44
pytest-cov
5+
flake8-pyproject

src/borg_import/borg.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import subprocess
2+
3+
from .helpers.timestamps import datetime_from_string
4+
5+
6+
def get_borg_archives(repository):
7+
"""Get all archive metadata discovered in the Borg repository."""
8+
# Get list of archives with their timestamps
9+
borg_cmdline = ['borg', 'list', '--format', '{name}{TAB}{time}{NL}', repository]
10+
output = subprocess.check_output(borg_cmdline).decode()
11+
12+
for line in output.splitlines():
13+
if not line.strip():
14+
continue
15+
16+
parts = line.split('\t', 1)
17+
if len(parts) == 2:
18+
name, timestamp_str = parts
19+
timestamp = datetime_from_string(timestamp_str)
20+
meta = dict(
21+
name=name,
22+
timestamp=timestamp,
23+
original_repository=repository,
24+
)
25+
yield meta

src/borg_import/helpers/timestamps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def datetime_from_string(s):
3232
'%Y-%m-%d %H:%M',
3333
# date tool output [C / en_US locale]:
3434
'%a %b %d %H:%M:%S %Z %Y',
35+
# borg format with day of week
36+
'%a, %Y-%m-%d %H:%M:%S',
3537
# rsync-time-backup format
3638
'%Y-%m-%d-%H%M%S'
3739
# for more, see https://xkcd.com/1179/

src/borg_import/main.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import logging
3+
import os
34
import shutil
45
import shlex
56
import subprocess
@@ -10,12 +11,13 @@
1011
from .rsnapshots import get_snapshots
1112
from .rsynchl import get_rsyncsnapshots
1213
from .rsync_tmbackup import get_tmbackup_snapshots
14+
from .borg import get_borg_archives
1315

1416
log = logging.getLogger(__name__)
1517

1618

1719
def borg_import(args, archive_name, path, timestamp=None):
18-
borg_cmdline = ['borg', 'create']
20+
borg_cmdline = ['borg', 'create', '--numeric-ids', '--files-cache=mtime,size']
1921
if timestamp:
2022
borg_cmdline += '--timestamp', timestamp.isoformat()
2123
if args.create_options:
@@ -282,6 +284,87 @@ def import_rsync_tmbackup(self, args):
282284
import_journal.unlink()
283285

284286

287+
class borgImporter(Importer):
288+
name = 'borg'
289+
description = 'import archives from another Borg repository'
290+
epilog = """
291+
Imports archives from an existing Borg repository into a new one.
292+
293+
This is useful when a Borg repository needs to be rebuilt and all archives
294+
transferred from the old repository to a new one.
295+
296+
The importer extracts each archive from the source repository to a intermediate
297+
directory inside the current work directory (make sure there is enough space!)
298+
and then creates a new archive with the same name and timestamp in the destination
299+
repository.
300+
301+
Because the importer changes the current directory while importing archives,
302+
you need to give either absolute paths for the source and destination repositories
303+
or ssh:// URLs.
304+
305+
To avoid issues with user/group id-to-name mappings, the importer will only
306+
transfer the numeric user and group ids for the files inside the archives.
307+
308+
By default, archive names are preserved. Use --prefix to add a prefix to
309+
the imported archive names.
310+
"""
311+
312+
def populate_parser(self, parser):
313+
parser.add_argument('source_repository', metavar='SOURCE_REPOSITORY',
314+
help='Source Borg repository (must be a valid Borg repository spec)')
315+
parser.add_argument('repository', metavar='DESTINATION_REPOSITORY',
316+
help='Destination Borg repository (must be a valid Borg repository spec)')
317+
parser.set_defaults(function=self.import_borg)
318+
319+
def import_borg(self, args):
320+
existing_archives = list_borg_archives(args)
321+
322+
# Create a fixed unique directory inside the current working directory
323+
import_path = Path.cwd() / f"borg_import_{os.getpid()}"
324+
import_path.mkdir(exist_ok=True)
325+
326+
try:
327+
for archive in get_borg_archives(args.source_repository):
328+
name = archive['name']
329+
timestamp = archive['timestamp'].replace(microsecond=0)
330+
archive_name = args.prefix + name
331+
332+
if archive_name in existing_archives:
333+
print('Skipping (already exists in repository):', name)
334+
continue
335+
336+
print('Importing {} (timestamp {}) '.format(name, timestamp), end='')
337+
if archive_name != name:
338+
print('as', archive_name)
339+
else:
340+
print()
341+
342+
try:
343+
# Extract the archive from the source repository
344+
extract_cmdline = ['borg', 'extract', '--numeric-ids']
345+
extract_cmdline.append(args.source_repository + '::' + name)
346+
347+
print(' Extracting archive to import directory...')
348+
subprocess.check_call(extract_cmdline, cwd=str(import_path))
349+
350+
# Create a new archive in the destination repository
351+
borg_import(args, archive_name, str(import_path), timestamp=timestamp)
352+
353+
# Empty the directory after importing the archive
354+
print(' Cleaning import directory...')
355+
shutil.rmtree(import_path)
356+
import_path.mkdir(exist_ok=True)
357+
358+
except subprocess.CalledProcessError as cpe:
359+
print('Error during import of {}: {}'.format(name, cpe))
360+
if cpe.returncode != 1: # Borg returns 1 for warnings
361+
raise
362+
finally:
363+
# Clean up the import directory when done
364+
if import_path.exists():
365+
shutil.rmtree(import_path)
366+
367+
285368
def build_parser():
286369
common_parser = argparse.ArgumentParser(add_help=False)
287370
common_group = common_parser.add_argument_group('Common options')

src/borg_import/testsuite/__init__.py

Whitespace-only changes.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import subprocess
2+
3+
from borg_import.main import main
4+
5+
6+
def test_borg_import(tmpdir, monkeypatch):
7+
"""Test the borg importer by creating archives in a source repo and importing them to a target repo."""
8+
# Create source and target repository directories
9+
source_repo = tmpdir.mkdir("source_repo")
10+
target_repo = tmpdir.mkdir("target_repo")
11+
12+
# Create test data directories
13+
test_data = tmpdir.mkdir("test_data")
14+
archive1_data = test_data.mkdir("archive1")
15+
archive2_data = test_data.mkdir("archive2")
16+
17+
# Create some test files in the archive directories
18+
archive1_data.join("file1.txt").write("This is file 1 in archive 1")
19+
archive1_data.join("file2.txt").write("This is file 2 in archive 1")
20+
archive2_data.join("file1.txt").write("This is file 1 in archive 2")
21+
archive2_data.join("file2.txt").write("This is file 2 in archive 2")
22+
23+
# Initialize the source repository
24+
subprocess.check_call(["borg", "init", "--encryption=none", str(source_repo)])
25+
26+
# Create archives in the source repository
27+
subprocess.check_call([
28+
"borg", "create",
29+
f"{source_repo}::archive1",
30+
"."
31+
], cwd=str(archive1_data))
32+
33+
subprocess.check_call([
34+
"borg", "create",
35+
f"{source_repo}::archive2",
36+
"."
37+
], cwd=str(archive2_data))
38+
39+
# Initialize the target repository
40+
subprocess.check_call(["borg", "init", "--encryption=none", str(target_repo)])
41+
42+
# Set up command line arguments for borg-import
43+
monkeypatch.setattr("sys.argv", [
44+
"borg-import",
45+
"borg",
46+
str(source_repo),
47+
str(target_repo)
48+
])
49+
50+
# Run the borg-import command
51+
main()
52+
53+
# Verify that the archives were imported to the target repository
54+
output = subprocess.check_output(["borg", "list", "--short", str(target_repo)]).decode()
55+
archives = output.splitlines()
56+
57+
assert "archive1" in archives
58+
assert "archive2" in archives
59+
60+
# Extract the archives from the target repository and verify their contents
61+
extract_dir1 = tmpdir.mkdir("extract1")
62+
extract_dir2 = tmpdir.mkdir("extract2")
63+
64+
subprocess.check_call([
65+
"borg", "extract",
66+
f"{target_repo}::archive1"
67+
], cwd=str(extract_dir1))
68+
69+
subprocess.check_call([
70+
"borg", "extract",
71+
f"{target_repo}::archive2"
72+
], cwd=str(extract_dir2))
73+
74+
# Verify the contents of the extracted archives
75+
assert extract_dir1.join("file1.txt").read() == "This is file 1 in archive 1"
76+
assert extract_dir1.join("file2.txt").read() == "This is file 2 in archive 1"
77+
assert extract_dir2.join("file1.txt").read() == "This is file 1 in archive 2"
78+
assert extract_dir2.join("file2.txt").read() == "This is file 2 in archive 2"

0 commit comments

Comments
 (0)