diff --git a/README.md b/README.md index 8b6f68d..c3d21e1 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![pytest](https://github.com/quadsproject/quads-client/actions/workflows/pytest.yml/badge.svg)](https://github.com/quadsproject/quads-client/actions/workflows/pytest.yml) [![codecov](https://codecov.io/gh/quadsproject/quads-client/branch/main/graph/badge.svg)](https://codecov.io/gh/quadsproject/quads-client) [![PyPI version](https://img.shields.io/pypi/v/quads-client.svg)](https://pypi.org/project/quads-client/) +[![GitHub Release](https://img.shields.io/github/v/release/quadsproject/quads-client)](https://github.com/quadsproject/quads-client/releases/latest) [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -459,7 +460,7 @@ token-login # Option B: Register new account (legacy) register your.email@example.com YourPassword123 -# 2. Schedule hosts (automatic for 5 days or until Sunday 21:00 UTC) +# 2. Schedule hosts (duration configured by QUADS admin) schedule 3 description "My dev environment" schedule host01,host02 description "CI testing" schedule host-list hosts.txt description "Perf lab" @@ -489,25 +490,43 @@ cloud_list cloud_only **Context-Aware Argument Completion**: Press `Tab` to complete command arguments based on live server data -- **Cloud names**: `mod_cloud ` → shows available clouds -- **Hostnames**: `mark_broken ` → shows non-broken hosts -- **Assignment IDs**: `terminate ` → shows your active assignment IDs -- **Server names**: `connect ` → shows configured servers -- **Keywords**: `schedule ` → shows options like `description`, `nowipe`, `vlan`, `qinq`, `model`, `ram`, `disk-type`, `gpu-vendor`, `nic-vendor`, etc. +- **Cloud names**: `mod_cloud ` shows available clouds +- **Hostnames**: `mark_broken ` shows non-broken hosts +- **Assignment IDs**: `terminate ` shows your active assignment IDs +- **Server names**: `connect ` shows configured servers +- **Position-aware schedule**: Completions change based on argument position (cloud, hosts, dates, options) +- **Value completions**: `os ` shows available OS titles, `vlan ` shows free VLANs **Examples**: ``` -# SSM mode - schedule command shows hostnames first, then keywords +# SSM mode - position 1: hostnames, host-list, and count suggestions (quads1-dev) > schedule -host01.example.com host02.example.com host03.example.com 1 2 3 5 10 +host-list host01.example.com host02.example.com 1 2 3 5 10 -(quads1-dev) > schedule 3 -description nowipe vlan qinq model ram host-list +# SSM mode - position 2+: option keywords (after value keyword, no suggestions) +(quads1-dev) > schedule 3 description "test" +description nowipe vlan qinq os model ram disk-type ... -# Admin mode - schedule command shows cloud names first -(quads1-dev) > schedule +# Admin mode - position 1: cloud names +(quads1-dev) [ADMIN] > schedule cloud01 cloud02 cloud03 +# Admin mode - position 2: hostnames and host-list +(quads1-dev) [ADMIN] > schedule cloud02 +host-list host01.example.com host02.example.com host03.example.com + +# Admin mode - position 3-4: dates (free-form, no suggestions) +# Admin mode - position 5+: option keywords +(quads1-dev) [ADMIN] > schedule cloud02 host-list ~/hosts.txt 2026-08-01 2026-08-15 +description cloud-owner cc-users cloud-ticket vlan qinq os nowipe + +# Value completions from server +(quads1-dev) [ADMIN] > schedule cloud02 host01 2026-08-01 2026-08-15 os +RHEL 9.4 RHEL 8.10 CentOS 9 + +(quads1-dev) [ADMIN] > schedule cloud02 host01 2026-08-01 2026-08-15 vlan +1100 1200 1300 + # Cloud operations (quads1-dev) > cloud_list cloud cloud01 cloud02 cloud03 @@ -842,7 +861,7 @@ schedule description [OPTIO nic-speed - Minimum NIC speed in Gbps (count mode only) my_assignments - List all your assignments my_hosts - Show your currently scheduled hosts -available - Show available hosts for self-scheduling +ls_available - Show available hosts for self-scheduling terminate [hostname] - Terminate assignment or release host ``` @@ -882,11 +901,11 @@ schedule 2 description "ML training" gpu-vendor "NVIDIA Corporation" ram 256 schedule 4 description "High-NIC" interfaces 4 nic-vendor Mellanox ``` -You can also use `ls-available` to discover hosts first, then schedule specific ones by name: +You can also use `ls_available` to discover hosts first, then schedule specific ones by name: ```bash # Discover, then schedule by name -ls-available gpu-vendor "NVIDIA Corporation" ram 256 +ls_available gpu-vendor "NVIDIA Corporation" ram 256 schedule host03.example.com,host04.example.com description "ML training" ``` @@ -902,10 +921,10 @@ schedule 3 description "test" ``` **SSM Server Requirements:** -- QUADS server must have `self_serve_enabled: true` in quads.yml +- QUADS server must have `ssm_enable: true` in quads.yml - No ticketing system required for SSM mode -- Server limits: max 10 hosts per assignment, max 3 active assignments per user -- Auto-expiration: 5 days or Sunday 21:00 UTC (configurable server-side) +- Limits are configured by the QUADS admin per-lab (host count, active assignments, duration, etc.) +- quads-client itself imposes no scheduling limits ### Host Management (Admin) @@ -1122,8 +1141,7 @@ SSM users can: - **View** their own resources with `my_assignments` and `my_hosts` (ownership enforced) - **Track** any active move with `track`, `move_status`, and `activity` (global visibility) - **Terminate** assignments when done with `terminate` (own assignments only) -- **Duration**: Server-controlled (5 days or Sunday 21:00 UTC, whichever first) -- **Limits**: Max 10 hosts per assignment, max 3 active assignments per user +- **Duration and Limits**: Configured by the QUADS admin per-lab (quads-client imposes no limits) > [!TIP] > `my_hosts` and `my_assignments` only show clouds where you are the **owner**. If you are a CC-user on an admin-scheduled cloud, use `track ` or the GUI Move Progress view to follow its provisioning status. @@ -1188,7 +1206,7 @@ quads-client/ │ └── version.py - Version command ├── conf/ │ └── quads-client.yml.example - Example configuration -├── tests/ - pytest test suite (754 tests) +├── tests/ - pytest test suite (934 tests) │ ├── test_commands_programmatic.py - Tests for GUI-supporting programmatic methods │ └── ... - Other test files ├── rpm/ diff --git a/src/quads_client/commands/cloud.py b/src/quads_client/commands/cloud.py index a543bbc..9b2bac3 100644 --- a/src/quads_client/commands/cloud.py +++ b/src/quads_client/commands/cloud.py @@ -3,6 +3,7 @@ from tabulate import tabulate from quads_client.error_handler import require_connection +from quads_client.utils import resolve_os class CloudCommands: @@ -191,7 +192,7 @@ def cmd_cloud_create(self, args): if not self._require_connection(): return - if not args.strip(): + if not args.strip() or args.strip() in ("?", "-h", "--help"): self.shell.perror("Usage: cloud-create ") return @@ -342,7 +343,11 @@ def cmd_mod_cloud(self, args): return i += 2 elif parts[i] == "os" and i + 1 < len(parts): - updates["ostype"] = parts[i + 1] + resolved_os, os_error = resolve_os(self.shell.connection.api, parts[i + 1]) + if os_error: + self.shell.perror(os_error) + return + updates["ostype"] = resolved_os i += 2 elif parts[i] == "wipe": updates["wipe"] = True @@ -456,7 +461,7 @@ def cmd_cloud_only(self, args): cloud_name = args.strip() - if not cloud_name: + if not cloud_name or cloud_name in ("?", "-h", "--help"): self.shell.perror("Usage: cloud_only ") return diff --git a/src/quads_client/commands/host.py b/src/quads_client/commands/host.py index 5d62986..45657b1 100644 --- a/src/quads_client/commands/host.py +++ b/src/quads_client/commands/host.py @@ -49,7 +49,7 @@ def cmd_mark_broken(self, args): if not self._require_connection(): return - if not args.strip(): + if not args.strip() or args.strip() in ("?", "-h", "--help"): self.shell.perror("Usage: mark-broken ") return @@ -65,7 +65,7 @@ def cmd_mark_repaired(self, args): if not self._require_connection(): return - if not args.strip(): + if not args.strip() or args.strip() in ("?", "-h", "--help"): self.shell.perror("Usage: mark-repaired ") return @@ -81,7 +81,7 @@ def cmd_retire(self, args): if not self._require_connection(): return - if not args.strip(): + if not args.strip() or args.strip() in ("?", "-h", "--help"): self.shell.perror("Usage: retire ") return @@ -97,7 +97,7 @@ def cmd_unretire(self, args): if not self._require_connection(): return - if not args.strip(): + if not args.strip() or args.strip() in ("?", "-h", "--help"): self.shell.perror("Usage: unretire ") return diff --git a/src/quads_client/commands/schedule.py b/src/quads_client/commands/schedule.py index 9df9c43..e051b33 100644 --- a/src/quads_client/commands/schedule.py +++ b/src/quads_client/commands/schedule.py @@ -3,7 +3,7 @@ from quads_client.arg_parser import parse_extend_args, parse_schedule_admin_args, parse_shrink_args from quads_client.error_handler import handle_api_error, require_admin, require_connection -from quads_client.utils import format_schedule_datetime, parse_api_datetime +from quads_client.utils import format_schedule_datetime, parse_api_datetime, resolve_os def parse_flexible_datetime(date_str): @@ -261,7 +261,11 @@ def cmd_schedule_admin(self, args): if parsed["qinq"] is not None: batch_data["qinq"] = parsed["qinq"] if parsed.get("os"): - batch_data["ostype"] = parsed["os"] + resolved_os, os_error = resolve_os(self.shell.connection.api, parsed["os"]) + if os_error: + self.shell.perror(os_error) + return + batch_data["ostype"] = resolved_os try: result = self.shell.connection.api.create_schedules_batch(batch_data) diff --git a/src/quads_client/commands/server.py b/src/quads_client/commands/server.py index 96ab61d..8ba4334 100644 --- a/src/quads_client/commands/server.py +++ b/src/quads_client/commands/server.py @@ -451,7 +451,7 @@ def rm_server_programmatic(self, name): return (False, f"Failed to remove server: {e}") def cmd_add_server(self, args): - """Add a new server to configuration. Usage: add-server """ + """Add a new server to configuration. Usage: add-server [--no-verify]""" parts = args.split() if len(parts) < 4: self.shell.perror("Usage: add-server [--no-verify]") diff --git a/src/quads_client/commands/session.py b/src/quads_client/commands/session.py index 241fe63..2645e8a 100644 --- a/src/quads_client/commands/session.py +++ b/src/quads_client/commands/session.py @@ -90,7 +90,7 @@ def cmd_session(self, args): """ target = args.strip() - if not target or target == "?": + if not target or target in ("?", "-h", "--help"): self.shell.poutput("Usage: session ") self.shell.poutput("\nQuick switch to a session by ID or label.") self.shell.poutput("See also: session-list, session-switch") diff --git a/src/quads_client/commands/user.py b/src/quads_client/commands/user.py index e14b3ce..41d8765 100644 --- a/src/quads_client/commands/user.py +++ b/src/quads_client/commands/user.py @@ -10,6 +10,7 @@ extract_cloud_number, extract_hostname, get_username_short, + resolve_os, ) @@ -729,7 +730,11 @@ def cmd_schedule(self, args): if parsed["vlan"]: assignment_data["vlan"] = parsed["vlan"] if parsed["os"]: - assignment_data["ostype"] = parsed["os"] + resolved_os, os_error = resolve_os(self.shell.connection.api, parsed["os"]) + if os_error: + self.shell.perror(os_error) + return + assignment_data["ostype"] = resolved_os # Step 1: Create self-assignment (SSM endpoint auto-assigns cloud) assignment = auto_refresh_on_auth_error( diff --git a/src/quads_client/shell.py b/src/quads_client/shell.py index 99667e9..9ffd14d 100644 --- a/src/quads_client/shell.py +++ b/src/quads_client/shell.py @@ -20,11 +20,6 @@ from quads_client.utils import get_ssl_indicator -def _rl(ansi): - """Wrap ANSI escape code for readline (mark as non-printing)""" - return f"\001{ansi}\002" - - class QuadsClientShell(cmd2.Cmd): intro = "" # We'll use rich console for the banner @@ -120,7 +115,7 @@ def _get_activity_indicator(self): try: moves = self.connection.api.get_all_move_status() if moves: - self._cached_activity_indicator = f"{_rl(chr(27) + '[1;33m')}⚡{_rl(chr(27) + '[0m')}" + self._cached_activity_indicator = "\033[1;33m⚡\033[0m" else: self._cached_activity_indicator = "" except Exception: @@ -165,15 +160,13 @@ def _update_prompt(self): admin_badge = "" if self.connection and self.connection.is_admin: - admin_badge = f" {_rl(chr(27) + '[1;31m')}[ADMIN]{_rl(chr(27) + '[0m')}" + admin_badge = " \033[1;31m[ADMIN]\033[0m" activity = self._get_activity_indicator() - self.prompt = ( - f"{_rl(color)}{symbol} {session_info}({short_name}){activity}{admin_badge}{_rl(chr(27) + '[0m')} > " - ) + self.prompt = f"{color}{symbol} {session_info}({short_name}){activity}{admin_badge}\033[0m > " else: - self.prompt = f"{_rl(chr(27) + '[1;31m')}(disconnected){_rl(chr(27) + '[0m')} > " + self.prompt = "\033[1;31m(disconnected)\033[0m > " def _get_session_indicators(self) -> str: """Generate session indicator string like '[1:dev* 2:prod]'""" @@ -203,7 +196,6 @@ def _update_visible_commands(self): "cloud_delete", "mod_cloud", "find_free_cloud", - "cloud_only", "ls_vlan", "ls_hosts", "mark_broken", @@ -238,6 +230,7 @@ def _update_visible_commands(self): "my_assignments", "terminate", "cloud_list", + "cloud_only", "ls_available", "os_list", "move_status", @@ -282,7 +275,7 @@ def do_debug_admin(self, args): print("No connection") def do_connect(self, args): - """Connect to a QUADS server. Usage: connect [server_name]""" + """Connect to a QUADS server. Usage: connect [server_name|number] [session