Skip to content

Commit e5bd326

Browse files
authored
Merge pull request #8505 from fstagni/fix_install_not_release
fix: install non-released versions: handle space
2 parents 96c1113 + c6971fe commit e5bd326

4 files changed

Lines changed: 270 additions & 44 deletions

File tree

src/DIRAC/FrameworkSystem/Client/SystemAdministratorClientCLI.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python
22
########################################################################
3-
""" System Administrator Client Command Line Interface """
3+
"""System Administrator Client Command Line Interface"""
44

55
import atexit
66
import datetime
@@ -1066,13 +1066,11 @@ def do_update(self, args):
10661066
10671067
update <version>
10681068
"""
1069-
try:
1070-
version = args.split()[0]
1071-
except Exception as x:
1072-
gLogger.notice("ERROR: wrong input:", str(x))
1069+
version = args.strip()
1070+
if not version:
1071+
gLogger.notice("ERROR: no version specified")
10731072
gLogger.notice(self.do_update.__doc__)
10741073
return
1075-
10761074
client = SystemAdministratorClient(self.host, self.port)
10771075
gLogger.notice("Software update can take a while, please wait ...")
10781076
result = client.updateSoftware(version, timeout=600)

src/DIRAC/FrameworkSystem/Service/SystemAdministratorHandler.py

Lines changed: 89 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,33 @@
11
"""SystemAdministrator service is a tool to control and monitor the DIRAC services and agents"""
22

3-
import socket
4-
import os
5-
import re
6-
import time
73
import getpass
84
import importlib
9-
import shutil
5+
import os
106
import platform
11-
import tempfile
7+
import re
8+
import shutil
9+
import socket
1210
import subprocess
11+
import tempfile
12+
import time
1313
from datetime import datetime, timedelta
14-
import requests
1514

1615
import psutil
17-
from packaging.version import Version, InvalidVersion
18-
16+
import requests
1917
from diraccfg import CFG
18+
from packaging.version import InvalidVersion, Version
2019

21-
from DIRAC import S_OK, S_ERROR, gConfig, rootPath, gLogger, convertToPy3VersionNumber
20+
from DIRAC import S_ERROR, S_OK, convertToPy3VersionNumber, gConfig, gLogger, rootPath
21+
from DIRAC.ConfigurationSystem.Client import PathFinder
2222
from DIRAC.Core.DISET.RequestHandler import RequestHandler
23+
from DIRAC.Core.Security.Locations import getHostCertificateAndKeyLocation
24+
from DIRAC.Core.Security.X509Chain import X509Chain # pylint: disable=import-error
2325
from DIRAC.Core.Utilities import Os
2426
from DIRAC.Core.Utilities.Extensions import extensionsByPriority, getExtensionMetadata
2527
from DIRAC.Core.Utilities.File import mkLink
26-
from DIRAC.Core.Utilities.TimeUtilities import fromString, hour, day
2728
from DIRAC.Core.Utilities.Subprocess import shellCall
2829
from DIRAC.Core.Utilities.ThreadScheduler import gThreadScheduler
29-
from DIRAC.Core.Security.Locations import getHostCertificateAndKeyLocation
30-
from DIRAC.Core.Security.X509Chain import X509Chain # pylint: disable=import-error
31-
from DIRAC.ConfigurationSystem.Client import PathFinder
30+
from DIRAC.Core.Utilities.TimeUtilities import day, fromString, hour
3231
from DIRAC.FrameworkSystem.Client.ComponentInstaller import gComponentInstaller
3332
from DIRAC.FrameworkSystem.Client.ComponentMonitoringClient import ComponentMonitoringClient
3433

@@ -48,6 +47,76 @@ def loadDIRACCFG():
4847
return S_OK((cfgPath, diracCFG))
4948

5049

50+
def _normalise_version(version):
51+
"""Validate and normalise a raw version string supplied by the operator.
52+
53+
:param str version: Raw string as received from the client (may contain surrounding
54+
whitespace or use the spaced ``pkg @ url`` pip syntax).
55+
:returns: A 4-tuple ``(version, primaryExtension, released_version, isPrerelease)`` where
56+
57+
- *version* is the normalised version string ready to be passed to pip,
58+
- *primaryExtension* is the package name when the caller used
59+
``extension==version`` syntax, or ``None`` otherwise,
60+
- *released_version* is ``True`` when installing a PEP 440 release and
61+
``False`` when installing from a VCS URL,
62+
- *isPrerelease* is ``True`` when the PEP 440 version is a pre-release.
63+
64+
:rtype: tuple(str, str or None, bool, bool)
65+
:raises ValueError: When the version string is empty, or is not a valid PEP 440
66+
version and does not contain a recognised VCS URL.
67+
"""
68+
version = version.strip()
69+
if not version:
70+
raise ValueError("No version specified")
71+
72+
primaryExtension = None
73+
if "==" in version:
74+
primaryExtension, version = version.split("==", 1)
75+
76+
released_version = True
77+
isPrerelease = False
78+
79+
# Special aliases: install DIRAC from the integration branch
80+
if version.lower() in ("integration", "devel", "master", "main"):
81+
released_version = False
82+
version = "DIRAC[server] @ git+https://github.com/DIRACGrid/DIRAC.git@integration"
83+
return version, primaryExtension, released_version, isPrerelease
84+
85+
# Try to parse as a PEP 440 version number
86+
try:
87+
parsed = Version(version)
88+
isPrerelease = parsed.is_prerelease
89+
version = f"v{parsed}"
90+
except InvalidVersion:
91+
if "https://" in version:
92+
# Treat as a VCS URL (e.g. "DIRAC[server] @ git+https://...")
93+
released_version = False
94+
else:
95+
raise ValueError(f"Invalid version passed {version!r}")
96+
97+
return version, primaryExtension, released_version, isPrerelease
98+
99+
100+
def _directory_label(version, released_version):
101+
"""Derive the filesystem directory label for a given version.
102+
103+
For released versions this is the version string itself. For VCS URLs
104+
(pip ``pkg @ url`` syntax) it is the URL part, stripped of any
105+
``#egg=...`` fragment and surrounding whitespace.
106+
107+
:param str version: Normalised version string as returned by :func:`_normalise_version`.
108+
:param bool released_version: ``True`` when *version* is a PEP 440 release string.
109+
:returns: A filesystem-safe label derived from *version*.
110+
:rtype: str
111+
"""
112+
if released_version:
113+
return version
114+
# version is "pkg @ git+https://host/repo.git@branch"
115+
# Split on the *first* "@" (the pip separator) only, then strip spaces
116+
# and drop any "#egg=..." fragment so the branch name is preserved.
117+
return version.split("@", 1)[1].strip().split("#")[0]
118+
119+
51120
class SystemAdministratorHandler(RequestHandler):
52121
@classmethod
53122
def initializeHandler(cls, serviceInfo):
@@ -263,29 +332,11 @@ def export_updateSoftware(self, version):
263332
- a git tag/branch like "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
264333
"""
265334
# Validate and normalise the requested version
266-
primaryExtension = None
267-
if "==" in version:
268-
primaryExtension, version = version.split("==")
269-
270-
released_version = True
271-
isPrerelease = False
272-
273-
# Special cases (e.g. installing the integration/main branch)
274-
if version.lower() in ["integration", "devel", "master", "main"]:
275-
released_version = False
276-
version = "DIRAC[server] @ git+https://github.com/DIRACGrid/DIRAC.git@integration"
277-
278-
if released_version:
279-
try:
280-
version = Version(version)
281-
isPrerelease = version.is_prerelease
282-
version = f"v{version}"
283-
except InvalidVersion:
284-
if "https://" in version:
285-
released_version = False
286-
else:
287-
self.log.exception("Invalid version passed", version)
288-
return S_ERROR(f"Invalid version passed {version!r}")
335+
try:
336+
version, primaryExtension, released_version, isPrerelease = _normalise_version(version)
337+
except ValueError as e:
338+
self.log.exception("Invalid version passed", version)
339+
return S_ERROR(str(e))
289340

290341
# Find what to install
291342
otherExtensions = []
@@ -311,7 +362,7 @@ def export_updateSoftware(self, version):
311362
installer.flush()
312363
self.log.info("Downloaded DIRACOS installer to", installer.name)
313364

314-
directory = version if released_version else version.split("@")[1].split("#")[0]
365+
directory = _directory_label(version, released_version)
315366
newProPrefix = os.path.join(
316367
rootPath,
317368
"versions",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Unit tests for SystemAdministratorHandler helper functions and
2+
the SystemAdministratorClientCLI do_update input validation.
3+
"""
4+
5+
import pytest
6+
from unittest.mock import MagicMock, patch
7+
8+
from DIRAC.FrameworkSystem.Service.SystemAdministratorHandler import (
9+
_directory_label,
10+
_normalise_version,
11+
)
12+
13+
14+
# ---------------------------------------------------------------------------
15+
# Tests: _normalise_version
16+
# ---------------------------------------------------------------------------
17+
18+
19+
class TestNormaliseVersion:
20+
def test_empty_string_raises(self):
21+
with pytest.raises(ValueError, match="No version specified"):
22+
_normalise_version("")
23+
24+
def test_whitespace_only_raises(self):
25+
with pytest.raises(ValueError, match="No version specified"):
26+
_normalise_version(" ")
27+
28+
def test_released_version(self):
29+
version, primary, released, pre = _normalise_version("9.0.18")
30+
assert released is True
31+
assert pre is False
32+
assert version == "v9.0.18"
33+
assert primary is None
34+
35+
def test_released_prerelease_version(self):
36+
version, primary, released, pre = _normalise_version("9.0.18a1")
37+
assert released is True
38+
assert pre is True
39+
assert version == "v9.0.18a1"
40+
41+
def test_extension_syntax_splits_primary(self):
42+
version, primary, released, pre = _normalise_version("MyExtension==9.0.18")
43+
assert primary == "MyExtension"
44+
assert version == "v9.0.18"
45+
assert released is True
46+
47+
@pytest.mark.parametrize("keyword", ["integration", "devel", "master", "main"])
48+
def test_special_keywords(self, keyword):
49+
version, primary, released, pre = _normalise_version(keyword)
50+
assert released is False
51+
assert pre is False
52+
assert "DIRACGrid/DIRAC" in version
53+
assert "@integration" in version
54+
55+
def test_git_url_without_spaces(self):
56+
raw = "DIRAC[server]@git+https://github.com/fstagni/DIRAC.git@test_branch"
57+
version, primary, released, pre = _normalise_version(raw)
58+
assert released is False
59+
assert version == raw
60+
61+
def test_git_url_with_spaces_around_at(self):
62+
"""Leading/trailing whitespace is stripped; internal pip spaces are kept."""
63+
raw = "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
64+
version, primary, released, pre = _normalise_version(f" {raw} ")
65+
assert released is False
66+
assert version == raw
67+
68+
def test_invalid_version_no_url_raises(self):
69+
with pytest.raises(ValueError, match="Invalid version passed"):
70+
_normalise_version("not-a-valid-version")
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# Tests: _directory_label
75+
# ---------------------------------------------------------------------------
76+
77+
78+
class TestDirectoryLabel:
79+
def test_released_version_uses_version_directly(self):
80+
assert _directory_label("v9.0.18", released_version=True) == "v9.0.18"
81+
82+
def test_git_url_without_spaces(self):
83+
"""Branch name after the second '@' must be preserved."""
84+
version = "DIRAC[server]@git+https://github.com/fstagni/DIRAC.git@test_branch"
85+
assert (
86+
_directory_label(version, released_version=False) == "git+https://github.com/fstagni/DIRAC.git@test_branch"
87+
)
88+
89+
def test_git_url_with_spaces_around_pip_separator(self):
90+
"""Spaces around the pip '@' separator are stripped; branch part kept."""
91+
version = "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
92+
assert (
93+
_directory_label(version, released_version=False) == "git+https://github.com/fstagni/DIRAC.git@test_branch"
94+
)
95+
96+
def test_git_url_with_hash_fragment(self):
97+
"""#egg= fragment must be stripped from the directory label."""
98+
version = "DIRAC[server]@git+https://github.com/DIRACGrid/DIRAC.git@integration#egg=DIRAC"
99+
assert (
100+
_directory_label(version, released_version=False)
101+
== "git+https://github.com/DIRACGrid/DIRAC.git@integration"
102+
)
103+
104+
105+
# ---------------------------------------------------------------------------
106+
# Tests: CLI do_update input validation
107+
# ---------------------------------------------------------------------------
108+
109+
110+
class TestDoUpdate:
111+
"""Test SystemAdministratorClientCLI.do_update input validation."""
112+
113+
def _make_cli(self):
114+
from DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI import SystemAdministratorClientCLI
115+
116+
cli = SystemAdministratorClientCLI.__new__(SystemAdministratorClientCLI)
117+
cli.host = "localhost"
118+
cli.port = 9162
119+
return cli
120+
121+
def test_empty_args_prints_usage_and_returns(self):
122+
cli = self._make_cli()
123+
with (
124+
patch(
125+
"DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.SystemAdministratorClient"
126+
) as mock_client_cls,
127+
patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.gLogger") as mock_logger,
128+
):
129+
cli.do_update("")
130+
mock_client_cls.assert_not_called()
131+
assert mock_logger.notice.called
132+
133+
def test_whitespace_only_args_does_not_contact_server(self):
134+
cli = self._make_cli()
135+
with (
136+
patch(
137+
"DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.SystemAdministratorClient"
138+
) as mock_client_cls,
139+
patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.gLogger"),
140+
):
141+
cli.do_update(" ")
142+
mock_client_cls.assert_not_called()
143+
144+
def test_valid_version_calls_client(self):
145+
cli = self._make_cli()
146+
with (
147+
patch(
148+
"DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.SystemAdministratorClient"
149+
) as mock_client_cls,
150+
patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.gLogger"),
151+
):
152+
mock_instance = MagicMock()
153+
mock_instance.updateSoftware.return_value = {"OK": True, "Value": None}
154+
mock_client_cls.return_value = mock_instance
155+
156+
cli.do_update("9.0.18")
157+
158+
mock_client_cls.assert_called_once_with(cli.host, cli.port)
159+
mock_instance.updateSoftware.assert_called_once_with("9.0.18", timeout=600)
160+
161+
def test_git_url_with_spaces_passes_full_version_to_server(self):
162+
"""The version body (including internal spaces) is forwarded as-is."""
163+
cli = self._make_cli()
164+
with (
165+
patch(
166+
"DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.SystemAdministratorClient"
167+
) as mock_client_cls,
168+
patch("DIRAC.FrameworkSystem.Client.SystemAdministratorClientCLI.gLogger"),
169+
):
170+
mock_instance = MagicMock()
171+
mock_instance.updateSoftware.return_value = {"OK": True, "Value": None}
172+
mock_client_cls.return_value = mock_instance
173+
174+
raw = "DIRAC[server] @ git+https://github.com/fstagni/DIRAC.git@test_branch"
175+
cli.do_update(raw)
176+
177+
mock_instance.updateSoftware.assert_called_once_with(raw, timeout=600)

src/DIRAC/FrameworkSystem/Service/test/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)