Skip to content

Commit 0d626c2

Browse files
authored
Merge pull request #164 from sadsfae/fixos
fix: validate OS selection in schedule/mod-cloud
2 parents 4a0e8a3 + ae78cbf commit 0d626c2

7 files changed

Lines changed: 190 additions & 4 deletions

File tree

‎src/quads_client/commands/cloud.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from tabulate import tabulate
44

55
from quads_client.error_handler import require_connection
6+
from quads_client.utils import resolve_os
67

78

89
class CloudCommands:
@@ -342,7 +343,11 @@ def cmd_mod_cloud(self, args):
342343
return
343344
i += 2
344345
elif parts[i] == "os" and i + 1 < len(parts):
345-
updates["ostype"] = parts[i + 1]
346+
resolved_os, os_error = resolve_os(self.shell.connection.api, parts[i + 1])
347+
if os_error:
348+
self.shell.perror(os_error)
349+
return
350+
updates["ostype"] = resolved_os
346351
i += 2
347352
elif parts[i] == "wipe":
348353
updates["wipe"] = True

‎src/quads_client/commands/schedule.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from quads_client.arg_parser import parse_extend_args, parse_schedule_admin_args, parse_shrink_args
55
from quads_client.error_handler import handle_api_error, require_admin, require_connection
6-
from quads_client.utils import format_schedule_datetime, parse_api_datetime
6+
from quads_client.utils import format_schedule_datetime, parse_api_datetime, resolve_os
77

88

99
def parse_flexible_datetime(date_str):
@@ -261,7 +261,11 @@ def cmd_schedule_admin(self, args):
261261
if parsed["qinq"] is not None:
262262
batch_data["qinq"] = parsed["qinq"]
263263
if parsed.get("os"):
264-
batch_data["ostype"] = parsed["os"]
264+
resolved_os, os_error = resolve_os(self.shell.connection.api, parsed["os"])
265+
if os_error:
266+
self.shell.perror(os_error)
267+
return
268+
batch_data["ostype"] = resolved_os
265269

266270
try:
267271
result = self.shell.connection.api.create_schedules_batch(batch_data)

‎src/quads_client/commands/user.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
extract_cloud_number,
1111
extract_hostname,
1212
get_username_short,
13+
resolve_os,
1314
)
1415

1516

@@ -729,7 +730,11 @@ def cmd_schedule(self, args):
729730
if parsed["vlan"]:
730731
assignment_data["vlan"] = parsed["vlan"]
731732
if parsed["os"]:
732-
assignment_data["ostype"] = parsed["os"]
733+
resolved_os, os_error = resolve_os(self.shell.connection.api, parsed["os"])
734+
if os_error:
735+
self.shell.perror(os_error)
736+
return
737+
assignment_data["ostype"] = resolved_os
733738

