Skip to content

Commit a16e286

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

27 files changed

+4508
-0
lines changed

xcp-storage/pyproject.toml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
"F", # Pyflakes
45+
"I", # isort
46+
"N", # pep8-naming
47+
"PTH", # flake8-use-pathlib
48+
"Q", # flake8-quotes
49+
"RUF013", # ruff: implicit-optional
50+
"RUF059", # ruff: unused-unpacked-variable
51+
"SIM", # flake8-simplify
52+
"SLF", # flake8-self
53+
"T20", # flake8-print
54+
"UP" # pyupgrade
55+
]
56+
typing-modules = ["xcp_storage.typing"]
57+
58+
[tool.ruff.lint.isort.sections]
59+
"typing" = ["xcp_storage.typing"]
60+
61+
[tool.ruff.lint.isort]
62+
case-sensitive = false
63+
combine-as-imports = true
64+
force-sort-within-sections = true
65+
known-first-party = ["xcp_storage"]
66+
known-third-party = ["linstor"]
67+
lines-after-imports = 1
68+
order-by-type = false
69+
section-order = [
70+
"future",
71+
"standard-library",
72+
"third-party",
73+
"first-party",
74+
"local-folder",
75+
"typing"
76+
]

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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
# ------------------------------------------------------------------------------
52+
53+
@contextlib.contextmanager
54+
def _handle_drbd_json_error() -> Iterator[None]:
55+
try:
56+
yield
57+
except KeyError as e:
58+
log.error(
59+
f"The key `{e}` could not be found in the DRBD configuration. The JSON format may have changed.",
60+
exc_info=True
61+
)
62+
except Exception as e:
63+
log.error(f"Failed to parse DRBD configuration: `{e}`. The JSON format may have changed.", exc_info=True)
64+
65+
def _get_drbd_status(resource_name: str) -> Dict[str, Any]:
66+
try:
67+
stdout, _stderr, ret_code = run_command([
68+
_EXEC_PATH_DRBDSETUP, "status", resource_name, "--json"
69+
], simple=False)
70+
if ret_code != 0:
71+
return {}
72+
except Exception as e:
73+
log.error(f"Failed to get DRBD status: `{e}`.")
74+
return {}
75+
76+
try:
77+
status = json.loads(stdout)
78+
except Exception as e:
79+
log.error(f"Failed to read DRBD status as JSON: `{e}`.")
80+
return {}
81+
82+
with _handle_drbd_json_error():
83+
return status[0]
84+
return {}
85+
86+
# ------------------------------------------------------------------------------
87+
88+
def get_drbd_connection_address(resource_name: str, node_name: str) -> str:
89+
status = _get_drbd_status(resource_name)
90+
if not status:
91+
return ""
92+
93+
with _handle_drbd_json_error():
94+
for connection in status["connections"]:
95+
if connection["name"] == node_name:
96+
return connection["paths"][0]["remote_host"]["address"]
97+
return ""
98+
99+
def get_drbd_primary_address(resource_name: str) -> str:
100+
status = _get_drbd_status(resource_name)
101+
if not status:
102+
return ""
103+
104+
with _handle_drbd_json_error():
105+
if status["role"] == "Primary":
106+
return status["connections"][0]["paths"][0]["this_host"]["address"]
107+
108+
for connection in status["connections"]:
109+
if connection["peer-role"] == "Primary":
110+
return connection["paths"][0]["remote_host"]["address"]
111+
112+
return ""
113+
114+
# ------------------------------------------------------------------------------
115+
116+
class DrbdOpeners:
117+
# The duration is expressed in milliseconds.
118+
def __init__(self, pid: int, process_name: str, cmdline: List[str], open_duration: int) -> None:
119+
self.pid = pid
120+
self.process_name = process_name
121+
self.cmdline = cmdline
122+
self.open_duration = open_duration
123+
124+
@override
125+
def __repr__(self) -> str:
126+
return f"DrbdOpeners({self.pid}, {self.process_name}, {self.cmdline}, {self.open_duration})"
127+
128+
def get_drbd_local_openers(resource_name: str, volume_number: int) -> List[DrbdOpeners]:
129+
assert resource_name, "Cannot get DRBD openers without resource name."
130+
131+
path = Path(f"/sys/kernel/debug/drbd/resources/{resource_name}/volumes/{volume_number}/openers")
132+
try:
133+
lines = path.read_text().splitlines()
134+
except Exception as e:
135+
# The resource is probably available not on this node.
136+
log.info(f"Unable to get DRBD openers of volume `{resource_name}/{volume_number}`: `{e}`.")
137+
return []
138+
139+
drbd_openers = []
140+
for line in lines:
141+
match = _REGEX_DRBD_OPENER_LINE.match(line)
142+
if not match:
143+
log.warning(f"Unable to parse DRBD opener line with: `{line}`.")
144+
continue
145+
146+
groups = match.groups()
147+
pid = int(groups[1])
148+
drbd_openers.append(DrbdOpeners(
149+
pid=pid,
150+
process_name=groups[0],
151+
# Note: `cmdline`` is be empty for `mount` calls. Logic the PID is dead.
152+
cmdline=get_process_cmdline(pid),
153+
open_duration=int(groups[2])
154+
))
155+
156+
return drbd_openers
157+
158+
# ------------------------------------------------------------------------------
159+
160+
def demote_drbd(resource_name: str) -> bool:
161+
try:
162+
_stdout, stderr, ret_code = run_command([_EXEC_PATH_DRBDSETUP, "secondary", resource_name], simple=False)
163+
if not ret_code:
164+
return True
165+
log.error(f"Failed to demote DRBD resource `{resource_name}`: `{stderr}`.")
166+
except Exception as e:
167+
log.error(f"Failed to demote DRBD resource `{resource_name}`: `{e}`.")
168+
return False

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

Whitespace-only changes.

0 commit comments

Comments
 (0)