Skip to content

Commit 2e06fdd

Browse files
committed
feat(core): find ss executable path by OS ID (#12)
Executables were run through hardcoded absolute paths, it only works on some distributions. `ss` is in `/usr/bin` on the GitHub Ubuntu runners, instead of in `/usr/sbin` on XCP-ng so the LINSTOR controller tests are marked as failed. Now "/etc/os-release" is used to find alternate OS specific paths instead. Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 85dd4e6 commit 2e06fdd

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

‎src/xcp_storage/backends/linstor/controller.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1414

1515
from xcp_storage.backends.linstor.satellite import LINSTOR_SATELLITE_PORT_PLAIN, LINSTOR_SATELLITE_PORT_SSL
16+
from xcp_storage.config.platform import get_exec_path
1617
from xcp_storage.utils.process import run_command
1718
from xcp_storage.utils.service import (
1819
is_service_active,
@@ -30,7 +31,7 @@
3031

3132
# ------------------------------------------------------------------------------
3233

33-
_EXEC_PATH_SS: Final = "/usr/sbin/ss"
34+
_EXEC_PATH_SS: Final = get_exec_path("/usr/sbin/ss", {"debian": "/usr/bin/ss"})
3435

3536
_SERVICE_LINSTOR_CONTROLLER: Final = "linstor-controller"
3637

‎src/xcp_storage/config/platform.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
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 xcp_storage.typing import Final
15+
from functools import lru_cache
16+
from pathlib import Path
17+
18+
from xcp_storage.typing import Final, Mapping, Tuple
1619

1720
# ==============================================================================
1821
# Attributes that depend on the execution environment.
@@ -21,3 +24,35 @@
2124
# ==============================================================================
2225

2326
DEFAULT_FIREWALL_INPUT_CHAIN: Final = "xapi-INPUT"
27+
28+
# ------------------------------------------------------------------------------
29+
30+
_OS_RELEASE_PATH: Final = "/etc/os-release"
31+
32+
@lru_cache(maxsize=None)
33+
def get_os_ids() -> Tuple[str, ...]:
34+
"""
35+
Get the IDs of the current distribution, most specific first: `ID` followed
36+
by the values of `ID_LIKE` (e.g. `("ubuntu", "debian")`). The result is
37+
cached. An empty tuple is returned if `/etc/os-release` can't be read.
38+
"""
39+
40+
try:
41+
lines = Path(_OS_RELEASE_PATH).read_text(encoding="utf-8").splitlines()
42+
except OSError:
43+
return ()
44+
45+
values = {}
46+
for line in lines:
47+
key, _separator, value = line.partition("=")
48+
values[key.strip()] = value.strip().strip("\"'")
49+
50+
return tuple(values.get("ID", "").split() + values.get("ID_LIKE", "").split())
51+
52+
def get_exec_path(default: str, by_os_id: Mapping[str, str]) -> str:
53+
"""
54+
Get the path of an executable: the one registered for the first matching OS
55+
ID, otherwise `default`.
56+
"""
57+
58+
return next((by_os_id[os_id] for os_id in get_os_ids() if os_id in by_os_id), default)

‎tests/config/test_platform.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 pathlib import Path
16+
from unittest.mock import patch
17+
18+
import pytest
19+
20+
from xcp_storage.config.platform import get_exec_path, get_os_ids
21+
22+
from xcp_storage.typing import Final, Iterator, Tuple
23+
24+
# ==============================================================================
25+
26+
@pytest.fixture(autouse=True)
27+
def clear_os_ids_cache() -> Iterator[None]:
28+
get_os_ids.cache_clear()
29+
yield
30+
get_os_ids.cache_clear()
31+
32+
@pytest.fixture
33+
def os_release_path(tmp_path: Path) -> Iterator[Path]:
34+
path = tmp_path / "os-release"
35+
with patch("xcp_storage.config.platform._OS_RELEASE_PATH", str(path)):
36+
yield path
37+
38+
# ------------------------------------------------------------------------------
39+
40+
class TestGetOsIds:
41+
@pytest.mark.parametrize(("content", "expected"), [
42+
("ID=alpine\n", ("alpine",)),
43+
('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n', ("ubuntu", "debian")),
44+
('ID="rhel"\nID_LIKE="fedora centos"\n', ("rhel", "fedora", "centos")),
45+
("NAME=Foo\n", ()),
46+
])
47+
def test_parsing(self, os_release_path: Path, content: str, expected: Tuple[str, ...]) -> None:
48+
os_release_path.write_text(content)
49+
assert get_os_ids() == expected
50+
51+
def test_without_os_release_file(self, os_release_path: Path) -> None:
52+
assert not os_release_path.exists()
53+
assert get_os_ids() == ()
54+
55+
def test_os_release_file_is_cached(self, os_release_path: Path) -> None:
56+
os_release_path.write_text("ID=alpine\n")
57+
expected = get_os_ids()
58+
os_release_path.write_text("ID=ubuntu\n")
59+
assert get_os_ids() == expected
60+
61+
# ------------------------------------------------------------------------------
62+
63+
class TestGetExecPath:
64+
DEFAULT_PATH: Final = "/opt/default/tool"
65+
ALT_PATH: Final = "/opt/alt/tool"
66+
BY_OS_ID: Final = {"debian": ALT_PATH}
67+
68+
def test_with_mapping_id_match(self, os_release_path: Path) -> None:
69+
os_release_path.write_text("ID=debian\nID_LIKE=unknown\n")
70+
assert get_exec_path(self.DEFAULT_PATH, self.BY_OS_ID) == self.ALT_PATH
71+
72+
def test_with_mapping_id_like_match(self, os_release_path: Path) -> None:
73+
os_release_path.write_text("ID=ubuntu\nID_LIKE=debian\n")
74+
assert get_exec_path(self.DEFAULT_PATH, self.BY_OS_ID) == self.ALT_PATH
75+
76+
def test_without_mapping_match(self, os_release_path: Path) -> None:
77+
os_release_path.write_text("ID=alpine\n")
78+
assert get_exec_path(self.DEFAULT_PATH, self.BY_OS_ID) == self.DEFAULT_PATH
79+
80+
def test_without_os_release_file(self, os_release_path: Path) -> None:
81+
assert not os_release_path.exists()
82+
assert get_exec_path(self.DEFAULT_PATH, self.BY_OS_ID) == self.DEFAULT_PATH

0 commit comments

Comments
 (0)