Skip to content
Merged
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
166 changes: 122 additions & 44 deletions cli/testflinger_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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(
Expand All @@ -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."""
Expand Down
18 changes: 18 additions & 0 deletions cli/testflinger_cli/helpers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
147 changes: 145 additions & 2 deletions cli/testflinger_cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading