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 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, "-e", "--environment", metavar="key=value", help="Variables to pass to the test environment."
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, "-s", "--secret", metavar="key=value", help="Secret variables to pass to the test environment."
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.space='>= 40 GiB'``, such keys will be merged in the resulting environment "
182
- "with other keys sharing the path: ``cpu.family=79`` and ``cpu.model=6`` would be merged, "
183
- "not overwriting each other. See https://tmt.readthedocs.io/en/stable/spec/hardware.html "
184
- "for the hardware specification."
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
- console.print(f"🔎 api [blue]{get_url}[/blue]")
376
+ _console_print(f"🔎 api [blue]{get_url}[/blue]")
214
377
 
215
378
  if not no_wait:
216
- console.print("💡 waiting for request to finish, use ctrl+c to skip", style="bright_yellow")
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
- console.print("👶 request is [blue]waiting to be queued[/blue]")
416
+ _console_print("👶 request is [blue]waiting to be queued[/blue]")
250
417
 
251
418
  elif state == "queued":
252
- console.print("👷 request is [blue]queued[/blue]")
419
+ _console_print("👷 request is [blue]queued[/blue]")
253
420
 
254
421
  elif state == "running":
255
- console.print("🚀 request is [blue]running[/blue]")
256
- console.print(f"🚢 artifacts [blue]{request['run']['artifacts']}[/blue]")
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
- console.print(f"🚢 artifacts [blue]{request['run']['artifacts']}[/blue]")
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
- console.print("✅ tests passed", style="green")
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
- console.print(f"❌ tests {overall}", style="red")
437
+ _console_print(f"❌ tests {overall}", style="red")
270
438
  if overall == "error":
271
- console.print(f"{request['result']['summary']}", style="red")
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
- console.print(f"📛 pipeline error\n{request['result']['summary']}", style="red")
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] = typer.Option(
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, "-c", "--context", metavar="key=value", help="Context variables to pass to `tmt`."
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, "-t", "--tag", metavar="key=value", help="Tag cloud resources with given value."
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] = typer.Option(
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 conneted to the VPN.
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
- console.print(f"🌎 ssh root@{guest}")
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(f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null root@{guest}")
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
- except IndexError:
100
- exit_error(f"Options for {name} are invalid, must be defined as `key=value`")
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.17
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,,
@@ -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,,