tft-cli 0.0.17__py3-none-any.whl → 0.0.19__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/commands.py +266 -49
- tft/cli/config.py +5 -0
- tft/cli/tool.py +5 -0
- tft/cli/utils.py +42 -8
- {tft_cli-0.0.17.dist-info → tft_cli-0.0.19.dist-info}/METADATA +3 -1
- tft_cli-0.0.19.dist-info/RECORD +10 -0
- tft_cli-0.0.17.dist-info/RECORD +0 -10
- {tft_cli-0.0.17.dist-info → tft_cli-0.0.19.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.17.dist-info → tft_cli-0.0.19.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.17.dist-info → tft_cli-0.0.19.dist-info}/entry_points.txt +0 -0
tft/cli/commands.py
CHANGED
|
@@ -7,9 +7,11 @@ import os
|
|
|
7
7
|
import re
|
|
8
8
|
import shutil
|
|
9
9
|
import subprocess
|
|
10
|
+
import tempfile
|
|
10
11
|
import textwrap
|
|
11
12
|
import time
|
|
12
13
|
import urllib.parse
|
|
14
|
+
import xml.etree.ElementTree as ET
|
|
13
15
|
from enum import Enum
|
|
14
16
|
from typing import Any, Dict, List, Optional
|
|
15
17
|
|
|
@@ -18,12 +20,14 @@ import requests
|
|
|
18
20
|
import typer
|
|
19
21
|
from rich import print
|
|
20
22
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
23
|
+
from rich.table import Table
|
|
21
24
|
|
|
22
25
|
from tft.cli.config import settings
|
|
23
26
|
from tft.cli.utils import (
|
|
24
27
|
artifacts,
|
|
25
28
|
cmd_output_or_exit,
|
|
26
29
|
console,
|
|
30
|
+
console_stderr,
|
|
27
31
|
exit_error,
|
|
28
32
|
hw_constraints,
|
|
29
33
|
install_http_retries,
|
|
@@ -57,6 +61,11 @@ RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
|
|
|
57
61
|
DEFAULT_PIPELINE_TIMEOUT = 60 * 12
|
|
58
62
|
|
|
59
63
|
|
|
64
|
+
class WatchFormat(str, Enum):
|
|
65
|
+
text = 'text'
|
|
66
|
+
json = 'json'
|
|
67
|
+
|
|
68
|
+
|
|
60
69
|
class PipelineType(str, Enum):
|
|
61
70
|
tmt_multihost = "tmt-multihost"
|
|
62
71
|
|
|
@@ -127,11 +136,11 @@ OPTION_POST_INSTALL_SCRIPT: Optional[str] = typer.Option(
|
|
|
127
136
|
)
|
|
128
137
|
OPTION_KICKSTART: Optional[List[str]] = typer.Option(
|
|
129
138
|
None,
|
|
130
|
-
metavar="key=value",
|
|
139
|
+
metavar="key=value|@file",
|
|
131
140
|
help=(
|
|
132
141
|
"Kickstart specification to customize the guest installation. Expressed as a key=value pair. "
|
|
133
142
|
"For more information about the supported keys see "
|
|
134
|
-
"https://tmt.readthedocs.io/en/stable/spec/plans.html#kickstart."
|
|
143
|
+
"https://tmt.readthedocs.io/en/stable/spec/plans.html#kickstart. The @ prefix marks a yaml file to load."
|
|
135
144
|
),
|
|
136
145
|
)
|
|
137
146
|
OPTION_POOL: Optional[str] = typer.Option(
|
|
@@ -169,19 +178,27 @@ OPTION_DRY_RUN: bool = typer.Option(
|
|
|
169
178
|
False, help="Do not submit a request to Testing Farm, just print it.", rich_help_panel=RESERVE_PANEL_GENERAL
|
|
170
179
|
)
|
|
171
180
|
OPTION_VARIABLES: Optional[List[str]] = typer.Option(
|
|
172
|
-
None,
|
|
181
|
+
None,
|
|
182
|
+
"-e",
|
|
183
|
+
"--environment",
|
|
184
|
+
metavar="key=value|@file",
|
|
185
|
+
help="Variables to pass to the test environment. The @ prefix marks a yaml file to load.",
|
|
173
186
|
)
|
|
174
187
|
OPTION_SECRETS: Optional[List[str]] = typer.Option(
|
|
175
|
-
None,
|
|
188
|
+
None,
|
|
189
|
+
"-s",
|
|
190
|
+
"--secret",
|
|
191
|
+
metavar="key=value|@file",
|
|
192
|
+
help="Secret variables to pass to the test environment. The @ prefix marks a yaml file to load.",
|
|
176
193
|
)
|
|
177
194
|
OPTION_HARDWARE: List[str] = typer.Option(
|
|
178
195
|
None,
|
|
179
196
|
help=(
|
|
180
197
|
"HW requirements, expressed as key/value pairs. Keys can consist of several properties, "
|
|
181
|
-
"e.g. ``disk.
|
|
182
|
-
"with other keys sharing the path: ``cpu.family=79`` and ``cpu.model=6`` would be merged, "
|
|
183
|
-
"
|
|
184
|
-
"for the hardware
|
|
198
|
+
"e.g. ``disk.size='>= 40 GiB'``, such keys will be merged in the resulting environment "
|
|
199
|
+
"with other keys sharing the path: ``cpu.family=79`` and ``cpu.model=6`` would be merged, not overwriting "
|
|
200
|
+
"each other. See https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html#hardware "
|
|
201
|
+
"for the supported hardware selection possibilities."
|
|
185
202
|
),
|
|
186
203
|
)
|
|
187
204
|
OPTION_WORKER_IMAGE: Optional[str] = typer.Option(
|
|
@@ -197,11 +214,157 @@ OPTION_PARALLEL_LIMIT: Optional[int] = typer.Option(
|
|
|
197
214
|
)
|
|
198
215
|
|
|
199
216
|
|
|
217
|
+
def _parse_xunit(xunit: str):
|
|
218
|
+
"""
|
|
219
|
+
A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
|
|
220
|
+
|
|
221
|
+
The plans are returned as a {'arch': ['plan1', 'plan2', ..]} map. If it was impossible to deduce architecture
|
|
222
|
+
from a certain plan result (happens in case of early fails / infra issues), the plan will be listed under the 'N/A'
|
|
223
|
+
key.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def _add_plan(collection: dict, arch: str, plan: ET.Element):
|
|
227
|
+
# NOTE(ivasilev) name property will always be defined at this point, defaulting to '' to make type check happy
|
|
228
|
+
plan_name = plan.get('name', '')
|
|
229
|
+
if arch in collection:
|
|
230
|
+
collection[arch].append(plan_name)
|
|
231
|
+
else:
|
|
232
|
+
collection[arch] = [plan_name]
|
|
233
|
+
|
|
234
|
+
failed_plans = {}
|
|
235
|
+
passed_plans = {}
|
|
236
|
+
errored_plans = {}
|
|
237
|
+
|
|
238
|
+
results_root = ET.fromstring(xunit)
|
|
239
|
+
for plan in results_root.findall('./testsuite'):
|
|
240
|
+
# Try to get information about the environment (stored under testcase/testing-environment), may be
|
|
241
|
+
# absent if state is undefined
|
|
242
|
+
testing_environment: Optional[ET.Element] = plan.find('./testcase/testing-environment[@name="requested"]')
|
|
243
|
+
if not testing_environment:
|
|
244
|
+
console_stderr.print(
|
|
245
|
+
f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
|
|
246
|
+
)
|
|
247
|
+
arch = 'N/A'
|
|
248
|
+
else:
|
|
249
|
+
arch_property = testing_environment.find('./property[@name="arch"]')
|
|
250
|
+
if arch_property is None:
|
|
251
|
+
console_stderr.print(f'Could not find arch property for plan {plan.get("name")} results, skipping')
|
|
252
|
+
continue
|
|
253
|
+
# NOTE(ivasilev) arch property will always be defined at this point, defaulting to '' to make type check
|
|
254
|
+
# happy
|
|
255
|
+
arch = arch_property.get('value', '')
|
|
256
|
+
if plan.get('result') == 'passed':
|
|
257
|
+
_add_plan(passed_plans, arch, plan)
|
|
258
|
+
elif plan.get('result') == 'failed':
|
|
259
|
+
_add_plan(failed_plans, arch, plan)
|
|
260
|
+
else:
|
|
261
|
+
_add_plan(errored_plans, arch, plan)
|
|
262
|
+
|
|
263
|
+
# Let's remove possible duplicates among N/A errored out tests
|
|
264
|
+
if 'N/A' in errored_plans:
|
|
265
|
+
errored_plans['N/A'] = list(set(errored_plans['N/A']))
|
|
266
|
+
return passed_plans, failed_plans, errored_plans
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _get_request_summary(request: dict, session: requests.Session):
|
|
270
|
+
"""A helper that prepares json summary of the test run"""
|
|
271
|
+
state = request.get('state')
|
|
272
|
+
artifacts_url = (request.get('run') or {}).get('artifacts')
|
|
273
|
+
xpath_url = f'{artifacts_url}/results.xml' if artifacts_url else ''
|
|
274
|
+
xunit = (request.get('result') or {}).get('xunit') or '<testsuites></testsuites>'
|
|
275
|
+
if state not in ['queued', 'running'] and artifacts_url:
|
|
276
|
+
# NOTE(ivasilev) xunit can be None (ex. in case of timed out requests) so let's fetch results.xml and use it
|
|
277
|
+
# as source of truth
|
|
278
|
+
try:
|
|
279
|
+
response = session.get(xpath_url)
|
|
280
|
+
if response.status_code == 200:
|
|
281
|
+
xunit = response.text
|
|
282
|
+
except requests.exceptions.ConnectionError:
|
|
283
|
+
console_stderr.print("Could not get xunit results")
|
|
284
|
+
passed_plans, failed_plans, errored_plans = _parse_xunit(xunit)
|
|
285
|
+
overall = (request.get("result") or {}).get("overall")
|
|
286
|
+
arches_requested = [env['arch'] for env in request['environments_requested']]
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
'id': request['id'],
|
|
290
|
+
'state': request['state'],
|
|
291
|
+
'artifacts': artifacts_url,
|
|
292
|
+
'overall': overall,
|
|
293
|
+
'arches_requested': arches_requested,
|
|
294
|
+
'errored_plans': errored_plans,
|
|
295
|
+
'failed_plans': failed_plans,
|
|
296
|
+
'passed_plans': passed_plans,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_details=True):
|
|
301
|
+
if not format == WatchFormat.text:
|
|
302
|
+
# Nothing to do, table is printed only when text output is requested
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
def _get_plans_list(collection):
|
|
306
|
+
return list(collection.values())[0] if collection.values() else []
|
|
307
|
+
|
|
308
|
+
def _has_plan(collection, arch, plan):
|
|
309
|
+
return plan in collection.get(arch, [])
|
|
310
|
+
|
|
311
|
+
# Let's transform plans maps into collection of plans to display plan result per arch statistics
|
|
312
|
+
errored = _get_plans_list(summary['errored_plans'])
|
|
313
|
+
failed = _get_plans_list(summary['failed_plans'])
|
|
314
|
+
passed = _get_plans_list(summary['passed_plans'])
|
|
315
|
+
generic_info_table = Table(show_header=True, header_style="bold magenta")
|
|
316
|
+
arches_requested = summary['arches_requested']
|
|
317
|
+
artifacts_url = summary['artifacts'] or ''
|
|
318
|
+
for column in summary.keys():
|
|
319
|
+
generic_info_table.add_column(column)
|
|
320
|
+
generic_info_table.add_row(
|
|
321
|
+
summary['id'],
|
|
322
|
+
summary['state'],
|
|
323
|
+
f'[link]{artifacts_url}[/link]',
|
|
324
|
+
summary['overall'],
|
|
325
|
+
','.join(arches_requested),
|
|
326
|
+
str(len(errored)),
|
|
327
|
+
str(len(failed)),
|
|
328
|
+
str(len(passed)),
|
|
329
|
+
)
|
|
330
|
+
console.print(generic_info_table)
|
|
331
|
+
|
|
332
|
+
all_plans = sorted(set(errored + failed + passed))
|
|
333
|
+
details_table = Table(show_header=True, header_style="bold magenta")
|
|
334
|
+
for column in ["plan"] + arches_requested:
|
|
335
|
+
details_table.add_column(column)
|
|
336
|
+
|
|
337
|
+
for plan in all_plans:
|
|
338
|
+
row = [plan]
|
|
339
|
+
for arch in arches_requested:
|
|
340
|
+
if _has_plan(summary['passed_plans'], arch, plan):
|
|
341
|
+
res = '[green]pass[/green]'
|
|
342
|
+
elif _has_plan(summary['failed_plans'], arch, plan):
|
|
343
|
+
res = '[red]fail[/red]'
|
|
344
|
+
elif _has_plan(summary['errored_plans'], 'N/A', plan):
|
|
345
|
+
res = '[yellow]error[/yellow]'
|
|
346
|
+
else:
|
|
347
|
+
# If for some reason the plan has not been executed for this arch (this can happen after
|
|
348
|
+
# applying adjust rules) -> don't show anything
|
|
349
|
+
res = None
|
|
350
|
+
row.append(res)
|
|
351
|
+
details_table.add_row(*row)
|
|
352
|
+
if show_details:
|
|
353
|
+
console.print(details_table)
|
|
354
|
+
|
|
355
|
+
|
|
200
356
|
def watch(
|
|
201
357
|
api_url: str = typer.Option(settings.API_URL, help="Testing Farm API URL."),
|
|
202
358
|
id: str = typer.Option(..., help="Request ID to watch"),
|
|
203
359
|
no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
|
|
360
|
+
format: Optional[WatchFormat] = typer.Option(WatchFormat.text, help="Output format"),
|
|
204
361
|
):
|
|
362
|
+
def _console_print(*args, **kwargs):
|
|
363
|
+
"""A helper function that will skip printing to console if output format is json"""
|
|
364
|
+
if format == WatchFormat.json:
|
|
365
|
+
return
|
|
366
|
+
console.print(*args, **kwargs)
|
|
367
|
+
|
|
205
368
|
"""Watch request for completion."""
|
|
206
369
|
|
|
207
370
|
if not uuid_valid(id):
|
|
@@ -210,10 +373,10 @@ def watch(
|
|
|
210
373
|
get_url = urllib.parse.urljoin(api_url, f"/v0.1/requests/{id}")
|
|
211
374
|
current_state: str = ""
|
|
212
375
|
|
|
213
|
-
|
|
376
|
+
_console_print(f"🔎 api [blue]{get_url}[/blue]")
|
|
214
377
|
|
|
215
378
|
if not no_wait:
|
|
216
|
-
|
|
379
|
+
_console_print("💡 waiting for request to finish, use ctrl+c to skip", style="bright_yellow")
|
|
217
380
|
|
|
218
381
|
artifacts_shown = False
|
|
219
382
|
|
|
@@ -245,37 +408,45 @@ def watch(
|
|
|
245
408
|
|
|
246
409
|
current_state = state
|
|
247
410
|
|
|
411
|
+
request_summary = _get_request_summary(request, session)
|
|
412
|
+
if format == WatchFormat.json:
|
|
413
|
+
console.print(json.dumps(request_summary, indent=2))
|
|
414
|
+
|
|
248
415
|
if state == "new":
|
|
249
|
-
|
|
416
|
+
_console_print("👶 request is [blue]waiting to be queued[/blue]")
|
|
250
417
|
|
|
251
418
|
elif state == "queued":
|
|
252
|
-
|
|
419
|
+
_console_print("👷 request is [blue]queued[/blue]")
|
|
253
420
|
|
|
254
421
|
elif state == "running":
|
|
255
|
-
|
|
256
|
-
|
|
422
|
+
_console_print("🚀 request is [blue]running[/blue]")
|
|
423
|
+
_console_print(f"🚢 artifacts [blue]{request['run']['artifacts']}[/blue]")
|
|
257
424
|
artifacts_shown = True
|
|
258
425
|
|
|
259
426
|
elif state == "complete":
|
|
260
427
|
if not artifacts_shown:
|
|
261
|
-
|
|
428
|
+
_console_print(f"🚢 artifacts [blue]{request['run']['artifacts']}[/blue]")
|
|
262
429
|
|
|
263
430
|
overall = request["result"]["overall"]
|
|
264
431
|
if overall in ["passed", "skipped"]:
|
|
265
|
-
|
|
432
|
+
_console_print("✅ tests passed", style="green")
|
|
433
|
+
_print_summary_table(request_summary, format)
|
|
266
434
|
raise typer.Exit()
|
|
267
435
|
|
|
268
436
|
if overall in ["failed", "error", "unknown"]:
|
|
269
|
-
|
|
437
|
+
_console_print(f"❌ tests {overall}", style="red")
|
|
270
438
|
if overall == "error":
|
|
271
|
-
|
|
439
|
+
_console_print(f"{request['result']['summary']}", style="red")
|
|
440
|
+
_print_summary_table(request_summary, format)
|
|
272
441
|
raise typer.Exit(code=1)
|
|
273
442
|
|
|
274
443
|
elif state == "error":
|
|
275
|
-
|
|
444
|
+
_console_print(f"📛 pipeline error\n{request['result']['summary']}", style="red")
|
|
445
|
+
_print_summary_table(request_summary, format)
|
|
276
446
|
raise typer.Exit(code=2)
|
|
277
447
|
|
|
278
448
|
if no_wait:
|
|
449
|
+
_print_summary_table(request_summary, format, show_details=False)
|
|
279
450
|
raise typer.Exit()
|
|
280
451
|
|
|
281
452
|
time.sleep(settings.WATCH_TICK)
|
|
@@ -319,20 +490,15 @@ def request(
|
|
|
319
490
|
None,
|
|
320
491
|
help="Compose used to provision system-under-test. If not set, tests will expect 'container' provision method specified in tmt plans.", # noqa
|
|
321
492
|
),
|
|
322
|
-
hardware: List[str] =
|
|
323
|
-
None,
|
|
324
|
-
help=(
|
|
325
|
-
"HW requirements, expressed as key/value pairs. Keys can consist of several properties, "
|
|
326
|
-
"e.g. ``disk.space='>= 40 GiB'``, such keys will be merged in the resulting environment "
|
|
327
|
-
"with other keys sharing the path: ``cpu.family=79`` and ``cpu.model=6`` would be merged, "
|
|
328
|
-
"not overwriting each other. See https://tmt.readthedocs.io/en/stable/spec/hardware.html "
|
|
329
|
-
"for the hardware specification."
|
|
330
|
-
),
|
|
331
|
-
),
|
|
493
|
+
hardware: List[str] = OPTION_HARDWARE,
|
|
332
494
|
kickstart: Optional[List[str]] = OPTION_KICKSTART,
|
|
333
495
|
pool: Optional[str] = OPTION_POOL,
|
|
334
496
|
cli_tmt_context: Optional[List[str]] = typer.Option(
|
|
335
|
-
None,
|
|
497
|
+
None,
|
|
498
|
+
"-c",
|
|
499
|
+
"--context",
|
|
500
|
+
metavar="key=value|@file",
|
|
501
|
+
help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
|
|
336
502
|
),
|
|
337
503
|
variables: Optional[List[str]] = OPTION_VARIABLES,
|
|
338
504
|
secrets: Optional[List[str]] = OPTION_SECRETS,
|
|
@@ -340,10 +506,11 @@ def request(
|
|
|
340
506
|
None,
|
|
341
507
|
"-T",
|
|
342
508
|
"--tmt-environment",
|
|
343
|
-
metavar="key=value",
|
|
509
|
+
metavar="key=value|@file",
|
|
344
510
|
help=(
|
|
345
511
|
"Environment variables to pass to the tmt process. "
|
|
346
|
-
"Used to configure tmt report plugins like reportportal or polarion."
|
|
512
|
+
"Used to configure tmt report plugins like reportportal or polarion. "
|
|
513
|
+
"The @ prefix marks a yaml file to load."
|
|
347
514
|
),
|
|
348
515
|
),
|
|
349
516
|
no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
|
|
@@ -353,8 +520,13 @@ def request(
|
|
|
353
520
|
fedora_copr_build: List[str] = OPTION_FEDORA_COPR_BUILD,
|
|
354
521
|
repository: List[str] = OPTION_REPOSITORY,
|
|
355
522
|
repository_file: List[str] = OPTION_REPOSITORY_FILE,
|
|
523
|
+
sanity: bool = typer.Option(False, help="Run Testing Farm sanity test.", rich_help_panel=RESERVE_PANEL_GENERAL),
|
|
356
524
|
tags: Optional[List[str]] = typer.Option(
|
|
357
|
-
None,
|
|
525
|
+
None,
|
|
526
|
+
"-t",
|
|
527
|
+
"--tag",
|
|
528
|
+
metavar="key=value|@file",
|
|
529
|
+
help="Tag cloud resources with given value. The @ prefix marks a yaml file to load.",
|
|
358
530
|
),
|
|
359
531
|
watchdog_dispatch_delay: Optional[int] = typer.Option(
|
|
360
532
|
None,
|
|
@@ -396,6 +568,16 @@ def request(
|
|
|
396
568
|
"Only 'x86_64' architecture supported in this case."
|
|
397
569
|
)
|
|
398
570
|
|
|
571
|
+
if sanity:
|
|
572
|
+
if git_url or tmt_plan_name:
|
|
573
|
+
exit_error(
|
|
574
|
+
"The option [underline]--sanity[/underline] is mutually exclusive with "
|
|
575
|
+
"[underline]--git-url[/underline] and [underline]--plan[/underline]."
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
git_url = str(settings.TESTING_FARM_TESTS_GIT_URL)
|
|
579
|
+
tmt_plan_name = str(settings.TESTING_FARM_SANITY_PLAN)
|
|
580
|
+
|
|
399
581
|
# resolve git repository details from the current repository
|
|
400
582
|
if not git_url:
|
|
401
583
|
if not git_available:
|
|
@@ -603,7 +785,7 @@ def request(
|
|
|
603
785
|
exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
|
|
604
786
|
|
|
605
787
|
# watch
|
|
606
|
-
watch(api_url, response.json()['id'], no_wait)
|
|
788
|
+
watch(api_url, response.json()['id'], no_wait, format=WatchFormat.text)
|
|
607
789
|
|
|
608
790
|
|
|
609
791
|
def restart(
|
|
@@ -627,16 +809,7 @@ def restart(
|
|
|
627
809
|
git_url: Optional[str] = typer.Option(None, help="Force URL of the GIT repository to test."),
|
|
628
810
|
git_ref: Optional[str] = typer.Option(None, help="Force GIT ref or branch to test."),
|
|
629
811
|
git_merge_sha: Optional[str] = typer.Option(None, help="Force GIT ref or branch into which --ref will be merged."),
|
|
630
|
-
hardware: List[str] =
|
|
631
|
-
None,
|
|
632
|
-
help=(
|
|
633
|
-
"HW requirements, expressed as key/value pairs. Keys can consist of several properties, "
|
|
634
|
-
"e.g. ``disk.space='>= 40 GiB'``, such keys will be merged in the resulting environment "
|
|
635
|
-
"with other keys sharing the path: ``cpu.family=79`` and ``cpu.model=6`` would be merged, "
|
|
636
|
-
"not overwriting each other. See https://tmt.readthedocs.io/en/stable/spec/hardware.html "
|
|
637
|
-
"for the hardware specification."
|
|
638
|
-
),
|
|
639
|
-
),
|
|
812
|
+
hardware: List[str] = OPTION_HARDWARE,
|
|
640
813
|
tmt_plan_name: Optional[str] = OPTION_TMT_PLAN_NAME,
|
|
641
814
|
tmt_plan_filter: Optional[str] = OPTION_TMT_PLAN_FILTER,
|
|
642
815
|
tmt_test_name: Optional[str] = OPTION_TMT_TEST_NAME,
|
|
@@ -806,7 +979,7 @@ def restart(
|
|
|
806
979
|
exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
|
|
807
980
|
|
|
808
981
|
# watch
|
|
809
|
-
watch(str(api_url), response.json()['id'], no_wait)
|
|
982
|
+
watch(str(api_url), response.json()['id'], no_wait, format=WatchFormat.text)
|
|
810
983
|
|
|
811
984
|
|
|
812
985
|
def run(
|
|
@@ -894,6 +1067,8 @@ def run(
|
|
|
894
1067
|
if verbose:
|
|
895
1068
|
console.print(f"🔎 api [blue]{get_url}[/blue]")
|
|
896
1069
|
|
|
1070
|
+
search: Optional[re.Match[str]] = None
|
|
1071
|
+
|
|
897
1072
|
# wait for the sanity test to finish
|
|
898
1073
|
with Progress(
|
|
899
1074
|
SpinnerColumn(),
|
|
@@ -941,6 +1116,12 @@ def run(
|
|
|
941
1116
|
try:
|
|
942
1117
|
search = re.search(r'href="(.*)" name="workdir"', session.get(f"{artifacts_url}/results.xml").text)
|
|
943
1118
|
|
|
1119
|
+
except requests.exceptions.SSLError:
|
|
1120
|
+
console.print(
|
|
1121
|
+
"\r🚫 [yellow]artifacts unreachable via SSL, do you have RH CA certificates installed?[/yellow]"
|
|
1122
|
+
)
|
|
1123
|
+
console.print(f"\r🚢 artifacts [blue]{artifacts_url}[/blue]")
|
|
1124
|
+
|
|
944
1125
|
except requests.exceptions.ConnectionError:
|
|
945
1126
|
console.print("\r🚫 [yellow]artifacts unreachable, are you on VPN?[/yellow]")
|
|
946
1127
|
console.print(f"\r🚢 artifacts [blue]{artifacts_url}[/blue]")
|
|
@@ -949,7 +1130,6 @@ def run(
|
|
|
949
1130
|
if not search:
|
|
950
1131
|
exit_error("Could not find working directory, cannot continue")
|
|
951
1132
|
|
|
952
|
-
assert search
|
|
953
1133
|
workdir = str(search.groups(1)[0])
|
|
954
1134
|
output = f"{workdir}/testing-farm/sanity/execute/data/guest/default-0/testing-farm/script-1/output.txt"
|
|
955
1135
|
|
|
@@ -1186,12 +1366,24 @@ def reserve(
|
|
|
1186
1366
|
if not pipeline_log:
|
|
1187
1367
|
exit_error(f"Pipeline log was empty. Please file an issue to {settings.ISSUE_TRACKER}.")
|
|
1188
1368
|
|
|
1369
|
+
except requests.exceptions.SSLError:
|
|
1370
|
+
exit_error(
|
|
1371
|
+
textwrap.dedent(
|
|
1372
|
+
f"""
|
|
1373
|
+
Failed to access Testing Farm artifacts because of SSL validation error.
|
|
1374
|
+
If you use Red Hat Ranch please make sure you have Red Hat CA certificates installed.
|
|
1375
|
+
Otherwise file an issue to {settings.ISSUE_TRACKER}.
|
|
1376
|
+
"""
|
|
1377
|
+
)
|
|
1378
|
+
)
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1189
1381
|
except requests.exceptions.ConnectionError:
|
|
1190
1382
|
exit_error(
|
|
1191
1383
|
textwrap.dedent(
|
|
1192
1384
|
f"""
|
|
1193
1385
|
Failed to access Testing Farm artifacts.
|
|
1194
|
-
If you use Red Hat Ranch please make sure you are
|
|
1386
|
+
If you use Red Hat Ranch please make sure you are connected to the VPN.
|
|
1195
1387
|
Otherwise file an issue to {settings.ISSUE_TRACKER}.
|
|
1196
1388
|
"""
|
|
1197
1389
|
)
|
|
@@ -1224,10 +1416,35 @@ def reserve(
|
|
|
1224
1416
|
|
|
1225
1417
|
time.sleep(1)
|
|
1226
1418
|
|
|
1227
|
-
|
|
1419
|
+
sshproxy_url = urllib.parse.urljoin(str(settings.API_URL), f"v0.1/sshproxy?api_key={settings.API_TOKEN}")
|
|
1420
|
+
response = session.get(sshproxy_url)
|
|
1421
|
+
|
|
1422
|
+
content = response.json()
|
|
1423
|
+
|
|
1424
|
+
ssh_private_key = ""
|
|
1425
|
+
if content.get('ssh_private_key_base_64'):
|
|
1426
|
+
ssh_private_key = base64.b64decode(content['ssh_private_key_base_64']).decode()
|
|
1427
|
+
|
|
1428
|
+
ssh_proxy_option = f" -J {content['ssh_proxy']}" if content.get('ssh_proxy') else ""
|
|
1429
|
+
|
|
1430
|
+
ssh_private_key_option = ""
|
|
1431
|
+
if ssh_private_key:
|
|
1432
|
+
tmp = tempfile.NamedTemporaryFile(delete=False)
|
|
1433
|
+
tmp.write(ssh_private_key.encode())
|
|
1434
|
+
tmp.flush()
|
|
1435
|
+
tmp.close()
|
|
1436
|
+
|
|
1437
|
+
os.chmod(tmp.name, 0o600)
|
|
1438
|
+
|
|
1439
|
+
ssh_private_key_option = f" -i {tmp.name}"
|
|
1440
|
+
|
|
1441
|
+
console.print(f"🌎 ssh{ssh_proxy_option}{ssh_private_key_option} root@{guest}")
|
|
1228
1442
|
|
|
1229
1443
|
if autoconnect:
|
|
1230
|
-
os.system(
|
|
1444
|
+
os.system(
|
|
1445
|
+
f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null{ssh_proxy_option}{ssh_private_key_option} root@{guest}" # noqa: E501
|
|
1446
|
+
)
|
|
1447
|
+
os.unlink(tmp.name)
|
|
1231
1448
|
|
|
1232
1449
|
|
|
1233
1450
|
def update():
|
tft/cli/config.py
CHANGED
|
@@ -19,4 +19,9 @@ settings = LazySettings(
|
|
|
19
19
|
DEFAULT_API_RETRIES=7,
|
|
20
20
|
# should lead to delays of 0.5, 1, 2, 4, 8, 16, 32 seconds
|
|
21
21
|
DEFAULT_RETRY_BACKOFF_FACTOR=1,
|
|
22
|
+
# system CA certificates path, default for RHEL variants
|
|
23
|
+
REQUESTS_CA_BUNDLE="/etc/ssl/certs/ca-bundle.crt",
|
|
24
|
+
# Testing Farm sanity test,
|
|
25
|
+
TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
|
|
26
|
+
TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
|
|
22
27
|
)
|
tft/cli/tool.py
CHANGED
|
@@ -21,3 +21,8 @@ app.command()(commands.watch)
|
|
|
21
21
|
# This command is available only for the container based deployment
|
|
22
22
|
if os.path.exists(settings.CONTAINER_SIGN):
|
|
23
23
|
app.command()(commands.update)
|
|
24
|
+
|
|
25
|
+
# Expose REQUESTS_CA_BUNDLE in the environment for RHEL-like systems
|
|
26
|
+
# This is needed for custom CA certificates to nicely work.
|
|
27
|
+
if "REQUESTS_CA_BUNDLE" not in os.environ and os.path.exists(settings.REQUESTS_CA_BUNDLE):
|
|
28
|
+
os.environ["REQUESTS_CA_BUNDLE"] = settings.REQUESTS_CA_BUNDLE
|
tft/cli/utils.py
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
import glob
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
import sys
|
|
7
8
|
import uuid
|
|
8
|
-
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from typing import Any, Dict, List, NoReturn, Optional, Union
|
|
9
10
|
|
|
10
11
|
import requests
|
|
11
12
|
import requests.adapters
|
|
12
13
|
import typer
|
|
13
14
|
from rich.console import Console
|
|
15
|
+
from ruamel.yaml import YAML
|
|
14
16
|
from urllib3 import Retry
|
|
15
17
|
|
|
16
18
|
from tft.cli.config import settings
|
|
17
19
|
|
|
18
20
|
console = Console(soft_wrap=True)
|
|
21
|
+
console_stderr = Console(soft_wrap=True, file=sys.stderr)
|
|
19
22
|
|
|
20
23
|
|
|
21
|
-
def exit_error(error: str):
|
|
24
|
+
def exit_error(error: str) -> NoReturn:
|
|
22
25
|
"""Exit with given error message"""
|
|
23
26
|
console.print(f"⛔ {error}", style="red")
|
|
24
27
|
raise typer.Exit(code=255)
|
|
@@ -91,15 +94,46 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
|
|
|
91
94
|
return {key: value if key not in ("disk", "network") else [value] for key, value in constraints.items()}
|
|
92
95
|
|
|
93
96
|
|
|
97
|
+
def options_from_file(filepath) -> Dict[str, str]:
|
|
98
|
+
"""Read environment variables from a yaml file."""
|
|
99
|
+
|
|
100
|
+
with open(filepath, 'r') as file:
|
|
101
|
+
try:
|
|
102
|
+
yaml = YAML(typ="safe").load(file.read())
|
|
103
|
+
except Exception:
|
|
104
|
+
exit_error(f"Failed to load variables from yaml file {filepath}.")
|
|
105
|
+
|
|
106
|
+
if not yaml: # pyre-ignore[61] # pyre ignores NoReturn in exit_error
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
if not isinstance(yaml, dict): # pyre-ignore[61] # pyre ignores NoReturn in exit_error
|
|
110
|
+
exit_error(f"Environment file {filepath} is not a dict.")
|
|
111
|
+
|
|
112
|
+
if any([isinstance(value, (list, dict)) for value in yaml.values()]):
|
|
113
|
+
exit_error(f"Values of environment file {filepath} are not primitive types.")
|
|
114
|
+
|
|
115
|
+
return yaml # pyre-ignore[61] # pyre ignores NoReturn in exit_error
|
|
116
|
+
|
|
117
|
+
|
|
94
118
|
def options_to_dict(name: str, options: List[str]) -> Dict[str, str]:
|
|
95
|
-
"""Create a dictionary from list of `key=value` options"""
|
|
96
|
-
try:
|
|
97
|
-
return {option.split("=", 1)[0]: option.split("=", 1)[1] for option in options}
|
|
119
|
+
"""Create a dictionary from list of `key=value|@file` options"""
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
options_dict = {}
|
|
122
|
+
for option in options:
|
|
123
|
+
# Option is `@file`
|
|
124
|
+
if option.startswith('@'):
|
|
125
|
+
if not os.path.isfile(option[1:]):
|
|
126
|
+
exit_error(f"Invalid environment file in option `{option}` specified.")
|
|
127
|
+
options_dict.update(options_from_file(option[1:]))
|
|
128
|
+
|
|
129
|
+
# Option is `key=value`
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
options_dict.update({option.split("=", 1)[0]: option.split("=", 1)[1]})
|
|
133
|
+
except IndexError:
|
|
134
|
+
exit_error(f"Option `{option}` is invalid, must be defined as `key=value|@file`.")
|
|
101
135
|
|
|
102
|
-
return
|
|
136
|
+
return options_dict
|
|
103
137
|
|
|
104
138
|
|
|
105
139
|
def uuid_valid(value: str, version: int = 4) -> bool:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tft-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.19
|
|
4
4
|
Summary: Testing Farm CLI tool
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: Miroslav Vadkerti
|
|
@@ -15,4 +15,6 @@ Requires-Dist: click (>=8.0.4,<8.1.0)
|
|
|
15
15
|
Requires-Dist: colorama (>=0.4.4,<0.5.0)
|
|
16
16
|
Requires-Dist: dynaconf (>=3.1.7,<4.0.0)
|
|
17
17
|
Requires-Dist: requests (>=2.27.1,<3.0.0)
|
|
18
|
+
Requires-Dist: ruamel-yaml (>=0.18.6,<0.19.0)
|
|
19
|
+
Requires-Dist: setuptools
|
|
18
20
|
Requires-Dist: typer[all] (>=0.7.0,<0.8.0)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
+
tft/cli/commands.py,sha256=F9Mtg7x6al97Yo7WkTTTZHzNcMUXLa5A2h4J0H7ED0g,55908
|
|
3
|
+
tft/cli/config.py,sha256=lJ9TtsBAdcNDbh4xZd0x1b48V7IsGl3t7kALmNjCqNs,1115
|
|
4
|
+
tft/cli/tool.py,sha256=wFcVxe1NRGW8stputOZlKMasZHjpysas7f0sgpEzipQ,865
|
|
5
|
+
tft/cli/utils.py,sha256=9s7zY_k1MYYPTF4Gr2AMH2DMcySUCIgXbF3LjYa7bzY,7404
|
|
6
|
+
tft_cli-0.0.19.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
7
|
+
tft_cli-0.0.19.dist-info/METADATA,sha256=FfP_InbT9Kd1lXM85xL-1UIijPCoCk5ZagIBK_W2LGg,731
|
|
8
|
+
tft_cli-0.0.19.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
9
|
+
tft_cli-0.0.19.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
10
|
+
tft_cli-0.0.19.dist-info/RECORD,,
|
tft_cli-0.0.17.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
-
tft/cli/commands.py,sha256=vZhILnpLjcdRB81JtE95YXryRA7eEpHrAN-O1U_hN3A,47415
|
|
3
|
-
tft/cli/config.py,sha256=zqakqm4h4A1nbCaZVrIPji0EXc7pRVgNohvDwzn0wSk,842
|
|
4
|
-
tft/cli/tool.py,sha256=T0Ir3iRBX9cKmuEyQ5Lnu-Gel_wAwiQL9KSuGDuYhBc,577
|
|
5
|
-
tft/cli/utils.py,sha256=eQJy5V4Xqa_iiTSN6EnbRTXRbkd7-DVlj8BXISIUQp8,6045
|
|
6
|
-
tft_cli-0.0.17.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
7
|
-
tft_cli-0.0.17.dist-info/METADATA,sha256=_PMo9WgRRMvreqvFlCsI_UK4puNg319nAsxE9QC5Oro,659
|
|
8
|
-
tft_cli-0.0.17.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
9
|
-
tft_cli-0.0.17.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
10
|
-
tft_cli-0.0.17.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|