From 48ca14cc1a4c931df283ff3a0ee36b63476241ab Mon Sep 17 00:00:00 2001 From: Gajesh Bhat Date: Wed, 6 Aug 2025 18:24:47 -0700 Subject: [PATCH] feat(cli): add job status information to queue-status command Add job status counts and details to the existing queue-status command: - Show job running/completed counts in default output alongside agent status - Add --verbose flag to display individual job details with timestamps - Categorize jobs as waiting/running/completed (ignore cancelled jobs) - Include new JSON keys (jobs_running, jobs_completed) with timestamp info - Maintain backward compatibility with existing agent status display This addresses issue #116 by providing job status summary without changing the existing command behavior or requiring additional flags. Closes #116 (Internally #CERTTF-248) --- cli/testflinger_cli/__init__.py | 166 +++++++++++++++++++------- cli/testflinger_cli/helpers.py | 18 +++ cli/testflinger_cli/tests/test_cli.py | 147 ++++++++++++++++++++++- docs/tutorial/index.rst | 3 +- 4 files changed, 287 insertions(+), 47 deletions(-) diff --git a/cli/testflinger_cli/__init__.py b/cli/testflinger_cli/__init__.py index 9c4a20c2d..69f3a7ecf 100644 --- a/cli/testflinger_cli/__init__.py +++ b/cli/testflinger_cli/__init__.py @@ -316,6 +316,12 @@ def _add_queue_status_args(self, subparsers): ) parser.set_defaults(func=self.queue_status) parser.add_argument("queue_name") + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show individual jobs with details", + ) parser.add_argument( "--json", action="store_true", help="Print output in JSON format" ) @@ -424,12 +430,30 @@ def agent_status(self): print(output) def queue_status(self): - """Show the status of the agents in a specified queue.""" + """Show agent and job status in a specified queue.""" + # Get agent and job status data + agents_status = self._get_agents_status() + jobs_status = self._get_jobs_status() + + if self.args.json: + output = self._queue_status_format_json_output( + agents_status, jobs_status + ) + else: + output = self._queue_status_format_human_output( + agents_status, jobs_status + ) + + print(output) + + def _get_agents_status(self): + """Retrieve the status of the agents in a specified queue.""" try: try: - queue_status = self.client.get_agent_status_by_queue( + agents_status = self.client.get_agent_status_by_queue( self.args.queue_name ) + return agents_status except client.HTTPError as exc: if exc.status == HTTPStatus.NO_CONTENT: sys.exit( @@ -446,55 +470,109 @@ def queue_status(self): logger.debug("Unable to retrieve agent state: %s", exc) raise UnknownStatusError("queue") from exc - try: - jobs_queued = [ - job["job_id"] - for job in self.client.get_jobs_on_queue( - self.args.queue_name - ) - if job["job_state"] == "waiting" - ] - except client.HTTPError as exc: - if exc.status == HTTPStatus.NO_CONTENT: - jobs_queued = [] - else: - # If any other HTTP error, raise UnknownStatusError - raise UnknownStatusError("job") from exc - except (IOError, ValueError) as exc: - # For other types of network errors or JSONDecodeError - # if we got a bad return - logger.debug("Unable to retrieve job state: %s", exc) - raise UnknownStatusError("job") from exc - except UnknownStatusError as exc: sys.exit(exc) - if self.args.json: - output = json.dumps( - { - "queue": self.args.queue_name, - "jobs waiting": jobs_queued, - "agents": queue_status, - } - ) + def _get_jobs_status(self): + """Retrieve the status of jobs in a specified queue.""" + try: + jobs_data = self.client.get_jobs_on_queue(self.args.queue_name) + except client.HTTPError as exc: + if exc.status == HTTPStatus.NO_CONTENT: + jobs_data = [] + else: + logger.debug("Unable to retrieve job data: %s", exc) + jobs_data = [] + except (IOError, ValueError) as exc: + logger.debug("Unable to retrieve job data: %s", exc) + jobs_data = [] + + # Categorize jobs based on completion outcome + jobs_waiting = [] + jobs_running = [] + jobs_completed = [] + + for job in jobs_data: + # Handle MongoDB date structure or plain string + created_at = job.get("created_at", "") + if isinstance(created_at, dict) and "$date" in created_at: + created_at = created_at["$date"] + + job_info = { + "job_id": job["job_id"], + "created_at": created_at, + } + + job_state = job.get("job_state", "").lower() + if job_state == "waiting": + jobs_waiting.append(job_info) + elif job_state == "complete": + jobs_completed.append(job_info) + elif job_state not in ("cancelled",): # Ignore cancelled jobs + # All non-waiting, non-complete, non-cancelled are "running" + jobs_running.append(job_info) + + return { + "jobs_waiting": jobs_waiting, + "jobs_running": jobs_running, + "jobs_completed": jobs_completed, + } + + def _queue_status_format_json_output(self, agents_status, jobs_status): + """Format queue status output as JSON.""" + output_data = { + "queue": self.args.queue_name, + "agents": agents_status, + } + + if self.args.verbose: + # In verbose mode, include all job details + output_data.update(jobs_status) else: - agents = Counter( - ( - agent["status"] - if agent["status"] in ("waiting", "offline") - else "busy" - ) - for agent in queue_status + # In non-verbose mode, only include waiting jobs + output_data["jobs_waiting"] = jobs_status["jobs_waiting"] + return json.dumps(output_data, indent=2) + + def _queue_status_format_human_output(self, agents_status, jobs_status): + """Format queue status output for human reading.""" + # Get agent status count + agents = Counter( + ( + agent["status"] + if agent["status"] in ("waiting", "offline") + else "busy" ) + for agent in agents_status + ) - output = ( - f"Agents in queue: {agents.total()}\n" - f"Available: {agents['waiting']}\n" - f"Busy: {agents['busy']}\n" - f"Offline: {agents['offline']}\n" - f"Jobs waiting: {len(jobs_queued)}" + output_lines = [ + f"Agents in queue: {agents.total()}", + f"Available: {agents['waiting']}", + f"Busy: {agents['busy']}", + f"Offline: {agents['offline']}", + ] + + if self.args.verbose: + # Add individual job details + for job_type, jobs in jobs_status.items(): + if jobs: # Only show if there are jobs + job_type_display = job_type.replace("_", " ").title() + output_lines.append(f"\n{job_type_display}:") + for job in jobs: + timestamp = helpers.format_timestamp( + job.get("created_at", "") + ) + output_lines.append(f" {job['job_id']} - {timestamp}") + else: + output_lines.extend( + [ + f"Jobs waiting: {len(jobs_status['jobs_waiting'])}", + f"Jobs running: {len(jobs_status['jobs_running'])}", + f"Jobs completed: {len(jobs_status['jobs_completed'])}", + ] ) - print(output) + + return "\n".join(output_lines) def cancel(self, job_id=None): """Tell the server to cancel a specified JOB_ID.""" diff --git a/cli/testflinger_cli/helpers.py b/cli/testflinger_cli/helpers.py index 8bd2b3264..950817448 100644 --- a/cli/testflinger_cli/helpers.py +++ b/cli/testflinger_cli/helpers.py @@ -1,6 +1,7 @@ # Copyright (C) 2025 Canonical Ltd. """Helpers for the Testflinger CLI.""" +from datetime import datetime from os import getenv from pathlib import Path from typing import Optional @@ -163,3 +164,20 @@ def pretty_yaml_dump(obj, **kwargs) -> str: :return: A pretty representation of obj as a YAML string. """ return yaml.dump(obj, **kwargs) + + +def format_timestamp(timestamp_str: str) -> str: + """Format timestamp for human reading. + + :param timestamp_str: ISO format timestamp string + :return: Formatted timestamp string or original if parsing fails + """ + if not timestamp_str: + return "Unknown" + + try: + # Parse ISO format timestamp + dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + return timestamp_str diff --git a/cli/testflinger_cli/tests/test_cli.py b/cli/testflinger_cli/tests/test_cli.py index 71e399205..fe9532ae1 100644 --- a/cli/testflinger_cli/tests/test_cli.py +++ b/cli/testflinger_cli/tests/test_cli.py @@ -727,8 +727,23 @@ def test_queue_status(capsys, requests_mock): {"name": "fake_agent2", "state": "offline", "queues": ["fake"]}, ] - job_id = str(uuid.uuid1()) - fake_job_data = [{"job_id": job_id, "job_state": "waiting"}] + fake_job_data = [ + { + "job_id": str(uuid.uuid1()), + "job_state": "waiting", + "created_at": "2023-10-13T15:22:46Z", + }, + { + "job_id": str(uuid.uuid1()), + "job_state": "running", + "created_at": "2023-10-13T15:22:40Z", + }, + { + "job_id": str(uuid.uuid1()), + "job_state": "complete", + "created_at": "2023-10-13T15:22:30Z", + }, + ] requests_mock.get( URL + "/v1/queues/" + fake_queue + "/agents", json=fake_queue_data @@ -745,6 +760,134 @@ def test_queue_status(capsys, requests_mock): assert "Busy: 1" in std.out assert "Offline: 1" in std.out assert "Jobs waiting: 1" in std.out + assert "Jobs running: 1" in std.out + assert "Jobs completed: 1" in std.out + + +def test_queue_status_verbose(capsys, requests_mock): + """Test verbose queue status shows individual job details.""" + fake_queue = "fake" + fake_queue_data = [ + {"name": "fake_agent1", "state": "provision", "queues": ["fake"]}, + {"name": "fake_agent2", "state": "offline", "queues": ["fake"]}, + ] + + fake_job_data = [ + { + "job_id": "de153d8f-7d32-47d7-9a05-a20f2ef6bb35", + "job_state": "waiting", + "created_at": "2023-10-13T15:22:46Z", + }, + { + "job_id": "ba73620d-6d1a-45ab-bb68-a640e4e4c489", + "job_state": "running", + "created_at": "2023-10-13T15:22:40Z", + }, + { + "job_id": "8b0bb52f-08d8-4671-b275-55d84a965f7c", + "job_state": "complete", + "created_at": "2023-10-13T15:22:30Z", + }, + ] + + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/agents", json=fake_queue_data + ) + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/jobs", json=fake_job_data + ) + sys.argv = ["", "queue-status", "--verbose", fake_queue] + tfcli = testflinger_cli.TestflingerCli() + tfcli.queue_status() + std = capsys.readouterr() + + # Should show agent status + assert "Agents in queue: 2" in std.out + assert "Available: 0" in std.out + assert "Busy: 1" in std.out + assert "Offline: 1" in std.out + + # Should show individual job details (no counts in verbose mode) + assert "Jobs Waiting:" in std.out + assert "de153d8f-7d32-47d7-9a05-a20f2ef6bb35" in std.out + assert "Jobs Running:" in std.out + assert "ba73620d-6d1a-45ab-bb68-a640e4e4c489" in std.out + assert "Jobs Completed:" in std.out + assert "8b0bb52f-08d8-4671-b275-55d84a965f7c" in std.out + + +def test_queue_status_json(capsys, requests_mock): + """Test JSON output for queue status.""" + fake_queue = "fake" + fake_queue_data = [ + {"name": "fake_agent1", "state": "provision", "queues": ["fake"]}, + {"name": "fake_agent2", "state": "offline", "queues": ["fake"]}, + ] + + fake_job_data = [ + { + "job_id": str(uuid.uuid1()), + "job_state": "waiting", + "created_at": "2023-10-13T15:22:46Z", + }, + { + "job_id": str(uuid.uuid1()), + "job_state": "complete", + "created_at": "2023-10-13T15:22:30Z", + }, + ] + + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/agents", json=fake_queue_data + ) + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/jobs", json=fake_job_data + ) + sys.argv = ["", "queue-status", "--json", fake_queue] + tfcli = testflinger_cli.TestflingerCli() + tfcli.queue_status() + std = capsys.readouterr() + + # Parse JSON output (non-verbose should only have jobs_waiting) + output_data = json.loads(std.out) + assert output_data["queue"] == fake_queue + assert len(output_data["agents"]) == 2 + assert len(output_data["jobs_waiting"]) == 1 + # Non-verbose mode should only include jobs_waiting + assert "jobs_completed" not in output_data + assert "jobs_running" not in output_data + + +def test_queue_status_empty_queue(capsys, requests_mock): + """Test queue status with no agents (original behavior).""" + fake_queue = "empty" + + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/agents", + status_code=HTTPStatus.NO_CONTENT, + ) + sys.argv = ["", "queue-status", fake_queue] + tfcli = testflinger_cli.TestflingerCli() + + with pytest.raises(SystemExit) as exc_info: + tfcli.queue_status() + assert "No agent is listening on" in str(exc_info.value) + + +def test_queue_status_nonexistent_queue(requests_mock): + """Test queue status with nonexistent queue (original behavior).""" + fake_queue = "nonexistent" + + requests_mock.get( + URL + "/v1/queues/" + fake_queue + "/agents", + status_code=HTTPStatus.NOT_FOUND, + ) + sys.argv = ["", "queue-status", fake_queue] + tfcli = testflinger_cli.TestflingerCli() + + with pytest.raises(SystemExit) as exc_info: + tfcli.queue_status() + assert "does not exist" in str(exc_info.value) def test_retrieve_regular_user_role(tmp_path, requests_mock): diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index ae77967dc..21068e0fb 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -41,13 +41,14 @@ Once the installation is finished, you can execute the ``testflinger-cli`` comma ... positional arguments: - {artifacts,cancel,config,jobs,list-queues,poll,reserve,results,show,status,submit} + {artifacts,cancel,config,jobs,list-queues,poll,queue-status,reserve,results,show,status,submit} artifacts Download a tarball of artifacts saved for a specified job cancel Tell the server to cancel a specified JOB_ID config Get or set configuration options jobs List the previously started test jobs list-queues List the advertised queues on the Testflinger server poll Poll for output from a job until it is completed + queue-status Show the status of agents and jobs in a specified queue reserve Install and reserve a system results Get results JSON for a completed JOB_ID show Show the requested job JSON for a specified JOB_ID