diff --git a/cli/testflinger_cli/__init__.py b/cli/testflinger_cli/__init__.py index d84084904..a5323cdf5 100644 --- a/cli/testflinger_cli/__init__.py +++ b/cli/testflinger_cli/__init__.py @@ -350,6 +350,9 @@ def _add_agent_status_args(self, subparsers): parser.add_argument( "--json", action="store_true", help="Print output in JSON format" ) + parser.add_argument( + "--yaml", action="store_true", help="Print output in YAML format" + ) def _add_queue_status_args(self, subparsers): """Command line arguments for queue status.""" @@ -462,7 +465,10 @@ def agent_status(self): """Show the status of a specified agent.""" try: try: - agent_status = self.client.get_agent_data(self.args.agent_name) + agent_status = self.client.get_agent_data( + self.args.agent_name, + expanded_queue_info=(self.args.json or self.args.yaml), + ) except client.HTTPError as exc: if exc.status == HTTPStatus.NOT_FOUND: sys.exit(f"Agent '{self.args.agent_name}' does not exist.") @@ -477,13 +483,16 @@ def agent_status(self): except UnknownStatusError as exc: sys.exit(exc) - if self.args.json: + if self.args.json or self.args.yaml: # For unclear historical reasons, # the "name" and "state" fields were renamed, # so we maintain that for compatibility agent_status["agent"] = agent_status.pop("name") agent_status["status"] = agent_status.pop("state") - output = json.dumps(agent_status, sort_keys=True) + if self.args.json: + output = json.dumps(agent_status, sort_keys=True) + else: # yaml + output = helpers.pretty_yaml_dump(agent_status) else: output = agent_status["state"] print(output) @@ -492,7 +501,7 @@ def queue_status(self): """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() + jobs_status = self.client.get_jobs_status(self.args.queue_name) if self.args.json: output = self._queue_status_format_json_output( @@ -533,51 +542,6 @@ def _get_agents_status(self): except UnknownStatusError as exc: sys.exit(exc) - 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 = { @@ -942,9 +906,7 @@ def show(self): ) sys.exit(1) if self.args.yaml: - to_print = helpers.pretty_yaml_dump( - results, sort_keys=True, indent=4, default_flow_style=False - ) + to_print = helpers.pretty_yaml_dump(results) else: to_print = json.dumps(results, sort_keys=True, indent=4) print(to_print) @@ -1135,14 +1097,14 @@ def do_poll( prev_queue_pos = queue_pos if queue_pos == 0: print( - "This job will be picked up after the " - "current job is complete (it is next in line)" + "This job will be picked up after the current " + "(1) job ahead of it is complete" ) else: print( f"This job will be picked up after the " - f"current job and {queue_pos} job(s) ahead " - f"of it in the queue are complete" + f"{queue_pos + 1} jobs ahead of it in the " + f"queue are complete" ) time.sleep(10) except (IOError, client.HTTPError): diff --git a/cli/testflinger_cli/client.py b/cli/testflinger_cli/client.py index 3343d4959..69af6d6bf 100644 --- a/cli/testflinger_cli/client.py +++ b/cli/testflinger_cli/client.py @@ -245,14 +245,82 @@ def get_status(self, job_id: str) -> dict: job_status["job_state"] = data.get("job_state") return job_status - def get_agent_data(self, agent_name: str) -> dict: + def get_agent_data( + self, agent_name: str, expanded_queue_info: bool = False + ) -> dict: """Get all the data for a specified agent. :param agent_name: Name of the agent to retrieve its data + :param expanded_queue_info: If True, expand queue info with job counts + (default: False) :return: Dict containing all the data from the agent. """ endpoint = f"/v1/agents/data/{agent_name}" - return json.loads(self.get(endpoint)) + data = json.loads(self.get(endpoint)) + if expanded_queue_info: + # TODO: If get_jobs_on_queue could accept a filter to identify + # jobs which are not finished running vs those that are we could + # more readily get the information we need by filtering on the + # server instead of here. + queues = data.pop("queues") + data["queues"] = {} + for queue in queues: + jobs = self.get_jobs_status(queue) + job_count = len(jobs["jobs_waiting"]) + len( + jobs["jobs_running"] + ) + data["queues"][queue] = {"num_jobs": job_count} + return data + + def get_jobs_status(self, queue: str) -> dict: + """Retrieve the status of jobs in a specified queue. + + :param queue: Name of the queue to retrieve job status from + :return: Dict containing categorized jobs with keys: + 'jobs_waiting', 'jobs_running', 'jobs_completed' + """ + try: + jobs_data = self.get_jobs_on_queue(queue) + except 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 get_agent_status_by_queue(self, queue: str) -> list[dict]: """Get the status of the agents by a specified queue. @@ -411,7 +479,7 @@ def get_logs( return json.loads(self.get(complete_url_frag)) def get_job_position(self, job_id): - """Get the status of a test job. + """Get the position of a test job. :param job_id: ID for the test job diff --git a/cli/testflinger_cli/helpers.py b/cli/testflinger_cli/helpers.py index 950817448..8377d3ed2 100644 --- a/cli/testflinger_cli/helpers.py +++ b/cli/testflinger_cli/helpers.py @@ -157,13 +157,33 @@ def multiline_str_representer(dumper, data): yaml.add_representer(str, multiline_str_representer) -def pretty_yaml_dump(obj, **kwargs) -> str: +def pretty_yaml_dump( + obj: object, + sort_keys: bool = True, + indent: int = 2, + **kwargs, +) -> str: """Create a pretty YAML representation of obj. :param obj: The object to be represented. + :param sort_keys: Whether to sort keys in the output (default: True). + :param indent: Number of spaces to use for indentation (default: 2). + :param kwargs: Additional keyword arguments to pass to yaml.dump(). :return: A pretty representation of obj as a YAML string. """ - return yaml.dump(obj, **kwargs) + + class YamlDumper(yaml.Dumper): + def increase_indent(self, flow=False, indentless=False): + return super(YamlDumper, self).increase_indent(flow, False) + + return yaml.dump( + obj, + Dumper=YamlDumper, + default_flow_style=False, + sort_keys=sort_keys, + indent=indent, + **kwargs, + ) def format_timestamp(timestamp_str: str) -> str: diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index dfc96276e..9f01ff7fb 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -888,7 +888,8 @@ def test_poll_with_phase_filter(requests_mock): } } requests_mock.get( - URL + f"/v1/result/{job_id}/log/output?start_fragment=0&phase=provision", + URL + + f"/v1/result/{job_id}/log/output?start_fragment=0&phase=provision", json=mock_response, ) @@ -947,7 +948,9 @@ def test_agent_status_json(capsys, requests_mock): "provision_streak_count": 1, "provision_streak_type": "pass", } + fake_job_data = [] requests_mock.get(URL + "/v1/agents/data/" + fake_agent, json=fake_return) + requests_mock.get(URL + "/v1/queues/fake/jobs", json=fake_job_data) sys.argv = ["", "agent-status", fake_agent, "--json"] tfcli = testflinger_cli.TestflingerCli() tfcli.agent_status() @@ -955,7 +958,7 @@ def test_agent_status_json(capsys, requests_mock): expected_out = { "agent": "fake_agent", "status": "waiting", - "queues": ["fake"], + "queues": {"fake": {"num_jobs": 0}}, "provision_streak_count": 1, "provision_streak_type": "pass", } @@ -1279,6 +1282,7 @@ def combined_log_wrapper_2( # Should have slept between iterations assert len(sleep_calls) >= 2 + def test_live_polling_with_empty_poll(capsys, requests_mock, monkeypatch): """Test that live output handles empty polls correctly.""" job_id = str(uuid.uuid1()) diff --git a/cli/tests/test_helpers.py b/cli/tests/test_helpers.py index c17f951f7..a07a47cdd 100644 --- a/cli/tests/test_helpers.py +++ b/cli/tests/test_helpers.py @@ -67,7 +67,4 @@ def test_pretty_yaml_dump(): other """ ) - assert ( - pretty_yaml_dump(multiline, indent=4, default_flow_style=False).strip() - == result.strip() - ) + assert pretty_yaml_dump(multiline, indent=4).strip() == result.strip() diff --git a/docs/.custom_wordlist.txt b/docs/.custom_wordlist.txt index 2c9e1f36e..dd1d2b771 100644 --- a/docs/.custom_wordlist.txt +++ b/docs/.custom_wordlist.txt @@ -1,14 +1,21 @@ autoinstall dragonboard +fragment_number Lenovo +log_data logfile muxpi netboot oemrecovery +passwordless +preconfigured preloaded -SDWire -Tegra Queryable +rclone +SDWire stderr -fragment_number -log_data +supervisord +systemd +Tegra +url +webdav diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 6185defcb..a25c33121 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -16,6 +16,7 @@ Work with jobs via Testflinger CLI cancel-job reserve-job search-job + view-agent-status job-priority authentication manage-client-permissions diff --git a/docs/how-to/view-agent-status.rst b/docs/how-to/view-agent-status.rst new file mode 100644 index 000000000..171f01422 --- /dev/null +++ b/docs/how-to/view-agent-status.rst @@ -0,0 +1,24 @@ +View agent status +================= + +You can view the status of an agent using the agent-status command. This command +displays the current state of an agent, including its status and the queues it +listens to. + +.. code-block:: shell + + testflinger-cli agent-status + +By default, this command returns a brief status output. You can also request +structured output in JSON or YAML format using the ``--json`` or ``--yaml`` flags: + +.. code-block:: shell + + testflinger-cli agent-status --json + +.. code-block:: shell + + testflinger-cli agent-status --yaml + +The structured output includes additional information such as the number of jobs +waiting and running in each queue the agent listens to.