Skip to content

Commit 968f7da

Browse files
Merge pull request borgbackup#9676 from ThomasWaldmann/support-rest-http-stdio
Support rest http stdio
2 parents b8034fc + 5584bff commit 968f7da

11 files changed

Lines changed: 161 additions & 33 deletions

File tree

‎.github/workflows/canary.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ jobs:
136136
run: |
137137
# build borg.exe
138138
. env/bin/activate
139-
pip install -e ".[cockpit,s3,sftp,rest,rclone]"
139+
pip install -e ".[cockpit,s3,sftp,rclone]"
140140
mkdir -p dist/binary
141141
pyinstaller -y --clean --distpath=dist/binary scripts/borg.exe.spec
142142
# build sdist and wheel in dist/...

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,13 @@ jobs:
243243
# Start ssh-agent and add our key so paramiko can use the agent
244244
eval "$(ssh-agent -s)"
245245
ssh-add ~/.ssh/id_ed25519
246+
sudo python3 -m venv /opt/borgstore-venv
247+
sudo /opt/borgstore-venv/bin/pip install -U pip setuptools wheel
248+
sudo /opt/borgstore-venv/bin/pip install "borgstore[rest]"
249+
sudo ln -sf /opt/borgstore-venv/bin/borgstore-server-rest /usr/local/bin/borgstore-server-rest
246250
# Export SFTP test URL for tox via GITHUB_ENV
247251
echo "BORG_TEST_SFTP_REPO=sftp://sftpuser@localhost:22/borg/sftp-repo" >> $GITHUB_ENV
252+
echo "BORG_TEST_REST_REPO=rest://sftpuser@localhost:22/borg/rest-repo" >> $GITHUB_ENV
248253
249254
- name: Install and configure MinIO S3 server (test only)
250255
if: ${{ runner.os == 'Linux' && !contains(matrix.toxenv, 'mypy') && !contains(matrix.toxenv, 'docs') }}
@@ -281,13 +286,13 @@ jobs:
281286
- name: Install borgbackup
282287
run: |
283288
if [[ "$TOXENV" == *"llfuse"* ]]; then
284-
pip install -ve ".[llfuse,cockpit,s3,sftp,rest,rclone]"
289+
pip install -ve ".[llfuse,cockpit,s3,sftp,rclone]"
285290
elif [[ "$TOXENV" == *"pyfuse3"* ]]; then
286-
pip install -ve ".[pyfuse3,cockpit,s3,sftp,rest,rclone]"
291+
pip install -ve ".[pyfuse3,cockpit,s3,sftp,rclone]"
287292
elif [[ "$TOXENV" == *"mfusepy"* ]]; then
288-
pip install -ve ".[mfusepy,cockpit,s3,sftp,rest,rclone]"
293+
pip install -ve ".[mfusepy,cockpit,s3,sftp,rclone]"
289294
else
290-
pip install -ve ".[cockpit,s3,sftp,rest,rclone]"
295+
pip install -ve ".[cockpit,s3,sftp,rclone]"
291296
fi
292297
293298
- name: Build Borg fat binaries (${{ matrix.binary }})
@@ -461,7 +466,7 @@ jobs:
461466
pip -V
462467
python -m pip install --upgrade pip wheel
463468
pip install -r requirements.d/development.lock.txt
464-
pip install -e ".[mfusepy,cockpit,s3,sftp,rest,rclone]"
469+
pip install -e ".[mfusepy,cockpit,s3,sftp,rclone]"
465470
tox -e py311-mfusepy
466471
467472
if [[ "${{ matrix.do_binaries }}" == "true" && "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
@@ -659,7 +664,7 @@ jobs:
659664
run: |
660665
# build borg.exe
661666
. env/bin/activate
662-
pip install -e ".[cockpit,s3,sftp,rest,rclone]"
667+
pip install -e ".[cockpit,s3,sftp,rclone]"
663668
mkdir -p dist/binary
664669
pyinstaller -y --clean --distpath=dist/binary scripts/borg.exe.spec
665670
# build sdist and wheel in dist/...

‎docs/usage/general/repository-urls.rst.inc‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ expanded by your shell).
1212

1313
Note: You may also prepend ``file://`` to a filesystem path to use URL style.
1414

15-
**Remote repositories** accessed via SSH user@host:
15+
**Remote repositories** accessed via SSH user@host (REST http over stdio):
16+
17+
``rest://user@host:port//abs/path/to/repo`` — absolute path
18+
19+
``rest://user@host:port/rel/path/to/repo`` — path relative to the current directory
20+
21+
**Remote repositories** accessed via SSH user@host (legacy borg RPC protocol):
1622

1723
``ssh://user@host:port//abs/path/to/repo`` — absolute path
1824

‎pyproject.toml‎

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ license = "BSD-3-Clause"
3131
license-files = ["LICENSE", "AUTHORS"]
3232
dependencies = [
3333
"borghash ~= 0.1.0",
34-
"borgstore ~= 0.5.0",
34+
"borgstore[rest] ~= 0.5.1",
3535
"msgpack >=1.0.3, <=1.1.2",
3636
"packaging",
3737
"platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin'", # for macOS: breaking changes in 3.0.0.
@@ -51,10 +51,9 @@ mfusepy = ["mfusepy >= 3.1.0, <4.0.0"] # fuse 2+3, high-level
5151
# a pypi release of borgbackup can't contain a dependency on github!
5252
# mfusepym = ["mfusepy @ git+https://github.com/mxmlnkn/mfusepy.git@master"]
5353
nofuse = []
54-
s3 = ["borgstore[s3] ~= 0.5.0"]
55-
sftp = ["borgstore[sftp] ~= 0.5.0"]
56-
rclone = ["borgstore[rclone] ~= 0.5.0"]
57-
rest = ["borgstore[rest] ~= 0.5.0"]
54+
s3 = ["borgstore[rest,s3] ~= 0.5.1"]
55+
sftp = ["borgstore[rest,sftp] ~= 0.5.1"]
56+
rclone = ["borgstore[rest,rclone] ~= 0.5.1"]
5857
cockpit = ["textual>=6.8.0"] # might also work with older versions, untested
5958

6059
[project.urls]
@@ -192,71 +191,71 @@ pass_env = ["*"] # needed by tox4, so env vars are visible for building borg
192191

193192
[tool.tox.env.py310-llfuse]
194193
set_env = {BORG_FUSE_IMPL = "llfuse"}
195-
extras = ["llfuse", "sftp", "s3", "rest", "rclone"]
194+
extras = ["llfuse", "sftp", "s3", "rclone"]
196195

197196
[tool.tox.env.py310-pyfuse3]
198197
set_env = {BORG_FUSE_IMPL = "pyfuse3"}
199-
extras = ["pyfuse3", "sftp", "s3", "rest", "rclone"]
198+
extras = ["pyfuse3", "sftp", "s3", "rclone"]
200199

201200
[tool.tox.env.py310-mfusepy]
202201
set_env = {BORG_FUSE_IMPL = "mfusepy"}
203-
extras = ["mfusepy", "sftp", "s3", "rest", "rclone"]
202+
extras = ["mfusepy", "sftp", "s3", "rclone"]
204203

205204
[tool.tox.env.py311-none]
206205

207206
[tool.tox.env.py311-llfuse]
208207
set_env = {BORG_FUSE_IMPL = "llfuse"}
209-
extras = ["llfuse", "sftp", "s3", "rest", "rclone"]
208+
extras = ["llfuse", "sftp", "s3", "rclone"]
210209

211210
[tool.tox.env.py311-pyfuse3]
212211
set_env = {BORG_FUSE_IMPL = "pyfuse3"}
213-
extras = ["pyfuse3", "sftp", "s3", "rest", "rclone"]
212+
extras = ["pyfuse3", "sftp", "s3", "rclone"]
214213

215214
[tool.tox.env.py311-mfusepy]
216215
set_env = {BORG_FUSE_IMPL = "mfusepy"}
217-
extras = ["mfusepy", "sftp", "s3", "rest", "rclone"]
216+
extras = ["mfusepy", "sftp", "s3", "rclone"]
218217

219218
[tool.tox.env.py312-none]
220219

221220
[tool.tox.env.py312-llfuse]
222221
set_env = {BORG_FUSE_IMPL = "llfuse"}
223-
extras = ["llfuse", "sftp", "s3", "rest", "rclone"]
222+
extras = ["llfuse", "sftp", "s3", "rclone"]
224223

225224
[tool.tox.env.py312-pyfuse3]
226225
set_env = {BORG_FUSE_IMPL = "pyfuse3"}
227-
extras = ["pyfuse3", "sftp", "s3", "rest", "rclone"]
226+
extras = ["pyfuse3", "sftp", "s3", "rclone"]
228227

229228
[tool.tox.env.py312-mfusepy]
230229
set_env = {BORG_FUSE_IMPL = "mfusepy"}
231-
extras = ["mfusepy", "sftp", "s3", "rest", "rclone"]
230+
extras = ["mfusepy", "sftp", "s3", "rclone"]
232231

233232
[tool.tox.env.py313-none]
234233

235234
[tool.tox.env.py313-llfuse]
236235
set_env = {BORG_FUSE_IMPL = "llfuse"}
237-
extras = ["llfuse", "sftp", "s3", "rest", "rclone"]
236+
extras = ["llfuse", "sftp", "s3", "rclone"]
238237

239238
[tool.tox.env.py313-pyfuse3]
240239
set_env = {BORG_FUSE_IMPL = "pyfuse3"}
241-
extras = ["pyfuse3", "sftp", "s3", "rest", "rclone"]
240+
extras = ["pyfuse3", "sftp", "s3", "rclone"]
242241

243242
[tool.tox.env.py313-mfusepy]
244243
set_env = {BORG_FUSE_IMPL = "mfusepy"}
245-
extras = ["mfusepy", "sftp", "s3", "rest", "rclone"]
244+
extras = ["mfusepy", "sftp", "s3", "rclone"]
246245

247246
[tool.tox.env.py314-none]
248247

249248
[tool.tox.env.py314-llfuse]
250249
set_env = {BORG_FUSE_IMPL = "llfuse"}
251-
extras = ["llfuse", "sftp", "s3", "rest", "rclone"]
250+
extras = ["llfuse", "sftp", "s3", "rclone"]
252251

253252
[tool.tox.env.py314-pyfuse3]
254253
set_env = {BORG_FUSE_IMPL = "pyfuse3"}
255-
extras = ["pyfuse3", "sftp", "s3", "rest", "rclone"]
254+
extras = ["pyfuse3", "sftp", "s3", "rclone"]
256255

257256
[tool.tox.env.py314-mfusepy]
258257
set_env = {BORG_FUSE_IMPL = "mfusepy"}
259-
extras = ["mfusepy", "sftp", "s3", "rest", "rclone"]
258+
extras = ["mfusepy", "sftp", "s3", "rclone"]
260259

261260
[tool.tox.env.ruff]
262261
skip_install = true

‎src/borg/archiver/_common.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def get_repository(location, *, create, exclusive, lock_wait, lock, args, v1_leg
4242
)
4343

4444
elif (
45-
location.proto in ("sftp", "file", "http", "https", "rclone", "s3", "b2") and not v1_legacy
45+
location.proto in ("rest", "sftp", "file", "http", "https", "rclone", "s3", "b2") and not v1_legacy
4646
): # stuff directly supported by borgstore
4747
repository = Repository(location, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock)
4848

‎src/borg/conftest.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def __init__(self):
9696
self.patterns_file_path: str | None = None
9797

9898
def get_kind(self) -> str:
99-
if self.repository_location.startswith("ssh://__testsuite__"):
99+
if self.repository_location.startswith("ssh://__testsuite__") or self.repository_location.startswith("rest://"):
100100
return "remote"
101101
elif self.EXE == "borg.exe":
102102
return "binary"
@@ -152,7 +152,7 @@ def maybe_clear_flags_and_retry(func, path, _exc_info):
152152

153153
@pytest.fixture()
154154
def remote_archiver(archiver):
155-
archiver.repository_location = "ssh://__testsuite__/" + str(archiver.repository_path)
155+
archiver.repository_location = "rest://" + "/" + str(archiver.repository_path)
156156
yield archiver
157157

158158

‎src/borg/helpers/parseformat.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,19 @@ class Location:
552552
re.VERBOSE,
553553
)
554554

555+
# REST http via stdio (via ssh, if host given):
556+
rest_re = re.compile(
557+
r"(?P<proto>(rest))://"
558+
+ r"("
559+
+ optional_user_re
560+
+ host_re
561+
+ optional_port_re
562+
+ r")?"
563+
+ r"/" # this is the separator, not part of the path!
564+
+ abs_or_rel_path_re,
565+
re.VERBOSE,
566+
)
567+
555568
# BorgStore REST server
556569
# (http|https)://user:pass@host:port/
557570
http_re = re.compile(
@@ -624,6 +637,14 @@ def parse(self, text, overrides={}):
624637

625638
def _parse(self, text):
626639
m = self.ssh_or_sftp_re.match(text)
640+
if m:
641+
self.proto = m.group("proto")
642+
self.user = m.group("user")
643+
self._host = m.group("host")
644+
self.port = m.group("port") and int(m.group("port")) or None
645+
self.path = os.path.normpath(m.group("path"))
646+
return True
647+
m = self.rest_re.match(text)
627648
if m:
628649
self.proto = m.group("proto")
629650
self.user = m.group("user")
@@ -692,7 +713,7 @@ def canonical_path(self):
692713
return self.path
693714
if self.proto == "rclone":
694715
return f"{self.proto}:{self.path}"
695-
if self.proto in ("sftp", "ssh", "s3", "b2", "http", "https"):
716+
if self.proto in ("rest", "sftp", "ssh", "s3", "b2", "http", "https"):
696717
return (
697718
f"{self.proto}://"
698719
f"{(self.user + '@') if self.user else ''}"

‎src/borg/testsuite/archiver/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ def open_archive(repo_path, name):
178178

179179
def open_repository(archiver):
180180
if archiver.get_kind() == "remote":
181-
return RemoteRepository(Location(archiver.repository_location))
181+
location = Location(archiver.repository_location)
182+
if location.proto == "rest":
183+
return Repository(location, exclusive=True)
184+
return RemoteRepository(location)
182185
else:
183186
return Repository(archiver.repository_path, exclusive=True)
184187

‎src/borg/testsuite/archiver/checks_test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ def test_unknown_mandatory_feature_in_cache(archivers, request):
278278

279279
# Begin Remote Tests
280280
def test_remote_repo_restrict_to_path(remote_archiver):
281+
if remote_archiver.repository_location.startswith("rest://"):
282+
pytest.skip("Not applicable for rest:// protocol")
281283
original_location, repo_path = remote_archiver.repository_location, remote_archiver.repository_path
282284
# restricted to repo directory itself:
283285
with patch.object(RemoteRepository, "extra_test_args", ["--restrict-to-path", repo_path]):
@@ -306,6 +308,8 @@ def test_remote_repo_restrict_to_path(remote_archiver):
306308

307309

308310
def test_remote_repo_restrict_to_repository(remote_archiver):
311+
if remote_archiver.repository_location.startswith("rest://"):
312+
pytest.skip("Not applicable for rest:// protocol")
309313
repo_path = remote_archiver.repository_path
310314
# restricted to repo directory itself:
311315
with patch.object(RemoteRepository, "extra_test_args", ["--restrict-to-repository", repo_path]):

‎src/borg/testsuite/archiver/remote_repo_test.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111

1212
SFTP_URL = os.environ.get("BORG_TEST_SFTP_REPO")
13+
REST_URL = os.environ.get("BORG_TEST_REST_REPO")
1314
S3_URL = os.environ.get("BORG_TEST_S3_REPO")
1415

1516

@@ -57,6 +58,30 @@ def test_rclone_repo_basics(archiver, tmp_path):
5758
cmd(archiver, "repo-delete")
5859

5960

61+
@pytest.mark.skipif(not REST_URL, reason="BORG_TEST_REST_REPO not set.")
62+
def test_rest_repo_basics(archiver):
63+
create_regular_file(archiver.input_path, "file1", size=100 * 1024)
64+
create_regular_file(archiver.input_path, "file2", size=10 * 1024)
65+
archiver.repository_location = REST_URL
66+
archive_name = "test-archive"
67+
cmd(archiver, "repo-create", RK_ENCRYPTION)
68+
cmd(archiver, "create", archive_name, "input")
69+
list_output = cmd(archiver, "repo-list")
70+
assert archive_name in list_output
71+
archive_list_output = cmd(archiver, "list", archive_name)
72+
assert "input/file1" in archive_list_output
73+
assert "input/file2" in archive_list_output
74+
with changedir("output"):
75+
cmd(archiver, "extract", archive_name)
76+
assert_dirs_equal(
77+
archiver.input_path, os.path.join(archiver.output_path, "input"), ignore_flags=True, ignore_xattrs=True
78+
)
79+
cmd(archiver, "delete", "-a", archive_name)
80+
list_output = cmd(archiver, "repo-list")
81+
assert archive_name not in list_output
82+
cmd(archiver, "repo-delete")
83+
84+
6085
@pytest.mark.skipif(not SFTP_URL, reason="BORG_TEST_SFTP_REPO not set.")
6186
def test_sftp_repo_basics(archiver):
6287
create_regular_file(archiver.input_path, "file1", size=100 * 1024)

0 commit comments

Comments
 (0)