Skip to content

Commit 7772f86

Browse files
borg importer
1 parent 2d54c74 commit 7772f86

8 files changed

Lines changed: 189 additions & 6 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: 2 additions & 5 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 = [
@@ -84,7 +81,7 @@ passenv = ["*"]
8481

8582
[tool.tox.env.testenv]
8683
deps = ["-rrequirements.d/development.txt"]
87-
commands = [["pytest", "-rs", "--cov=borg_import", "--cov-config=pyproject.toml", "--pyargs={posargs:borg_import.helpers.testsuite}"]]
84+
commands = [["pytest", "-rs", "--cov=borg_import", "--cov-config=pyproject.toml", "--pyargs={posargs:borg_import.helpers.testsuite borg_import.testsuite}"]]
8885

8986
[tool.tox.env.flake8]
9087
deps = ["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: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44
import shlex
55
import subprocess
66
import sys
7+
import tempfile
78
import textwrap
89
from pathlib import Path
910

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']
1921
if timestamp:
2022
borg_cmdline += '--timestamp', timestamp.isoformat()
2123
if args.create_options:
@@ -282,6 +284,67 @@ 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 temporary
297+
directory and then creates a new archive with the same name and timestamp in
298+
the destination repository.
299+
300+
By default, archive names are preserved. Use --prefix to add a prefix to
301+
the imported archive names.
302+
"""
303+
304+
def populate_parser(self, parser):
305+
parser.add_argument('source_repository', metavar='SOURCE_REPOSITORY',
306+
help='Source Borg repository (must be a valid Borg repository spec)')
307+
parser.add_argument('repository', metavar='DESTINATION_REPOSITORY',
308+
help='Destination Borg repository (must be a valid Borg repository spec)')
309+
parser.set_defaults(function=self.import_borg)
310+
311+
def import_borg(self, args):
312+
existing_archives = list_borg_archives(args)
313+
314+
for archive in get_borg_archives(args.source_repository):
315+
name = archive['name']
316+
timestamp = archive['timestamp'].replace(microsecond=0)
317+
archive_name = args.prefix + name
318+
319+
if archive_name in existing_archives:
320+
print('Skipping (already exists in repository):', name)
321+
continue
322+
323+
print('Importing {} (timestamp {}) '.format(name, timestamp), end='')
324+
if archive_name != name:
325+
print('as', archive_name)
326+
else:
327+
print()
328+
329+
# Create a temporary directory for extraction
330+
with tempfile.TemporaryDirectory() as extract_path:
331+
try:
332+
# Extract the archive from the source repository
333+
extract_cmdline = ['borg', 'extract', '--numeric-owner']
334+
extract_cmdline.append(args.source_repository + '::' + name)
335+
336+
print(' Extracting archive to temporary directory...')
337+
subprocess.check_call(extract_cmdline, cwd=extract_path)
338+
339+
# Create a new archive in the destination repository
340+
borg_import(args, archive_name, extract_path, timestamp=timestamp)
341+
342+
except subprocess.CalledProcessError as cpe:
343+
print('Error during import of {}: {}'.format(name, cpe))
344+
if cpe.returncode != 1: # Borg returns 1 for warnings
345+
raise
346+
347+
285348
def build_parser():
286349
common_parser = argparse.ArgumentParser(add_help=False)
287350
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", "--numeric-ids",
29+
f"{source_repo}::archive1",
30+
"."
31+
], cwd=str(archive1_data))
32+
33+
subprocess.check_call([
34+
"borg", "create", "--numeric-ids",
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)