tft-cli 0.0.22__py3-none-any.whl → 0.0.24__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
@@ -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,220 @@ 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 _option_debug_reservation(panel: Optional[str] = None) -> bool:
282
+ return typer.Option(
283
+ False,
284
+ help="Enable debug messages in the reservation code. Useful for testing changes to reservation code.",
285
+ rich_help_panel=panel,
286
+ )
287
+
288
+
289
+ def _generate_tmt_extra_args(step: str) -> Optional[List[str]]:
290
+ return typer.Option(
291
+ None,
292
+ help=(
293
+ f"Additional options passed to the \"{step}\" step. "
294
+ "Can be specified multiple times for multiple additions."
295
+ ),
296
+ rich_help_panel=REQUEST_PANEL_TMT,
297
+ )
298
+
299
+
300
+ def _sanity_reserve() -> None:
301
+ """
302
+ Sanity checks for reservation support.
303
+ """
304
+
305
+ # Check of SSH_AUTH_SOCK is defined
306
+ ssh_auth_sock = os.getenv("SSH_AUTH_SOCK")
307
+ if not ssh_auth_sock:
308
+ exit_error(
309
+ "No 'ssh-agent' seems to be running, it is required for reservations to work, cannot continue.\n"
310
+ "SSH_AUTH_SOCK is not defined, make sure the ssh-agent is running by executing 'eval `ssh-agent`'."
311
+ )
312
+
313
+ # Check if SSH_AUTH_SOCK exists
314
+ if not os.path.exists(ssh_auth_sock):
315
+ exit_error(
316
+ "SSH_AUTH_SOCK socket does not exist, make sure the ssh-agent is running by executing 'eval `ssh-agent`'."
317
+ )
318
+
319
+ # Check if value of SSH_AUTH_SOCK is socket
320
+ if not stat.S_ISSOCK(os.stat(ssh_auth_sock).st_mode):
321
+ exit_error("SSH_AUTH_SOCK is not a socket, make sure the ssh-agent is running by executing 'eval `ssh-agent`'.")
322
+
323
+ # Check if ssh-add -L is not empty
324
+ ssh_add_output = subprocess.run(["ssh-add", "-L"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
325
+ if ssh_add_output.returncode != 0:
326
+ exit_error("No SSH identities found in the SSH agent. Please run `ssh-add`.")
327
+
328
+
329
+ def _handle_reservation(session, request_id: str, autoconnect: bool = False) -> None:
330
+ """
331
+ Handle the reservation for :py:func:``request`` and :py:func:``restart`` commands.
332
+ """
333
+ # Get artifacts url
334
+ request_url = urllib.parse.urljoin(settings.API_URL, f"/v0.1/requests/{request_id}")
335
+ response = session.get(request_url)
336
+ artifacts_url = response.json()['run']['artifacts']
337
+
338
+ try:
339
+ pipeline_log = session.get(f"{artifacts_url}/pipeline.log").text
340
+
341
+ if not pipeline_log:
342
+ exit_error(f"Pipeline log was empty. Please file an issue to {settings.ISSUE_TRACKER}.")
343
+
344
+ except requests.exceptions.SSLError:
345
+ exit_error(
346
+ textwrap.dedent(
347
+ f"""
348
+ Failed to access Testing Farm artifacts because of SSL validation error.
349
+ If you use Red Hat Ranch please make sure you have Red Hat CA certificates installed.
350
+ Otherwise file an issue to {settings.ISSUE_TRACKER}.
351
+ """
352
+ )
353
+ )
354
+ return
355
+
356
+ except requests.exceptions.ConnectionError:
357
+ exit_error(
358
+ textwrap.dedent(
359
+ f"""
360
+ Failed to access Testing Farm artifacts.
361
+ If you use Red Hat Ranch please make sure you are connected to the VPN.
362
+ Otherwise file an issue to {settings.ISSUE_TRACKER}.
363
+ """
364
+ )
365
+ )
366
+ return
367
+
368
+ # match any hostname or IP address from gluetool modules log
369
+ guests = re.findall(r'Guest is ready.*root@([\d\w\.-]+)', pipeline_log)
370
+
371
+ if not guests:
372
+ exit_error(
373
+ textwrap.dedent(
374
+ f"""
375
+ No guests found to connect to. This is unexpected, please file an issue
376
+ to {settings.ISSUE_TRACKER}.
377
+ """
378
+ )
379
+ )
380
+
381
+ if len(guests) > 1:
382
+ for guest in guests:
383
+ console.print(f"🌎 ssh root@{guest}")
384
+ return
385
+ else:
386
+ console.print(f"🌎 ssh root@{guests[0]}")
387
+
388
+ if autoconnect:
389
+ os.system(f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null root@{guests[0]}") # noqa: E501
390
+
391
+
392
+ def _localhost_ingress_rule(session: requests.Session) -> str:
393
+ try:
394
+ get_ip = session.get(settings.PUBLIC_IP_CHECKER_URL)
395
+ except requests.exceptions.RequestException as err:
396
+ exit_error(f"Could not get workstation ip to form a security group rule: {err}")
397
+
398
+ if get_ip.ok:
399
+ ip = get_ip.text.strip()
400
+ return f"-1:{ip}:-1"
401
+
402
+ else:
403
+ exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
404
+
405
+
406
+ def _add_reservation(
407
+ ssh_public_keys: List[str],
408
+ rules: Dict[str, Any],
409
+ duration: int,
410
+ environment: Dict[str, Any],
411
+ debug_reservation: bool,
412
+ ):
413
+ """
414
+ Add discovery of the reservation test to the given environment.
415
+ """
416
+ authorized_keys = read_glob_paths(ssh_public_keys).encode("utf-8")
417
+ if not authorized_keys:
418
+ exit_error(f"No public SSH keys found under {', '.join(ssh_public_keys)}, cannot continue.")
419
+
420
+ authorized_keys_bytes = base64.b64encode(authorized_keys)
421
+
422
+ if "secrets" not in environment or environment["secrets"] is None:
423
+ environment["secrets"] = {}
424
+
425
+ environment["secrets"].update({"TF_RESERVATION_AUTHORIZED_KEYS_BASE64": authorized_keys_bytes.decode("utf-8")})
426
+
427
+ if "settings" not in environment or environment["settings"] is None:
428
+ environment["settings"] = {}
429
+
430
+ if "provisioning" not in environment["settings"] or environment["settings"]["provisioning"] is None:
431
+ environment["settings"]["provisioning"] = {}
432
+
433
+ environment["settings"]["provisioning"].update(rules)
434
+
435
+ if "variables" not in environment or environment["variables"] is None:
436
+ environment["variables"] = {}
437
+
438
+ environment["variables"].update({"TF_RESERVATION_DURATION": str(duration)})
439
+
440
+ if debug_reservation:
441
+ environment["variables"].update({"TF_RESERVATION_DEBUG": "1"})
442
+
443
+ if "tmt" not in environment or environment["tmt"] is None:
444
+ environment["tmt"] = {"extra_args": {}}
445
+
446
+ if "extra_args" not in environment["tmt"] or environment["tmt"]["extra_args"] is None:
447
+ environment["tmt"]["extra_args"] = {}
448
+
449
+ if "discover" not in environment["tmt"]["extra_args"] or environment["tmt"]["extra_args"]["discover"] is None:
450
+ environment["tmt"]["extra_args"]["discover"] = []
451
+
452
+ # add reservation if not already present
453
+ if RESERVE_TMT_DISCOVER_EXTRA_ARGS not in environment["tmt"]["extra_args"]["discover"]:
454
+ environment["tmt"]["extra_args"]["discover"].append(RESERVE_TMT_DISCOVER_EXTRA_ARGS)
455
+
456
+
457
+ def _contains_compose(environments: List[Dict[str, Any]]):
458
+ """
459
+ Returns true if any of environments has ``os.compose`` defined.
460
+ """
461
+ for environment in environments:
462
+ if "os" in environment and environment["os"]:
463
+ if "compose" in environment["os"] and environment["os"]["compose"]:
464
+ return True
465
+ return False
244
466
 
245
467
 
246
468
  # NOTE(ivasilev) Largely borrowed from artemis-cli
@@ -288,6 +510,14 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
288
510
  return security_group_rules
289
511
 
290
512
 
513
+ def _get_headers(api_key: str) -> Dict[str, str]:
514
+ """
515
+ Return a dict with headers for a request to Testing Farm API.
516
+ Used for authentication.
517
+ """
518
+ return {'Authorization': f'Bearer {api_key}'}
519
+
520
+
291
521
  def _parse_xunit(xunit: str):
292
522
  """
293
523
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
@@ -307,13 +537,14 @@ def _parse_xunit(xunit: str):
307
537
 
308
538
  failed_plans = {}
309
539
  passed_plans = {}
540
+ skipped_plans = {}
310
541
  errored_plans = {}
311
542
 
312
543
  results_root = ET.fromstring(xunit)
313
544
  for plan in results_root.findall('./testsuite'):
314
- # Try to get information about the environment (stored under testcase/testing-environment), may be
545
+ # Try to get information about the environment (stored under ./testing-environment), may be
315
546
  # absent if state is undefined
316
- testing_environment: Optional[ET.Element] = plan.find('./testcase/testing-environment[@name="requested"]')
547
+ testing_environment: Optional[ET.Element] = plan.find('./testing-environment[@name="requested"]')
317
548
  if not testing_environment:
318
549
  console_stderr.print(
319
550
  f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
@@ -331,13 +562,15 @@ def _parse_xunit(xunit: str):
331
562
  _add_plan(passed_plans, arch, plan)
332
563
  elif plan.get('result') == 'failed':
333
564
  _add_plan(failed_plans, arch, plan)
565
+ elif plan.get('result') == 'skipped':
566
+ _add_plan(skipped_plans, arch, plan)
334
567
  else:
335
568
  _add_plan(errored_plans, arch, plan)
336
569
 
337
570
  # Let's remove possible duplicates among N/A errored out tests
338
571
  if 'N/A' in errored_plans:
339
572
  errored_plans['N/A'] = list(set(errored_plans['N/A']))
340
- return passed_plans, failed_plans, errored_plans
573
+ return passed_plans, failed_plans, skipped_plans, errored_plans
341
574
 
342
575
 
343
576
  def _get_request_summary(request: dict, session: requests.Session):
@@ -355,7 +588,7 @@ def _get_request_summary(request: dict, session: requests.Session):
355
588
  xunit = response.text
356
589
  except requests.exceptions.ConnectionError:
357
590
  console_stderr.print("Could not get xunit results")
358
- passed_plans, failed_plans, errored_plans = _parse_xunit(xunit)
591
+ passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit)
359
592
  overall = (request.get("result") or {}).get("overall")
360
593
  arches_requested = [env['arch'] for env in request['environments_requested']]
361
594
 
@@ -367,6 +600,7 @@ def _get_request_summary(request: dict, session: requests.Session):
367
600
  'arches_requested': arches_requested,
368
601
  'errored_plans': errored_plans,
369
602
  'failed_plans': failed_plans,
603
+ 'skipped_plans': skipped_plans,
370
604
  'passed_plans': passed_plans,
371
605
  }
372
606
 
@@ -385,6 +619,7 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
385
619
  # Let's transform plans maps into collection of plans to display plan result per arch statistics
386
620
  errored = _get_plans_list(summary['errored_plans'])
387
621
  failed = _get_plans_list(summary['failed_plans'])
622
+ skipped = _get_plans_list(summary['skipped_plans'])
388
623
  passed = _get_plans_list(summary['passed_plans'])
389
624
  generic_info_table = Table(show_header=True, header_style="bold magenta")
390
625
  arches_requested = summary['arches_requested']
@@ -399,11 +634,12 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
399
634
  ','.join(arches_requested),
400
635
  str(len(errored)),
401
636
  str(len(failed)),
637
+ str(len(skipped)),
402
638
  str(len(passed)),
403
639
  )
404
640
  console.print(generic_info_table)
405
641
 
406
- all_plans = sorted(set(errored + failed + passed))
642
+ all_plans = sorted(set(errored + failed + skipped + passed))
407
643
  details_table = Table(show_header=True, header_style="bold magenta")
408
644
  for column in ["plan"] + arches_requested:
409
645
  details_table.add_column(column)
@@ -413,6 +649,8 @@ def _print_summary_table(summary: dict, format: Optional[WatchFormat], show_deta
413
649
  for arch in arches_requested:
414
650
  if _has_plan(summary['passed_plans'], arch, plan):
415
651
  res = '[green]pass[/green]'
652
+ elif _has_plan(summary['skipped_plans'], arch, plan):
653
+ res = '[white]skip[/white]'
416
654
  elif _has_plan(summary['failed_plans'], arch, plan):
417
655
  res = '[red]fail[/red]'
418
656
  elif _has_plan(summary['errored_plans'], 'N/A', plan):
@@ -432,6 +670,8 @@ def watch(
432
670
  id: str = typer.Option(..., help="Request ID to watch"),
433
671
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
434
672
  format: Optional[WatchFormat] = typer.Option(WatchFormat.text, help="Output format"),
673
+ autoconnect: bool = typer.Option(True, hidden=True),
674
+ reserve: bool = typer.Option(False, hidden=True),
435
675
  ):
436
676
  def _console_print(*args, **kwargs):
437
677
  """A helper function that will skip printing to console if output format is json"""
@@ -458,6 +698,24 @@ def watch(
458
698
  session = requests.Session()
459
699
  install_http_retries(session)
460
700
 
701
+ def _is_reserved(session, request):
702
+ artifacts_url = (request.get('run') or {}).get('artifacts')
703
+
704
+ if not artifacts_url:
705
+ return False
706
+
707
+ try:
708
+ workdir = re.search(r'href="(.*)" name="workdir"', session.get(f"{artifacts_url}/results.xml").text)
709
+ except requests.exceptions.SSLError:
710
+ exit_error("Artifacts unreachable via SSL, do you have RH CA certificates installed?[/yellow]")
711
+
712
+ if workdir:
713
+ # finish early if reservation is running
714
+ if re.search(r"\[\+\] Reservation tick:", session.get(f"{workdir.group(1)}/log.txt").text):
715
+ return True
716
+
717
+ return False
718
+
461
719
  while True:
462
720
  try:
463
721
  response = session.get(get_url)
@@ -477,6 +735,11 @@ def watch(
477
735
  state = request["state"]
478
736
 
479
737
  if state == current_state:
738
+ # check for reservation status and finish early if reserved
739
+ if reserve and _is_reserved(session, request):
740
+ _handle_reservation(session, request["id"], autoconnect)
741
+ return
742
+
480
743
  time.sleep(1)
481
744
  continue
482
745
 
@@ -524,6 +787,10 @@ def watch(
524
787
  _print_summary_table(request_summary, format)
525
788
  raise typer.Exit(code=2)
526
789
 
790
+ elif state in ["canceled", "cancel-requested"]:
791
+ _console_print("⚠️ pipeline cancelled", style="yellow")
792
+ raise typer.Exit(code=3)
793
+
527
794
  if no_wait:
528
795
  _print_summary_table(request_summary, format, show_details=False)
529
796
  raise typer.Exit()
@@ -539,7 +806,7 @@ def version():
539
806
  def request(
540
807
  api_url: str = ARGUMENT_API_URL,
541
808
  api_token: str = ARGUMENT_API_TOKEN,
542
- timeout: Optional[int] = typer.Option(
809
+ timeout: int = typer.Option(
543
810
  DEFAULT_PIPELINE_TIMEOUT,
544
811
  help="Set the timeout for the request in minutes. If the test takes longer than this, it will be terminated.",
545
812
  ),
@@ -624,6 +891,14 @@ def request(
624
891
  None, help="URL of the icon of the user's webpage. It will be shown in the results viewer."
625
892
  ),
626
893
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
894
+ tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
895
+ tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
896
+ tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
897
+ reserve: bool = OPTION_RESERVE,
898
+ ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
899
+ autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
900
+ reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
901
+ debug_reservation: bool = _option_debug_reservation(REQUEST_PANEL_RESERVE),
627
902
  ):
628
903
  """
629
904
  Request testing from Testing Farm.
@@ -653,6 +928,9 @@ def request(
653
928
  git_url = str(settings.TESTING_FARM_TESTS_GIT_URL)
654
929
  tmt_plan_name = str(settings.TESTING_FARM_SANITY_PLAN)
655
930
 
931
+ if reserve:
932
+ _sanity_reserve()
933
+
656
934
  # resolve git repository details from the current repository
657
935
  if not git_url:
658
936
  if not git_available:
@@ -687,7 +965,7 @@ def request(
687
965
  git_ref = cmd_output_or_exit("git rev-parse HEAD", "could not autodetect git ref")
688
966
 
689
967
  # detect test type from local files
690
- if os.path.exists(".fmf/version"):
968
+ if os.path.exists(os.path.join((tmt_path or ""), ".fmf/version")):
691
969
  test_type = "fmf"
692
970
  elif os.path.exists("tests/tests.yml"):
693
971
  test_type = "sti"
@@ -780,8 +1058,46 @@ def request(
780
1058
  if tmt_environment:
781
1059
  environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
782
1060
 
1061
+ if tmt_discover or tmt_prepare or tmt_finish:
1062
+ if "extra_args" not in environment["tmt"]:
1063
+ environment["tmt"]["extra_args"] = {}
1064
+
1065
+ if tmt_discover:
1066
+ environment["tmt"]["extra_args"]["discover"] = tmt_discover
1067
+
1068
+ if tmt_prepare:
1069
+ environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1070
+
1071
+ if tmt_finish:
1072
+ environment["tmt"]["extra_args"]["finish"] = tmt_finish
1073
+
783
1074
  environments.append(environment)
784
1075
 
1076
+ # Setting up retries
1077
+ session = requests.Session()
1078
+ install_http_retries(session)
1079
+
1080
+ if reserve:
1081
+ if not _contains_compose(environments):
1082
+ exit_error("Reservations are not supported with container executions, cannot continue")
1083
+
1084
+ if len(environments) > 1:
1085
+ exit_error("Reservations are currently supported for a single plan, cannot continue")
1086
+
1087
+ rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1088
+
1089
+ for environment in environments:
1090
+ _add_reservation(
1091
+ ssh_public_keys=ssh_public_keys,
1092
+ rules=rules,
1093
+ duration=reservation_duration,
1094
+ environment=environment,
1095
+ debug_reservation=debug_reservation,
1096
+ )
1097
+
1098
+ machine_pre = "Machine" if len(environments) == 1 else str(len(environments)) + " machines"
1099
+ console.print(f"🛟 {machine_pre} will be reserved after testing")
1100
+
785
1101
  if any(
786
1102
  provisioning_detail
787
1103
  for provisioning_detail in [
@@ -817,7 +1133,6 @@ def request(
817
1133
 
818
1134
  # create final request
819
1135
  request = TestingFarmRequestV1
820
- request["api_key"] = api_token
821
1136
  if test_type == "fmf":
822
1137
  test["path"] = tmt_path
823
1138
  request["test"]["fmf"] = test
@@ -826,7 +1141,23 @@ def request(
826
1141
 
827
1142
  request["environments"] = environments
828
1143
  request["settings"] = {}
829
- request["settings"]["pipeline"] = {"timeout": timeout}
1144
+
1145
+ if reserve or pipeline_type or parallel_limit or timeout != DEFAULT_PIPELINE_TIMEOUT:
1146
+ request["settings"]["pipeline"] = {}
1147
+
1148
+ # in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
1149
+ if reserve:
1150
+ if reservation_duration > timeout:
1151
+ request["settings"]["pipeline"] = {"timeout": reservation_duration}
1152
+ console.print(f"⏳ Maximum reservation time is {reservation_duration} minutes")
1153
+ else:
1154
+ request["settings"]["pipeline"] = {"timeout": timeout}
1155
+ console.print(f"⏳ Maximum reservation time is {timeout} minutes")
1156
+
1157
+ # forced pipeline timeout
1158
+ elif timeout != DEFAULT_PIPELINE_TIMEOUT:
1159
+ console.print(f"⏳ Pipeline timeout forced to {timeout} minutes")
1160
+ request["settings"]["pipeline"] = {"timeout": timeout}
830
1161
 
831
1162
  if pipeline_type:
832
1163
  request["settings"]["pipeline"]["type"] = pipeline_type.value
@@ -849,18 +1180,14 @@ def request(
849
1180
  # submit request to Testing Farm
850
1181
  post_url = urllib.parse.urljoin(api_url, "v0.1/requests")
851
1182
 
852
- # Setting up retries
853
- session = requests.Session()
854
- install_http_retries(session)
855
-
856
1183
  # dry run
857
1184
  if dry_run:
858
1185
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
859
- print(json.dumps(request, indent=4, separators=(',', ': ')))
1186
+ print_json(json.dumps(request, indent=4, separators=(',', ': ')))
860
1187
  raise typer.Exit()
861
1188
 
862
1189
  # handle errors
863
- response = session.post(post_url, json=request)
1190
+ response = session.post(post_url, json=request, headers=_get_headers(api_token))
864
1191
  if response.status_code == 401:
865
1192
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
866
1193
 
@@ -874,11 +1201,14 @@ def request(
874
1201
  print(response.text)
875
1202
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
876
1203
 
877
- # watch
878
- watch(api_url, response.json()['id'], no_wait, format=WatchFormat.text)
1204
+ request_id = response.json()['id']
1205
+
1206
+ # Watch the request and handle reservation
1207
+ watch(api_url, request_id, no_wait, reserve=reserve, autoconnect=autoconnect, format=WatchFormat.text)
879
1208
 
880
1209
 
881
1210
  def restart(
1211
+ context: typer.Context,
882
1212
  request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
883
1213
  api_url: str = ARGUMENT_API_URL,
884
1214
  internal_api_url: str = typer.Argument(
@@ -905,12 +1235,20 @@ def restart(
905
1235
  tmt_plan_filter: Optional[str] = OPTION_TMT_PLAN_FILTER,
906
1236
  tmt_test_name: Optional[str] = OPTION_TMT_TEST_NAME,
907
1237
  tmt_test_filter: Optional[str] = OPTION_TMT_TEST_FILTER,
908
- tmt_path: str = OPTION_TMT_PATH,
1238
+ tmt_path: Optional[str] = OPTION_TMT_PATH,
1239
+ tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1240
+ tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1241
+ tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
909
1242
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
910
1243
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
911
1244
  dry_run: bool = OPTION_DRY_RUN,
912
1245
  pipeline_type: Optional[PipelineType] = OPTION_PIPELINE_TYPE,
913
1246
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
1247
+ reserve: bool = OPTION_RESERVE,
1248
+ ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
1249
+ autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
1250
+ reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
1251
+ debug_reservation: bool = _option_debug_reservation(REQUEST_PANEL_RESERVE),
914
1252
  ):
915
1253
  """
916
1254
  Restart a Testing Farm request.
@@ -932,14 +1270,14 @@ def restart(
932
1270
  _request_id = uuid_match.group()
933
1271
 
934
1272
  # 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}")
1273
+ get_url = urllib.parse.urljoin(str(internal_api_url), f"v0.1/requests/{_request_id}")
936
1274
 
937
1275
  # Setting up retries
938
1276
  session = requests.Session()
939
1277
  install_http_retries(session)
940
1278
 
941
1279
  # Get the request details
942
- response = session.get(get_url)
1280
+ response = session.get(get_url, headers=_get_headers(api_token))
943
1281
 
944
1282
  if response.status_code == 401:
945
1283
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1018,6 +1356,25 @@ def restart(
1018
1356
  for environment in request['environments']:
1019
1357
  environment["pool"] = pool
1020
1358
 
1359
+ if tmt_discover or tmt_prepare or tmt_finish:
1360
+ for environment in request["environments"]:
1361
+ if "tmt" not in environment:
1362
+ environment["tmt"] = {"extra_args": {}}
1363
+ if "extra_args" not in environment["tmt"]:
1364
+ environment["tmt"]["extra_args"] = {}
1365
+
1366
+ if tmt_discover:
1367
+ for environment in request["environments"]:
1368
+ environment["tmt"]["extra_args"]["discover"] = tmt_discover
1369
+
1370
+ if tmt_prepare:
1371
+ for environment in request["environments"]:
1372
+ environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1373
+
1374
+ if tmt_finish:
1375
+ for environment in request["environments"]:
1376
+ environment["tmt"]["extra_args"]["finish"] = tmt_finish
1377
+
1021
1378
  test_type = "fmf" if "fmf" in request["test"] else "sti"
1022
1379
 
1023
1380
  if tmt_plan_name:
@@ -1031,7 +1388,9 @@ def restart(
1031
1388
  request["test"][test_type]["plan_filter"] = tmt_plan_filter
1032
1389
 
1033
1390
  if test_type == "fmf":
1034
- request["test"][test_type]["path"] = tmt_path
1391
+ # The method explained in https://github.com/fastapi/typer/discussions/668
1392
+ if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE: # pyre-ignore[16]
1393
+ request["test"][test_type]["path"] = tmt_path
1035
1394
 
1036
1395
  # worker image
1037
1396
  if worker_image:
@@ -1041,9 +1400,6 @@ def restart(
1041
1400
  # it is required to have also pipeline key set, otherwise API will fail
1042
1401
  request["settings"]["pipeline"] = request["settings"].get("pipeline", {})
1043
1402
 
1044
- # Add API key
1045
- request['api_key'] = api_token
1046
-
1047
1403
  if pipeline_type or parallel_limit:
1048
1404
  if "settings" not in request:
1049
1405
  request["settings"] = {}
@@ -1066,6 +1422,31 @@ def restart(
1066
1422
 
1067
1423
  environment["settings"]["provisioning"]["tags"] = options_to_dict("tags", tags)
1068
1424
 
1425
+ if reserve:
1426
+ if not _contains_compose(request["environments"]):
1427
+ exit_error("Reservations are not supported with container executions, cannot continue")
1428
+
1429
+ if len(request["environments"]) > 1:
1430
+ exit_error("Reservations are currently supported for a single plan, cannot continue")
1431
+
1432
+ rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1433
+
1434
+ for environment in request["environments"]:
1435
+ _add_reservation(
1436
+ ssh_public_keys=ssh_public_keys,
1437
+ rules=rules,
1438
+ duration=reservation_duration,
1439
+ environment=environment,
1440
+ debug_reservation=debug_reservation,
1441
+ )
1442
+
1443
+ machine_pre = (
1444
+ "Machine" if len(request["environments"]) == 1 else str(len(request["environments"])) + " machines"
1445
+ )
1446
+ console.print(
1447
+ f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
1448
+ )
1449
+
1069
1450
  # dry run
1070
1451
  if dry_run:
1071
1452
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
@@ -1076,7 +1457,7 @@ def restart(
1076
1457
  post_url = urllib.parse.urljoin(str(api_url), "v0.1/requests")
1077
1458
 
1078
1459
  # handle errors
1079
- response = session.post(post_url, json=request)
1460
+ response = session.post(post_url, json=request, headers=_get_headers(api_token))
1080
1461
  if response.status_code == 401:
1081
1462
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1082
1463
 
@@ -1091,7 +1472,9 @@ def restart(
1091
1472
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
1092
1473
 
1093
1474
  # watch
1094
- watch(str(api_url), response.json()['id'], no_wait, format=WatchFormat.text)
1475
+ watch(
1476
+ str(api_url), response.json()['id'], no_wait, reserve=reserve, autoconnect=autoconnect, format=WatchFormat.text
1477
+ )
1095
1478
 
1096
1479
 
1097
1480
  def run(
@@ -1118,7 +1501,6 @@ def run(
1118
1501
 
1119
1502
  # create request
1120
1503
  request = TestingFarmRequestV1
1121
- request["api_key"] = settings.API_TOKEN
1122
1504
 
1123
1505
  test = TestTMT
1124
1506
  test["url"] = RUN_REPO
@@ -1162,7 +1544,7 @@ def run(
1162
1544
  raise typer.Exit()
1163
1545
 
1164
1546
  # handle errors
1165
- response = session.post(post_url, json=request)
1547
+ response = session.post(post_url, json=request, headers=_get_headers(settings.API_TOKEN))
1166
1548
  if response.status_code == 401:
1167
1549
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1168
1550
 
@@ -1214,6 +1596,10 @@ def run(
1214
1596
  if state in ["complete", "error"]:
1215
1597
  break
1216
1598
 
1599
+ if state in ["canceled", "cancel-requested"]:
1600
+ progress.stop()
1601
+ exit_error("Request canceled.")
1602
+
1217
1603
  time.sleep(1)
1218
1604
 
1219
1605
  # workaround TFT-1690
@@ -1254,18 +1640,8 @@ def run(
1254
1640
 
1255
1641
 
1256
1642
  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
- ),
1643
+ ssh_public_keys: List[str] = _option_ssh_public_keys(RESERVE_PANEL_GENERAL),
1644
+ reservation_duration: int = _option_reservation_duration(RESERVE_PANEL_GENERAL),
1269
1645
  arch: str = typer.Option(
1270
1646
  "x86_64", help="Hardware platform of the system to be provisioned.", rich_help_panel=RESERVE_PANEL_ENVIRONMENT
1271
1647
  ),
@@ -1290,15 +1666,17 @@ def reserve(
1290
1666
  help="Output only the request ID.",
1291
1667
  rich_help_panel=RESERVE_PANEL_OUTPUT,
1292
1668
  ),
1293
- autoconnect: bool = typer.Option(
1294
- True, help="Automatically connect to the guest via SSH.", rich_help_panel=RESERVE_PANEL_GENERAL
1295
- ),
1669
+ autoconnect: bool = _option_autoconnect(RESERVE_PANEL_GENERAL),
1296
1670
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
1297
1671
  security_group_rule_ingress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_INGRESS,
1298
1672
  security_group_rule_egress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_EGRESS,
1299
1673
  skip_workstation_access: bool = typer.Option(
1300
1674
  False, help="Do not allow ingress traffic from this workstation's ip to the reserved machine"
1301
1675
  ),
1676
+ git_ref: Optional[str] = typer.Option(
1677
+ None, help="Force GIT ref or branch. Useful for testing changes to reservation plan."
1678
+ ),
1679
+ debug_reservation: bool = _option_debug_reservation(),
1302
1680
  ):
1303
1681
  """
1304
1682
  Reserve a system in Testing Farm.
@@ -1308,27 +1686,7 @@ def reserve(
1308
1686
  if not print_only_request_id:
1309
1687
  console.print(message)
1310
1688
 
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`.")
1689
+ _sanity_reserve()
1332
1690
 
1333
1691
  # check for token
1334
1692
  if not settings.API_TOKEN:
@@ -1340,7 +1698,7 @@ def reserve(
1340
1698
  # test details
1341
1699
  test = TestTMT
1342
1700
  test["url"] = RESERVE_URL
1343
- test["ref"] = RESERVE_REF
1701
+ test["ref"] = git_ref or RESERVE_REF
1344
1702
  test["name"] = RESERVE_PLAN
1345
1703
 
1346
1704
  # environment details
@@ -1396,41 +1754,37 @@ def reserve(
1396
1754
  if post_install_script:
1397
1755
  environment["settings"]["provisioning"]["post_install_script"] = post_install_script
1398
1756
 
1757
+ # Setting up retries
1758
+ session = requests.Session()
1759
+ install_http_retries(session)
1760
+
1399
1761
  if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
1400
1762
  ingress_rules = security_group_rule_ingress or []
1401
1763
  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}")
1764
+ ingress_rules.append(_localhost_ingress_rule(session))
1413
1765
 
1414
1766
  rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
1415
1767
  environment["settings"]["provisioning"].update(rules)
1416
1768
 
1417
1769
  console.print(f"🕗 Reserved for [blue]{str(reservation_duration)}[/blue] minutes")
1418
- environment["variables"] = {"TF_RESERVATION_DURATION": str(reservation_duration)}
1770
+
1771
+ if "variables" not in environment or environment["variables"] is None:
1772
+ environment["variables"] = {}
1773
+
1774
+ environment["variables"]["TF_RESERVATION_DURATION"] = str(reservation_duration)
1775
+
1776
+ if debug_reservation:
1777
+ environment["variables"]["TF_RESERVATION_DEBUG"] = "1"
1419
1778
 
1420
1779
  authorized_keys = read_glob_paths(ssh_public_keys).encode("utf-8")
1421
1780
  if not authorized_keys:
1422
1781
  exit_error(f"No public SSH keys found under {', '.join(ssh_public_keys)}, cannot continue.")
1423
1782
 
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
1783
  authorized_keys_bytes = base64.b64encode(authorized_keys)
1429
1784
  environment["secrets"] = {"TF_RESERVATION_AUTHORIZED_KEYS_BASE64": authorized_keys_bytes.decode("utf-8")}
1430
1785
 
1431
1786
  # create final request
1432
1787
  request = TestingFarmRequestV1
1433
- request["api_key"] = settings.API_TOKEN
1434
1788
  request["test"]["fmf"] = test
1435
1789
 
1436
1790
  # worker image
@@ -1451,10 +1805,6 @@ def reserve(
1451
1805
  # submit request to Testing Farm
1452
1806
  post_url = urllib.parse.urljoin(str(settings.API_URL), "v0.1/requests")
1453
1807
 
1454
- # Setting up retries
1455
- session = requests.Session()
1456
- install_http_retries(session)
1457
-
1458
1808
  # dry run
1459
1809
  if dry_run:
1460
1810
  if print_only_request_id:
@@ -1465,7 +1815,7 @@ def reserve(
1465
1815
  raise typer.Exit()
1466
1816
 
1467
1817
  # handle errors
1468
- response = session.post(post_url, json=request)
1818
+ response = session.post(post_url, json=request, headers=_get_headers(settings.API_TOKEN))
1469
1819
  if response.status_code == 401:
1470
1820
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1471
1821
 
@@ -1524,7 +1874,11 @@ def reserve(
1524
1874
  current_state = state
1525
1875
 
1526
1876
  if state in ["complete", "error"]:
1527
- exit_error("Reservation failed, check API request or contact Testing Farm")
1877
+ exit_error("Reservation failed, check the API request or contact Testing Farm.")
1878
+
1879
+ if state in ["canceled", "cancel-requested"]:
1880
+ progress.stop()
1881
+ exit_error("Reservation canceled.")
1528
1882
 
1529
1883
  if not print_only_request_id and task_id is not None:
1530
1884
  progress.update(task_id, description=f"Reservation job is [yellow]{current_state}[/yellow]")
@@ -1579,6 +1933,10 @@ def reserve(
1579
1933
  )
1580
1934
  )
1581
1935
 
1936
+ if '[testing-farm-request] Cancelling pipeline' in pipeline_log:
1937
+ progress.stop()
1938
+ exit_error('Pipeline was canceled.')
1939
+
1582
1940
  if '[pre-artifact-installation]' in pipeline_log:
1583
1941
  current_state = "preparing environment"
1584
1942
 
@@ -1644,7 +2002,7 @@ def cancel(
1644
2002
  install_http_retries(session)
1645
2003
 
1646
2004
  # Get the request details
1647
- response = session.delete(request_url, json={"api_key": api_token})
2005
+ response = session.delete(request_url, headers=_get_headers(api_token))
1648
2006
 
1649
2007
  if response.status_code == 401:
1650
2008
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1662,3 +2020,74 @@ def cancel(
1662
2020
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
1663
2021
 
1664
2022
  console.print("✅ Request [yellow]cancellation requested[/yellow]. It will be canceled soon.")
2023
+
2024
+
2025
+ def encrypt(
2026
+ message: str = typer.Argument(..., help="Message to be encrypted."),
2027
+ api_url: str = ARGUMENT_API_URL,
2028
+ api_token: str = ARGUMENT_API_TOKEN,
2029
+ git_url: Optional[str] = typer.Option(
2030
+ None,
2031
+ help="URL of a GIT repository to which the secret will be tied. If not set, it is detected from the current "
2032
+ "git repository.",
2033
+ ),
2034
+ token_id: Optional[str] = typer.Option(
2035
+ None,
2036
+ help="Token ID to which the secret will be tied. If not set, Token ID will be detected from provided Token.",
2037
+ ),
2038
+ ):
2039
+ """
2040
+ Create secrets for use in in-repository configuration.
2041
+ """
2042
+
2043
+ # check for token
2044
+ if not api_token:
2045
+ exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
2046
+
2047
+ git_available = bool(shutil.which("git"))
2048
+
2049
+ # resolve git repository details from the current repository
2050
+ if not git_url:
2051
+ if not git_available:
2052
+ exit_error("no git url defined")
2053
+ git_url = cmd_output_or_exit("git remote get-url origin", "could not auto-detect git url")
2054
+ # use https instead git when auto-detected
2055
+ # GitLab: git@github.com:containers/podman.git
2056
+ # GitHub: git@gitlab.com:testing-farm/cli.git, git+ssh://git@gitlab.com/spoore/centos_rpms_jq.git
2057
+ # Pagure: ssh://git@pagure.io/fedora-ci/messages.git
2058
+ assert git_url
2059
+ git_url = re.sub(r"^(?:(?:git\+)?ssh://)?git@([^:/]*)[:/](.*)", r"https://\1/\2", git_url)
2060
+
2061
+ payload = {'url': git_url, 'message': message}
2062
+
2063
+ if token_id:
2064
+ payload['token_id'] = token_id
2065
+ console_stderr.print(f'🔒 Encrypting secret for token id {token_id} for repository {git_url}')
2066
+ else:
2067
+ console_stderr.print(f'🔒 Encrypting secret for your token in repo {git_url}')
2068
+
2069
+ # submit request to Testing Farm
2070
+ post_url = urllib.parse.urljoin(api_url, "/v0.1/secrets/encrypt")
2071
+
2072
+ session = requests.Session()
2073
+ response = session.post(post_url, json=payload, headers={'Authorization': f'Bearer {api_token}'})
2074
+
2075
+ # handle errors
2076
+ if response.status_code == 401:
2077
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2078
+
2079
+ if response.status_code == 400:
2080
+ exit_error(
2081
+ f"Request is invalid. {response.json().get('message') or 'Reason unknown.'}."
2082
+ f"\nPlease file an issue to {settings.ISSUE_TRACKER} if unsure."
2083
+ )
2084
+
2085
+ if response.status_code != 200:
2086
+ console_stderr.print(response.text)
2087
+ exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
2088
+
2089
+ console_stderr.print(
2090
+ "💡 See https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html#secrets-in-repo-config for more "
2091
+ "information on how to store the secret in repository."
2092
+ )
2093
+ console.print(response.text)