tft-cli 0.0.22__tar.gz → 0.0.23__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tft-cli
3
- Version: 0.0.22
3
+ Version: 0.0.23
4
4
  Summary: Testing Farm CLI tool
5
5
  License: Apache-2.0
6
6
  Author: Miroslav Vadkerti
@@ -11,11 +11,12 @@ Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.9
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
- Requires-Dist: click (>=8.0.4,<8.1.0)
14
+ Requires-Dist: click (>=8.0.4,<9.0.0)
15
15
  Requires-Dist: colorama (>=0.4.4,<0.5.0)
16
- Requires-Dist: dynaconf (>=3.1.7,<4.0.0)
16
+ Requires-Dist: dynaconf (>=3.1.2,<4.0.0)
17
17
  Requires-Dist: requests (>=2.27.1,<3.0.0)
18
- Requires-Dist: rich (>=12,<13)
19
- Requires-Dist: ruamel-yaml (>=0.18.6,<0.19.0)
18
+ Requires-Dist: rich (>=12)
19
+ Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
20
20
  Requires-Dist: setuptools
21
- Requires-Dist: typer[all] (>=0.7.0,<0.8.0)
21
+ Requires-Dist: shellingham (>=1.3.0,<2.0.0)
22
+ Requires-Dist: typer (>=0.11)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "tft-cli"
3
- version = "0.0.22"
3
+ version = "0.0.23"
4
4
  description = "Testing Farm CLI tool"
5
5
  authors = ["Miroslav Vadkerti <mvadkert@redhat.com>"]
6
6
  license = "Apache-2.0"
@@ -14,16 +14,18 @@ testing-farm = "tft.cli.tool:app"
14
14
 
15
15
  [tool.poetry.dependencies]
16
16
  python = "^3.9"
17
- typer = {extras = ["all"], version = "^0.7.0"}
18
17
  # click 8.1.0 broke typer 0.4.0
19
18
  # https://github.com/tiangolo/typer/pull/375
20
- click = "~8.0.4"
21
- dynaconf = "^3.1.7"
19
+ click = "^8.0.4"
20
+ typer = ">=0.11"
21
+ dynaconf = "^3.1.2"
22
22
  colorama = "^0.4.4"
23
23
  requests = "^2.27.1"
24
- rich = "^12"
25
- ruamel-yaml = "^0.18.6"
24
+ rich = ">=12"
25
+ ruamel-yaml = "^0.18.5"
26
26
  setuptools = "*"
27
+ # can be removed after bumping typer to ">=0.12"
28
+ shellingham = ">=1.3.0,<2.0.0"
27
29
 
28
30
  [tool.poetry.dev-dependencies]
29
31
  pyre-check = "^0.9.10"
@@ -19,7 +19,8 @@ from typing import Any, Dict, List, Optional
19
19
  import pkg_resources
20
20
  import requests
21
21
  import typer
22
- from rich import print
22
+ from click.core import ParameterSource # pyre-ignore[21]
23
+ from rich import print, print_json
23
24
  from rich.progress import Progress, SpinnerColumn, TextColumn
24
25
  from rich.table import Table
25
26
 
