Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 18 additions & 56 deletions cli/testflinger_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.")
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
74 changes: 71 additions & 3 deletions cli/testflinger_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions cli/testflinger_cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -947,15 +948,17 @@ 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()
std = capsys.readouterr()
expected_out = {
"agent": "fake_agent",
"status": "waiting",
"queues": ["fake"],
"queues": {"fake": {"num_jobs": 0}},
"provision_streak_count": 1,
"provision_streak_type": "pass",
}
Expand Down Expand Up @@ -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())
Expand Down
5 changes: 1 addition & 4 deletions cli/tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
15 changes: 11 additions & 4 deletions docs/.custom_wordlist.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/how-to/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/how-to/view-agent-status.rst
Original file line number Diff line number Diff line change
@@ -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 <agent_name>

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 <agent_name> --json

.. code-block:: shell

testflinger-cli agent-status <agent_name> --yaml

The structured output includes additional information such as the number of jobs
waiting and running in each queue the agent listens to.