Skip to content

Commit 25a9fdd

Browse files
committed
Merge with upstream
2 parents 78c2a8c + 7826de0 commit 25a9fdd

File tree

5 files changed

+172
-70
lines changed

5 files changed

+172
-70
lines changed

LICENSE

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) 2025, Brookhaven National Laboratory
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
11+
2. Redistributions in binary form must reproduce the above copyright notice,
12+
this list of conditions and the following disclaimer in the documentation
13+
and/or other materials provided with the distribution.
14+
15+
3. Neither the name of the copyright holder nor the names of its
16+
contributors may be used to endorse or promote products derived from
17+
this software without specific prior written permission.
18+
19+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

src/manage_iocs/commands.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import inspect
22
import socket
33
import sys
4+
import time as ttime
45
from subprocess import PIPE, Popen
56

67
from . import __version__, utils
78

8-
EXTRA_PAD_WIDTH = 3
9+
EXTRA_PAD_WIDTH = 5
10+
11+
# Length added to string to account for ANSI color escape codes
12+
ANSI_COLOR_ESC_CODE_LEN = 9
913

1014

1115
def version():
@@ -50,23 +54,38 @@ def attach(ioc: str):
5054

5155
def report():
5256
"""Show config(s) of an all IOCs on localhost"""
57+
base_hostname = socket.gethostname()
58+
if "." in base_hostname:
59+
base_hostname = base_hostname.split(".")[0]
60+
5361
iocs = [
5462
ioc_config
5563
for ioc_config in utils.find_iocs().values()
56-
if ioc_config.host == "localhost" or ioc_config.host == socket.gethostname()
64+
if ioc_config.host == "localhost"
65+
or ioc_config.host == base_hostname
66+
or ioc_config.host == socket.gethostname()
5767
]
68+
69+
if len(iocs) == 0:
70+
print("No IOCs found on configured to run on this host.")
71+
print(f"Searched in: {utils.IOC_SEARCH_PATH}")
72+
return 1
73+
5874
max_base_len = max(len(str(ioc.path)) for ioc in iocs) + EXTRA_PAD_WIDTH
5975
max_ioc_name_len = max(len(ioc.name) for ioc in iocs) + EXTRA_PAD_WIDTH
6076
max_user_len = max(len(ioc.user) for ioc in iocs) + EXTRA_PAD_WIDTH
6177
max_port_len = max(len(str(ioc.procserv_port)) for ioc in iocs) + EXTRA_PAD_WIDTH
62-
max_exec_len = max(len(ioc.exec_path) for ioc in iocs) + max_base_len
78+
max_exec_len = max(len(ioc.exec_path) for ioc in iocs) + max_base_len - EXTRA_PAD_WIDTH
6379

