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 +522 -93
- tft/cli/commands.py.backup +2211 -0
- 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.24.dist-info}/METADATA +7 -6
- tft_cli-0.0.24.dist-info/RECORD +11 -0
- tft_cli-0.0.22.dist-info/RECORD +0 -10
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.24.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.24.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.22.dist-info → tft_cli-0.0.24.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,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
|
|
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('./
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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] =
|
|
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
|
-
),
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|