734739
# Step 1: Create self-assignment (SSM endpoint auto-assigns cloud)
735740
assignment = auto_refresh_on_auth_error(

‎src/quads_client/utils.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,33 @@ def validate_cloud_exists(api, cloud_name: str) -> bool:
271271
"""
272272
clouds = api.filter_clouds({"name": cloud_name})
273273
return bool(clouds)
274+
275+
276+
def resolve_os(api, os_input: str):
277+
"""
278+
Validate and resolve an OS selection against the server's OS list.
279+
280+
Matches by Title (case-insensitive) or by numeric Id.
281+
Returns (canonical_title, None) on success, (None, error_string) on failure.
282+
"""
283+
os_list = api.get_os_list()
284+
if not os_list:
285+
return None, f"OS '{os_input}' not found"
286+
287+
os_lower = os_input.lower()
288+
for entry in os_list:
289+
title = entry.get("Title", "")
290+
if title.lower() == os_lower:
291+
return title, None
292+
293+
if os_input.isdigit():
294+
os_id = int(os_input)
295+
for entry in os_list:
296+
if entry.get("Id") == os_id:
297+
return entry.get("Title"), None
298+
299+
available = ", ".join(entry.get("Title", "") for entry in os_list if entry.get("Title"))
300+
error = f"OS '{os_input}' not found"
301+
if available:
302+
error += f"\nAvailable: {available}"
303+
return None, error

‎tests/test_commands_cloud.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,28 @@ def test_mod_cloud_with_os(mock_shell):
233233
mock_shell.connection.is_connected = True
234234
mock_shell.connection.api.get_active_cloud_assignment.return_value = {"id": 42}
235235
mock_shell.connection.api.update_assignment.return_value = {"status": "success"}
236+
mock_shell.connection.api.get_os_list.return_value = [
237+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
238+
]
236239

237240
cloud_cmd = CloudCommands(mock_shell)
238241
cloud_cmd.cmd_mod_cloud('cloud05 os "RHEL 9.4"')
239242

240243
mock_shell.connection.api.update_assignment.assert_called_once_with(42, {"ostype": "RHEL 9.4"})
244+
245+
246+
def test_mod_cloud_with_invalid_os(mock_shell):
247+
"""Test mod-cloud rejects invalid OS and does not call API"""
248+
mock_shell.connection.is_connected = True
249+
mock_shell.connection.api.get_active_cloud_assignment.return_value = {"id": 42}
250+
mock_shell.connection.api.get_os_list.return_value = [
251+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
252+
]
253+
254+
cloud_cmd = CloudCommands(mock_shell)
255+
cloud_cmd.cmd_mod_cloud('cloud05 os "Windows 11"')
256+
257+
mock_shell.connection.api.update_assignment.assert_not_called()
258+
mock_shell.perror.assert_called()
259+
error_msg = mock_shell.perror.call_args[0][0]
260+
assert "not found" in error_msg

‎tests/test_commands_unified_schedule.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,37 @@ def test_schedule_ssm_with_os(self, mock_shell):
9797
"owner": "alice",
9898
}
9999
mock_shell.connection.api.create_schedule.return_value = {"id": 1}
100+
mock_shell.connection.api.get_os_list.return_value = [
101+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
102+
]
100103

101104
user_cmd = UserCommands(mock_shell)
102105
user_cmd.cmd_schedule('1 description "Test" os "RHEL 9.4"')
103106

104107
call_args = mock_shell.connection.api.create_self_assignment.call_args[0][0]
105108
assert call_args["ostype"] == "RHEL 9.4"
106109

110+
def test_schedule_ssm_with_invalid_os(self, mock_shell):
111+
"""Test SSM schedule rejects invalid OS and does not call API"""
112+
mock_shell.connection.is_connected = True
113+
mock_shell.connection.is_authenticated = True
114+
mock_shell.connection.is_admin = False
115+
mock_shell.connection.username = "alice@example.com"
116+
mock_shell.connection.api.filter_available.return_value = [
117+
{"name": "host01.example.com"},
118+
]
119+
mock_shell.connection.api.get_os_list.return_value = [
120+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
121+
]
122+
123+
user_cmd = UserCommands(mock_shell)
124+
user_cmd.cmd_schedule('1 description "Test" os "--help"')
125+
126+
mock_shell.connection.api.create_self_assignment.assert_not_called()
127+
mock_shell.perror.assert_called()
128+
error_msg = mock_shell.perror.call_args[0][0]
129+
assert "not found" in error_msg
130+
107131
def test_schedule_ssm_insufficient_hosts(self, mock_shell):
108132
"""Test SSM schedule with insufficient available hosts"""
109133
mock_shell.connection.is_connected = True
@@ -703,6 +727,9 @@ def test_schedule_admin_batch_with_os(self, mock_shell):
703727
"schedules_created": 1,
704728
"hostnames": ["host01.example.com"],
705729
}
730+
mock_shell.connection.api.get_os_list.return_value = [
731+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
732+
]
706733

707734
schedule_cmd = ScheduleCommands(mock_shell)
708735
cmd = (
@@ -713,3 +740,25 @@ def test_schedule_admin_batch_with_os(self, mock_shell):
713740

714741
batch_data = mock_shell.connection.api.create_schedules_batch.call_args[0][0]
715742
assert batch_data["ostype"] == "RHEL 9.4"
743+
744+
def test_schedule_admin_batch_with_invalid_os(self, mock_shell):
745+
"""Test batch schedule rejects invalid OS and does not call API"""
746+
mock_shell.connection.is_connected = True
747+
mock_shell.connection.is_authenticated = True
748+
mock_shell.connection.is_admin = True
749+
mock_shell.connection.api.filter_clouds.return_value = [{"name": "cloud02"}]
750+
mock_shell.connection.api.get_os_list.return_value = [
751+
{"Id": 1, "Title": "RHEL 9.4", "Release Name": "", "Family": "Redhat"},
752+
]
753+
754+
schedule_cmd = ScheduleCommands(mock_shell)
755+
cmd = (
756+
'cloud02 host01 "2026-05-11 22:00" "2026-06-11 22:00" '
757+
'description "Test" cloud-owner jdoe cloud-ticket 123 os "bogus"'
758+
)
759+
schedule_cmd.cmd_schedule_admin(cmd)
760+
761+
mock_shell.connection.api.create_schedules_batch.assert_not_called()
762+
mock_shell.perror.assert_called()
763+
error_msg = mock_shell.perror.call_args[0][0]
764+
assert "not found" in error_msg

‎tests/test_utils.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
get_ssl_indicator,
1313
get_ssl_status_text,
1414
get_username_short,
15+
resolve_os,
1516
validate_cloud_exists,
1617
)
1718

@@ -485,3 +486,75 @@ def test_validate_cloud_exists_false():
485486

486487
assert result is False
487488
mock_api.filter_clouds.assert_called_once_with({"name": "cloud99"})
489+
490+
491+
OS_LIST = [
492+
{"Id": 1, "Title": "Rocky Linux 9.8", "Release Name": "", "Family": "Redhat"},
493+
{"Id": 2, "Title": "Fedora 42", "Release Name": "", "Family": "Redhat"},
494+
{"Id": 3, "Title": "RHEL 10.0", "Release Name": "", "Family": "Redhat"},
495+
]
496+
497+
498+
def test_resolve_os_exact_title():
499+
mock_api = MagicMock()
500+
mock_api.get_os_list.return_value = OS_LIST
501+
title, error = resolve_os(mock_api, "RHEL 10.0")
502+
assert title == "RHEL 10.0"
503+
assert error is None
504+
505+
506+
def test_resolve_os_case_insensitive():
507+
mock_api = MagicMock()
508+
mock_api.get_os_list.return_value = OS_LIST
509+
title, error = resolve_os(mock_api, "rhel 10.0")
510+
assert title == "RHEL 10.0"
511+
assert error is None
512+
title, error = resolve_os(mock_api, "fedora 42")
513+
assert title == "Fedora 42"
514+
assert error is None
515+
516+
517+
def test_resolve_os_by_id():
518+
mock_api = MagicMock()
519+
mock_api.get_os_list.return_value = OS_LIST
520+
title, error = resolve_os(mock_api, "2")
521+
assert title == "Fedora 42"
522+
assert error is None
523+
title, error = resolve_os(mock_api, "3")
524+
assert title == "RHEL 10.0"
525+
assert error is None
526+
527+
528+
def test_resolve_os_no_match():
529+
mock_api = MagicMock()
530+
mock_api.get_os_list.return_value = OS_LIST
531+
title, error = resolve_os(mock_api, "--help")
532+
assert title is None
533+
assert "not found" in error
534+
assert "Available:" in error
535+
536+
537+
def test_resolve_os_no_match_shows_available():
538+
mock_api = MagicMock()
539+
mock_api.get_os_list.return_value = OS_LIST
540+
title, error = resolve_os(mock_api, "Windows 11")
541+
assert title is None
542+
assert "Rocky Linux 9.8" in error
543+
assert "Fedora 42" in error
544+
assert "RHEL 10.0" in error
545+
546+
547+
def test_resolve_os_empty_list():
548+
mock_api = MagicMock()
549+
mock_api.get_os_list.return_value = []
550+
title, error = resolve_os(mock_api, "RHEL 10.0")
551+
assert title is None
552+
assert "not found" in error
553+
554+
555+
def test_resolve_os_none_list():
556+
mock_api = MagicMock()
557+
mock_api.get_os_list.return_value = None
558+
title, error = resolve_os(mock_api, "RHEL 10.0")
559+
assert title is None
560+
assert "not found" in error

0 commit comments

Comments
 (0)