tft-cli 0.0.26__py3-none-any.whl → 0.0.29__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
@@ -20,19 +20,22 @@ from typing import Any, Dict, List, Optional
20
20
 
21
21
  import requests
22
22
  import typer
23
- from click.core import ParameterSource # pyre-ignore[21]
23
+ from click.core import ParameterSource
24
24
  from rich import print, print_json
25
25
  from rich.progress import Progress, SpinnerColumn, TextColumn
26
- from rich.table import Table
26
+ from rich.table import Table # type: ignore
27
27
 
28
28
  from tft.cli.config import settings
29
29
  from tft.cli.utils import (
30
30
  artifacts,
31
+ authorization_headers,
31
32
  check_unexpected_arguments,
32
33
  cmd_output_or_exit,
33
34
  console,
34
35
  console_stderr,
36
+ edit_with_editor,
35
37
  exit_error,
38
+ extract_uuid,
36
39
  hw_constraints,
37
40
  install_http_retries,
38
41
  normalize_multistring_option,
@@ -65,8 +68,12 @@ RESERVE_URL = os.getenv("TESTING_FARM_RESERVE_URL", "https://gitlab.com/testing-
65
68
  RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
66
69
  RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
67
70
 
71
+ # NOTE(mvadkert): note that reservation duration is different per ranch,
72
+ # ignore this fact for now here for reservations
68
73
  DEFAULT_PIPELINE_TIMEOUT = 60 * 12
69
74
 
75
+ DEFAULT_AGE = "7d"
76
+
70
77
  # SSH command options for reservation connections
71
78
  SSH_RESERVATION_OPTIONS = (
72
79
  "ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null -oServerAliveInterval=60 -oServerAliveCountMax=3"
@@ -85,6 +92,20 @@ class PipelineType(str, Enum):
85
92
  tmt_multihost = "tmt-multihost"
86
93
 
87
94
 
95
+ class PipelineState(str, Enum):
96
+ new = "new"
97
+ queued = "queued"
98
+ running = "running"
99
+ complete = "complete"
100
+ error = "error"
101
+ canceled = "canceled"
102
+
103
+
104
+ class Ranch(str, Enum):
105
+ public = "public"
106
+ redhat = "redhat"
107
+
108
+
88
109
  # Arguments and options that are shared among multiple commands
89
110
  ARGUMENT_API_URL: str = typer.Argument(
90
111
  settings.API_URL, envvar="TESTING_FARM_API_URL", metavar='', rich_help_panel='Environment variables'
@@ -99,6 +120,12 @@ ARGUMENT_API_TOKEN: str = typer.Argument(
99
120
  metavar='',
100
121
  rich_help_panel='Environment variables',
101
122
  )
123
+ ARGUMENT_INTERNAL_API_URL: str = typer.Argument(
124
+ settings.INTERNAL_API_URL,
125
+ envvar="TESTING_FARM_INTERNAL_API_URL",
126
+ metavar='',
127
+ rich_help_panel='Environment variables',
128
+ )
102
129
  OPTION_API_TOKEN: str = typer.Option(
103
130
  settings.API_TOKEN,
104
131
  envvar="TESTING_FARM_API_TOKEN",
@@ -106,6 +133,33 @@ OPTION_API_TOKEN: str = typer.Option(
106
133
  metavar='',
107
134
  rich_help_panel='Environment variables',
108
135
  )
136
+
137
+ # Restart command specific arguments for source operations
138
+ ARGUMENT_SOURCE_API_URL: str = typer.Argument(
139
+ None, envvar="TESTING_FARM_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
140
+ )
141
+ ARGUMENT_INTERNAL_SOURCE_API_URL: str = typer.Argument(
142
+ None, envvar="TESTING_FARM_INTERNAL_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
143
+ )
144
+ ARGUMENT_SOURCE_API_TOKEN: str = typer.Argument(
145
+ None,
146
+ envvar="TESTING_FARM_SOURCE_API_TOKEN",
147
+ show_default=False,
148
+ metavar='',
149
+ rich_help_panel='Environment variables',
150
+ )
151
+
152
+ # Restart command specific arguments for target operations
153
+ ARGUMENT_TARGET_API_URL: str = typer.Argument(
154
+ None, envvar="TESTING_FARM_TARGET_API_URL", metavar='', rich_help_panel='Environment variables'
155
+ )
156
+ ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
157
+ None,
158
+ envvar="TESTING_FARM_TARGET_API_TOKEN",
159
+ show_default=False,
160
+ metavar='',
161
+ rich_help_panel='Environment variables',
162
+ )
109
163
  OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
110
164
  None,
111
165
  "--plan",
@@ -278,6 +332,17 @@ OPTION_TMT_CONTEXT: Optional[List[str]] = typer.Option(
278
332
  metavar="key=value|@file",
279
333
  help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
280
334
  )
335
+ OPTION_TMT_ENVIRONMENT: Optional[List[str]] = typer.Option(
336
+ None,
337
+ "-T",
338
+ "--tmt-environment",
339
+ metavar="key=value|@file",
340
+ help=(
341
+ "Environment variables to pass to the tmt process. "
342
+ "Used to configure tmt report plugins like reportportal or polarion. "
343
+ "The @ prefix marks a yaml file to load."
344
+ ),
345
+ )
281
346
 
282
347
 
283
348
  def _option_autoconnect(panel: str) -> bool:
@@ -427,6 +492,24 @@ def _localhost_ingress_rule(session: requests.Session) -> str:
427
492
  exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
428
493
 
429
494
 
495
+ def _extend_test_name_for_reservation(tmt_test_name: Optional[str]) -> Optional[str]:
496
+ """
497
+ Extend test name to include the reservation test when --reserve is used.
498
+ """
499
+ if tmt_test_name:
500
+ return f"{tmt_test_name}|{RESERVE_TEST}"
501
+ return None
502
+
503
+
504
+ def _extend_test_filter_for_reservation(tmt_test_filter: Optional[str]) -> Optional[str]:
505
+ """
506
+ Extend test filter to include the reservation test when --reserve is used.
507
+ """
508
+ if tmt_test_filter:
509
+ return f"{tmt_test_filter} | name:{RESERVE_TEST}"
510
+ return None
511
+
512
+
430
513
  def _add_reservation(
431
514
  ssh_public_keys: List[str],
432
515
  rules: Dict[str, Any],
@@ -534,14 +617,6 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
534
617
  return security_group_rules
535
618
 
536
619
 
537
- def _get_headers(api_key: str) -> Dict[str, str]:
538
- """
539
- Return a dict with headers for a request to Testing Farm API.
540
- Used for authentication.
541
- """
542
- return {'Authorization': f'Bearer {api_key}'}
543
-
544
-
545
620
  def _parse_xunit(xunit: str):
546
621
  """
547
622
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
@@ -835,9 +910,14 @@ def request(
835
910
  context: typer.Context,
836
911
  api_url: str = ARGUMENT_API_URL,
837
912
  api_token: str = ARGUMENT_API_TOKEN,
838
- timeout: int = typer.Option(
839
- DEFAULT_PIPELINE_TIMEOUT,
840
- help="Set the timeout for the request in minutes. If the test takes longer than this, it will be terminated.",
913
+ timeout: Optional[int] = typer.Option(
914
+ None,
915
+ help=(
916
+ "Set the timeout for the request in minutes. "
917
+ "If the request takes longer than this, it will be terminated. "
918
+ "The timeout is counted from the time the request is switched to the running state."
919
+ "For default timeout see https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html."
920
+ ),
841
921
  ),
842
922
  test_type: str = typer.Option("fmf", help="Test type to use, if not set autodetected."),
843
923
  tmt_plan_name: Optional[str] = OPTION_TMT_PLAN_NAME,
@@ -871,17 +951,7 @@ def request(
871
951
  cli_tmt_context: Optional[List[str]] = OPTION_TMT_CONTEXT,
872
952
  variables: Optional[List[str]] = OPTION_VARIABLES,
873
953
  secrets: Optional[List[str]] = OPTION_SECRETS,
874
- tmt_environment: Optional[List[str]] = typer.Option(
875
- None,
876
- "-T",
877
- "--tmt-environment",
878
- metavar="key=value|@file",
879
- help=(
880
- "Environment variables to pass to the tmt process. "
881
- "Used to configure tmt report plugins like reportportal or polarion. "
882
- "The @ prefix marks a yaml file to load."
883
- ),
884
- ),
954
+ tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
885
955
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
886
956
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
887
957
  redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
@@ -1027,10 +1097,16 @@ def request(
1027
1097
  test["plan_filter"] = tmt_plan_filter
1028
1098
 
1029
1099
  if tmt_test_name:
1030
- test["test_name"] = tmt_test_name
1100
+ if reserve:
1101
+ test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
1102
+ else:
1103
+ test["test_name"] = tmt_test_name
1031
1104
 
1032
1105
  if tmt_test_filter:
1033
- test["test_filter"] = tmt_test_filter
1106
+ if reserve:
1107
+ test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
1108
+ else:
1109
+ test["test_filter"] = tmt_test_filter
1034
1110
 
1035
1111
  if sti_playbooks:
1036
1112
  test["playbooks"] = sti_playbooks
@@ -1066,7 +1142,7 @@ def request(
1066
1142
 
1067
1143
  if kickstart:
1068
1144
  # Typer escapes newlines in options, we need to unescape them
1069
- kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart] # pyre-ignore[6]
1145
+ kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
1070
1146
  environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
1071
1147
 
1072
1148
  if redhat_brew_build:
@@ -1113,7 +1189,10 @@ def request(
1113
1189
  if len(environments) > 1:
1114
1190
  exit_error("Reservations are currently supported for a single plan, cannot continue")
1115
1191
 
1116
- rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1192
+ # support cases where the user has multiple localhost addresses
1193
+ rules = _parse_security_group_rules(
1194
+ list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
1195
+ )
1117
1196
 
1118
1197
  for environment in environments:
1119
1198
  _add_reservation(
@@ -1171,11 +1250,14 @@ def request(
1171
1250
  request["environments"] = environments
1172
1251
  request["settings"] = {}
1173
1252
 
1174
- if reserve or pipeline_type or parallel_limit or timeout != DEFAULT_PIPELINE_TIMEOUT:
1253
+ forced_pipeline_timeout = context.get_parameter_source("timeout") == ParameterSource.COMMANDLINE
1254
+
1255
+ if reserve or pipeline_type or parallel_limit or forced_pipeline_timeout:
1175
1256
  request["settings"]["pipeline"] = {}
1176
1257
 
1177
1258
  # in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
1178
1259
  if reserve:
1260
+ timeout = timeout or DEFAULT_PIPELINE_TIMEOUT
1179
1261
  if reservation_duration > timeout:
1180
1262
  request["settings"]["pipeline"] = {"timeout": reservation_duration}
1181
1263
  console.print(f"⏳ Maximum reservation time is {reservation_duration} minutes")
@@ -1184,7 +1266,7 @@ def request(
1184
1266
  console.print(f"⏳ Maximum reservation time is {timeout} minutes")
1185
1267
 
1186
1268
  # forced pipeline timeout
1187
- elif timeout != DEFAULT_PIPELINE_TIMEOUT:
1269
+ elif forced_pipeline_timeout:
1188
1270
  console.print(f"⏳ Pipeline timeout forced to {timeout} minutes")
1189
1271
  request["settings"]["pipeline"] = {"timeout": timeout}
1190
1272
 
@@ -1216,7 +1298,7 @@ def request(
1216
1298
  raise typer.Exit()
1217
1299
 
1218
1300
  # handle errors
1219
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1301
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1220
1302
  if response.status_code == 401:
1221
1303
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1222
1304
 
@@ -1240,13 +1322,13 @@ def restart(
1240
1322
  context: typer.Context,
1241
1323
  request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
1242
1324
  api_url: str = ARGUMENT_API_URL,
1243
- internal_api_url: str = typer.Argument(
1244
- settings.INTERNAL_API_URL,
1245
- envvar="TESTING_FARM_INTERNAL_API_URL",
1246
- metavar='',
1247
- rich_help_panel='Environment variables',
1248
- ),
1325
+ internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
1249
1326
  api_token: str = ARGUMENT_API_TOKEN,
1327
+ source_api_url: Optional[str] = ARGUMENT_SOURCE_API_URL,
1328
+ internal_source_api_url: Optional[str] = ARGUMENT_INTERNAL_SOURCE_API_URL,
1329
+ source_api_token: Optional[str] = ARGUMENT_SOURCE_API_TOKEN,
1330
+ target_api_url: Optional[str] = ARGUMENT_TARGET_API_URL,
1331
+ target_api_token: Optional[str] = ARGUMENT_TARGET_API_TOKEN,
1250
1332
  compose: Optional[str] = typer.Option(
1251
1333
  None,
1252
1334
  help="Force compose used to provision test environment.", # noqa
@@ -1280,6 +1362,13 @@ def restart(
1280
1362
  autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
1281
1363
  reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
1282
1364
  debug_reservation: bool = _option_debug_reservation(REQUEST_PANEL_RESERVE),
1365
+ edit: bool = typer.Option(
1366
+ False,
1367
+ help=(
1368
+ "Edit the request JSON in editor before submitting. "
1369
+ "Use the EDITOR environment variable to adjust the editor if needed."
1370
+ ),
1371
+ ),
1283
1372
  ):
1284
1373
  """
1285
1374
  Restart a Testing Farm request.
@@ -1288,30 +1377,39 @@ def restart(
1288
1377
  """
1289
1378
 
1290
1379
  # Accept these arguments only via environment variables
1291
- check_unexpected_arguments(context, "api_url", "api_token", "internal_api_url")
1292
-
1293
- # UUID pattern
1294
- uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
1380
+ check_unexpected_arguments(
1381
+ context,
1382
+ "api_url",
1383
+ "api_token",
1384
+ "internal_api_url",
1385
+ "source_api_url",
1386
+ "internal_source_api_url",
1387
+ "source_api_token",
1388
+ "target_api_url",
1389
+ "target_api_token",
1390
+ )
1295
1391
 
1296
- # Find the UUID in the string
1297
- uuid_match = uuid_pattern.search(request_id)
1392
+ # Determine source configuration (fallback to general settings)
1393
+ effective_source_api_url = source_api_url or api_url
1394
+ effective_internal_source_api_url = internal_source_api_url or internal_api_url
1395
+ effective_source_api_token = source_api_token or api_token
1298
1396
 
1299
- if not uuid_match:
1300
- exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
1301
- return
1397
+ # Determine target configuration (fallback to general settings)
1398
+ effective_target_api_url = target_api_url or api_url
1399
+ effective_target_api_token = target_api_token or api_token
1302
1400
 
1303
- # Extract the UUID from the match object
1304
- _request_id = uuid_match.group()
1401
+ # Extract the UUID from the request_id string
1402
+ _request_id = extract_uuid(request_id)
1305
1403
 
1306
1404
  # Construct URL to the internal API
1307
- get_url = urllib.parse.urljoin(str(internal_api_url), f"v0.1/requests/{_request_id}")
1405
+ get_url = urllib.parse.urljoin(str(effective_internal_source_api_url), f"v0.1/requests/{_request_id}")
1308
1406
 
1309
1407
  # Setting up retries
1310
1408
  session = requests.Session()
1311
1409
  install_http_retries(session)
1312
1410
 
1313
1411
  # Get the request details
1314
- response = session.get(get_url, headers=_get_headers(api_token))
1412
+ response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
1315
1413
 
1316
1414
  if response.status_code == 401:
1317
1415
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1319,10 +1417,11 @@ def restart(
1319
1417
  # The API token is valid, but it doesn't own the request
1320
1418
  if response.status_code == 403:
1321
1419
  console.print(
1322
- "⚠️ [yellow] You are not the owner of this request. Any secrets associated with the request will not be included on the restart.[/yellow]" # noqa: E501
1420
+ "⚠️ [yellow] You are not the owner of this request. Any secrets associated with the "
1421
+ "request will not be included on the restart.[/yellow]"
1323
1422
  )
1324
- # Construct URL to the internal API
1325
- get_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
1423
+ # Construct URL to the API
1424
+ get_url = urllib.parse.urljoin(str(effective_source_api_url), f"v0.1/requests/{_request_id}")
1326
1425
 
1327
1426
  # Get the request details
1328
1427
  response = session.get(get_url)
@@ -1360,10 +1459,16 @@ def restart(
1360
1459
  test["ref"] = git_ref
1361
1460
 
1362
1461
  if tmt_test_name:
1363
- test["test_name"] = tmt_test_name
1462
+ if reserve:
1463
+ test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
1464
+ else:
1465
+ test["test_name"] = tmt_test_name
1364
1466
 
1365
1467
  if tmt_test_filter:
1366
- test["test_filter"] = tmt_test_filter
1468
+ if reserve:
1469
+ test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
1470
+ else:
1471
+ test["test_filter"] = tmt_test_filter
1367
1472
 
1368
1473
  merge_sha_info = ""
1369
1474
  if git_merge_sha:
@@ -1431,7 +1536,7 @@ def restart(
1431
1536
 
1432
1537
  if test_type == "fmf":
1433
1538
  # The method explained in https://github.com/fastapi/typer/discussions/668
1434
- if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE: # pyre-ignore[16]
1539
+ if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
1435
1540
  request["test"][test_type]["path"] = tmt_path
1436
1541
 
1437
1542
  # worker image
@@ -1471,7 +1576,10 @@ def restart(
1471
1576
  if len(request["environments"]) > 1:
1472
1577
  exit_error("Reservations are currently supported for a single plan, cannot continue")
1473
1578
 
1474
- rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1579
+ # support cases where the user has multiple localhost addresses
1580
+ rules = _parse_security_group_rules(
1581
+ list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
1582
+ )
1475
1583
 
1476
1584
  for environment in request["environments"]:
1477
1585
  _add_reservation(
@@ -1489,6 +1597,18 @@ def restart(
1489
1597
  f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
1490
1598
  )
1491
1599
 
1600
+ # edit request if requested
1601
+ if edit:
1602
+ while True:
1603
+ try:
1604
+ request = json.loads(edit_with_editor(json.dumps(request, indent=2), "editing request"))
1605
+ break
1606
+ except (TypeError, ValueError) as error:
1607
+ console.print(f"⛔ Edited request is not a valid JSON, cannot continue: {error}", style="red")
1608
+ if typer.confirm("❓️ Edit again?"):
1609
+ continue
1610
+ raise typer.Exit(code=255)
1611
+
1492
1612
  # dry run
1493
1613
  if dry_run:
1494
1614
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
@@ -1496,10 +1616,10 @@ def restart(
1496
1616
  raise typer.Exit()
1497
1617
 
1498
1618
  # submit request to Testing Farm
1499
- post_url = urllib.parse.urljoin(str(api_url), "v0.1/requests")
1619
+ post_url = urllib.parse.urljoin(str(effective_target_api_url), "v0.1/requests")
1500
1620
 
1501
1621
  # handle errors
1502
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1622
+ response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
1503
1623
  if response.status_code == 401:
1504
1624
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1505
1625
 
@@ -1516,7 +1636,7 @@ def restart(
1516
1636
  # watch
1517
1637
  watch(
1518
1638
  context,
1519
- str(api_url),
1639
+ str(effective_target_api_url),
1520
1640
  response.json()['id'],
1521
1641
  no_wait,
1522
1642
  reserve=reserve,
@@ -1597,7 +1717,7 @@ def run(
1597
1717
  raise typer.Exit()
1598
1718
 
1599
1719
  # handle errors
1600
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1720
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1601
1721
  if response.status_code == 401:
1602
1722
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1603
1723
 
@@ -1715,6 +1835,7 @@ def reserve(
1715
1835
  repository: List[str] = OPTION_REPOSITORY,
1716
1836
  repository_file: List[str] = OPTION_REPOSITORY_FILE,
1717
1837
  redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
1838
+ tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
1718
1839
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1719
1840
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1720
1841
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
@@ -1797,7 +1918,7 @@ def reserve(
1797
1918
 
1798
1919
  if kickstart:
1799
1920
  # Typer escapes newlines in options, we need to unescape them
1800
- kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart] # pyre-ignore[6]
1921
+ kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
1801
1922
  environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
1802
1923
 
1803
1924
  if redhat_brew_build:
@@ -1830,6 +1951,12 @@ def reserve(
1830
1951
  if tmt_finish:
1831
1952
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
1832
1953
 
1954
+ if tmt_environment:
1955
+ if "tmt" not in environment:
1956
+ environment["tmt"] = {}
1957
+
1958
+ environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
1959
+
1833
1960
  # Setting up retries
1834
1961
  session = requests.Session()
1835
1962
  install_http_retries(session)
@@ -1837,7 +1964,10 @@ def reserve(
1837
1964
  if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
1838
1965
  ingress_rules = security_group_rule_ingress or []
1839
1966
  if not skip_workstation_access:
1840
- ingress_rules.append(_localhost_ingress_rule(session))
1967
+ # support cases where the user has multiple localhost addresses
1968
+ ingress_rules.extend(
1969
+ {_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}
1970
+ )
1841
1971
 
1842
1972
  rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
1843
1973
  environment["settings"]["provisioning"].update(rules)
@@ -1891,7 +2021,7 @@ def reserve(
1891
2021
  raise typer.Exit()
1892
2022
 
1893
2023
  # handle errors
1894
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
2024
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1895
2025
  if response.status_code == 401:
1896
2026
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1897
2027
 
@@ -2057,23 +2187,13 @@ def cancel(
2057
2187
  # Accept these arguments only via environment variables
2058
2188
  check_unexpected_arguments(context, "api_url", "api_token")
2059
2189
 
2060
- # UUID pattern
2061
- uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
2062
-
2063
- # Find the UUID in the string
2064
- uuid_match = uuid_pattern.search(request_id)
2065
-
2066
- if not uuid_match:
2067
- exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
2068
- return
2190
+ # Extract the UUID from the request_id string
2191
+ _request_id = extract_uuid(request_id)
2069
2192
 
2070
2193
  if not api_token:
2071
2194
  exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
2072
2195
  return
2073
2196
 
2074
- # Extract the UUID from the match object
2075
- _request_id = uuid_match.group()
2076
-
2077
2197
  # Construct URL to the internal API
2078
2198
  request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
2079
2199
 
@@ -2082,11 +2202,17 @@ def cancel(
2082
2202
  install_http_retries(session)
2083
2203
 
2084
2204
  # Get the request details
2085
- response = session.delete(request_url, headers=_get_headers(api_token))
2205
+ response = session.delete(request_url, headers=authorization_headers(api_token))
2086
2206
 
2087
2207
  if response.status_code == 401:
2088
2208
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2089
2209
 
2210
+ if response.status_code == 403:
2211
+ exit_error(
2212
+ "You cannot cancel foreign requests. You can only cancel your own requests "
2213
+ "or must have 'admin' permissions."
2214
+ )
2215
+
2090
2216
  if response.status_code == 404:
2091
2217
  exit_error("Request was not found. Verify the request ID is correct.")
2092
2218
 
tft/cli/config.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # Copyright Contributors to the Testing Farm project.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- from dynaconf import LazySettings
4
+ from dynaconf import LazySettings # type: ignore
5
5
 
6
6
  settings = LazySettings(
7
7
  # all environment variables have `TESTING_FARM_` prefix
@@ -10,6 +10,13 @@ settings = LazySettings(
10
10
  API_URL="https://api.dev.testing-farm.io/v0.1",
11
11
  INTERNAL_API_URL="https://internal.api.dev.testing-farm.io/v0.1",
12
12
  API_TOKEN=None,
13
+ # Restart command specific source API configuration (fallback to general settings)
14
+ SOURCE_API_URL=None,
15
+ INTERNAL_SOURCE_API_URL=None,
16
+ SOURCE_API_TOKEN=None,
17
+ # Restart command specific target API configuration (fallback to general settings)
18
+ TARGET_API_URL=None,
19
+ TARGET_API_TOKEN=None,
13
20
  ISSUE_TRACKER="https://gitlab.com/testing-farm/general/-/issues/new",
14
21
  STATUS_PAGE="https://status.testing-farm.io",
15
22
  ONBOARDING_DOCS="https://docs.testing-farm.io/Testing%20Farm/0.1/onboarding.html",
@@ -27,4 +34,6 @@ settings = LazySettings(
27
34
  TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
28
35
  TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
29
36
  PUBLIC_IP_CHECKER_URL="https://ipv4.icanhazip.com",
37
+ # number or tries for resolving localhost public IP, useful if the user has multiple IPs
38
+ PUBLIC_IP_RESOLVE_TRIES=1,
30
39
  )
tft/cli/tool.py CHANGED
@@ -6,11 +6,13 @@ import os
6
6
  import typer
7
7
 
8
8
  import tft.cli.commands as commands
9
+ from tft.cli.command.listing import listing
9
10
  from tft.cli.config import settings
10
11
 
11
12
  app = typer.Typer()
12
13
 
13
14
  app.command()(commands.cancel)
15
+ app.command(name="list")(listing)
14
16
  app.command()(commands.request)
15
17
  app.command()(commands.restart)
16
18
  app.command()(commands.reserve)