Skip to content

Commit c420c44

Browse files
committed
feat(scripts): add lvm-thin-helper script
Signed-off-by: Alexandre Sollier <alexandre.sollier@vates.tech>
1 parent c1a3729 commit c420c44

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ endif()
3434
# ------------------------------------------------------------------------------
3535

3636
set(SCRIPT_LIST
37+
"lvm-thin-helper"
3738
"xcp-linstor-manager"
3839
)
3940

‎scripts/lvm-thin-helper.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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 argparse
18+
from dataclasses import dataclass
19+
import json
20+
import logging
21+
from pathlib import Path
22+
23+
import xcp_storage.log as log
24+
from xcp_storage.utils.json import JsonDict
25+
from xcp_storage.utils.process import run_command
26+
27+
from xcp_storage.typing import Optional
28+
29+
# ==============================================================================
30+
31+
logger = log.get_logger() # Use default logger.
32+
33+
# ------------------------------------------------------------------------------
34+
35+
def _parse_fullreport(vg_name: str, file_path: Optional[Path]) -> JsonDict:
36+
if file_path:
37+
with file_path.open() as file:
38+
fullreport_str = file.read()
39+
else:
40+
fullreport_str = run_command([
41+
"lvm", "fullreport", "--reportformat", "json", vg_name
42+
], expected_ret_code=0)
43+
44+
fullreport = json.loads(fullreport_str)
45+
46+
if len(fullreport["report"]) != 1:
47+
raise ValueError("The lvm-fullreport(8) result must contain exactly one report.")
48+
49+
return fullreport["report"][0]
50+
51+
# ------------------------------------------------------------------------------
52+
53+
@dataclass
54+
class CliArgs:
55+
vg: str
56+
pool_lv: str
57+
58+
file: Optional[Path]
59+
verbose: bool
60+
61+
def _get_cli_args() -> CliArgs:
62+
parser = argparse.ArgumentParser(
63+
prog="lvm-thin-helper",
64+
description="Find the physical volumes that contain LVM2 thin-provisioned volumes.",
65+
)
66+
67+
parser.add_argument("vg", help="Name of the LVM2 VG to check.")
68+
parser.add_argument("pool_lv", help="Name of the LVM2 LV that acts as the data pool.")
69+
70+
parser.add_argument(
71+
"-f",
72+
"--file",
73+
help="Path to a json-formatted LVM2 fullreport file to use instead of live metadata. "
74+
"This file must only contain the report for a single VG. See lvm-fullreport(8) for more details.",
75+
type=Path
76+
)
77+
78+
parser.add_argument(
79+
"-v",
80+
"--verbose",
81+
help="Enable verbose logging.",
82+
action="store_true",
83+
default=False
84+
)
85+
86+
return CliArgs(**vars(parser.parse_args()))
87+
88+
# ------------------------------------------------------------------------------
89+
90+
if __name__ == "__main__":
91+
args = _get_cli_args()
92+
logger.setLevel(logging.DEBUG if args.verbose else logging.INFO)
93+
94+
# 1. Get the LVM2 fullreport for the target VG.
95+
report = _parse_fullreport(args.vg, args.file)
96+
97+
# 2. Make sure we have the correct VG.
98+
vg_count = len(report["vg"])
99+
if vg_count != 1:
100+
raise ValueError(f"Expected 1 VG, got {vg_count}.")
101+
102+
vg_name = report["vg"][0]["vg_name"]
103+
if vg_name != args.vg:
104+
raise ValueError(f"Expected VG named `{args.vg}`, got `{vg_name}`.")
105+
106+
# 3. Get all LVs that have "thin" segment types.
107+
lvs_uuid_with_thin_segments = frozenset(seg["lv_uuid"] for seg in report["seg"] if seg["segtype"] == "thin")
108+
logger.debug("LVs with thin segments: %s", lvs_uuid_with_thin_segments)
109+
110+
# 4. Get the UUIDs of the target pool LV.
111+
pool_lvs = [lv for lv in report["lv"] if lv["lv_name"] == args.pool_lv]
112+
113+
if len(pool_lvs) != 1:
114+
raise ValueError(f"Expected 1 pool LV, got {len(pool_lvs)}.")
115+
116+
pool_lv_uuid = pool_lvs[0]["lv_uuid"]
117+
logger.debug("Pool LV UUID: %s", pool_lv_uuid)
118+
119+
pool_data_lv_uuid = pool_lvs[0]["data_lv_uuid"]
120+
logger.debug("Pool data LV UUID: %s", pool_data_lv_uuid)
121+
122+
# 5. Get the thin-provisioned LVs part of the target pool LV.
123+
thin_provisioned_lvs_name = [
124+
lv["lv_name"] for lv in report["lv"]
125+
if lv["lv_uuid"] in lvs_uuid_with_thin_segments and lv["pool_lv_uuid"] == pool_lv_uuid
126+
]
127+
128+
logger.debug("Thin-provisioned LVs: %s", thin_provisioned_lvs_name)
129+
130+
# 6. Get the PVs that have segments of the target pool LV.
131+
pvs_uuid_with_pool_lv_segments = frozenset(
132+
pvseg["pv_uuid"] for pvseg in report["pvseg"]
133+
if pvseg["lv_uuid"] == pool_data_lv_uuid
134+
)
135+
136+
pv_name_by_uuid = {pv["pv_uuid"]: pv["pv_name"] for pv in report["pv"]}
137+
pvs_name_with_pool_lv_segments = [pv_name_by_uuid[pv_uuid] for pv_uuid in pvs_uuid_with_pool_lv_segments]
138+
139+
logger.debug("PVs with segments of the pool LV: %s", pvs_name_with_pool_lv_segments)
140+
141+
# 7. Profit!
142+
print(json.dumps({"pvs": pvs_name_with_pool_lv_segments, "lvs": thin_provisioned_lvs_name})) # noqa: T201

0 commit comments

Comments
 (0)