Skip to content

Commit ddae01c

Browse files
committed
feat(core): add DRBD helpers (#11)
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 14f6cfa commit ddae01c

9 files changed

Lines changed: 669 additions & 0 deletions

File tree

‎src/xcp_storage/backends/__init__.py‎

Whitespace-only changes.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Copyright (C) 2026 Vates SAS
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
15+
import contextlib
16+
from dataclasses import dataclass
17+
import json
18+
from pathlib import Path
19+
import re
20+
21+
import xcp_storage.log as log
22+
from xcp_storage.utils.process import (
23+
get_process_cmdline,
24+
run_command,
25+
)
26+
27+
from xcp_storage.typing import (
28+
Any,
29+
Dict,
30+
Final,
31+
Iterator,
32+
List,
33+
)
34+
35+
# ==============================================================================
36+
37+
logger = log.get_logger() # Use default logger.
38+
39+
# ------------------------------------------------------------------------------
40+
41+
DRBD_BY_RES_PATH: Final = "/dev/drbd/by-res/"
42+
43+
DRBD_PORT_RANGE: Final = (7000, 8000)
44+
45+
# ------------------------------------------------------------------------------
46+
47+
_EXEC_PATH_DRBDSETUP: Final = "/usr/sbin/drbdsetup"
48+
49+
_REGEX_DRBD_OPENER_LINE: Final = re.compile(r"(.*)\s+(\d+)\s+(\d+)")
50+
51+
# ------------------------------------------------------------------------------
52+
53+
def build_drbd_path(resource_name: str, volume_number: int) -> str:
54+
return f"{DRBD_BY_RES_PATH}{resource_name}/{volume_number}"
55+
56+
def get_drbd_name_from_path(path: str) -> str:
57+
# Assume that we have a path like this:
58+
# - "/dev/drbd/by-res/xcp-volume-<UUID>/0"
59+
# - "../xcp-volume-<UUID>/0"
60+
if path.startswith(DRBD_BY_RES_PATH):
61+
prefix_len = len(DRBD_BY_RES_PATH)
62+
elif path.startswith("../"):
63+
prefix_len = 3
64+
else:
65+
return ""
66+
67+
res_name_end = path.find("/", prefix_len)
68+
if res_name_end == -1:
69+
return ""
70+
71+
return path[prefix_len:res_name_end]
72+
73+
# ------------------------------------------------------------------------------
74+
75+
@contextlib.contextmanager
76+
def _handle_drbd_json_error() -> Iterator[None]:
77+
try:
78+
yield
79+
except KeyError as e:
80+
logger.exception(
81+
"The key `%s` could not be found in the DRBD configuration. The JSON format may have changed.", e
82+
)
83+
except Exception as e:
84+
logger.exception("Failed to parse DRBD configuration: `%s`. The JSON format may have changed.", e)
85+
86+
def _get_drbd_status(resource_name: str) -> Dict[str, Any]:
87+
try:
88+
stdout, _stderr, ret_code = run_command([
89+
_EXEC_PATH_DRBDSETUP, "status", resource_name, "--json"
90+
], simple=False)
91+
if ret_code != 0:
92+
return {}
93+
except Exception as e:
94+
logger.error("Failed to get DRBD status: `%s`.", e)
95+
return {}
96+
97+
try:
98+
status = json.loads(stdout)
99+
except Exception as e:
100+
logger.error("Failed to read DRBD status as JSON: `%s`.", e)
101+
return {}
102+
103+
with _handle_drbd_json_error():
104+
return status[0]
105+
return {}
106+
107+
# ------------------------------------------------------------------------------
108+
109+
def get_drbd_connection_address(resource_name: str, node_name: str) -> str:
110+
status = _get_drbd_status(resource_name)
111+
if not status:
112+
return ""
113+
114+
with _handle_drbd_json_error():
115+
for connection in status["connections"]:
116+
if connection["name"] == node_name:
117+
return connection["paths"][0]["remote_host"]["address"]
118+
return ""
119+
120+
def get_drbd_primary_address(resource_name: str) -> str:
121+
status = _get_drbd_status(resource_name)
122+
if not status:
123+
return ""
124+
125+
with _handle_drbd_json_error():
126+
if status["role"] == "Primary":
127+
return status["connections"][0]["paths"][0]["this_host"]["address"]
128+
129+
for connection in status["connections"]:
130+
if connection["peer-role"] == "Primary":
131+
return connection["paths"][0]["remote_host"]["address"]
132+
133+
return ""
134+
135+
# ------------------------------------------------------------------------------
136+
137+
@dataclass
138+
class DrbdOpener:
139+
pid: int
140+
process_name: str
141+
cmdline: List[str]
142+
# The duration is expressed in milliseconds.
143+
open_duration: int
144+
145+
def get_drbd_local_openers(resource_name: str, volume_number: int) -> List[DrbdOpener]:
146+
assert resource_name, "Cannot get DRBD openers without resource name."
147+
148+
path = Path(f"/sys/kernel/debug/drbd/resources/{resource_name}/volumes/{volume_number}/openers")
149+
try:
150+
lines = path.read_text().splitlines()
151+
except Exception as e:
152+
# The resource is probably available not on this node.
153+
logger.info("Unable to get DRBD openers of volume `%s/%d`: `%s`.", resource_name, volume_number, e)
154+
return []
155+
156+
drbd_openers = []
157+
for line in lines:
158+
match = _REGEX_DRBD_OPENER_LINE.match(line)
159+
if not match:
160+
logger.warning("Unable to parse DRBD opener line with: `%s`.", line)
161+
continue
162+
163+
groups = match.groups()
164+
pid = int(groups[1])
165+
drbd_openers.append(DrbdOpener(
166+
pid=pid,
167+
process_name=groups[0],
168+
# Note: `cmdline`` is empty for `mount` calls. Logic the PID is dead.
169+
cmdline=get_process_cmdline(pid),
170+
open_duration=int(groups[2])
171+
))
172+
173+
return drbd_openers
174+
175+
# ------------------------------------------------------------------------------
176+
177+
def demote_drbd(resource_name: str) -> bool:
178+
error_message = ""
179+
try:
180+
_stdout, stderr, ret_code = run_command([_EXEC_PATH_DRBDSETUP, "secondary", resource_name], simple=False)
181+
if not ret_code:
182+
return True
183+
error_message = stderr
184+
except Exception as e:
185+
error_message = str(e)
186+
logger.error("Failed to demote DRBD resource `%s`: `%s`.", resource_name, error_message)
187+
return False

‎src/xcp_storage/rpc/api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
# You should have received a copy of the GNU General Public License
1313
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1414

15+
import xcp_storage.rpc.modules.drbd as drbd
1516
import xcp_storage.rpc.modules.echo as echo
1617

1718
# ==============================================================================
1819

1920
__all__ = [
21+
"drbd",
2022
"echo"
2123
]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright (C) 2026 Vates SAS
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
15+
from dataclasses import asdict
16+
17+
from xcp_storage.backends.drbd import get_drbd_local_openers
18+
from xcp_storage.rpc.dispatcher import ApiDispatcher
19+
from xcp_storage.utils.json import JsonDict
20+
21+
from xcp_storage.typing import (
22+
assert_type,
23+
cast,
24+
Dict,
25+
List,
26+
MYPY,
27+
PYREFLY,
28+
Union,
29+
)
30+
31+
# ==============================================================================
32+
33+
@ApiDispatcher.method
34+
def get_openers(resource_name: str, volume_number: int) -> List[JsonDict]:
35+
openers = [asdict(opener) for opener in get_drbd_local_openers(resource_name, volume_number)]
36+
# Some linters, such as pyrefly, may struggle to convert a type to a recursive type.
37+
# We therefore perform a static check (so that it's validated by linters) and we cast it explicitly.
38+
if PYREFLY and not MYPY:
39+
assert_type(openers, List[Dict[str, Union[int, str, List[str]]]])
40+
return cast(List[JsonDict], openers)

‎src/xcp_storage/typing/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,13 @@ def __call__(self, *_args, **_kwargs): # type: ignore # noqa: ANN002, ANN003, A
6363

6464
ParamSpec = _SubscriptableListMock() # type: ignore
6565
Concatenate = _SubscriptableListMock() # type: ignore
66+
67+
if not hasattr(typing, "assert_type"):
68+
_T = TypeVar("T") # noqa: F405
69+
def assert_type(value: _T, expected_type: Type[_T]) -> None: # noqa: F405
70+
pass
71+
72+
# ------------------------------------------------------------------------------
73+
74+
MYPY: Final = False # noqa: F405
75+
PYREFLY: Final = False # noqa: F405

‎src/xcp_storage/utils/process.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# You should have received a copy of the GNU General Public License
1313
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1414

15+
from pathlib import Path
1516
import subprocess
1617

1718
import xcp_storage.log as log
@@ -157,3 +158,13 @@ def run_command(
157158
ret_code_callback=ret_code_callback,
158159
quiet=quiet
159160
)
161+
162+
# ------------------------------------------------------------------------------
163+
164+
def get_process_cmdline(pid: int) -> List[str]:
165+
path = Path(f"/proc/{pid}/cmdline")
166+
try:
167+
return [arg.decode() for arg in path.read_bytes().split(b"\0") if arg]
168+
except Exception as e:
169+
logger.info("Unable to get command line of PID `%d`: `%s`.", pid, e)
170+
return []

0 commit comments

Comments
 (0)