@@ -40,13 +41,14 @@ from tft.cli.utils import (
40
41
 
41
42
  cli_version: str = pkg_resources.get_distribution("tft-cli").version
42
43
 
43
- TestingFarmRequestV1: Dict[str, Any] = {'api_key': None, 'test': {}, 'environments': None}
44
+ TestingFarmRequestV1: Dict[str, Any] = {'test': {}, 'environments': None}
44
45
  Environment: Dict[str, Any] = {'arch': None, 'os': None, 'pool': None, 'artifacts': None, 'variables': {}}
45
46
  TestTMT: Dict[str, Any] = {'url': None, 'ref': None, 'name': None}
46
47
  TestSTI: Dict[str, Any] = {'url': None, 'ref': None}
47
48
 
48
49
  REQUEST_PANEL_TMT = "TMT Options"
49
50
  REQUEST_PANEL_STI = "STI Options"
51
+ REQUEST_PANEL_RESERVE = "Reserve Options"
50
52
 
51
53
  RESERVE_PANEL_GENERAL = "General Options"
52
54
  RESERVE_PANEL_ENVIRONMENT = "Environment Options"
@@ -56,8 +58,10 @@ RUN_REPO = "https://gitlab.com/testing-farm/tests"
56
58
  RUN_PLAN = "/testing-farm/sanity"
57
59
 
58
60
  RESERVE_PLAN = os.getenv("TESTING_FARM_RESERVE_PLAN", "/testing-farm/reserve")
61
+ RESERVE_TEST = os.getenv("TESTING_FARM_RESERVE_TEST", "/testing-farm/reserve-system")
59
62
  RESERVE_URL = os.getenv("TESTING_FARM_RESERVE_URL", "https://gitlab.com/testing-farm/tests")
60
63
  RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
64
+ RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
61
65
 
62
66
  DEFAULT_PIPELINE_TIMEOUT = 60 * 12
63
67
 
@@ -174,10 +178,14 @@ OPTION_POOL: Optional[str] = typer.Option(
174
178
  rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
175
179
  )
176
180
  OPTION_REDHAT_BREW_BUILD: List[str] = typer.Option(
177
- None, help="Brew build task IDs to install on the test environment.", rich_help_panel=RESERVE_PANEL_ENVIRONMENT
181
+ None,
182
+ help="Brew build task IDs or build NVRs to install on the test environment.",
183
+ rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
178
184
  )
179
185
  OPTION_FEDORA_KOJI_BUILD: List[str] = typer.Option(
180
- None, help="Koji build task IDs to install on the test environment.", rich_help_panel=RESERVE_PANEL_ENVIRONMENT
186
+ None,
187
+ help="Koji build task IDs or build NVRs to install on the test environment.",
188
+ rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
181
189
  )
182
190
  OPTION_FEDORA_COPR_BUILD: List[str] = typer.Option(
183
191
  None,
@@ -241,6 +249,203 @@ OPTION_TAGS = typer.Option(
241
249
  metavar="key=value|@file",
242
250
  help="Tag cloud resources with given value. The @ prefix marks a yaml file to load.",
243
251
  )
252
+ OPTION_RESERVE: bool = typer.Option(
253
+ False,
254
+ help="Reserve machine after testing, similarly to the `reserve` command.",
255
+ rich_help_panel=REQUEST_PANEL_RESERVE,
256
+ )
257
+
258
+
259
+ def _option_autoconnect(panel: str) -> bool:
260
+ return typer.Option(True, help="Automatically connect to the guest via SSH.", rich_help_panel=panel)
261
+
262
+
263
+ def _option_ssh_public_keys(panel: str) -> List[str]:
264
+ return typer.Option(
265
+ ["~/.ssh/*.pub"],
266
+ "--ssh-public-key",
267
+ help="Path to SSH public key(s) used to connect. Supports globbing.",
268
+ rich_help_panel=panel,
269
+ )
270
+
271
+
272
+ def _option_reservation_duration(panel: str) -> int:
273
+ return typer.Option(
274
+ settings.DEFAULT_RESERVATION_DURATION,
275
+ "--duration",
276
+ help="Set the reservation duration in minutes. By default the reservation is for 30 minutes.",
277
+ rich_help_panel=panel,
278
+ )
279
+
280
+
281
+ def _generate_tmt_extra_args(step: str) -> Optional[List[str]]:
282
+ return typer.Option(
283
+ None,
284
+ help=(
285
+ f"Additional options passed to the \"{step}\" step. "
286
+ "Can be specified multiple times for multiple additions."
287
+ ),
288
+ rich_help_panel=REQUEST_PANEL_TMT,
289
+ )
290
+
291
+
292
+ def _sanity_reserve() -> None:
293
+ """
294
+ Sanity checks for reservation support.
295
+ """
296
+
297
+ # Check of SSH_AUTH_SOCK is defined
298
+ ssh_auth_sock = os.getenv("SSH_AUTH_SOCK")
299
+ if not ssh_auth_sock:
300
+ exit_error(
301
+ "No 'ssh-agent' seems to be running, it is required for reservations to work, cannot continue.\n"
302
+ "SSH_AUTH_SOCK is not defined, make sure the ssh-agent is running by executing 'eval `ssh-agent`'."
303
+ )
304
+
305
+ # Check if SSH_AUTH_SOCK exists
306
+ if not os.path.exists(ssh_auth_sock):
307
+ exit_error(
308
+ "SSH_AUTH_SOCK socket does not exist, make sure the ssh-agent is running by executing 'eval `ssh-agent`'."
309
+ )
310
+
311
+ # Check if value of SSH_AUTH_SOCK is socket
312
+ if not stat.S_ISSOCK(os.stat(ssh_auth_sock).st_mode):
313
+ exit_error("SSH_AUTH_SOCK is not a socket, make sure the ssh-agent is running by executing 'eval `ssh-agent`'.")
314
+
315
+ # Check if ssh-add -L is not empty
316
+ ssh_add_output = subprocess.run(["ssh-add", "-L"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
317
+ if ssh_add_output.returncode != 0:
318
+ exit_error("No SSH identities found in the SSH agent. Please run `ssh-add`.")
319
+
320
+
321
+ def _handle_reservation(session, request_id: str, autoconnect: bool = False) -> None:
322
+ """
323
+ Handle the reservation for :py:func:``request`` and :py:func:``restart`` commands.
324
+ """
325
+ # Get artifacts url
326
+ request_url = urllib.parse.urljoin(settings.API_URL, f"/v0.1/requests/{request_id}")
327
+ response = session.get(request_url)
328
+ artifacts_url = response.json()['run']['artifacts']
329
+
330
+ try:
331
+ pipeline_log = session.get(f"{artifacts_url}/pipeline.log").text
332
+
333
+ if not pipeline_log:
334
+ exit_error(f"Pipeline log was empty. Please file an issue to {settings.ISSUE_TRACKER}.")
335
+
336
+ except requests.exceptions.SSLError:
337
+ exit_error(
338
+ textwrap.dedent(
339
+ f"""
340
+ Failed to access Testing Farm artifacts because of SSL validation error.
341
+ If you use Red Hat Ranch please make sure you have Red Hat CA certificates installed.
342
+ Otherwise file an issue to {settings.ISSUE_TRACKER}.
343
+ """
344
+ )
345
+ )
346
+ return
347
+
348
+ except requests.exceptions.ConnectionError:
349
+ exit_error(
350
+ textwrap.dedent(
351
+ f"""
352
+ Failed to access Testing Farm artifacts.
353
+ If you use Red Hat Ranch please make sure you are connected to the VPN.
354
+ Otherwise file an issue to {settings.ISSUE_TRACKER}.
355
+ """
356
+ )
357
+ )
358
+ return
359
+
360
+ # match any hostname or IP address from gluetool modules log
361
+ guests = re.findall(r'Guest is ready.*root@([\d\w\.-]+)', pipeline_log)
362
+
363
+ if not guests:
364
+ exit_error(
365
+ textwrap.dedent(
366
+ f"""
367
+ No guests found to connect to. This is unexpected, please file an issue
368
+ to {settings.ISSUE_TRACKER}.
369
+ """
370
+ )
371
+ )
372
+
373
+ if len(guests) > 1:
374
+ for guest in guests:
375
+ console.print(f"🌎 ssh root@{guest}")
376
+ return
377
+ else:
378
+ console.print(f"🌎 ssh root@{guests[0]}")
379
+
380
+ if autoconnect:
381
+ os.system(f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null root@{guests[0]}") # noqa: E501
382
+
383
+
384
+ def _localhost_ingress_rule(session: requests.Session) -> str:
385
+ try:
386
+ get_ip = session.get(settings.PUBLIC_IP_CHECKER_URL)
387
+ except requests.exceptions.RequestException as err:
388
+ exit_error(f"Could not get workstation ip to form a security group rule: {err}")
389
+
390
+ if get_ip.ok:
391
+ ip = get_ip.text.strip()
392
+ return f"-1:{ip}:-1"
393
+
394
+ else:
395
+ exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
396
+
397
+
398
+ def _add_reservation(ssh_public_keys: List[str], rules: Dict[str, Any], duration: int, environment: Dict[str, Any]):
399
+ """
400
+ Add discovery of the reservation test to the given environment.
401
+ """
402
+ authorized_keys = read_glob_paths(ssh_public_keys).encode("utf-8")
403
+ if not authorized_keys:
404
+ exit_error(f"No public SSH keys found under {', '.join(ssh_public_keys)}, cannot continue.")
405
+
406
+ authorized_keys_bytes = base64.b64encode(authorized_keys)
407
+
408
+ if "secrets" not in environment or environment["secrets"] is None:
409
+ environment["secrets"] = {}
410
+
411
+ environment["secrets"].update({"TF_RESERVATION_AUTHORIZED_KEYS_BASE64": authorized_keys_bytes.decode("utf-8")})
412
+
413
+ if "settings" not in environment or environment["settings"] is None:
414
+ environment["settings"] = {}
415
+
416
+ if "provisioning" not in environment["settings"] or environment["settings"]["provisioning"] is None:
417
+ environment["settings"]["provisioning"] = {}
418
+
419
+ environment["settings"]["provisioning"].update(rules)
420
+
421
+ if "variables" not in environment or environment["variables"] is None:
422
+ environment["variables"] = {}
423
+
424
+ environment["variables"].update({"TF_RESERVATION_DURATION": str(duration)})
425
+
426
+ if "tmt" not in environment or environment["tmt"] is None:
427
+ environment["tmt"] = {"extra_args": {}}
428
+
429
+ if "extra_args" not in environment["tmt"] or environment["tmt"]["extra_args"] is None:
430
+ environment["tmt"]["extra_args"] = {}
431
+
432
+ if "discover" not in environment["tmt"]["extra_args"] or environment["tmt"]["extra_args"]["discover"] is None:
433
+ environment["tmt"]["extra_args"]["discover"] = []
434
+
435
+ # add reservation if not already present
436
+ if RESERVE_TMT_DISCOVER_EXTRA_ARGS not in environment["tmt"]["extra_args"]["discover"]:
437
+ environment["tmt"]["extra_args"]["discover"].append(RESERVE_TMT_DISCOVER_EXTRA_ARGS)
438
+
439
+
440
+ def _contains_compose(environments: List[Dict[str, Any]]):
441
+ """
442
+ Returns true if any of environments has ``os.compose`` defined.
443
+ """
444
+ for environment in environments:
445
+ if "os" in environment and environment["os"]:
446
+ if "compose" in environment["os"] and environment["os"]["compose"]:
447
+ return True
448
+ return False
244
449
 
245
450
 
246
451
  # NOTE(ivasilev) Largely borrowed from artemis-cli
@@ -288,6 +493,14 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
288
493
  return security_group_rules
289
494
 
290
495
 
496
+ def _get_headers(api_key: str) -> Dict[str, str]:
497
+ """
498
+ Return a dict with headers for a request to Testing Farm API.
499
+ Used for authentication.
500
+ """
501
+ return {'Authorization': f'Bearer {api_key}'}
502
+
503
+
291
504
  def _parse_xunit(xunit: str):
292
505
  """
293
506
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
@@ -307,13 +520,14 @@ def _parse_xunit(xunit: str):
307
520
 
308
521
  failed_plans = {}
309
522
  passed_plans = {}
523
+ skipped_plans = {}
310
524
  errored_plans = {}
311
525
 
312
526
  results_root = ET.fromstring(xunit)
313
527
  for plan in results_root.findall('./testsuite'):
314
- # Try to get information about the environment (stored under testcase/testing-environment), may be
528
+ # Try to get information about the environment (stored under ./testing-environment), may be
315
529
  # absent if state is undefined
316
- testing_environment: Optional[ET.Element] = plan.find('./testcase/testing-environment[@name="requested"]')
530
+ testing_environment: Optional[ET.Element] = plan.find('./testing-environment[@name="requested"]')
317
531
  if not testing_environment:
318
532
  console_stderr.print(
319
533
  f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
@@ -331,13 +545,15 @@ def _parse_xunit(xunit: str):
331
545
  _add_plan(passed_plans, arch, plan)
332
546
  elif plan.get('result') == 'failed':
333
547
  _add_plan(failed_plans, arch, plan)
548
+ elif plan.get('result') == 'skipped':
549
+ _add_plan(skipped_plans, arch, plan)
334
550
  else:
335
551
  _add_plan(errored_plans, arch, plan)
336
552
 
337
553
  # Let's remove possible duplicates among N/A errored out tests
338
554
  if 'N/A' in errored_plans:
339
555
  errored_plans['N/A'] = list(set(errored_plans['N/A']))
340
- return passed_plans, failed_plans, errored_plans
556
+ return passed_plans, failed_plans, skipped_plans, errored_plans
341
557
 
342
558
 
343
559
  def _get_request_summary(request: dict, session: requests.Session):
@@ -355,7 +571,7 @@ def _get_request_summary(request: dict, session: requests.Session):
355
571
  xunit = response.text
356
572
  except requests.exceptions.ConnectionError:
357
573
  console_stderr.print("Could not get xunit results")
358
- passed_plans, failed_plans, errored_plans = _parse_xunit(xunit)
574
+ passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit)
359
575
  overall = (request.get("result") or {}).get("overall")
360
576
  arches_requested = [env['arch'] for env in request['environments_requested']]
361
577
 
@@ -367,6 +583,7 @@ def _get_request_summary(request: dict, session: requests.Session):
367
583
  'arches_requested': arches_requested,
368
584
  'errored_plans': errored_plans,
369
585
  'failed_plans': failed_plans,
586
+ 'skipped_plans': skipped_plans,
370
587
  'passed_plans': passed_plans,
371
588
  }
372
589
 
@@ -385,6 +602,7 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
385
602
  # Let's transform plans maps into collection of plans to display plan result per arch statistics
386
603
  errored = _get_plans_list(summary['errored_plans'])
387
604
  failed = _get_plans_list(summary['failed_plans'])
605
+ skipped = _get_plans_list(summary['skipped_plans'])
388
606
  passed = _get_plans_list(summary['passed_plans'])
389
607
  generic_info_table = Table(show_header=True, header_style="bold magenta")
390
608
  arches_requested = summary['arches_requested']
@@ -399,11 +617,12 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
399
617
  ','.join(arches_requested),
400
618
  str(len(errored)),
401
619
  str(len(failed)),
620
+ str(len(skipped)),
402
621
  str(len(passed)),
403
622
  )
404
623
  console.print(generic_info_table)
405
624
 
406
- all_plans = sorted(set(errored + failed + passed))
625
+ all_plans = sorted(set(errored + failed + skipped + passed))
407
626
  details_table = Table(show_header=True, header_style="bold magenta")
408
627
  for column in ["plan"] + arches_requested:
409
628
  details_table.add_column(column)
@@ -413,6 +632,8 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
413
632
  for arch in arches_requested:
414
633
  if _has_plan(summary['passed_plans'], arch, plan):
415
634
  res = '[green]pass[/green]'
635
+ elif _has_plan(summary['skipped_plans'], arch, plan):
636
+ res = '[white]skip[/white]'
416
637
  elif _has_plan(summary['failed_plans'], arch, plan):
417
638
  res = '[red]fail[/red]'
418
639
  elif _has_plan(summary['errored_plans'], 'N/A', plan):
@@ -432,6 +653,8 @@ def watch(
432
653
  id: str = typer.Option(..., help="Request ID to watch"),
433
654
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
434
655
  format: Optional[WatchFormat] = typer.Option(WatchFormat.text, help="Output format"),
656
+ autoconnect: bool = typer.Option(True, hidden=True),
657
+ reserve: bool = typer.Option(False, hidden=True),
435
658
  ):
436
659
  def _console_print(*args, **kwargs):
437
660
  """A helper function that will skip printing to console if output format is json"""
@@ -458,6 +681,24 @@ def watch(
458
681
  session = requests.Session()
459
682
  install_http_retries(session)
460
683
 
684
+ def _is_reserved(session, request):
685
+ artifacts_url = (request.get('run') or {}).get('artifacts')
686
+
687
+ if not artifacts_url:
688
+ return False
689
+
690
+ try:
691
+ workdir = re.search(r'href="(.*)" name="workdir"', session.get(f"{artifacts_url}/results.xml").text)
692
+ except requests.exceptions.SSLError:
693
+ exit_error("Artifacts unreachable via SSL, do you have RH CA certificates installed?[/yellow]")
694
+
695
+ if workdir:
696
+ # finish early if reservation is running
697
+ if re.search(r"\[\+\] Reservation tick:", session.get(f"{workdir.group(1)}/log.txt").text):
698
+ return True
699
+
700
+ return False
701
+
461
702
  while True:
462
703
  try:
463
704
  response = session.get(get_url)
@@ -477,6 +718,11 @@ def watch(
477
718
  state = request["state"]
478
719
 
479
720
  if state == current_state:
721
+ # check for reservation status and finish early if reserved
722
+ if reserve and _is_reserved(session, request):
723
+ _handle_reservation(session, request["id"], autoconnect)
724
+ return
725
+
480
726
  time.sleep(1)
481
727
  continue
482
728
 
@@ -539,7 +785,7 @@ def version():
539
785
  def request(
540
786
  api_url: str = ARGUMENT_API_URL,
541
787
  api_token: str = ARGUMENT_API_TOKEN,
542
- timeout: Optional[int] = typer.Option(
788
+ timeout: int = typer.Option(
543
789
  DEFAULT_PIPELINE_TIMEOUT,
544
790
  help="Set the timeout for the request in minutes. If the test takes longer than this, it will be terminated.",
545
791
  ),
@@ -624,6 +870,13 @@ def request(
624
870
  None, help="URL of the icon of the user's webpage. It will be shown in the results viewer."
625
871
  ),
626
872
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
873
+ tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
874
+ tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
875
+ tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
876
+ reserve: bool = OPTION_RESERVE,
877
+ ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
878
+ autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
879
+ reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
627
880
  ):
628
881
  """
629
882
  Request testing from Testing Farm.
@@ -653,6 +906,9 @@ def request(
653
906
  git_url = str(settings.TESTING_FARM_TESTS_GIT_URL)
654
907
  tmt_plan_name = str(settings.TESTING_FARM_SANITY_PLAN)
655
908
 
909
+ if reserve:
910
+ _sanity_reserve()
911
+
656
912
  # resolve git repository details from the current repository
657
913
  if not git_url:
658
914
  if not git_available:
@@ -687,7 +943,7 @@ def request(
687
943
  git_ref = cmd_output_or_exit("git rev-parse HEAD", "could not autodetect git ref")
688
944
 
689
945
  # detect test type from local files
690
- if os.path.exists(".fmf/version"):
946
+ if os.path.exists(os.path.join((tmt_path or ""), ".fmf/version")):
691
947
  test_type = "fmf"
692
948
  elif os.path.exists("tests/tests.yml"):
693
949
  test_type = "sti"
@@ -780,8 +1036,42 @@ def request(
780
1036
  if tmt_environment:
781
1037
  environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
782
1038
 
1039
+ if tmt_discover or tmt_prepare or tmt_finish:
1040
+ if "extra_args" not in environment["tmt"]:
1041
+ environment["tmt"]["extra_args"] = {}
1042
+
1043
+ if tmt_discover:
1044
+ environment["tmt"]["extra_args"]["discover"] = tmt_discover
1045
+
1046
+ if tmt_prepare:
1047
+ environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1048
+
1049
+ if tmt_finish:
1050
+ environment["tmt"]["extra_args"]["finish"] = tmt_finish
1051
+
783
1052
  environments.append(environment)
784
1053
 
1054
+ # Setting up retries
1055
+ session = requests.Session()
1056
+ install_http_retries(session)
1057
+
1058
+ if reserve:
1059
+ if not _contains_compose(environments):
1060
+ exit_error("Reservations are not supported with container executions, cannot continue")
1061
+
1062
+ if len(environments) > 1:
1063
+ exit_error("Reservations are currently supported for a single plan, cannot continue")
1064
+
1065
+ rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1066
+
1067
+ for environment in environments:
1068
+ _add_reservation(
1069
+ ssh_public_keys=ssh_public_keys, rules=rules, duration=reservation_duration, environment=environment
1070
+ )
1071
+
1072
+ machine_pre = "Machine" if len(environments) == 1 else str(len(environments)) + " machines"
1073
+ console.print(f"🛟 {machine_pre} will be reserved after testing")
1074
+
785
1075
  if any(
786
1076
  provisioning_detail
787
1077
  for provisioning_detail in [
@@ -817,7 +1107,6 @@ def request(
817
1107
 
818
1108
  # create final request
819
1109
  request = TestingFarmRequestV1
820
- request["api_key"] = api_token
821
1110
  if test_type == "fmf":
822
1111
  test["path"] = tmt_path
823
1112
  request["test"]["fmf"] = test
@@ -826,7 +1115,18 @@ def request(
826
1115
 
827
1116
  request["environments"] = environments
828
1117
  request["settings"] = {}
829
- request["settings"]["pipeline"] = {"timeout": timeout}
1118
+
1119
+ if reserve or pipeline_type or parallel_limit:
1120
+ request["settings"]["pipeline"] = {}
1121
+
1122
+ # in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
1123
+ if reserve:
1124
+ if reservation_duration > timeout:
1125
+ request["settings"]["pipeline"] = {"timeout": reservation_duration}
1126
+ console.print(f"⏳ Maximum reservation time is {reservation_duration} minutes")
1127
+ else:
1128
+ request["settings"]["pipeline"] = {"timeout": timeout}
1129
+ console.print(f"⏳ Maximum reservation time is {timeout} minutes")
830
1130
 
831
1131
  if pipeline_type:
832
1132
  request["settings"]["pipeline"]["type"] = pipeline_type.value
@@ -849,18 +1149,14 @@ def request(
849
1149
  # submit request to Testing Farm
850
1150
  post_url = urllib.parse.urljoin(api_url, "v0.1/requests")
851
1151
 
852
- # Setting up retries
853
- session = requests.Session()
854
- install_http_retries(session)
855
-
856
1152
  # dry run
857
1153
  if dry_run:
858
1154
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
859
- print(json.dumps(request, indent=4, separators=(',', ': ')))
1155
+ print_json(json.dumps(request, indent=4, separators=(',', ': ')))
860
1156
  raise typer.Exit()
861
1157
 
862
1158
  # handle errors
863
- response = session.post(post_url, json=request)
1159
+ response = session.post(post_url, json=request, headers=_get_headers(api_token))
864
1160
  if response.status_code == 401:
865
1161
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
866
1162
 
@@ -874,11 +1170,14 @@ def request(
874
1170
  print(response.text)
875
1171
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
876
1172
 
877
- # watch
878
- watch(api_url, response.json()['id'], no_wait, format=WatchFormat.text)
1173
+ request_id = response.json()['id']
1174
+
1175
+ # Watch the request and handle reservation
1176
+ watch(api_url, request_id, no_wait, reserve=reserve, autoconnect=autoconnect, format=WatchFormat.text)
879
1177
 
880
1178
 
881
1179
  def restart(
1180
+ context: typer.Context,
882
1181
  request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
883
1182
  api_url: str = ARGUMENT_API_URL,
884
1183
  internal_api_url: str = typer.Argument(
@@ -905,12 +1204,19 @@ def restart(
905
1204
  tmt_plan_filter: Optional[str] = OPTION_TMT_PLAN_FILTER,
906
1205
  tmt_test_name: Optional[str] = OPTION_TMT_TEST_NAME,
907
1206
  tmt_test_filter: Optional[str] = OPTION_TMT_TEST_FILTER,
908
- tmt_path: str = OPTION_TMT_PATH,
1207
+ tmt_path: Optional[str] = OPTION_TMT_PATH,
1208
+ tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1209
+ tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1210
+ tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
909
1211
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
910
1212
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
911
1213
  dry_run: bool = OPTION_DRY_RUN,
912
1214
  pipeline_type: Optional[PipelineType] = OPTION_PIPELINE_TYPE,
913
1215
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
1216
+ reserve: bool = OPTION_RESERVE,
1217
+ ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
1218
+ autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
1219
+ reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
914
1220
  ):
915
1221
  """
916
1222
  Restart a Testing Farm request.
@@ -932,14 +1238,14 @@ def restart(
932
1238
  _request_id = uuid_match.group()
933
1239
 
934
1240
  # Construct URL to the internal API
935
- get_url = urllib.parse.urljoin(str(internal_api_url), f"v0.1/requests/{_request_id}?api_key={api_token}")
1241
+ get_url = urllib.parse.urljoin(str(internal_api_url), f"v0.1/requests/{_request_id}")
936
1242
 
937
1243
  # Setting up retries
938
1244
  session = requests.Session()
939
1245
  install_http_retries(session)
940
1246
 
941
1247
  # Get the request details
942
- response = session.get(get_url)
1248
+ response = session.get(get_url, headers=_get_headers(api_token))
943
1249
 
944
1250
  if response.status_code == 401:
945
1251
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1018,6 +1324,25 @@ def restart(
1018
1324
  for environment in request['environments']:
1019
1325
  environment["pool"] = pool
1020
1326
 
1327
+ if tmt_discover or tmt_prepare or tmt_finish:
1328
+ for environment in request["environments"]:
1329
+ if "tmt" not in environment:
1330
+ environment["tmt"] = {"extra_args": {}}
1331
+ if "extra_args" not in environment["tmt"]:
1332
+ environment["tmt"]["extra_args"] = {}
1333
+
1334
+ if tmt_discover:
1335
+ for environment in request["environments"]:
1336
+ environment["tmt"]["extra_args"]["discover"] = tmt_discover
1337
+
1338
+ if tmt_prepare:
1339
+ for environment in request["environments"]:
1340
+ environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1341
+
1342
+ if tmt_finish:
1343
+ for environment in request["environments"]:
1344
+ environment["tmt"]["extra_args"]["finish"] = tmt_finish
1345
+
1021
1346
  test_type = "fmf" if "fmf" in request["test"] else "sti"
1022
1347
 
1023
1348
  if tmt_plan_name:
@@ -1031,7 +1356,9 @@ def restart(
1031
1356
  request["test"][test_type]["plan_filter"] = tmt_plan_filter
1032
1357
 
1033
1358
  if test_type == "fmf":
1034
- request["test"][test_type]["path"] = tmt_path
1359
+ # The method explained in https://github.com/fastapi/typer/discussions/668
1360
+ if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE: # pyre-ignore[16]
1361
+ request["test"][test_type]["path"] = tmt_path
1035
1362
 
1036
1363
  # worker image
1037
1364
  if worker_image:
@@ -1041,9 +1368,6 @@ def restart(
1041
1368
  # it is required to have also pipeline key set, otherwise API will fail
1042
1369
  request["settings"]["pipeline"] = request["settings"].get("pipeline", {})
1043
1370
 
1044
- # Add API key
1045
- request['api_key'] = api_token
1046
-
1047
1371
  if pipeline_type or parallel_limit:
1048
1372
  if "settings" not in request:
1049
1373
  request["settings"] = {}
@@ -1066,6 +1390,27 @@ def restart(
1066
1390
 
1067
1391
  environment["settings"]["provisioning"]["tags"] = options_to_dict("tags", tags)
1068
1392
 
1393
+ if reserve:
1394
+ if not _contains_compose(request["environments"]):
1395
+ exit_error("Reservations are not supported with container executions, cannot continue")
1396
+
1397
+ if len(request["environments"]) > 1:
1398
+ exit_error("Reservations are currently supported for a single plan, cannot continue")
1399
+
1400
+ rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1401
+
1402
+ for environment in request["environments"]:
1403
+ _add_reservation(
1404
+ ssh_public_keys=ssh_public_keys, rules=rules, duration=reservation_duration, environment=environment
1405
+ )
1406
+
1407
+ machine_pre = (
1408
+ "Machine" if len(request["environments"]) == 1 else str(len(request["environments"])) + " machines"
1409
+ )
1410
+ console.print(
1411
+ f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
1412
+ )
1413
+
1069
1414
  # dry run
1070
1415
  if dry_run:
1071
1416
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
@@ -1076,7 +1421,7 @@ def restart(
1076
1421
  post_url = urllib.parse.urljoin(str(api_url), "v0.1/requests")
1077
1422
 
1078
1423
  # handle errors
1079
- response = session.post(post_url, json=request)
1424
+ response = session.post(post_url, json=request, headers=_get_headers(api_token))
1080
1425
  if response.status_code == 401:
1081
1426
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1082
1427
 
@@ -1091,7 +1436,9 @@ def restart(
1091
1436
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
1092
1437
 
1093
1438
  # watch
1094
- watch(str(api_url), response.json()['id'], no_wait, format=WatchFormat.text)
1439
+ watch(
1440
+ str(api_url), response.json()['id'], no_wait, reserve=reserve, autoconnect=autoconnect, format=WatchFormat.text
1441
+ )
1095
1442
 
1096
1443
 
1097
1444
  def run(
@@ -1118,7 +1465,6 @@ def run(
1118
1465
 
1119
1466
  # create request
1120
1467
  request = TestingFarmRequestV1
1121
- request["api_key"] = settings.API_TOKEN
1122
1468
 
1123
1469
  test = TestTMT
1124
1470
  test["url"] = RUN_REPO
@@ -1162,7 +1508,7 @@ def run(
1162
1508
  raise typer.Exit()
1163
1509
 
1164
1510
  # handle errors
1165
- response = session.post(post_url, json=request)
1511
+ response = session.post(post_url, json=request, headers=_get_headers(settings.API_TOKEN))
1166
1512
  if response.status_code == 401:
1167
1513
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1168
1514
 
@@ -1254,18 +1600,8 @@ def run(
1254
1600
 
1255
1601
 
1256
1602
  def reserve(
1257
- ssh_public_keys: List[str] = typer.Option(
1258
- ["~/.ssh/*.pub"],
1259
- "--ssh-public-key",
1260
- help="Path to SSH public key(s) used to connect. Supports globbing.",
1261
- rich_help_panel=RESERVE_PANEL_GENERAL,
1262
- ),
1263
- reservation_duration: int = typer.Option(
1264
- 30,
1265
- "--duration",
1266
- help="Set the reservation duration in minutes. By default the reservation is for 30 minutes.",
1267
- rich_help_panel=RESERVE_PANEL_GENERAL,
1268
- ),
1603
+ ssh_public_keys: List[str] = _option_ssh_public_keys(RESERVE_PANEL_GENERAL),
1604
+ reservation_duration: int = _option_reservation_duration(RESERVE_PANEL_GENERAL),
1269
1605
  arch: str = typer.Option(
1270
1606
  "x86_64", help="Hardware platform of the system to be provisioned.", rich_help_panel=RESERVE_PANEL_ENVIRONMENT
1271
1607
  ),
@@ -1290,15 +1626,16 @@ def reserve(
1290
1626
  help="Output only the request ID.",
1291
1627
  rich_help_panel=RESERVE_PANEL_OUTPUT,
1292
1628
  ),
1293
- autoconnect: bool = typer.Option(
1294
- True, help="Automatically connect to the guest via SSH.", rich_help_panel=RESERVE_PANEL_GENERAL
1295
- ),
1629
+ autoconnect: bool = _option_autoconnect(RESERVE_PANEL_GENERAL),
1296
1630
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
1297
1631
  security_group_rule_ingress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_INGRESS,
1298
1632
  security_group_rule_egress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_EGRESS,
1299
1633
  skip_workstation_access: bool = typer.Option(
1300
1634
  False, help="Do not allow ingress traffic from this workstation's ip to the reserved machine"
1301
1635
  ),
1636
+ git_ref: Optional[str] = typer.Option(
1637
+ None, help="Force GIT ref or branch. Useful for testing changes to reservation plan."
1638
+ ),
1302
1639
  ):
1303
1640
  """
1304
1641
  Reserve a system in Testing Farm.
@@ -1308,27 +1645,7 @@ def reserve(
1308
1645
  if not print_only_request_id:
1309
1646
  console.print(message)
1310
1647
 
1311
- # Sanity checks for ssh-agent
1312
-
1313
- # Check of SSH_AUTH_SOCK is defined
1314
- ssh_auth_sock = os.getenv("SSH_AUTH_SOCK")
1315
- if not ssh_auth_sock:
1316
- exit_error("SSH_AUTH_SOCK is not defined, make sure the ssh-agent is running by executing 'eval `ssh-agent`'.")
1317
-
1318
- # Check if SSH_AUTH_SOCK exists
1319
- if not os.path.exists(ssh_auth_sock):
1320
- exit_error(
1321
- "SSH_AUTH_SOCK socket does not exist, make sure the ssh-agent is running by executing 'eval `ssh-agent`'."
1322
- )
1323
-
1324
- # Check if value of SSH_AUTH_SOCK is socket
1325
- if not stat.S_ISSOCK(os.stat(ssh_auth_sock).st_mode):
1326
- exit_error("SSH_AUTH_SOCK is not a socket, make sure the ssh-agent is running by executing 'eval `ssh-agent`'.")
1327
-
1328
- # Check if ssh-add -L is not empty
1329
- ssh_add_output = subprocess.run(["ssh-add", "-L"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1330
- if ssh_add_output.returncode != 0:
1331
- exit_error("No SSH identities found in the SSH agent. Please run `ssh-add`.")
1648
+ _sanity_reserve()
1332
1649
 
1333
1650
  # check for token
1334
1651
  if not settings.API_TOKEN:
@@ -1340,7 +1657,7 @@ def reserve(
1340
1657
  # test details
1341
1658
  test = TestTMT
1342
1659
  test["url"] = RESERVE_URL
1343
- test["ref"] = RESERVE_REF
1660
+ test["ref"] = git_ref or RESERVE_REF
1344
1661
  test["name"] = RESERVE_PLAN
1345
1662
 
1346
1663
  # environment details
@@ -1396,20 +1713,14 @@ def reserve(
1396
1713
  if post_install_script:
1397
1714
  environment["settings"]["provisioning"]["post_install_script"] = post_install_script
1398
1715
 
1716
+ # Setting up retries
1717
+ session = requests.Session()
1718
+ install_http_retries(session)
1719
+
1399
1720
  if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
1400
1721
  ingress_rules = security_group_rule_ingress or []
1401
1722
  if not skip_workstation_access:
1402
- try:
1403
- get_ip = requests.get(settings.PUBLIC_IP_CHECKER_URL)
1404
- except requests.exceptions.RequestException as err:
1405
- exit_error(f"Could not get workstation ip to form a security group rule: {err}")
1406
-
1407
- if get_ip.ok:
1408
- ip = get_ip.text.strip()
1409
- ingress_rules.append(f'-1:{ip}:-1') # noqa: E231
1410
-
1411
- else:
1412
- exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
1723
+ ingress_rules.append(_localhost_ingress_rule(session))
1413
1724
 
1414
1725
  rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
1415
1726
  environment["settings"]["provisioning"].update(rules)
@@ -1421,16 +1732,11 @@ def reserve(
1421
1732
  if not authorized_keys:
1422
1733
  exit_error(f"No public SSH keys found under {', '.join(ssh_public_keys)}, cannot continue.")
1423
1734
 
1424
- # check for ssh-agent, we require it for a pleasant usage
1425
- if not os.getenv('SSH_AUTH_SOCK'):
1426
- exit_error("No 'ssh-agent' seems to be running, it is required for reservations to work, cannot continue.")
1427
-
1428
1735
  authorized_keys_bytes = base64.b64encode(authorized_keys)
1429
1736
  environment["secrets"] = {"TF_RESERVATION_AUTHORIZED_KEYS_BASE64": authorized_keys_bytes.decode("utf-8")}
1430
1737
 
1431
1738
  # create final request
1432
1739
  request = TestingFarmRequestV1
1433
- request["api_key"] = settings.API_TOKEN
1434
1740
  request["test"]["fmf"] = test
1435
1741
 
1436
1742
  # worker image
@@ -1451,10 +1757,6 @@ def reserve(
1451
1757
  # submit request to Testing Farm
1452
1758
  post_url = urllib.parse.urljoin(str(settings.API_URL), "v0.1/requests")
1453
1759
 
1454
- # Setting up retries
1455
- session = requests.Session()
1456
- install_http_retries(session)
1457
-
1458
1760
  # dry run
1459
1761
  if dry_run:
1460
1762
  if print_only_request_id:
@@ -1465,7 +1767,7 @@ def reserve(
1465
1767
  raise typer.Exit()
1466
1768
 
1467
1769
  # handle errors
1468
- response = session.post(post_url, json=request)
1770
+ response = session.post(post_url, json=request, headers=_get_headers(settings.API_TOKEN))
1469
1771
  if response.status_code == 401:
1470
1772
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1471
1773
 
@@ -1644,7 +1946,7 @@ def cancel(
1644
1946
  install_http_retries(session)
1645
1947
 
1646
1948
  # Get the request details
1647
- response = session.delete(request_url, json={"api_key": api_token})
1949
+ response = session.delete(request_url, headers=_get_headers(api_token))
1648
1950
 
1649
1951
  if response.status_code == 401:
1650
1952
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1662,3 +1964,74 @@ def cancel(
1662
1964
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
1663
1965
 
1664
1966
  console.print("✅ Request [yellow]cancellation requested[/yellow]. It will be canceled soon.")
1967
+
1968
+
1969
+ def encrypt(
1970
+ message: str = typer.Argument(..., help="Message to be encrypted."),
1971
+ api_url: str = ARGUMENT_API_URL,
1972
+ api_token: str = ARGUMENT_API_TOKEN,
1973
+ git_url: Optional[str] = typer.Option(
1974
+ None,
1975
+ help="URL of a GIT repository to which the secret will be tied. If not set, it is detected from the current "
1976
+ "git repository.",
1977
+ ),
1978
+ token_id: Optional[str] = typer.Option(
1979
+ None,
1980
+ help="Token ID to which the secret will be tied. If not set, Token ID will be detected from provided Token.",
1981
+ ),
1982
+ ):
1983
+ """
1984
+ Create secrets for use in in-repository configuration.
1985
+ """
1986
+
1987
+ # check for token
1988
+ if not api_token:
1989
+ exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
1990
+
1991
+ git_available = bool(shutil.which("git"))
1992
+
1993
+ # resolve git repository details from the current repository
1994
+ if not git_url:
1995
+ if not git_available:
1996
+ exit_error("no git url defined")
1997
+ git_url = cmd_output_or_exit("git remote get-url origin", "could not auto-detect git url")
1998
+ # use https instead git when auto-detected
1999
+ # GitLab: git@github.com:containers/podman.git
2000
+ # GitHub: git@gitlab.com:testing-farm/cli.git, git+ssh://git@gitlab.com/spoore/centos_rpms_jq.git
2001
+ # Pagure: ssh://git@pagure.io/fedora-ci/messages.git
2002
+ assert git_url
2003
+ git_url = re.sub(r"^(?:(?:git\+)?ssh://)?git@([^:/]*)[:/](.*)", r"https://\1/\2", git_url)
2004
+
2005
+ payload = {'url': git_url, 'message': message}
2006
+
2007
+ if token_id:
2008
+ payload['token_id'] = token_id
2009
+ console_stderr.print(f'🔒 Encrypting secret for token id {token_id} for repository {git_url}')
2010
+ else:
2011
+ console_stderr.print(f'🔒 Encrypting secret for your token in repo {git_url}')
2012
+
2013
+ # submit request to Testing Farm
2014
+ post_url = urllib.parse.urljoin(api_url, "/v0.1/secrets/encrypt")
2015
+
2016
+ session = requests.Session()
2017
+ response = session.post(post_url, json=payload, headers={'Authorization': f'Bearer {api_token}'})
2018
+
2019
+ # handle errors
2020
+ if response.status_code == 401:
2021
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2022
+
2023
+ if response.status_code == 400:
2024
+ exit_error(
2025
+ f"Request is invalid. {response.json().get('message') or 'Reason unknown.'}."
2026
+ f"\nPlease file an issue to {settings.ISSUE_TRACKER} if unsure."
2027
+ )
2028
+
2029
+ if response.status_code != 200:
2030
+ console_stderr.print(response.text)
2031
+ exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
2032
+
2033
+ console_stderr.print(
2034
+ "💡 See https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html#secrets-in-repo-config for more "
2035
+ "information on how to store the secret in repository."
2036
+ )
2037
+ console.print(response.text)
@@ -17,6 +17,8 @@ settings = LazySettings(
17
17
  WATCH_TICK=3,
18
18
  DEFAULT_API_TIMEOUT=10,
19
19
  DEFAULT_API_RETRIES=7,
20
+ # default reservation duration in minutes
21
+ DEFAULT_RESERVATION_DURATION=30,
20
22
  # should lead to delays of 0.5, 1, 2, 4, 8, 16, 32 seconds
21
23
  DEFAULT_RETRY_BACKOFF_FACTOR=1,
22
24
  # system CA certificates path, default for RHEL variants
@@ -24,5 +26,5 @@ settings = LazySettings(
24
26
  # Testing Farm sanity test,
25
27
  TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
26
28
  TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
27
- PUBLIC_IP_CHECKER_URL="http://icanhazip.com",
29
+ PUBLIC_IP_CHECKER_URL="https://ipv4.icanhazip.com",
28
30
  )
@@ -17,6 +17,7 @@ app.command()(commands.reserve)
17
17
  app.command()(commands.run)
18
18
  app.command()(commands.version)
19
19
  app.command()(commands.watch)
20
+ app.command()(commands.encrypt)
20
21
 
21
22
  # This command is available only for the container based deployment
22
23
  if os.path.exists(settings.CONTAINER_SIGN):
@@ -69,10 +69,29 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
69
69
  if not path or not value:
70
70
  exit_error(f"cannot parse hardware constraint `{raw_constraint}`")
71
71
 
72
+ path_splitted = path.split('.')
73
+ first_key = path_splitted[0]
74
+
75
+ # Special handling for network and disk as they are lists
76
+ if first_key in ("network", "disk"):
77
+ if first_key not in constraints:
78
+ constraints[first_key] = []
79
+
80
+ if len(path_splitted) > 1:
81
+ new_dict = {}
82
+ current = new_dict
83
+ # Handle all nested levels except the last one
84
+ for key in path_splitted[1:-1]:
85
+ current[key] = {}
86
+ current = current[key]
87
+ # Set the final value
88
+ current[path_splitted[-1]] = value
89
+ constraints[first_key].append(new_dict)
90
+ continue
91
+
72
92
  # Walk the path, step by step, and initialize containers along the way. The last step is not
73
93
  # a name of another nested container, but actually a name in the last container.
74
94
  container: Any = constraints
75
- path_splitted = path.split('.')
76
95
 
77
96
  while len(path_splitted) > 1:
78
97
  step = path_splitted.pop(0)
@@ -91,9 +110,7 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
91
110
  value_mixed = False
92
111
 
93
112
  container[path_splitted.pop()] = value_mixed
94
-
95
- # automatically convert disk and network values to a list, as the standard requires
96
- return {key: value if key not in ("disk", "network") else [value] for key, value in constraints.items()}
113
+ return constraints
97
114
 
98
115
 
99
116
  def options_from_file(filepath) -> Dict[str, str]:
File without changes
File without changes