tft-cli 0.0.26__py3-none-any.whl → 0.0.28__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tft/cli/command/__init__.py +2 -0
- tft/cli/command/listing.py +836 -0
- tft/cli/commands.py +134 -57
- tft/cli/config.py +10 -1
- tft/cli/tool.py +2 -0
- tft/cli/utils.py +149 -5
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/METADATA +2 -1
- tft_cli-0.0.28.dist-info/RECORD +12 -0
- tft_cli-0.0.26.dist-info/RECORD +0 -10
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# Copyright Contributors to the Testing Farm project.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import pendulum
|
|
14
|
+
import requests
|
|
15
|
+
import typer
|
|
16
|
+
from click.core import ParameterSource
|
|
17
|
+
from rich.progress import Progress, SpinnerColumn
|
|
18
|
+
from rich.syntax import Syntax
|
|
19
|
+
from rich.table import Table # type: ignore
|
|
20
|
+
from ruamel.yaml import YAML # type: ignore
|
|
21
|
+
|
|
22
|
+
from tft.cli.commands import (
|
|
23
|
+
ARGUMENT_API_TOKEN,
|
|
24
|
+
ARGUMENT_API_URL,
|
|
25
|
+
ARGUMENT_INTERNAL_API_URL,
|
|
26
|
+
PipelineState,
|
|
27
|
+
)
|
|
28
|
+
from tft.cli.config import settings
|
|
29
|
+
from tft.cli.utils import (
|
|
30
|
+
Age,
|
|
31
|
+
OutputFormat,
|
|
32
|
+
authorization_headers,
|
|
33
|
+
check_unexpected_arguments,
|
|
34
|
+
console,
|
|
35
|
+
exit_error,
|
|
36
|
+
extract_uuid,
|
|
37
|
+
install_http_retries,
|
|
38
|
+
uuid_valid,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Maximum lenght of compose which is still shown in table listing
|
|
42
|
+
MAX_COMPOSE_LENGTH = 30
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Ranch(str, Enum):
|
|
46
|
+
public = "public"
|
|
47
|
+
redhat = "redhat"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_artifacts_url(request):
|
|
51
|
+
"""
|
|
52
|
+
Extract artifacts URL from request.
|
|
53
|
+
"""
|
|
54
|
+
return (request.get('run', {}) or {}).get('artifacts', '<unavailable>')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_ranch(artifacts_url):
|
|
58
|
+
"""
|
|
59
|
+
Deduce ranch according to artifacts url.
|
|
60
|
+
"""
|
|
61
|
+
if 'unavailable' in artifacts_url:
|
|
62
|
+
return '<unknown>'
|
|
63
|
+
|
|
64
|
+
if 'redhat.com' in artifacts_url:
|
|
65
|
+
return 'redhat'
|
|
66
|
+
|
|
67
|
+
if 'testing-farm.io' in artifacts_url:
|
|
68
|
+
return 'public'
|
|
69
|
+
|
|
70
|
+
return '<unrecognized ranch>'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_ranch_colored(artifacts_url):
|
|
74
|
+
"""
|
|
75
|
+
Deduce ranch according to artifacts url and return with color formatting.
|
|
76
|
+
"""
|
|
77
|
+
ranch = get_ranch(artifacts_url)
|
|
78
|
+
|
|
79
|
+
if ranch == 'redhat':
|
|
80
|
+
return '[red]redhat[/red]'
|
|
81
|
+
elif ranch == 'public':
|
|
82
|
+
return '[blue]public[/blue]'
|
|
83
|
+
else:
|
|
84
|
+
return ranch
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def calculate_started_time(request):
|
|
88
|
+
"""
|
|
89
|
+
Calculate started time as: created + queued_time
|
|
90
|
+
Returns None if calculation cannot be performed (missing data)
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
created_str = request.get('created')
|
|
94
|
+
queued_time = request.get('queued_time')
|
|
95
|
+
|
|
96
|
+
if not created_str or queued_time is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Parse created datetime
|
|
100
|
+
created_dt = pendulum.parse(created_str, tz="UTC")
|
|
101
|
+
|
|
102
|
+
# Add queued_time (in seconds)
|
|
103
|
+
started_dt = created_dt.add(seconds=float(queued_time))
|
|
104
|
+
|
|
105
|
+
return started_dt
|
|
106
|
+
|
|
107
|
+
except (ValueError, TypeError, AttributeError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def calculate_finished_time(request):
|
|
112
|
+
"""
|
|
113
|
+
Calculate finished time as: created + queued_time + run_time
|
|
114
|
+
Returns None if calculation cannot be performed (missing data or request not finished)
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
# Only calculate for completed requests
|
|
118
|
+
if request.get('state') not in ['complete', 'error', 'canceled']:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
created_str = request.get('created')
|
|
122
|
+
queued_time = request.get('queued_time')
|
|
123
|
+
run_time = request.get('run_time')
|
|
124
|
+
|
|
125
|
+
if not created_str or queued_time is None or run_time is None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Parse created datetime
|
|
129
|
+
created_dt = pendulum.parse(created_str, tz="UTC")
|
|
130
|
+
|
|
131
|
+
# Add queued_time and run_time (both in seconds)
|
|
132
|
+
total_seconds = float(queued_time) + float(run_time)
|
|
133
|
+
finished_dt = created_dt.add(seconds=total_seconds)
|
|
134
|
+
|
|
135
|
+
return finished_dt
|
|
136
|
+
|
|
137
|
+
except (ValueError, TypeError, AttributeError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_reservation_table(requests_json: Any, show_utc: bool) -> None:
|
|
142
|
+
"""Show list of reservation requests as a special table."""
|
|
143
|
+
table = Table(show_header=True, header_style="bold magenta", expand=True)
|
|
144
|
+
|
|
145
|
+
for column in ["state", "id", "ranch", "env", "user@ip", "started"]:
|
|
146
|
+
table.add_column(column, justify="left" if column in ["env", "user@ip", "id"] else "center")
|
|
147
|
+
|
|
148
|
+
def extract_guest_ip(request):
|
|
149
|
+
"""Extract guest IP from pipeline log."""
|
|
150
|
+
artifacts_url = get_artifacts_url(request)
|
|
151
|
+
if artifacts_url == '<unavailable>':
|
|
152
|
+
return request, "<not-yet-available>"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
import requests
|
|
156
|
+
|
|
157
|
+
session = requests.Session()
|
|
158
|
+
pipeline_log = session.get(f"{artifacts_url}/pipeline.log").text
|
|
159
|
+
guests = re.findall(r'Guest is ready.*root@([\d\w\.-]+)', pipeline_log)
|
|
160
|
+
return request, f"root@{guests[0]}" if guests else "<not-yet-available>"
|
|
161
|
+
except: # noqa: E722
|
|
162
|
+
return request, "<not-yet-available>"
|
|
163
|
+
|
|
164
|
+
# Filter only active reservation requests (new, queued, running)
|
|
165
|
+
reservation_requests = []
|
|
166
|
+
for request in requests_json:
|
|
167
|
+
# Only include active states
|
|
168
|
+
if request.get('state') not in ['new', 'queued', 'running']:
|
|
169
|
+
continue
|
|
170
|
+
# Check if this is a reservation request by looking for reservation indicators
|
|
171
|
+
environments = request.get('environments_requested', [])
|
|
172
|
+
for env in environments:
|
|
173
|
+
variables = env.get('variables') or {}
|
|
174
|
+
if 'TF_RESERVATION_DURATION' in variables:
|
|
175
|
+
reservation_requests.append(request)
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if not reservation_requests:
|
|
179
|
+
console.print("No active reservations found")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Sort reservations
|
|
183
|
+
sorted_requests = sorted(reservation_requests, key=lambda request: request['created'], reverse=True)
|
|
184
|
+
|
|
185
|
+
# Extract IPs in parallel
|
|
186
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
187
|
+
ip_results = list(executor.map(extract_guest_ip, sorted_requests))
|
|
188
|
+
|
|
189
|
+
# Create IP lookup map
|
|
190
|
+
ip_map = {req['id']: ip for req, ip in ip_results}
|
|
191
|
+
|
|
192
|
+
for request in sorted_requests:
|
|
193
|
+
artifacts_url = get_artifacts_url(request)
|
|
194
|
+
ranch = get_ranch_colored(artifacts_url)
|
|
195
|
+
|
|
196
|
+
# Get environment info
|
|
197
|
+
envs = []
|
|
198
|
+
for environment in request['environments_requested']:
|
|
199
|
+
arch = environment['arch']
|
|
200
|
+
os_info = environment.get('os') or {}
|
|
201
|
+
os_compose = os_info.get('compose')
|
|
202
|
+
if os_compose:
|
|
203
|
+
if len(os_compose) > MAX_COMPOSE_LENGTH:
|
|
204
|
+
envs.append(f"{arch:>7} (<too-long>)")
|
|
205
|
+
else:
|
|
206
|
+
envs.append(f"{arch:>7} ({os_compose})")
|
|
207
|
+
else:
|
|
208
|
+
envs.append(f"{arch:>7} (container)")
|
|
209
|
+
envs = list(dict.fromkeys(envs))
|
|
210
|
+
|
|
211
|
+
# Get time info
|
|
212
|
+
parsed_time = pendulum.parse(request['created'], tz="UTC")
|
|
213
|
+
if show_utc:
|
|
214
|
+
localized_time = parsed_time
|
|
215
|
+
else:
|
|
216
|
+
localized_time = parsed_time.in_timezone(pendulum.local_timezone()) # type: ignore
|
|
217
|
+
time_display = localized_time.to_datetime_string()
|
|
218
|
+
if show_utc:
|
|
219
|
+
time_display += " UTC"
|
|
220
|
+
|
|
221
|
+
# Get guest IP from pre-computed map
|
|
222
|
+
user_ip = ip_map.get(request['id'], "<not-yet-available>")
|
|
223
|
+
|
|
224
|
+
row = [
|
|
225
|
+
request.get('state', 'unknown'),
|
|
226
|
+
request['id'], # Show full request ID
|
|
227
|
+
ranch,
|
|
228
|
+
"\n".join(envs),
|
|
229
|
+
user_ip,
|
|
230
|
+
time_display,
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
table.add_row(*row)
|
|
234
|
+
|
|
235
|
+
if sys.stdin.isatty():
|
|
236
|
+
console.print(table)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
print(table)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def render_table(
|
|
243
|
+
requests_json: Any, show_token_id: bool, show_time: bool, show_utc: bool, ranch: Optional[str]
|
|
244
|
+
) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Show list of requests as a table.
|
|
247
|
+
"""
|
|
248
|
+
table = Table(show_header=True, header_style="bold magenta", expand=True)
|
|
249
|
+
|
|
250
|
+
for column in ["artifacts", "state", "ranch", "type", "env", "git", "created", "started", "finished"]:
|
|
251
|
+
table.add_column(column, justify="left" if column in ["env", "git"] else "center")
|
|
252
|
+
|
|
253
|
+
if show_token_id:
|
|
254
|
+
table.add_column("token id")
|
|
255
|
+
|
|
256
|
+
def shorten_git_url(url: str) -> Tuple[str, ...]:
|
|
257
|
+
orig_url = url
|
|
258
|
+
|
|
259
|
+
url = url.replace("https://github.com/", "[green] github[/green] ")
|
|
260
|
+
url = url.replace("https://gitlab.com/", "[orange_red1] gitlab[/orange_red1] ")
|
|
261
|
+
url = url.replace("https://*****@gitlab.com/redhat/", "[dark_orange3] gitlab-rh[/dark_orange3] ")
|
|
262
|
+
url = url.replace("https://*****@gitlab.com/", "[orange_red1] gitlab[/orange_red1] ")
|
|
263
|
+
url = url.replace("https://gitlab.cee.redhat.com/", "[dark_orange] gitlab-cee[/dark_orange] ")
|
|
264
|
+
url = url.replace("https://*****@gitlab.cee.redhat.com/", "[dark_orange] gitlab-cee[/dark_orange] ")
|
|
265
|
+
url = url.replace("https://pkgs.devel.redhat.com/", "[red3] rhel[/red3] ")
|
|
266
|
+
url = url.replace("https://src.fedoraproject.org/", "[bright_blue] fedora[/bright_blue] ")
|
|
267
|
+
|
|
268
|
+
if url == orig_url:
|
|
269
|
+
return "", orig_url
|
|
270
|
+
|
|
271
|
+
return tuple(url.rsplit(maxsplit=1))
|
|
272
|
+
|
|
273
|
+
def get_state_icon(request):
|
|
274
|
+
"""
|
|
275
|
+
Transforms the state and result into a single state of the request
|
|
276
|
+
"""
|
|
277
|
+
if request["state"] == "new":
|
|
278
|
+
return "🆕"
|
|
279
|
+
if request["state"] == "queued":
|
|
280
|
+
return "⌛️"
|
|
281
|
+
if request["state"] == "running":
|
|
282
|
+
return "🚀"
|
|
283
|
+
if request["state"] in ("canceled", "cancel-requested"):
|
|
284
|
+
return "🚫"
|
|
285
|
+
if request["state"] == "error":
|
|
286
|
+
return "🔥"
|
|
287
|
+
if request["state"] != "complete":
|
|
288
|
+
exit_error("Invalid state {state}")
|
|
289
|
+
if request["result"]["overall"] == "passed":
|
|
290
|
+
return "✅"
|
|
291
|
+
if request["result"]["overall"] == "failed":
|
|
292
|
+
return "❌"
|
|
293
|
+
if request["result"]["overall"] == "error":
|
|
294
|
+
return "⛔️"
|
|
295
|
+
if request["result"]["overall"] == "skipped":
|
|
296
|
+
return "⤼"
|
|
297
|
+
return "<unknown>"
|
|
298
|
+
|
|
299
|
+
for request in sorted(requests_json, key=lambda request: request['created'], reverse=True):
|
|
300
|
+
request_type = "fmf" if request["test"].get("fmf") else "sti"
|
|
301
|
+
request_type_human = "[blue]tmt[/blue]" if request_type == "fmf" else "[yellow]sti[/yellow]"
|
|
302
|
+
url = request['test'][request_type].get('url')
|
|
303
|
+
ref = request['test'][request_type].get('ref')
|
|
304
|
+
artifacts_url = get_artifacts_url(request)
|
|
305
|
+
short_ref = ref[:8] if len(ref) == 40 else ref
|
|
306
|
+
envs = []
|
|
307
|
+
for environment in request['environments_requested']:
|
|
308
|
+
arch = environment['arch']
|
|
309
|
+
os_info = environment.get('os') or {}
|
|
310
|
+
os_compose = os_info.get('compose')
|
|
311
|
+
if os_compose:
|
|
312
|
+
# Check if compose contains disk_image or boot_image and display <hidden-flasher-image> instead
|
|
313
|
+
if len(os_compose) > 20:
|
|
314
|
+
envs.append(f"{arch:>7} (<too-long>)")
|
|
315
|
+
else:
|
|
316
|
+
envs.append(f"{arch:>7} ({os_compose})")
|
|
317
|
+
else:
|
|
318
|
+
envs.append(f"{arch:>7} (container)")
|
|
319
|
+
envs = list(dict.fromkeys(envs)) # Remove duplicates while preserving order
|
|
320
|
+
|
|
321
|
+
git_type, git_url = shorten_git_url(url)
|
|
322
|
+
|
|
323
|
+
# Calculate all three times: created, started, finished
|
|
324
|
+
created_dt = pendulum.parse(request['created'], tz="UTC")
|
|
325
|
+
started_dt = calculate_started_time(request)
|
|
326
|
+
finished_dt = calculate_finished_time(request)
|
|
327
|
+
|
|
328
|
+
def format_time_display(dt):
|
|
329
|
+
if dt is None:
|
|
330
|
+
return "N/A"
|
|
331
|
+
localized_time = dt if show_utc else dt.in_timezone(pendulum.local_timezone())
|
|
332
|
+
if show_time:
|
|
333
|
+
display = localized_time.to_datetime_string()
|
|
334
|
+
if show_utc:
|
|
335
|
+
display += " UTC"
|
|
336
|
+
return display
|
|
337
|
+
else:
|
|
338
|
+
return dt.diff_for_humans()
|
|
339
|
+
|
|
340
|
+
created_display = format_time_display(created_dt)
|
|
341
|
+
started_display = format_time_display(started_dt)
|
|
342
|
+
finished_display = format_time_display(finished_dt)
|
|
343
|
+
|
|
344
|
+
row = [
|
|
345
|
+
f"[link={artifacts_url}]{request['id']}[/link]" if artifacts_url != '<unavailable>' else '<unavailable>',
|
|
346
|
+
get_state_icon(request),
|
|
347
|
+
get_ranch_colored(artifacts_url),
|
|
348
|
+
f"[yellow]{request_type_human}[/yellow]",
|
|
349
|
+
"\n".join(envs),
|
|
350
|
+
f"{git_type} [link={url}]{git_url}[/link] [green]({short_ref})[/green]",
|
|
351
|
+
created_display,
|
|
352
|
+
started_display,
|
|
353
|
+
finished_display,
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
if show_token_id:
|
|
357
|
+
row.append(request.get('token_id', 'N/A'))
|
|
358
|
+
|
|
359
|
+
table.add_row(*row)
|
|
360
|
+
|
|
361
|
+
if sys.stdin.isatty():
|
|
362
|
+
console.print(table)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
print(table)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_datetime_str(dt_str, show_utc=False):
|
|
369
|
+
"""Formats an ISO datetime string to a more readable format."""
|
|
370
|
+
|
|
371
|
+
if not dt_str or dt_str == 'N/A':
|
|
372
|
+
return "N/A"
|
|
373
|
+
try:
|
|
374
|
+
# Parse with pendulum, handling UTC properly
|
|
375
|
+
dt_obj = pendulum.parse(dt_str, tz="UTC")
|
|
376
|
+
|
|
377
|
+
if show_utc:
|
|
378
|
+
# Show in UTC
|
|
379
|
+
return dt_obj.format("YYYY-MM-DD [at] HH:mm:ss") + " UTC"
|
|
380
|
+
else:
|
|
381
|
+
# Show in local timezone by default
|
|
382
|
+
local_dt = dt_obj.in_timezone(pendulum.local_timezone())
|
|
383
|
+
tz_name = local_dt.timezone_name
|
|
384
|
+
return local_dt.format("YYYY-MM-DD [at] HH:mm:ss") + f" {tz_name}"
|
|
385
|
+
except (ValueError, TypeError):
|
|
386
|
+
# if parsing fails, return the original string
|
|
387
|
+
return dt_str
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _format_time(seconds):
|
|
391
|
+
"""Converts seconds to a human-readable format."""
|
|
392
|
+
|
|
393
|
+
if seconds is None:
|
|
394
|
+
return "N/A"
|
|
395
|
+
try:
|
|
396
|
+
seconds = float(seconds)
|
|
397
|
+
minutes, seconds = divmod(seconds, 60)
|
|
398
|
+
return f"{int(minutes)}m {seconds:.2f}s"
|
|
399
|
+
except (ValueError, TypeError):
|
|
400
|
+
return "N/A"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _has_meaningful_content(data: dict[str, Any]) -> bool:
|
|
404
|
+
"""Check if a dictionary has any meaningful content (non-None, non-empty values)."""
|
|
405
|
+
for value in data.values():
|
|
406
|
+
if value is None:
|
|
407
|
+
continue
|
|
408
|
+
if isinstance(value, (list, dict)) and len(value) == 0:
|
|
409
|
+
continue
|
|
410
|
+
if isinstance(value, dict):
|
|
411
|
+
if _has_meaningful_content(value):
|
|
412
|
+
return True
|
|
413
|
+
else:
|
|
414
|
+
return True
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _print_nested_dict(table: Any, data: dict[str, Any], indent_level: int = 0):
|
|
419
|
+
"""Recursively prints a nested dictionary, skipping None values and empty collections."""
|
|
420
|
+
|
|
421
|
+
prefix = " " * indent_level
|
|
422
|
+
for key, value in data.items():
|
|
423
|
+
if value is None:
|
|
424
|
+
continue
|
|
425
|
+
# Skip empty collections (lists, dicts)
|
|
426
|
+
if isinstance(value, (list, dict)) and len(value) == 0:
|
|
427
|
+
continue
|
|
428
|
+
if isinstance(value, dict):
|
|
429
|
+
table.add_row(f"{prefix}[bold]{key}[/bold]", "")
|
|
430
|
+
_print_nested_dict(table, value, indent_level + 1)
|
|
431
|
+
else:
|
|
432
|
+
table.add_row(f"{prefix}{key}", str(value))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def render_text(requests_json: Any, brief: bool, show_utc: bool = False, show_token_id: bool = False) -> None:
|
|
436
|
+
"""Show list of requests as a text."""
|
|
437
|
+
|
|
438
|
+
header_style = "bold magenta"
|
|
439
|
+
|
|
440
|
+
# enumerate and print request metadata
|
|
441
|
+
for i, request_item in enumerate(requests_json):
|
|
442
|
+
if not request_item:
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
446
|
+
table.add_column()
|
|
447
|
+
table.add_column()
|
|
448
|
+
|
|
449
|
+
# Get artifacts URL and ranch
|
|
450
|
+
artifacts_url = get_artifacts_url(request_item)
|
|
451
|
+
ranch = get_ranch_colored(artifacts_url)
|
|
452
|
+
|
|
453
|
+
table.add_row(f"[{header_style}]Artifacts[/{header_style}]", f"[link={artifacts_url}]{artifacts_url}[/link]")
|
|
454
|
+
table.add_row(f"[{header_style}]Ranch[/{header_style}]", ranch)
|
|
455
|
+
table.add_row(f"[{header_style}]State[/{header_style}]", request_item.get('state'))
|
|
456
|
+
|
|
457
|
+
if show_token_id:
|
|
458
|
+
table.add_row(f"[{header_style}]Token ID[/{header_style}]", request_item.get('token_id', 'N/A'))
|
|
459
|
+
result = request_item.get('result', {})
|
|
460
|
+
if result:
|
|
461
|
+
table.add_row(f"[{header_style}]Result[/{header_style}]", result.get('overall'))
|
|
462
|
+
if result.get('summary'):
|
|
463
|
+
table.add_row(f"[{header_style}]Summary[/{header_style}]", result.get('summary'))
|
|
464
|
+
|
|
465
|
+
table.add_row(
|
|
466
|
+
f"[{header_style}]Created[/{header_style}]",
|
|
467
|
+
_format_datetime_str(request_item.get('created'), show_utc=show_utc),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Add started time if available
|
|
471
|
+
started_dt = calculate_started_time(request_item)
|
|
472
|
+
if started_dt:
|
|
473
|
+
started_localized = started_dt if show_utc else started_dt.in_timezone(pendulum.local_timezone())
|
|
474
|
+
started_display = started_localized.format("YYYY-MM-DD [at] HH:mm:ss")
|
|
475
|
+
if show_utc:
|
|
476
|
+
started_display += " UTC"
|
|
477
|
+
else:
|
|
478
|
+
started_display += f" {started_localized.timezone_name}"
|
|
479
|
+
table.add_row(f"[{header_style}]Started[/{header_style}]", started_display)
|
|
480
|
+
|
|
481
|
+
# Add finished time if available
|
|
482
|
+
finished_dt = calculate_finished_time(request_item)
|
|
483
|
+
if finished_dt:
|
|
484
|
+
finished_localized = finished_dt if show_utc else finished_dt.in_timezone(pendulum.local_timezone())
|
|
485
|
+
finished_display = finished_localized.format("YYYY-MM-DD [at] HH:mm:ss")
|
|
486
|
+
if show_utc:
|
|
487
|
+
finished_display += " UTC"
|
|
488
|
+
else:
|
|
489
|
+
finished_display += f" {finished_localized.timezone_name}"
|
|
490
|
+
table.add_row(f"[{header_style}]Finished[/{header_style}]", finished_display)
|
|
491
|
+
|
|
492
|
+
if not brief:
|
|
493
|
+
table.add_row(
|
|
494
|
+
f"[{header_style}]Queued Time[/{header_style}]", _format_time(request_item.get('queued_time'))
|
|
495
|
+
)
|
|
496
|
+
table.add_row(f"[{header_style}]Run Time[/{header_style}]", _format_time(request_item.get('run_time')))
|
|
497
|
+
|
|
498
|
+
if 'test' in request_item and request_item['test']:
|
|
499
|
+
table.add_row(f"[{header_style}]Test[/{header_style}]", "")
|
|
500
|
+
_print_nested_dict(table, request_item['test'], 1)
|
|
501
|
+
|
|
502
|
+
if 'environments_requested' in request_item and request_item['environments_requested']:
|
|
503
|
+
table.add_row(f"[{header_style}]Environments[/{header_style}]", "")
|
|
504
|
+
for i, env in enumerate(request_item['environments_requested']):
|
|
505
|
+
table.add_row(f" [bold]Environment {i+1}[/bold]", "")
|
|
506
|
+
_print_nested_dict(table, env, 2)
|
|
507
|
+
|
|
508
|
+
if (
|
|
509
|
+
'settings' in request_item
|
|
510
|
+
and request_item.get('settings')
|
|
511
|
+
and _has_meaningful_content(request_item['settings'])
|
|
512
|
+
):
|
|
513
|
+
table.add_row(f"[{header_style}]Settings[/{header_style}]", "")
|
|
514
|
+
_print_nested_dict(table, request_item['settings'], 1)
|
|
515
|
+
|
|
516
|
+
if 'user' in request_item and request_item.get('user') and _has_meaningful_content(request_item['user']):
|
|
517
|
+
table.add_row(f"[{header_style}]User[/{header_style}]", "")
|
|
518
|
+
_print_nested_dict(table, request_item['user'], 1)
|
|
519
|
+
|
|
520
|
+
console.print(table)
|
|
521
|
+
|
|
522
|
+
# visual boundary between test requests
|
|
523
|
+
if i < len(requests_json) - 1:
|
|
524
|
+
console.print("─" * 15)
|
|
525
|
+
else:
|
|
526
|
+
console.print()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def listing(
|
|
530
|
+
context: typer.Context,
|
|
531
|
+
api_token: str = ARGUMENT_API_TOKEN,
|
|
532
|
+
api_url: str = ARGUMENT_API_URL,
|
|
533
|
+
internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
|
|
534
|
+
states: List[PipelineState] = typer.Option(
|
|
535
|
+
[state for state in PipelineState],
|
|
536
|
+
"--state",
|
|
537
|
+
help=(
|
|
538
|
+
"State of requests to show, by default all requests created in the past day are shown. "
|
|
539
|
+
"Can be specified multiple times."
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
mine: bool = typer.Option(
|
|
543
|
+
True, '--mine/--all', help="Show only my requests or all requests. By default only your requests are shown."
|
|
544
|
+
),
|
|
545
|
+
age: Age = typer.Option(
|
|
546
|
+
"1d",
|
|
547
|
+
parser=lambda value: Age.from_string(value),
|
|
548
|
+
metavar="AGE",
|
|
549
|
+
help=(
|
|
550
|
+
"Maximum age of the request (based on created time) represented in [VALUE][UNIT] format. "
|
|
551
|
+
f"Accepted units are: {Age.available_units()}"
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
min_age: Optional[Age] = typer.Option(
|
|
555
|
+
None,
|
|
556
|
+
parser=lambda value: Age.from_string(value),
|
|
557
|
+
metavar="MINIMUM_AGE",
|
|
558
|
+
help=(
|
|
559
|
+
"Minimum age of the request (based on created time) represented in [VALUE][UNIT] format. "
|
|
560
|
+
f"Accepted units are: {Age.available_units()}"
|
|
561
|
+
),
|
|
562
|
+
),
|
|
563
|
+
format: OutputFormat = typer.Option(
|
|
564
|
+
"table", help=f"Output format to use. Possible formats: {OutputFormat.available_formats()}"
|
|
565
|
+
),
|
|
566
|
+
show_time: bool = typer.Option(
|
|
567
|
+
False, help="Show date instead of human readable diff in text output, i.e. 1 hour ago"
|
|
568
|
+
),
|
|
569
|
+
show_utc: bool = typer.Option(False, help="Show UTC time instead of local timezone"),
|
|
570
|
+
show_secrets: bool = typer.Option(
|
|
571
|
+
False, help="Show secrets. When listing all requests this requires 'admin' privileges."
|
|
572
|
+
),
|
|
573
|
+
show_token_id: bool = typer.Option(
|
|
574
|
+
False, "--show-token-id", help="Show token ID submitting the request. Requires admin token."
|
|
575
|
+
),
|
|
576
|
+
ranch: Optional[Ranch] = typer.Option(
|
|
577
|
+
None,
|
|
578
|
+
help=(
|
|
579
|
+
"For your requests ranch is enforced by the given token. "
|
|
580
|
+
"When listing all requests, you can use this option to restrict listing for a specific ranch"
|
|
581
|
+
),
|
|
582
|
+
),
|
|
583
|
+
brief: bool = typer.Option(False, "--brief", help="Show brief output (only basic information)."),
|
|
584
|
+
ids: Optional[List[str]] = typer.Option(
|
|
585
|
+
None, "--id", help="Request ID(s) to show. Can be specified multiple times or contain partial UUID strings."
|
|
586
|
+
),
|
|
587
|
+
token_id: Optional[str] = typer.Option(
|
|
588
|
+
None, "--token-id", help="Show requests for a specific token ID. Must be a valid UUID4."
|
|
589
|
+
),
|
|
590
|
+
reserve: bool = typer.Option(False, "-r", "--reservations", help="Show active reservations."),
|
|
591
|
+
):
|
|
592
|
+
"""
|
|
593
|
+
List Testing Farm requests.
|
|
594
|
+
|
|
595
|
+
By default all your requests are shown.
|
|
596
|
+
|
|
597
|
+
The ranch is detected from your token.
|
|
598
|
+
|
|
599
|
+
State emojis in table format (combine request state and overall result):
|
|
600
|
+
🆕 new, ⌛️ queued, 🚀 running, 🚫 canceled, 🔥 infrastructure error,
|
|
601
|
+
✅ passed, ❌ failed, ⛔️ test error, ⤼ skipped
|
|
602
|
+
"""
|
|
603
|
+
# Accept these arguments only via environment variables
|
|
604
|
+
check_unexpected_arguments(context, "api_url", "api_token")
|
|
605
|
+
|
|
606
|
+
# Validate conflicting options
|
|
607
|
+
if ids:
|
|
608
|
+
if context.get_parameter_source("mine") == ParameterSource.COMMANDLINE:
|
|
609
|
+
if mine:
|
|
610
|
+
exit_error(
|
|
611
|
+
"The '--id' option conflicts with '--mine'. "
|
|
612
|
+
"When specifying request IDs, ownership filtering is not applicable."
|
|
613
|
+
)
|
|
614
|
+
else:
|
|
615
|
+
exit_error(
|
|
616
|
+
"The '--id' option conflicts with '--all'. "
|
|
617
|
+
"When specifying request IDs, ownership filtering is not applicable."
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
if context.get_parameter_source("age") == ParameterSource.COMMANDLINE:
|
|
621
|
+
exit_error(
|
|
622
|
+
"The '--id' option conflicts with '--age'. "
|
|
623
|
+
"When specifying request IDs, age filtering is not applicable."
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
if context.get_parameter_source("min_age") == ParameterSource.COMMANDLINE:
|
|
627
|
+
exit_error(
|
|
628
|
+
"The '--id' option conflicts with '--min-age'. "
|
|
629
|
+
"When specifying request IDs, age filtering is not applicable."
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if reserve:
|
|
633
|
+
exit_error(
|
|
634
|
+
"The '--reservations' option cannot be used with '--id'. "
|
|
635
|
+
"Use '--reservations' without specifying request IDs."
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
elif show_secrets:
|
|
639
|
+
exit_error("The '--show-secrets' option can be used only with '--id' option.")
|
|
640
|
+
|
|
641
|
+
# Validate reserve conflicts with explicit format
|
|
642
|
+
if reserve and context.get_parameter_source("format") == ParameterSource.COMMANDLINE:
|
|
643
|
+
exit_error(
|
|
644
|
+
"The '--reservations' option conflicts with explicit '--format'. "
|
|
645
|
+
"Reservations use a specialized table format that cannot be changed."
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Validate ranch conflicts with mine
|
|
649
|
+
if mine and ranch:
|
|
650
|
+
exit_error(
|
|
651
|
+
"The '--ranch' option conflicts with '--mine'. "
|
|
652
|
+
"When showing your own requests, ranch filtering is not applicable."
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# Validate token_id
|
|
656
|
+
if token_id:
|
|
657
|
+
# Validate UUID4 format
|
|
658
|
+
if not uuid_valid(token_id, version=4):
|
|
659
|
+
exit_error(f"Invalid token ID '{token_id}'. Token ID must be a valid UUID4.")
|
|
660
|
+
|
|
661
|
+
# Check conflicts with mine/all
|
|
662
|
+
if context.get_parameter_source("mine") == ParameterSource.COMMANDLINE:
|
|
663
|
+
if mine:
|
|
664
|
+
exit_error(
|
|
665
|
+
"The '--token-id' option conflicts with '--mine'. "
|
|
666
|
+
"Token filtering shows requests for any token, not just yours."
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
exit_error(
|
|
670
|
+
"The '--token-id' option conflicts with '--all'. "
|
|
671
|
+
"Token filtering is already specific to the given token."
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Use internal API if showing secrets, otherwise use public API
|
|
675
|
+
base_url = internal_api_url if show_secrets else api_url
|
|
676
|
+
|
|
677
|
+
# Build base URL with age parameter
|
|
678
|
+
url_params = f"created_after={age.to_string()}"
|
|
679
|
+
|
|
680
|
+
# Add ranch parameter if specified (only for --all, not --mine)
|
|
681
|
+
if ranch and not mine and not token_id:
|
|
682
|
+
url_params += f"&ranch={ranch.value}"
|
|
683
|
+
|
|
684
|
+
# Add token_id parameter if specified
|
|
685
|
+
if token_id:
|
|
686
|
+
url_params += f"&token_id={token_id}"
|
|
687
|
+
# When using token_id, behave like --all (don't use authentication)
|
|
688
|
+
mine = False
|
|
689
|
+
|
|
690
|
+
# When using specific request IDs, behave like --all (don't use authentication)
|
|
691
|
+
if ids:
|
|
692
|
+
mine = False
|
|
693
|
+
|
|
694
|
+
base_request_url = urllib.parse.urljoin(base_url, f"v0.1/requests?{url_params}")
|
|
695
|
+
|
|
696
|
+
# Setting up HTTP retries
|
|
697
|
+
session = requests.Session()
|
|
698
|
+
install_http_retries(session)
|
|
699
|
+
|
|
700
|
+
def handle_response_errors(response: requests.Response) -> None:
|
|
701
|
+
if response.status_code == 401:
|
|
702
|
+
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
703
|
+
|
|
704
|
+
if response.status_code != 200:
|
|
705
|
+
exit_error(
|
|
706
|
+
f"Unexpected error {response.text}. "
|
|
707
|
+
f"Check {settings.STATUS_PAGE}. "
|
|
708
|
+
f"File an issue to {settings.ISSUE_TRACKER} if needed."
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# Handle minimum age
|
|
712
|
+
if min_age:
|
|
713
|
+
base_request_url = f"{base_request_url}&created_before={min_age.to_string()}"
|
|
714
|
+
|
|
715
|
+
# check for token
|
|
716
|
+
if not api_token and (mine or show_secrets):
|
|
717
|
+
exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
|
|
718
|
+
|
|
719
|
+
# Validate token if provided
|
|
720
|
+
if api_token:
|
|
721
|
+
whoami_url = urllib.parse.urljoin(api_url, "v0.1/whoami")
|
|
722
|
+
try:
|
|
723
|
+
response = session.get(whoami_url, headers=authorization_headers(api_token))
|
|
724
|
+
if response.status_code == 401:
|
|
725
|
+
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
726
|
+
elif response.status_code != 200:
|
|
727
|
+
exit_error(
|
|
728
|
+
f"Token validation failed with status {response.status_code}. "
|
|
729
|
+
f"Check {settings.STATUS_PAGE}. "
|
|
730
|
+
f"File an issue to {settings.ISSUE_TRACKER} if needed."
|
|
731
|
+
)
|
|
732
|
+
except requests.RequestException as e:
|
|
733
|
+
exit_error(f"Failed to validate token: {e}")
|
|
734
|
+
|
|
735
|
+
# Handle specific request IDs
|
|
736
|
+
if ids:
|
|
737
|
+
extracted_ids = [extract_uuid(id_string) for id_string in ids]
|
|
738
|
+
|
|
739
|
+
# Fetch individual requests
|
|
740
|
+
with Progress(SpinnerColumn(), transient=True) as progress:
|
|
741
|
+
progress.add_task(description="")
|
|
742
|
+
|
|
743
|
+
def fetch_individual_request(request_id: str):
|
|
744
|
+
# Use internal API if showing secrets, otherwise use public API
|
|
745
|
+
|
|
746
|
+
request_url = urllib.parse.urljoin(base_url, f"v0.1/requests/{request_id}")
|
|
747
|
+
if mine or show_secrets:
|
|
748
|
+
response = session.get(request_url, headers=authorization_headers(api_token))
|
|
749
|
+
else:
|
|
750
|
+
response = session.get(request_url)
|
|
751
|
+
|
|
752
|
+
if response.status_code == 404:
|
|
753
|
+
console.print(f"Request {request_id} not found", style="yellow")
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
handle_response_errors(response)
|
|
757
|
+
return response.json()
|
|
758
|
+
|
|
759
|
+
requests_json = []
|
|
760
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
761
|
+
results = executor.map(fetch_individual_request, extracted_ids)
|
|
762
|
+
|
|
763
|
+
for result in results:
|
|
764
|
+
if result:
|
|
765
|
+
requests_json.append(result)
|
|
766
|
+
else:
|
|
767
|
+
# Original logic for fetching by states and age
|
|
768
|
+
with Progress(SpinnerColumn(), transient=True) as progress:
|
|
769
|
+
progress.add_task(description="")
|
|
770
|
+
|
|
771
|
+
# Lookup only current users requests
|
|
772
|
+
def fetch(url: str):
|
|
773
|
+
if mine:
|
|
774
|
+
response = session.get(url, headers=authorization_headers(api_token))
|
|
775
|
+
else:
|
|
776
|
+
response = session.get(url)
|
|
777
|
+
|
|
778
|
+
handle_response_errors(response)
|
|
779
|
+
|
|
780
|
+
return response.json() or []
|
|
781
|
+
|
|
782
|
+
requests_json: List[dict[str, Any]] = []
|
|
783
|
+
|
|
784
|
+
urls = (f"{base_request_url}&state={state.value}" for state in states)
|
|
785
|
+
|
|
786
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
787
|
+
results = executor.map(fetch, urls)
|
|
788
|
+
|
|
789
|
+
for result in results:
|
|
790
|
+
requests_json.extend(result)
|
|
791
|
+
|
|
792
|
+
# For single request ID, default to text format and verbose mode if not explicitly set
|
|
793
|
+
if ids and len(requests_json) == 1:
|
|
794
|
+
if context.get_parameter_source("format") == ParameterSource.DEFAULT:
|
|
795
|
+
format = OutputFormat.text
|
|
796
|
+
if context.get_parameter_source("brief") == ParameterSource.DEFAULT:
|
|
797
|
+
brief = False # Ensure verbose mode for single requests
|
|
798
|
+
|
|
799
|
+
# Validate show-secrets only works with text format (after format adjustments)
|
|
800
|
+
if show_secrets and format != OutputFormat.text:
|
|
801
|
+
exit_error("The '--show-secrets' option only works with text output format. Use '--format' text to force.")
|
|
802
|
+
|
|
803
|
+
# Validate brief only works with text format
|
|
804
|
+
if brief and format != OutputFormat.text:
|
|
805
|
+
exit_error("The '--brief' option only works with text output format. Use '--format' text.")
|
|
806
|
+
|
|
807
|
+
if format == OutputFormat.json:
|
|
808
|
+
json_dump = json.dumps(requests_json) or '[]'
|
|
809
|
+
console.print_json(json_dump)
|
|
810
|
+
return
|
|
811
|
+
|
|
812
|
+
if not requests_json:
|
|
813
|
+
console.print("No requests found")
|
|
814
|
+
return
|
|
815
|
+
|
|
816
|
+
if format == OutputFormat.yaml:
|
|
817
|
+
yaml_dump = io.StringIO()
|
|
818
|
+
YAML().dump(requests_json, yaml_dump)
|
|
819
|
+
syntax = Syntax(yaml_dump.getvalue(), 'yaml')
|
|
820
|
+
console.print(syntax)
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
if format == OutputFormat.table:
|
|
824
|
+
if reserve:
|
|
825
|
+
render_reservation_table(requests_json=requests_json, show_utc=show_utc)
|
|
826
|
+
else:
|
|
827
|
+
render_table(
|
|
828
|
+
requests_json=requests_json,
|
|
829
|
+
show_token_id=show_token_id,
|
|
830
|
+
show_time=show_time,
|
|
831
|
+
show_utc=show_utc,
|
|
832
|
+
ranch=ranch,
|
|
833
|
+
)
|
|
834
|
+
return
|
|
835
|
+
|
|
836
|
+
render_text(requests_json=requests_json, brief=brief, show_utc=show_utc, show_token_id=show_token_id)
|