64-
print(
80+
header = (
6581
f"{'BASE'.ljust(max_base_len)}| {'IOC'.ljust(max_ioc_name_len)}| "
6682
f"{'USER'.ljust(max_user_len)}| {'PORT'.ljust(max_port_len)}| "
6783
f"{'EXEC'.ljust(max_exec_len)}"
6884
)
85+
print(header)
86+
print("-" * len(header))
6987
for ioc in iocs:
88+
ttime.sleep(0.01)
7089
print(
7190
f"{str(ioc.path).ljust(max_base_len)}| {ioc.name.ljust(max_ioc_name_len)}| "
7291
f"{ioc.user.ljust(max_user_len)}| {str(ioc.procserv_port).ljust(max_port_len)}| "
@@ -197,8 +216,11 @@ def install(ioc: str):
197216

198217
service_file = utils.SYSTEMD_SERVICE_PATH / f"softioc-{ioc}.service"
199218
ioc_config = utils.find_iocs()[ioc]
219+
base_hostname = socket.gethostname()
220+
if "." in base_hostname:
221+
base_hostname = base_hostname.split(".")[0]
200222

201-
if socket.gethostname() != ioc_config.host and ioc_config.host != "localhost":
223+
if ioc_config.host not in [base_hostname, "localhost", socket.gethostname()]:
202224
raise RuntimeError(
203225
f"Cannot install IOC '{ioc}' on this host; configured host is '{ioc_config.host}'!"
204226
)
@@ -245,21 +267,35 @@ def status():
245267
"""Get the status of the given IOC."""
246268

247269
ret = 0
248-
statuses: dict[str, tuple[str, str]] = {}
270+
statuses: dict[str, tuple[str, bool]] = {}
249271
installed_iocs = utils.find_installed_iocs().keys()
272+
if len(installed_iocs) == 0:
273+
print("No Installed IOCs found on this host.")
274+
return 1
250275

251276
for installed_ioc in installed_iocs:
252-
status_ret, status = utils.get_ioc_statuses(installed_ioc)
253-
ret += status_ret # TODO: Log warning
254-
statuses[installed_ioc] = status
277+
try:
278+
statuses[installed_ioc] = utils.get_ioc_status(installed_ioc)
279+
except RuntimeError:
280+
pass # TODO: Handle this better?
255281

256282
max_ioc_name_len = max(len(ioc_name) for ioc_name in statuses.keys()) + EXTRA_PAD_WIDTH
257283
max_status_len = max(len(status[0]) for status in statuses.values()) + EXTRA_PAD_WIDTH
258-
max_enabled_len = max(len(status[1]) for status in statuses.values()) + EXTRA_PAD_WIDTH
284+
max_enabled_len = len("Auto-Start")
259285

260286
print(f"{'IOC'.ljust(max_ioc_name_len)}{'Status'.ljust(max_status_len)}Auto-Start")
287+
ttime.sleep(0.01)
261288
print("-" * (max_ioc_name_len + max_status_len + max_enabled_len))
262-
for ioc_name, (status_str, enabled_str) in statuses.items():
263-
print(f"{ioc_name.ljust(max_ioc_name_len)}{status_str.ljust(max_status_len)}{enabled_str}")
289+
for ioc_name, (state, is_enabled) in statuses.items():
290+
ttime.sleep(0.01)
291+
if state == "Running":
292+
state_str = f"\033[92m{state}\033[0m" # Green
293+
elif state == "Stopped":
294+
state_str = f"\033[91m{state}\033[0m" # Red
295+
else:
296+
state_str = f"\033[93m{state}\033[0m" # Yellow
297+
print(
298+
f"{ioc_name.ljust(max_ioc_name_len)}{state_str.ljust(max_status_len + ANSI_COLOR_ESC_CODE_LEN)}{'Enabled' if is_enabled else 'Disabled'}" # noqa: E501
299+
)
264300

265301
return ret

src/manage_iocs/utils.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from pathlib import Path
66
from subprocess import PIPE, Popen
77

8-
IOC_SEARCH_PATH = (
9-
[Path("/epics/iocs"), Path("/opt/epics/iocs"), Path("/opt/iocs")]
10-
+ [Path(p) for p in os.environ["MANAGE_IOCS_SEARCH_PATH"].split(os.pathsep)]
11-
if "MANAGE_IOCS_SEARCH_PATH" in os.environ
12-
else []
13-
)
8+
IOC_SEARCH_PATH = [Path("/epics/iocs"), Path("/opt/epics/iocs"), Path("/opt/iocs")]
9+
if "MANAGE_IOCS_SEARCH_PATH" in os.environ:
10+
IOC_SEARCH_PATH.extend(
11+
[Path(p) for p in os.environ["MANAGE_IOCS_SEARCH_PATH"].split(os.pathsep)]
12+
)
13+
1414
SYSTEMD_SERVICE_PATH = Path("/etc/systemd/system")
1515

1616

@@ -90,24 +90,27 @@ def systemctl_passthrough(action: str, ioc: str) -> tuple[str, str, int]:
9090
"""Helper to call systemctl with the given action and IOC name."""
9191
proc = Popen(["systemctl", action, f"softioc-{ioc}.service"], stdin=PIPE, stdout=PIPE)
9292
out, err = proc.communicate()
93-
return out.decode().strip(), err.decode().strip(), proc.returncode
93+
decoded_out = out.decode().strip() if out else ""
94+
decoded_err = err.decode().strip() if err else ""
95+
return decoded_out, decoded_err, proc.returncode
9496

9597

96-
def get_ioc_statuses(ioc_name: str) -> tuple[int, tuple[str, str]]:
98+
def get_ioc_status(ioc_name: str) -> tuple[str, bool]:
9799
"""Get the active and enabled status of the given IOC."""
98100

99-
status, _, ret = systemctl_passthrough("is-active", ioc_name)
100-
if status == "inactive":
101-
status = "Stopped"
102-
elif status == "active":
103-
status = "Running"
104-
else:
105-
status = status.capitalize()
101+
state, err, _ = systemctl_passthrough("is-active", ioc_name)
102+
103+
# Convert to more user-friendly terms
104+
if state == "active":
105+
state = "Running"
106+
elif state == "inactive":
107+
state = "Stopped"
106108

107-
enabled, _, ret_enable = systemctl_passthrough("is-enabled", ioc_name)
108-
ret = ret + ret_enable
109+
enabled, err, _ = systemctl_passthrough("is-enabled", ioc_name)
110+
if enabled not in ("enabled", "disabled"):
111+
raise RuntimeError(err)
109112

110-
return ret, (status, enabled.capitalize())
113+
return state.capitalize(), enabled == "enabled"
111114

112115

113116
def requires_root(func: Callable):

tests/test_commands.py

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55
import manage_iocs
66
import manage_iocs.commands as cmds
77
import manage_iocs.utils
8-
from manage_iocs.utils import find_installed_iocs, get_ioc_statuses
8+
from manage_iocs.utils import find_installed_iocs, get_ioc_status
9+
10+
11+
def strip_ansi_codes(s: str) -> str:
12+
import re
13+
14+
ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
15+
return ansi_escape.sub("", s)
916

1017

1118
def test_version(capsys):
@@ -44,22 +51,35 @@ def normalize_whitespace(s: str) -> str:
4451
assert normalize_whitespace(line) in normalize_whitespace(captured.out)
4552

4653

54+
def test_report_no_iocs(monkeypatch, capsys):
55+
monkeypatch.setattr(
56+
manage_iocs.utils,
57+
"find_iocs",
58+
lambda: {},
59+
)
60+
61+
rc = cmds.report()
62+
captured = capsys.readouterr()
63+
assert "No IOCs found on configured to run on this host." in captured.out
64+
assert rc == 1
65+
66+
4767
@pytest.mark.parametrize(
4868
"ioc_name, command, before_state, before_enabled, after_state, after_enabled, as_root",
4969
[
50-
("ioc1", cmds.stop, "Running", "Enabled", "Stopped", "Enabled", False),
51-
("ioc3", cmds.stop, "Running", "Disabled", "Stopped", "Disabled", False),
52-
("ioc4", cmds.stop, "Stopped", "Disabled", "Stopped", "Disabled", False),
53-
("ioc1", cmds.start, "Running", "Enabled", "Running", "Enabled", False),
54-
("ioc3", cmds.start, "Running", "Disabled", "Running", "Disabled", False),
55-
("ioc4", cmds.start, "Stopped", "Disabled", "Running", "Disabled", False),
56-
("ioc3", cmds.enable, "Running", "Disabled", "Running", "Enabled", True),
57-
("ioc4", cmds.enable, "Stopped", "Disabled", "Stopped", "Enabled", True),
58-
("ioc1", cmds.disable, "Running", "Enabled", "Running", "Disabled", True),
59-
("ioc3", cmds.disable, "Running", "Disabled", "Running", "Disabled", True),
60-
("ioc1", cmds.restart, "Running", "Enabled", "Running", "Enabled", False),
61-
("ioc4", cmds.restart, "Stopped", "Disabled", "Running", "Disabled", False),
62-
("ioc3", cmds.restart, "Running", "Disabled", "Running", "Disabled", False),
70+
("ioc1", cmds.stop, "Running", True, "Stopped", True, False),
71+
("ioc3", cmds.stop, "Running", False, "Stopped", False, False),
72+
("ioc4", cmds.stop, "Stopped", False, "Stopped", False, False),
73+
("ioc1", cmds.start, "Running", True, "Running", True, False),
74+
("ioc3", cmds.start, "Running", False, "Running", False, False),
75+
("ioc4", cmds.start, "Stopped", False, "Running", False, False),
76+
("ioc3", cmds.enable, "Running", False, "Running", True, True),
77+
("ioc4", cmds.enable, "Stopped", False, "Stopped", True, True),
78+
("ioc1", cmds.disable, "Running", True, "Running", False, True),
79+
("ioc3", cmds.disable, "Running", False, "Running", False, True),
80+
("ioc1", cmds.restart, "Running", True, "Running", True, False),
81+
("ioc4", cmds.restart, "Stopped", False, "Running", False, False),
82+
("ioc3", cmds.restart, "Running", False, "Running", False, False),
6383
],
6484
)
6585
def test_state_change_commands(
@@ -76,14 +96,12 @@ def test_state_change_commands(
7696
if not as_root:
7797
monkeypatch.setattr(os, "geteuid", lambda: 1000) # Mock as non-root user
7898

79-
_, status = get_ioc_statuses(ioc_name)
80-
assert status == (before_state, before_enabled)
99+
assert get_ioc_status(ioc_name) == (before_state, before_enabled)
81100

82101
rc = command(ioc_name)
83102
assert rc == 0
84103

85-
_, status = get_ioc_statuses(ioc_name)
86-
assert status == (after_state, after_enabled)
104+
assert get_ioc_status(ioc_name) == (after_state, after_enabled)
87105

88106

89107
def test_install_new_ioc(sample_iocs, monkeypatch):
@@ -92,8 +110,7 @@ def test_install_new_ioc(sample_iocs, monkeypatch):
92110
rc = cmds.install("ioc2")
93111
assert rc == 0
94112

95-
_, status = get_ioc_statuses("ioc2")
96-
assert status == ("Stopped", "Disabled")
113+
assert get_ioc_status("ioc2") == ("Stopped", False)
97114

98115
assert "ioc2" in find_installed_iocs()
99116

@@ -135,8 +152,8 @@ def test_requires_root(sample_iocs, monkeypatch, command):
135152
[
136153
(cmds.startall, "Running", None),
137154
(cmds.stopall, "Stopped", None),
138-
(cmds.enableall, None, "Enabled"),
139-
(cmds.disableall, None, "Disabled"),
155+
(cmds.enableall, None, True),
156+
(cmds.disableall, None, False),
140157
],
141158
)
142159
def test_state_change_all(sample_iocs, cmd, expected_state, expected_enabled):
@@ -150,9 +167,9 @@ def test_state_change_all(sample_iocs, cmd, expected_state, expected_enabled):
150167
# Check all are running
151168
for ioc in installed_iocs.values():
152169
if expected_state is not None:
153-
assert get_ioc_statuses(ioc.name)[1][0] == expected_state
170+
assert get_ioc_status(ioc.name)[0] == expected_state
154171
if expected_enabled is not None:
155-
assert get_ioc_statuses(ioc.name)[1][1] == expected_enabled
172+
assert get_ioc_status(ioc.name)[1] is expected_enabled
156173

157174

158175
@pytest.mark.parametrize("as_root", [True, False])
@@ -173,22 +190,38 @@ def test_status(sample_iocs, capsys, monkeypatch, as_root):
173190
rc = cmds.status()
174191
captured = capsys.readouterr()
175192
expected_output = """IOC Status Auto-Start
176-
----------------------------
193+
--------------------------
177194
ioc1 Running Enabled
178195
ioc3 Running Disabled
179196
ioc4 Stopped Disabled
180197
ioc5 Stopped Enabled
181198
"""
182199

183-
def normalize_whitespace(s: str) -> str:
184-
return "\n".join(" ".join(line.split()) for line in s.strip().splitlines())
200+
def normalize_whitespace_and_ansi_codes(s: str) -> str:
201+
whitespace_normalized = "\n".join(" ".join(line.split()) for line in s.strip().splitlines())
202+
return strip_ansi_codes(whitespace_normalized)
185203

186204
for line in expected_output.strip().splitlines():
187-
assert normalize_whitespace(line) in normalize_whitespace(captured.out)
205+
assert normalize_whitespace_and_ansi_codes(line) in normalize_whitespace_and_ansi_codes(
206+
captured.out
207+
)
188208

189209
assert rc == 0
190210

191211

212+
def test_status_no_installed_iocs(sample_iocs, monkeypatch, capsys):
213+
monkeypatch.setattr(
214+
manage_iocs.utils,
215+
"find_installed_iocs",
216+
lambda: {},
217+
)
218+
219+
rc = cmds.status()
220+
captured = capsys.readouterr()
221+
assert "No Installed IOCs found on this host." in captured.out
222+
assert rc == 1
223+
224+
192225
@pytest.mark.parametrize(
193226
"cmd, expected_message",
194227
[

0 commit comments

Comments
 (0)