Skip to content
Snippets Groups Projects
Commit 6e3106e9 authored by Georges Racinet's avatar Georges Racinet
Browse files

RepositoryService: implement RemoveRepository

Closes #95

This is in advance of the HGitaly3 milestone, but needed for
gitaly-backup (removal occurs before restoration).

We don't attempt to provide the same level of locking as Gitaly
does, both because this is not the best time to handle locking
for mutation methods and because this is a special case, but
we split the work into atomic rename and cleanup (cheap to
implement).

See also heptapod#534
parent 2abfea4c
No related branches found
No related tags found
1 merge request!100RepositoryService: implement RemoveRepository
Pipeline #53753 passed
......@@ -7,6 +7,7 @@
from grpc import StatusCode
import logging
import os
import shutil
import tempfile
from mercurial import (
......@@ -70,6 +71,8 @@
GetArchiveResponse,
HasLocalBranchesRequest,
HasLocalBranchesResponse,
RemoveRepositoryRequest,
RemoveRepositoryResponse,
SearchFilesByContentRequest,
SearchFilesByContentResponse,
SearchFilesByNameRequest,
......@@ -311,6 +314,47 @@
return not_implemented(context, SearchFilesByContentResponse,
issue=80) # pragma no cover
def RemoveRepository(self, request: RemoveRepositoryRequest,
context) -> RemoveRepositoryResponse:
# The protocol comment says, as of Gitaly 14.8:
# RemoveRepository will move the repository to
# `+gitaly/tmp/<relative_path>_removed` and
# eventually remove it.
# In that sentence, the "eventually" could imply that it is
# asynchronous (as the Rails app does), but it is not in the
# Gitaly server implementation. The renaming is done for
# atomicity purposes.
logger.debug("RemoveRepository request=%r", message.Logging(request))
try:
repo_path = self.repo_disk_path(request.repository, context)
except KeyError as exc:
if exc.args[0] == 'storage':
return invalid_argument(
context, RemoveRepositoryResponse,
message="Unkbown storage %r" % exc.args[1]
)
raise # pragma no cover (not triggerable at this point)
if not os.path.exists(repo_path):
# same error message as Gitaly (probably no need to repeat
# repo details, since request often logged client-side)
return not_found(context, RemoveRepositoryResponse,
message="repository does not exist")
trash_path = os.path.join(
self.temp_dir(request.repository.storage_name, context),
os.path.basename(repo_path) + b'+removed')
# The rename being atomic, it avoids leaving a crippled repo behind
# in case of problem in the removal.
# TODO Gitaly also performs some kind of locking (not clear
# if Mercurial locks would be appropriate because of the renaming)
# and lengthy rechecks to safeguard against race conditions,
# and finally the vote related to the multi-phase commit for praefect
os.rename(repo_path, trash_path)
shutil.rmtree(trash_path) # not atomic
return RemoveRepositoryResponse()
def SetFullPath(self, request: SetFullPathRequest,
context) -> SetFullPathResponse:
try:
......
......@@ -7,6 +7,7 @@
from contextlib import contextmanager
from io import BytesIO
import grpc
import os
from pathlib import Path
import shutil
import tarfile
......@@ -33,6 +34,7 @@
FindMergeBaseRequest,
GetArchiveRequest,
HasLocalBranchesRequest,
RemoveRepositoryRequest,
RepositoryExistsRequest,
SetFullPathRequest,
WriteRefRequest,
......@@ -99,6 +101,13 @@
self.repo_wrapper = self.make_repo_wrapper(rel_path,
storage_name=storage_name)
def remove_repository(self, grpc_repo=None):
if grpc_repo is None:
grpc_repo = self.grpc_repo
return self.stub.RemoveRepository(RemoveRepositoryRequest(
repository=grpc_repo))
def set_full_path(self, path, grpc_repo=None):
if grpc_repo is None:
grpc_repo = self.grpc_repo
......@@ -420,6 +429,32 @@
storage_path.mkdir(exist_ok=True)
def test_remove_repository(fixture_with_repo):
fixture = fixture_with_repo
wrapper, grpc_repo = fixture.repo_wrapper, fixture.grpc_repo
fixture.remove_repository()
assert not wrapper.path.exists()
# no other leftovers alongside the removed repo
assert os.listdir(fixture.storage_path()) == ['+hgitaly']
# no leftover in the temporary directory either
tmp_dir = wrapper.path.parent / '+hgitaly/tmp'
assert not os.listdir(tmp_dir)
# unknown storage and repo
with pytest.raises(grpc.RpcError) as exc_info:
fixture.remove_repository(grpc_repo=Repository(storage_name='unknown'))
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
with pytest.raises(grpc.RpcError) as exc_info:
fixture.remove_repository(
grpc_repo=Repository(storage_name=grpc_repo.storage_name,
relative_path='no/such/path'))
assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND
def test_set_full_path(fixture_with_repo):
fixture = fixture_with_repo
wrapper = fixture.repo_wrapper
......
......@@ -20,6 +20,7 @@
CreateBundleFromRefListRequest,
CreateRepositoryFromBundleRequest,
FindMergeBaseRequest,
RemoveRepositoryRequest,
SetFullPathRequest,
)
from hgitaly.stub.repository_service_pb2_grpc import RepositoryServiceStub
......@@ -167,6 +168,25 @@
assert 'exists already' in exc.details()
def test_remove_repository(gitaly_comparison, server_repos_root):
fixture = gitaly_comparison
grpc_repo = fixture.gitaly_repo
rpc_helper = fixture.rpc_helper(
stub_cls=RepositoryServiceStub,
method_name='RemoveRepository',
request_cls=RemoveRepositoryRequest,
)
assert_compare_errors = rpc_helper.assert_compare_errors
# unknown storage and missing repo
assert_compare_errors(same_details=False,
repository=Repository(storage_name='unknown',
relative_path='/some/path'))
assert_compare_errors(
repository=Repository(storage_name=grpc_repo.storage_name,
relative_path='no/such/path'))
def test_set_full_path(gitaly_comparison, server_repos_root):
fixture = gitaly_comparison
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment