Skip to content

Commit 0027277

Browse files
committed
feat(linstor): add LinstorManager class
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent d7cd0b1 commit 0027277

27 files changed

+4752
-0
lines changed

xcp-storage/pyproject.toml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
[tool.mypy]
2+
mypy_path = "stubs"
3+
4+
disallow_untyped_defs = true
5+
enable_error_code = ["explicit-override"]
6+
ignore_missing_imports = false
7+
implicit_optional = false
8+
9+
[[tool.mypy.overrides]]
10+
module = "setuptools.*"
11+
ignore_missing_imports = true
12+
13+
# To generate stubs, run the following commands in the linstor source folder:
14+
# ./setup.py build
15+
# stubgen --include-private build/lib/linstor/ -o <XCP_STORAGE_PATH>/stubs/
16+
# echo "from . import sharedconsts as consts" >> <XCP_STORAGE_PATH>/stubs/linstor/__init__.pyi
17+
[[tool.mypy.overrides]]
18+
module = "linstor.*"
19+
20+
disallow_untyped_defs = false
21+
disable_error_code = [
22+
"arg-type",
23+
"attr-defined",
24+
"explicit-override",
25+
"name-defined",
26+
"no-redef",
27+
"override"
28+
]
29+
30+
[tool.ruff]
31+
line-length = 120
32+
33+
[tool.ruff.lint]
34+
exclude = ["stubs/*"]
35+
ignore = [
36+
"B019", # flake8-bugbear: cached-instance-method
37+
"UP006", # pyupgrade: non-pep585-annotation
38+
"UP007", # pyupgrade: non-pep604-annotation-union
39+
"UP045" # pyupgrade: non-pep604-annotation-optional
40+
]
41+
select = [
42+
"ARG", # flake8-unused-arguments
43+
"B", # flake8-bugbear
44+
"C90", # mccabe
45+
"F", # Pyflakes
46+
"I", # isort
47+
"N", # pep8-naming
48+
"PTH", # flake8-use-pathlib
49+
"Q", # flake8-quotes
50+
"RUF013", # ruff: implicit-optional
51+
"RUF059", # ruff: unused-unpacked-variable
52+
"SIM", # flake8-simplify
53+
"SLF", # flake8-self
54+
"T20", # flake8-print
55+
"UP" # pyupgrade
56+
]
57+
typing-modules = ["xcp_storage.typing"]
58+
59+
[tool.ruff.lint.isort.sections]
60+
"typing" = ["xcp_storage.typing"]
61+
62+
[tool.ruff.lint.isort]
63+
case-sensitive = false
64+
combine-as-imports = true
65+
force-sort-within-sections = true
66+
known-first-party = ["xcp_storage"]
67+
known-third-party = ["linstor"]
68+
lines-after-imports = 1
69+
order-by-type = false
70+
section-order = [
71+
"future",
72+
"standard-library",
73+
"third-party",
74+
"first-party",
75+
"local-folder",
76+
"typing"
77+
]

xcp-storage/setup.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (C) 2026 Vates SAS
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
16+
from setuptools import find_packages, setup
17+
18+
setup(
19+
name="xcp-storage",
20+
version="1.0.0",
21+
description="XCP storage layer, scripts and plugins",
22+
author="Ronan Abhamon <ronan.abhamon@vates.tech>",
23+
author_email="ronan.abhamon@vates.tech",
24+
url="https://vates.tech",
25+
license="GPLv3",
26+
packages=find_packages(
27+
where="src",
28+
),
29+
python_requires=">=3.6",
30+
package_dir={"": "src"},
31+
scripts=[]
32+
)

xcp-storage/src/xcp_storage/__init__.py

Whitespace-only changes.

xcp-storage/src/xcp_storage/backends/__init__.py

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

xcp-storage/src/xcp_storage/backends/linstor/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)