tft-cli 0.0.22__py3-none-any.whl → 0.0.23__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 +464 -91
- tft/cli/config.py +3 -1
- tft/cli/tool.py +1 -0
- tft/cli/utils.py +21 -4
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.23.dist-info}/METADATA +7 -6
- tft_cli-0.0.23.dist-info/RECORD +10 -0
- tft_cli-0.0.22.dist-info/RECORD +0 -10
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.23.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.23.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.23.dist-info}/entry_points.txt +0 -0
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
|
|
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] = {'
|
|
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,
|
|
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,
|
|
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
|
|
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('./
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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] =
|
|
1258
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
tft/cli/config.py
CHANGED
|
@@ -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="
|
|
29
|
+
PUBLIC_IP_CHECKER_URL="https://ipv4.icanhazip.com",
|
|
28
30
|
)
|
tft/cli/tool.py
CHANGED
|
@@ -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):
|
tft/cli/utils.py
CHANGED
|
@@ -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]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tft-cli
|
|
3
|
-
Version: 0.0.
|
|
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,<
|
|
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.
|
|
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
|
|
19
|
-
Requires-Dist: ruamel-yaml (>=0.18.
|
|
18
|
+
Requires-Dist: rich (>=12)
|
|
19
|
+
Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
|
|
20
20
|
Requires-Dist: setuptools
|
|
21
|
-
Requires-Dist:
|
|
21
|
+
Requires-Dist: shellingham (>=1.3.0,<2.0.0)
|
|
22
|
+
Requires-Dist: typer (>=0.11)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
+
tft/cli/commands.py,sha256=7ngkjZDlabd44NiCzQunUUIbBLWbm61eTMhCyqV2gfQ,77778
|
|
3
|
+
tft/cli/config.py,sha256=JiVLrgM4REWdljA9DjAuH4fNR1ekE7Crv2oM6vBPt9Q,1254
|
|
4
|
+
tft/cli/tool.py,sha256=nuz57u3yE4fKgdMgNtAFM7PCTcF6PSNFBQbCYDzxBOw,897
|
|
5
|
+
tft/cli/utils.py,sha256=KAk52TRCqHvrIZEKTpJn7HWskeEU4yvNEVlfnkfgMW4,8191
|
|
6
|
+
tft_cli-0.0.23.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
7
|
+
tft_cli-0.0.23.dist-info/METADATA,sha256=_xV2L07TUW8s1Iww24Agl0U7BJ_hVBuwCgbq7nwKGug,789
|
|
8
|
+
tft_cli-0.0.23.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
9
|
+
tft_cli-0.0.23.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
10
|
+
tft_cli-0.0.23.dist-info/RECORD,,
|
tft_cli-0.0.22.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
-
tft/cli/commands.py,sha256=mRIeB9JsvRHhiAGJUxXLFMTKuvEt2AGN8AhVujBQcZo,62824
|
|
3
|
-
tft/cli/config.py,sha256=CQrqucLMNlRrjK_y5WY6vLbAYA5TcnHG_kw8CgJ09OA,1165
|
|
4
|
-
tft/cli/tool.py,sha256=wFcVxe1NRGW8stputOZlKMasZHjpysas7f0sgpEzipQ,865
|
|
5
|
-
tft/cli/utils.py,sha256=iWQnjA5rYhwMaz8cP_zmfJ5HihTbaKE3XzZk0N4nfcE,7664
|
|
6
|
-
tft_cli-0.0.22.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
7
|
-
tft_cli-0.0.22.dist-info/METADATA,sha256=1FC__NfxUWFgQUr-8R1glWqIKi3kBvsSpZrM4rEBUEM,762
|
|
8
|
-
tft_cli-0.0.22.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
9
|
-
tft_cli-0.0.22.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
10
|
-
tft_cli-0.0.22.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|