tft-cli 0.0.26__py3-none-any.whl → 0.0.28__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/command/__init__.py +2 -0
- tft/cli/command/listing.py +836 -0
- tft/cli/commands.py +134 -57
- tft/cli/config.py +10 -1
- tft/cli/tool.py +2 -0
- tft/cli/utils.py +149 -5
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/METADATA +2 -1
- tft_cli-0.0.28.dist-info/RECORD +12 -0
- tft_cli-0.0.26.dist-info/RECORD +0 -10
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.26.dist-info → tft_cli-0.0.28.dist-info}/entry_points.txt +0 -0
tft/cli/commands.py
CHANGED
|
@@ -20,19 +20,22 @@ from typing import Any, Dict, List, Optional
|
|
|
20
20
|
|
|
21
21
|
import requests
|
|
22
22
|
import typer
|
|
23
|
-
from click.core import ParameterSource
|
|
23
|
+
from click.core import ParameterSource
|
|
24
24
|
from rich import print, print_json
|
|
25
25
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
26
|
-
from rich.table import Table
|
|
26
|
+
from rich.table import Table # type: ignore
|
|
27
27
|
|
|
28
28
|
from tft.cli.config import settings
|
|
29
29
|
from tft.cli.utils import (
|
|
30
30
|
artifacts,
|
|
31
|
+
authorization_headers,
|
|
31
32
|
check_unexpected_arguments,
|
|
32
33
|
cmd_output_or_exit,
|
|
33
34
|
console,
|
|
34
35
|
console_stderr,
|
|
36
|
+
edit_with_editor,
|
|
35
37
|
exit_error,
|
|
38
|
+
extract_uuid,
|
|
36
39
|
hw_constraints,
|
|
37
40
|
install_http_retries,
|
|
38
41
|
normalize_multistring_option,
|
|
@@ -66,6 +69,7 @@ RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
|
|
|
66
69
|
RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
|
|
67
70
|
|
|
68
71
|
DEFAULT_PIPELINE_TIMEOUT = 60 * 12
|
|
72
|
+
DEFAULT_AGE = "7d"
|
|
69
73
|
|
|
70
74
|
# SSH command options for reservation connections
|
|
71
75
|
SSH_RESERVATION_OPTIONS = (
|
|
@@ -85,6 +89,20 @@ class PipelineType(str, Enum):
|
|
|
85
89
|
tmt_multihost = "tmt-multihost"
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
class PipelineState(str, Enum):
|
|
93
|
+
new = "new"
|
|
94
|
+
queued = "queued"
|
|
95
|
+
running = "running"
|
|
96
|
+
complete = "complete"
|
|
97
|
+
error = "error"
|
|
98
|
+
canceled = "canceled"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Ranch(str, Enum):
|
|
102
|
+
public = "public"
|
|
103
|
+
redhat = "redhat"
|
|
104
|
+
|
|
105
|
+
|
|
88
106
|
# Arguments and options that are shared among multiple commands
|
|
89
107
|
ARGUMENT_API_URL: str = typer.Argument(
|
|
90
108
|
settings.API_URL, envvar="TESTING_FARM_API_URL", metavar='', rich_help_panel='Environment variables'
|
|
@@ -99,6 +117,12 @@ ARGUMENT_API_TOKEN: str = typer.Argument(
|
|
|
99
117
|
metavar='',
|
|
100
118
|
rich_help_panel='Environment variables',
|
|
101
119
|
)
|
|
120
|
+
ARGUMENT_INTERNAL_API_URL: str = typer.Argument(
|
|
121
|
+
settings.INTERNAL_API_URL,
|
|
122
|
+
envvar="TESTING_FARM_INTERNAL_API_URL",
|
|
123
|
+
metavar='',
|
|
124
|
+
rich_help_panel='Environment variables',
|
|
125
|
+
)
|
|
102
126
|
OPTION_API_TOKEN: str = typer.Option(
|
|
103
127
|
settings.API_TOKEN,
|
|
104
128
|
envvar="TESTING_FARM_API_TOKEN",
|
|
@@ -106,6 +130,33 @@ OPTION_API_TOKEN: str = typer.Option(
|
|
|
106
130
|
metavar='',
|
|
107
131
|
rich_help_panel='Environment variables',
|
|
108
132
|
)
|
|
133
|
+
|
|
134
|
+
# Restart command specific arguments for source operations
|
|
135
|
+
ARGUMENT_SOURCE_API_URL: str = typer.Argument(
|
|
136
|
+
None, envvar="TESTING_FARM_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
|
|
137
|
+
)
|
|
138
|
+
ARGUMENT_INTERNAL_SOURCE_API_URL: str = typer.Argument(
|
|
139
|
+
None, envvar="TESTING_FARM_INTERNAL_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
|
|
140
|
+
)
|
|
141
|
+
ARGUMENT_SOURCE_API_TOKEN: str = typer.Argument(
|
|
142
|
+
None,
|
|
143
|
+
envvar="TESTING_FARM_SOURCE_API_TOKEN",
|
|
144
|
+
show_default=False,
|
|
145
|
+
metavar='',
|
|
146
|
+
rich_help_panel='Environment variables',
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Restart command specific arguments for target operations
|
|
150
|
+
ARGUMENT_TARGET_API_URL: str = typer.Argument(
|
|
151
|
+
None, envvar="TESTING_FARM_TARGET_API_URL", metavar='', rich_help_panel='Environment variables'
|
|
152
|
+
)
|
|
153
|
+
ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
|
|
154
|
+
None,
|
|
155
|
+
envvar="TESTING_FARM_TARGET_API_TOKEN",
|
|
156
|
+
show_default=False,
|
|
157
|
+
metavar='',
|
|
158
|
+
rich_help_panel='Environment variables',
|
|
159
|
+
)
|
|
109
160
|
OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
|
|
110
161
|
None,
|
|
111
162
|
"--plan",
|
|
@@ -534,14 +585,6 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
|
|
|
534
585
|
return security_group_rules
|
|
535
586
|
|
|
536
587
|
|
|
537
|
-
def _get_headers(api_key: str) -> Dict[str, str]:
|
|
538
|
-
"""
|
|
539
|
-
Return a dict with headers for a request to Testing Farm API.
|
|
540
|
-
Used for authentication.
|
|
541
|
-
"""
|
|
542
|
-
return {'Authorization': f'Bearer {api_key}'}
|
|
543
|
-
|
|
544
|
-
|
|
545
588
|
def _parse_xunit(xunit: str):
|
|
546
589
|
"""
|
|
547
590
|
A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
|
|
@@ -1066,7 +1109,7 @@ def request(
|
|
|
1066
1109
|
|
|
1067
1110
|
if kickstart:
|
|
1068
1111
|
# Typer escapes newlines in options, we need to unescape them
|
|
1069
|
-
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1112
|
+
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1070
1113
|
environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
|
|
1071
1114
|
|
|
1072
1115
|
if redhat_brew_build:
|
|
@@ -1113,7 +1156,10 @@ def request(
|
|
|
1113
1156
|
if len(environments) > 1:
|
|
1114
1157
|
exit_error("Reservations are currently supported for a single plan, cannot continue")
|
|
1115
1158
|
|
|
1116
|
-
|
|
1159
|
+
# support cases where the user has multiple localhost addresses
|
|
1160
|
+
rules = _parse_security_group_rules(
|
|
1161
|
+
list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
|
|
1162
|
+
)
|
|
1117
1163
|
|
|
1118
1164
|
for environment in environments:
|
|
1119
1165
|
_add_reservation(
|
|
@@ -1216,7 +1262,7 @@ def request(
|
|
|
1216
1262
|
raise typer.Exit()
|
|
1217
1263
|
|
|
1218
1264
|
# handle errors
|
|
1219
|
-
response = session.post(post_url, json=request, headers=
|
|
1265
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1220
1266
|
if response.status_code == 401:
|
|
1221
1267
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1222
1268
|
|
|
@@ -1240,13 +1286,13 @@ def restart(
|
|
|
1240
1286
|
context: typer.Context,
|
|
1241
1287
|
request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
|
|
1242
1288
|
api_url: str = ARGUMENT_API_URL,
|
|
1243
|
-
internal_api_url: str =
|
|
1244
|
-
settings.INTERNAL_API_URL,
|
|
1245
|
-
envvar="TESTING_FARM_INTERNAL_API_URL",
|
|
1246
|
-
metavar='',
|
|
1247
|
-
rich_help_panel='Environment variables',
|
|
1248
|
-
),
|
|
1289
|
+
internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
|
|
1249
1290
|
api_token: str = ARGUMENT_API_TOKEN,
|
|
1291
|
+
source_api_url: Optional[str] = ARGUMENT_SOURCE_API_URL,
|
|
1292
|
+
internal_source_api_url: Optional[str] = ARGUMENT_INTERNAL_SOURCE_API_URL,
|
|
1293
|
+
source_api_token: Optional[str] = ARGUMENT_SOURCE_API_TOKEN,
|
|
1294
|
+
target_api_url: Optional[str] = ARGUMENT_TARGET_API_URL,
|
|
1295
|
+
target_api_token: Optional[str] = ARGUMENT_TARGET_API_TOKEN,
|
|
1250
1296
|
compose: Optional[str] = typer.Option(
|
|
1251
1297
|
None,
|
|
1252
1298
|
help="Force compose used to provision test environment.", # noqa
|
|
@@ -1280,6 +1326,13 @@ def restart(
|
|
|
1280
1326
|
autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
|
|
1281
1327
|
reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
|
|
1282
1328
|
debug_reservation: bool = _option_debug_reservation(REQUEST_PANEL_RESERVE),
|
|
1329
|
+
edit: bool = typer.Option(
|
|
1330
|
+
False,
|
|
1331
|
+
help=(
|
|
1332
|
+
"Edit the request JSON in editor before submitting. "
|
|
1333
|
+
"Use the EDITOR environment variable to adjust the editor if needed."
|
|
1334
|
+
),
|
|
1335
|
+
),
|
|
1283
1336
|
):
|
|
1284
1337
|
"""
|
|
1285
1338
|
Restart a Testing Farm request.
|
|
@@ -1288,30 +1341,39 @@ def restart(
|
|
|
1288
1341
|
"""
|
|
1289
1342
|
|
|
1290
1343
|
# Accept these arguments only via environment variables
|
|
1291
|
-
check_unexpected_arguments(
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1344
|
+
check_unexpected_arguments(
|
|
1345
|
+
context,
|
|
1346
|
+
"api_url",
|
|
1347
|
+
"api_token",
|
|
1348
|
+
"internal_api_url",
|
|
1349
|
+
"source_api_url",
|
|
1350
|
+
"internal_source_api_url",
|
|
1351
|
+
"source_api_token",
|
|
1352
|
+
"target_api_url",
|
|
1353
|
+
"target_api_token",
|
|
1354
|
+
)
|
|
1295
1355
|
|
|
1296
|
-
#
|
|
1297
|
-
|
|
1356
|
+
# Determine source configuration (fallback to general settings)
|
|
1357
|
+
effective_source_api_url = source_api_url or api_url
|
|
1358
|
+
effective_internal_source_api_url = internal_source_api_url or internal_api_url
|
|
1359
|
+
effective_source_api_token = source_api_token or api_token
|
|
1298
1360
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1361
|
+
# Determine target configuration (fallback to general settings)
|
|
1362
|
+
effective_target_api_url = target_api_url or api_url
|
|
1363
|
+
effective_target_api_token = target_api_token or api_token
|
|
1302
1364
|
|
|
1303
|
-
# Extract the UUID from the
|
|
1304
|
-
_request_id =
|
|
1365
|
+
# Extract the UUID from the request_id string
|
|
1366
|
+
_request_id = extract_uuid(request_id)
|
|
1305
1367
|
|
|
1306
1368
|
# Construct URL to the internal API
|
|
1307
|
-
get_url = urllib.parse.urljoin(str(
|
|
1369
|
+
get_url = urllib.parse.urljoin(str(effective_internal_source_api_url), f"v0.1/requests/{_request_id}")
|
|
1308
1370
|
|
|
1309
1371
|
# Setting up retries
|
|
1310
1372
|
session = requests.Session()
|
|
1311
1373
|
install_http_retries(session)
|
|
1312
1374
|
|
|
1313
1375
|
# Get the request details
|
|
1314
|
-
response = session.get(get_url, headers=
|
|
1376
|
+
response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
|
|
1315
1377
|
|
|
1316
1378
|
if response.status_code == 401:
|
|
1317
1379
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
@@ -1319,10 +1381,11 @@ def restart(
|
|
|
1319
1381
|
# The API token is valid, but it doesn't own the request
|
|
1320
1382
|
if response.status_code == 403:
|
|
1321
1383
|
console.print(
|
|
1322
|
-
"⚠️ [yellow] You are not the owner of this request. Any secrets associated with the
|
|
1384
|
+
"⚠️ [yellow] You are not the owner of this request. Any secrets associated with the "
|
|
1385
|
+
"request will not be included on the restart.[/yellow]"
|
|
1323
1386
|
)
|
|
1324
|
-
# Construct URL to the
|
|
1325
|
-
get_url = urllib.parse.urljoin(str(
|
|
1387
|
+
# Construct URL to the API
|
|
1388
|
+
get_url = urllib.parse.urljoin(str(effective_source_api_url), f"v0.1/requests/{_request_id}")
|
|
1326
1389
|
|
|
1327
1390
|
# Get the request details
|
|
1328
1391
|
response = session.get(get_url)
|
|
@@ -1431,7 +1494,7 @@ def restart(
|
|
|
1431
1494
|
|
|
1432
1495
|
if test_type == "fmf":
|
|
1433
1496
|
# The method explained in https://github.com/fastapi/typer/discussions/668
|
|
1434
|
-
if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
|
|
1497
|
+
if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
|
|
1435
1498
|
request["test"][test_type]["path"] = tmt_path
|
|
1436
1499
|
|
|
1437
1500
|
# worker image
|
|
@@ -1471,7 +1534,10 @@ def restart(
|
|
|
1471
1534
|
if len(request["environments"]) > 1:
|
|
1472
1535
|
exit_error("Reservations are currently supported for a single plan, cannot continue")
|
|
1473
1536
|
|
|
1474
|
-
|
|
1537
|
+
# support cases where the user has multiple localhost addresses
|
|
1538
|
+
rules = _parse_security_group_rules(
|
|
1539
|
+
list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
|
|
1540
|
+
)
|
|
1475
1541
|
|
|
1476
1542
|
for environment in request["environments"]:
|
|
1477
1543
|
_add_reservation(
|
|
@@ -1489,6 +1555,18 @@ def restart(
|
|
|
1489
1555
|
f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
|
|
1490
1556
|
)
|
|
1491
1557
|
|
|
1558
|
+
# edit request if requested
|
|
1559
|
+
if edit:
|
|
1560
|
+
while True:
|
|
1561
|
+
try:
|
|
1562
|
+
request = json.loads(edit_with_editor(json.dumps(request, indent=2), "editing request"))
|
|
1563
|
+
break
|
|
1564
|
+
except (TypeError, ValueError) as error:
|
|
1565
|
+
console.print(f"⛔ Edited request is not a valid JSON, cannot continue: {error}", style="red")
|
|
1566
|
+
if typer.confirm("❓️ Edit again?"):
|
|
1567
|
+
continue
|
|
1568
|
+
raise typer.Exit(code=255)
|
|
1569
|
+
|
|
1492
1570
|
# dry run
|
|
1493
1571
|
if dry_run:
|
|
1494
1572
|
console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
|
|
@@ -1496,10 +1574,10 @@ def restart(
|
|
|
1496
1574
|
raise typer.Exit()
|
|
1497
1575
|
|
|
1498
1576
|
# submit request to Testing Farm
|
|
1499
|
-
post_url = urllib.parse.urljoin(str(
|
|
1577
|
+
post_url = urllib.parse.urljoin(str(effective_target_api_url), "v0.1/requests")
|
|
1500
1578
|
|
|
1501
1579
|
# handle errors
|
|
1502
|
-
response = session.post(post_url, json=request, headers=
|
|
1580
|
+
response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
|
|
1503
1581
|
if response.status_code == 401:
|
|
1504
1582
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1505
1583
|
|
|
@@ -1516,7 +1594,7 @@ def restart(
|
|
|
1516
1594
|
# watch
|
|
1517
1595
|
watch(
|
|
1518
1596
|
context,
|
|
1519
|
-
str(
|
|
1597
|
+
str(effective_target_api_url),
|
|
1520
1598
|
response.json()['id'],
|
|
1521
1599
|
no_wait,
|
|
1522
1600
|
reserve=reserve,
|
|
@@ -1597,7 +1675,7 @@ def run(
|
|
|
1597
1675
|
raise typer.Exit()
|
|
1598
1676
|
|
|
1599
1677
|
# handle errors
|
|
1600
|
-
response = session.post(post_url, json=request, headers=
|
|
1678
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1601
1679
|
if response.status_code == 401:
|
|
1602
1680
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1603
1681
|
|
|
@@ -1797,7 +1875,7 @@ def reserve(
|
|
|
1797
1875
|
|
|
1798
1876
|
if kickstart:
|
|
1799
1877
|
# Typer escapes newlines in options, we need to unescape them
|
|
1800
|
-
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1878
|
+
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1801
1879
|
environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
|
|
1802
1880
|
|
|
1803
1881
|
if redhat_brew_build:
|
|
@@ -1837,7 +1915,10 @@ def reserve(
|
|
|
1837
1915
|
if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
|
|
1838
1916
|
ingress_rules = security_group_rule_ingress or []
|
|
1839
1917
|
if not skip_workstation_access:
|
|
1840
|
-
|
|
1918
|
+
# support cases where the user has multiple localhost addresses
|
|
1919
|
+
ingress_rules.extend(
|
|
1920
|
+
{_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}
|
|
1921
|
+
)
|
|
1841
1922
|
|
|
1842
1923
|
rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
|
|
1843
1924
|
environment["settings"]["provisioning"].update(rules)
|
|
@@ -1891,7 +1972,7 @@ def reserve(
|
|
|
1891
1972
|
raise typer.Exit()
|
|
1892
1973
|
|
|
1893
1974
|
# handle errors
|
|
1894
|
-
response = session.post(post_url, json=request, headers=
|
|
1975
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1895
1976
|
if response.status_code == 401:
|
|
1896
1977
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1897
1978
|
|
|
@@ -2057,23 +2138,13 @@ def cancel(
|
|
|
2057
2138
|
# Accept these arguments only via environment variables
|
|
2058
2139
|
check_unexpected_arguments(context, "api_url", "api_token")
|
|
2059
2140
|
|
|
2060
|
-
# UUID
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
# Find the UUID in the string
|
|
2064
|
-
uuid_match = uuid_pattern.search(request_id)
|
|
2065
|
-
|
|
2066
|
-
if not uuid_match:
|
|
2067
|
-
exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
|
|
2068
|
-
return
|
|
2141
|
+
# Extract the UUID from the request_id string
|
|
2142
|
+
_request_id = extract_uuid(request_id)
|
|
2069
2143
|
|
|
2070
2144
|
if not api_token:
|
|
2071
2145
|
exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
|
|
2072
2146
|
return
|
|
2073
2147
|
|
|
2074
|
-
# Extract the UUID from the match object
|
|
2075
|
-
_request_id = uuid_match.group()
|
|
2076
|
-
|
|
2077
2148
|
# Construct URL to the internal API
|
|
2078
2149
|
request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
|
|
2079
2150
|
|
|
@@ -2082,11 +2153,17 @@ def cancel(
|
|
|
2082
2153
|
install_http_retries(session)
|
|
2083
2154
|
|
|
2084
2155
|
# Get the request details
|
|
2085
|
-
response = session.delete(request_url, headers=
|
|
2156
|
+
response = session.delete(request_url, headers=authorization_headers(api_token))
|
|
2086
2157
|
|
|
2087
2158
|
if response.status_code == 401:
|
|
2088
2159
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
2089
2160
|
|
|
2161
|
+
if response.status_code == 403:
|
|
2162
|
+
exit_error(
|
|
2163
|
+
"You cannot cancel foreign requests. You can only cancel your own requests "
|
|
2164
|
+
"or must have 'admin' permissions."
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2090
2167
|
if response.status_code == 404:
|
|
2091
2168
|
exit_error("Request was not found. Verify the request ID is correct.")
|
|
2092
2169
|
|
tft/cli/config.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Copyright Contributors to the Testing Farm project.
|
|
2
2
|
# SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
from dynaconf import LazySettings
|
|
4
|
+
from dynaconf import LazySettings # type: ignore
|
|
5
5
|
|
|
6
6
|
settings = LazySettings(
|
|
7
7
|
# all environment variables have `TESTING_FARM_` prefix
|
|
@@ -10,6 +10,13 @@ settings = LazySettings(
|
|
|
10
10
|
API_URL="https://api.dev.testing-farm.io/v0.1",
|
|
11
11
|
INTERNAL_API_URL="https://internal.api.dev.testing-farm.io/v0.1",
|
|
12
12
|
API_TOKEN=None,
|
|
13
|
+
# Restart command specific source API configuration (fallback to general settings)
|
|
14
|
+
SOURCE_API_URL=None,
|
|
15
|
+
INTERNAL_SOURCE_API_URL=None,
|
|
16
|
+
SOURCE_API_TOKEN=None,
|
|
17
|
+
# Restart command specific target API configuration (fallback to general settings)
|
|
18
|
+
TARGET_API_URL=None,
|
|
19
|
+
TARGET_API_TOKEN=None,
|
|
13
20
|
ISSUE_TRACKER="https://gitlab.com/testing-farm/general/-/issues/new",
|
|
14
21
|
STATUS_PAGE="https://status.testing-farm.io",
|
|
15
22
|
ONBOARDING_DOCS="https://docs.testing-farm.io/Testing%20Farm/0.1/onboarding.html",
|
|
@@ -27,4 +34,6 @@ settings = LazySettings(
|
|
|
27
34
|
TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
|
|
28
35
|
TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
|
|
29
36
|
PUBLIC_IP_CHECKER_URL="https://ipv4.icanhazip.com",
|
|
37
|
+
# number or tries for resolving localhost public IP, useful if the user has multiple IPs
|
|
38
|
+
PUBLIC_IP_RESOLVE_TRIES=1,
|
|
30
39
|
)
|
tft/cli/tool.py
CHANGED
|
@@ -6,11 +6,13 @@ import os
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
import tft.cli.commands as commands
|
|
9
|
+
from tft.cli.command.listing import listing
|
|
9
10
|
from tft.cli.config import settings
|
|
10
11
|
|
|
11
12
|
app = typer.Typer()
|
|
12
13
|
|
|
13
14
|
app.command()(commands.cancel)
|
|
15
|
+
app.command(name="list")(listing)
|
|
14
16
|
app.command()(commands.request)
|
|
15
17
|
app.command()(commands.restart)
|
|
16
18
|
app.command()(commands.reserve)
|
tft/cli/utils.py
CHANGED
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
import glob
|
|
5
5
|
import itertools
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import shlex
|
|
9
|
+
import shutil
|
|
8
10
|
import subprocess
|
|
9
11
|
import sys
|
|
12
|
+
import tempfile
|
|
10
13
|
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
11
16
|
from typing import Any, Dict, List, NoReturn, Optional, Union
|
|
12
17
|
|
|
18
|
+
import pendulum
|
|
13
19
|
import requests
|
|
14
20
|
import requests.adapters
|
|
15
21
|
import typer
|
|
16
|
-
from click.core import ParameterSource
|
|
22
|
+
from click.core import ParameterSource
|
|
17
23
|
from rich.console import Console
|
|
18
|
-
from ruamel.yaml import YAML
|
|
24
|
+
from ruamel.yaml import YAML # type: ignore
|
|
19
25
|
from urllib3 import Retry
|
|
20
26
|
|
|
21
27
|
from tft.cli.config import settings
|
|
@@ -24,6 +30,56 @@ console = Console(soft_wrap=True)
|
|
|
24
30
|
console_stderr = Console(soft_wrap=True, file=sys.stderr)
|
|
25
31
|
|
|
26
32
|
|
|
33
|
+
@dataclass
|
|
34
|
+
class Age:
|
|
35
|
+
value: int
|
|
36
|
+
unit: str
|
|
37
|
+
|
|
38
|
+
_unit_multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
39
|
+
_unit_human = {"s": "second", "m": "minute", "h": "hour", "d": "day"}
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_string(cls, age_string: str) -> 'Age':
|
|
43
|
+
value, unit = age_string[:-1], age_string[-1]
|
|
44
|
+
if unit not in cls._unit_multiplier:
|
|
45
|
+
raise typer.BadParameter(f"Age must end with {', '.join(cls._unit_multiplier.keys())}")
|
|
46
|
+
|
|
47
|
+
if not value.isdigit():
|
|
48
|
+
raise typer.BadParameter(f"Invalid age value {value}")
|
|
49
|
+
|
|
50
|
+
return cls(value=int(age_string[:-1]), unit=age_string[-1])
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def birth_date(self) -> pendulum.DateTime:
|
|
54
|
+
now = pendulum.now(tz="UTC")
|
|
55
|
+
return now - pendulum.duration(seconds=self.value * self._unit_multiplier[self.unit])
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def human(self) -> str:
|
|
59
|
+
return f"{self.value} {self._unit_human[self.unit]}{'s' if self.value > 1 else ''}"
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def available_units() -> str:
|
|
63
|
+
return "s (seconds), m (minutes), h (hours) or d (days)"
|
|
64
|
+
|
|
65
|
+
def to_string(self, format="%Y-%m-%dT%H:%M:%S") -> str:
|
|
66
|
+
return self.birth_date.strftime(format)
|
|
67
|
+
|
|
68
|
+
def __str__(self):
|
|
69
|
+
return f"{self.value}{self.unit}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OutputFormat(str, Enum):
|
|
73
|
+
text = "text"
|
|
74
|
+
json = "json"
|
|
75
|
+
yaml = "yaml"
|
|
76
|
+
table = "table"
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def available_formats():
|
|
80
|
+
return "text, json or table"
|
|
81
|
+
|
|
82
|
+
|
|
27
83
|
def exit_error(error: str) -> NoReturn:
|
|
28
84
|
"""Exit with given error message"""
|
|
29
85
|
console.print(f"⛔ {error}", style="red")
|
|
@@ -125,6 +181,19 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
|
|
|
125
181
|
value_mixed = False
|
|
126
182
|
|
|
127
183
|
container[path_splitted.pop()] = value_mixed
|
|
184
|
+
|
|
185
|
+
# Only process additional path elements if they exist
|
|
186
|
+
if path_splitted:
|
|
187
|
+
next_path = path_splitted.pop()
|
|
188
|
+
|
|
189
|
+
# handle compatible.distro
|
|
190
|
+
if next_path == 'distro':
|
|
191
|
+
container[next_path] = (
|
|
192
|
+
container[next_path].append(value_mixed) if next_path in container else [value_mixed]
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
container[next_path] = value_mixed
|
|
196
|
+
|
|
128
197
|
return constraints
|
|
129
198
|
|
|
130
199
|
|
|
@@ -187,6 +256,27 @@ def uuid_valid(value: str, version: int = 4) -> bool:
|
|
|
187
256
|
return False
|
|
188
257
|
|
|
189
258
|
|
|
259
|
+
def extract_uuid(value: str) -> str:
|
|
260
|
+
"""
|
|
261
|
+
Extracts a UUID from a string. If the string is already a valid UUID, returns it.
|
|
262
|
+
If the string contains a UUID, extracts and returns it.
|
|
263
|
+
Raises typer.Exit with error message if no valid UUID is found.
|
|
264
|
+
"""
|
|
265
|
+
# Check if the value is already a valid UUID
|
|
266
|
+
if uuid_valid(value):
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
# UUID pattern for extracting UUIDs from strings
|
|
270
|
+
uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
|
|
271
|
+
|
|
272
|
+
# Try to extract UUID from string
|
|
273
|
+
uuid_match = uuid_pattern.search(value)
|
|
274
|
+
if uuid_match:
|
|
275
|
+
return uuid_match.group()
|
|
276
|
+
|
|
277
|
+
exit_error(f"Could not find a valid Testing Farm request id in '{value}'.")
|
|
278
|
+
|
|
279
|
+
|
|
190
280
|
class TimeoutHTTPAdapter(requests.adapters.HTTPAdapter):
|
|
191
281
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
192
282
|
self.timeout = kwargs.pop('timeout', settings.DEFAULT_API_TIMEOUT)
|
|
@@ -216,7 +306,9 @@ def install_http_retries(
|
|
|
216
306
|
|
|
217
307
|
status_forcelist_extend = status_forcelist_extend or []
|
|
218
308
|
|
|
219
|
-
|
|
309
|
+
from typing import Any, Dict
|
|
310
|
+
|
|
311
|
+
params: Dict[str, Any] = {
|
|
220
312
|
"total": retries,
|
|
221
313
|
"status_forcelist": [
|
|
222
314
|
429, # Too Many Requests
|
|
@@ -226,9 +318,9 @@ def install_http_retries(
|
|
|
226
318
|
504, # Gateway Timeout
|
|
227
319
|
]
|
|
228
320
|
+ status_forcelist_extend,
|
|
229
|
-
allowed_retry_parameter: ['HEAD', 'GET', 'POST', 'DELETE', 'PUT'],
|
|
230
321
|
"backoff_factor": retry_backoff_factor,
|
|
231
322
|
}
|
|
323
|
+
params[allowed_retry_parameter] = ['HEAD', 'GET', 'POST', 'DELETE', 'PUT']
|
|
232
324
|
retry_strategy = Retry(**params)
|
|
233
325
|
|
|
234
326
|
timeout_adapter = TimeoutHTTPAdapter(timeout=timeout, max_retries=retry_strategy)
|
|
@@ -263,8 +355,60 @@ def read_glob_paths(glob_paths: List[str]) -> str:
|
|
|
263
355
|
|
|
264
356
|
def check_unexpected_arguments(context: typer.Context, *args: str) -> Union[None, NoReturn]:
|
|
265
357
|
for argument in args:
|
|
266
|
-
if context.get_parameter_source(argument) == ParameterSource.COMMANDLINE:
|
|
358
|
+
if context.get_parameter_source(argument) == ParameterSource.COMMANDLINE:
|
|
267
359
|
exit_error(
|
|
268
360
|
f"Unexpected argument '{context.params.get(argument)}'. "
|
|
269
361
|
"Please make sure you are passing the parameters correctly."
|
|
270
362
|
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def validate_age(value: str) -> Age:
|
|
366
|
+
if value.endswith("m"):
|
|
367
|
+
return Age(int(value[:-1]), "m")
|
|
368
|
+
elif value.endswith("d"):
|
|
369
|
+
return Age(int(value[:-1]), "d")
|
|
370
|
+
else:
|
|
371
|
+
raise ValueError("Age must end with 'm' for months or 'd' for days.")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def authorization_headers(api_key: str) -> Dict[str, str]:
|
|
375
|
+
"""
|
|
376
|
+
Return a dict with headers for a request to Testing Farm API.
|
|
377
|
+
Used for authentication.
|
|
378
|
+
"""
|
|
379
|
+
return {'Authorization': f'Bearer {api_key}'}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def edit_with_editor(data: Any, description: Optional[str]) -> Any:
|
|
383
|
+
"""
|
|
384
|
+
Open data in an editor for user modification and return it back.
|
|
385
|
+
If description specified, print it as a user message together with the used editor.
|
|
386
|
+
"""
|
|
387
|
+
# Get the editor from environment variable, fallback to sensible defaults
|
|
388
|
+
editor = os.environ.get('EDITOR')
|
|
389
|
+
if not editor:
|
|
390
|
+
# Try common editors in order of preference
|
|
391
|
+
for candidate in ['vim', 'vi', 'nano', 'emacs']:
|
|
392
|
+
if shutil.which(candidate):
|
|
393
|
+
editor = candidate
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
if not editor:
|
|
397
|
+
exit_error("No editor found. Please set the 'EDITOR' environment variable.")
|
|
398
|
+
|
|
399
|
+
# Create a temporary file with the JSON content
|
|
400
|
+
with tempfile.NamedTemporaryFile(mode='w') as temp_file:
|
|
401
|
+
temp_file.write(data)
|
|
402
|
+
temp_file.flush()
|
|
403
|
+
|
|
404
|
+
# Open the editor
|
|
405
|
+
if description:
|
|
406
|
+
console.print(f"✏️ {description}, editor '{editor}'")
|
|
407
|
+
result = subprocess.run([editor, temp_file.name])
|
|
408
|
+
|
|
409
|
+
if result.returncode != 0:
|
|
410
|
+
exit_error(f"Editor '{editor}' exited with non-zero status: {result.returncode}")
|
|
411
|
+
|
|
412
|
+
# Read the modified content
|
|
413
|
+
with open(temp_file.name, 'r') as modified_file:
|
|
414
|
+
return modified_file.read()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tft-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.28
|
|
4
4
|
Summary: Testing Farm CLI tool
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: Miroslav Vadkerti
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Requires-Dist: click (>=8.0.4,<9.0.0)
|
|
15
15
|
Requires-Dist: colorama (>=0.4.4,<0.5.0)
|
|
16
16
|
Requires-Dist: dynaconf (>=3.1.2,<4.0.0)
|
|
17
|
+
Requires-Dist: pendulum (>=3.0.0,<4.0.0)
|
|
17
18
|
Requires-Dist: requests (>=2.27.1,<3.0.0)
|
|
18
19
|
Requires-Dist: rich (>=12)
|
|
19
20
|
Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
+
tft/cli/command/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
3
|
+
tft/cli/command/listing.py,sha256=gEkoeKHOcEiBt2TyLzSAongOKHKi7BgEO7GQ0AAa0Ss,31276
|
|
4
|
+
tft/cli/commands.py,sha256=aZ_P7B9z4IorT6ksQH0ciCY6KxvL5q5NJdWuHNv9fHE,85890
|
|
5
|
+
tft/cli/config.py,sha256=9JWjEwBiS4_Eq1B53lxxCIceERJ7AeryUyqxj9fm9c4,1714
|
|
6
|
+
tft/cli/tool.py,sha256=AaJ7RZwWEoP7WA_f2NbmcZSolNETkTWvVAt9pgt9j-o,975
|
|
7
|
+
tft/cli/utils.py,sha256=j8B30qpieBUieaiVo_3h5bRqoKnGeGKxR9eJ7GZcNiM,13702
|
|
8
|
+
tft_cli-0.0.28.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
9
|
+
tft_cli-0.0.28.dist-info/METADATA,sha256=MybsQ9HApc32-yILZ14mMu8YcWCSlhtH0gioJoMI8ZM,830
|
|
10
|
+
tft_cli-0.0.28.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
11
|
+
tft_cli-0.0.28.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
12
|
+
tft_cli-0.0.28.dist-info/RECORD,,
|