tft-cli 0.0.25__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 +162 -74
- tft/cli/config.py +12 -3
- tft/cli/tool.py +2 -0
- tft/cli/utils.py +149 -5
- {tft_cli-0.0.25.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.25.dist-info/RECORD +0 -10
- {tft_cli-0.0.25.dist-info → tft_cli-0.0.28.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.25.dist-info → tft_cli-0.0.28.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.25.dist-info → tft_cli-0.0.28.dist-info}/entry_points.txt +0 -0
tft/cli/commands.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import base64
|
|
5
5
|
import codecs
|
|
6
|
+
import importlib.metadata
|
|
6
7
|
import ipaddress
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
@@ -17,22 +18,24 @@ import xml.etree.ElementTree as ET
|
|
|
17
18
|
from enum import Enum
|
|
18
19
|
from typing import Any, Dict, List, Optional
|
|
19
20
|
|
|
20
|
-
import pkg_resources
|
|
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,
|
|
@@ -41,7 +44,7 @@ from tft.cli.utils import (
|
|
|
41
44
|
uuid_valid,
|
|
42
45
|
)
|
|
43
46
|
|
|
44
|
-
cli_version: str =
|
|
47
|
+
cli_version: str = importlib.metadata.version("tft-cli")
|
|
45
48
|
|
|
46
49
|
TestingFarmRequestV1: Dict[str, Any] = {'test': {}, 'environments': None}
|
|
47
50
|
Environment: Dict[str, Any] = {'arch': None, 'os': None, 'pool': None, 'artifacts': None, 'variables': {}}
|
|
@@ -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",
|
|
@@ -271,6 +322,13 @@ OPTION_RESERVE: bool = typer.Option(
|
|
|
271
322
|
help="Reserve machine after testing, similarly to the `reserve` command.",
|
|
272
323
|
rich_help_panel=REQUEST_PANEL_RESERVE,
|
|
273
324
|
)
|
|
325
|
+
OPTION_TMT_CONTEXT: Optional[List[str]] = typer.Option(
|
|
326
|
+
None,
|
|
327
|
+
"-c",
|
|
328
|
+
"--context",
|
|
329
|
+
metavar="key=value|@file",
|
|
330
|
+
help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
|
|
331
|
+
)
|
|
274
332
|
|
|
275
333
|
|
|
276
334
|
def _option_autoconnect(panel: str) -> bool:
|
|
@@ -527,14 +585,6 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
|
|
|
527
585
|
return security_group_rules
|
|
528
586
|
|
|
529
587
|
|
|
530
|
-
def _get_headers(api_key: str) -> Dict[str, str]:
|
|
531
|
-
"""
|
|
532
|
-
Return a dict with headers for a request to Testing Farm API.
|
|
533
|
-
Used for authentication.
|
|
534
|
-
"""
|
|
535
|
-
return {'Authorization': f'Bearer {api_key}'}
|
|
536
|
-
|
|
537
|
-
|
|
538
588
|
def _parse_xunit(xunit: str):
|
|
539
589
|
"""
|
|
540
590
|
A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
|
|
@@ -761,7 +811,7 @@ def watch(
|
|
|
761
811
|
_handle_reservation(session, api_url, request["id"], autoconnect)
|
|
762
812
|
return
|
|
763
813
|
|
|
764
|
-
time.sleep(
|
|
814
|
+
time.sleep(settings.WATCH_TICK)
|
|
765
815
|
continue
|
|
766
816
|
|
|
767
817
|
current_state = state
|
|
@@ -861,13 +911,7 @@ def request(
|
|
|
861
911
|
hardware: List[str] = OPTION_HARDWARE,
|
|
862
912
|
kickstart: Optional[List[str]] = OPTION_KICKSTART,
|
|
863
913
|
pool: Optional[str] = OPTION_POOL,
|
|
864
|
-
cli_tmt_context: Optional[List[str]] =
|
|
865
|
-
None,
|
|
866
|
-
"-c",
|
|
867
|
-
"--context",
|
|
868
|
-
metavar="key=value|@file",
|
|
869
|
-
help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
|
|
870
|
-
),
|
|
914
|
+
cli_tmt_context: Optional[List[str]] = OPTION_TMT_CONTEXT,
|
|
871
915
|
variables: Optional[List[str]] = OPTION_VARIABLES,
|
|
872
916
|
secrets: Optional[List[str]] = OPTION_SECRETS,
|
|
873
917
|
tmt_environment: Optional[List[str]] = typer.Option(
|
|
@@ -1065,7 +1109,7 @@ def request(
|
|
|
1065
1109
|
|
|
1066
1110
|
if kickstart:
|
|
1067
1111
|
# Typer escapes newlines in options, we need to unescape them
|
|
1068
|
-
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1112
|
+
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1069
1113
|
environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
|
|
1070
1114
|
|
|
1071
1115
|
if redhat_brew_build:
|
|
@@ -1112,7 +1156,10 @@ def request(
|
|
|
1112
1156
|
if len(environments) > 1:
|
|
1113
1157
|
exit_error("Reservations are currently supported for a single plan, cannot continue")
|
|
1114
1158
|
|
|
1115
|
-
|
|
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
|
+
)
|
|
1116
1163
|
|
|
1117
1164
|
for environment in environments:
|
|
1118
1165
|
_add_reservation(
|
|
@@ -1215,7 +1262,7 @@ def request(
|
|
|
1215
1262
|
raise typer.Exit()
|
|
1216
1263
|
|
|
1217
1264
|
# handle errors
|
|
1218
|
-
response = session.post(post_url, json=request, headers=
|
|
1265
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1219
1266
|
if response.status_code == 401:
|
|
1220
1267
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1221
1268
|
|
|
@@ -1239,13 +1286,13 @@ def restart(
|
|
|
1239
1286
|
context: typer.Context,
|
|
1240
1287
|
request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
|
|
1241
1288
|
api_url: str = ARGUMENT_API_URL,
|
|
1242
|
-
internal_api_url: str =
|
|
1243
|
-
settings.INTERNAL_API_URL,
|
|
1244
|
-
envvar="TESTING_FARM_INTERNAL_API_URL",
|
|
1245
|
-
metavar='',
|
|
1246
|
-
rich_help_panel='Environment variables',
|
|
1247
|
-
),
|
|
1289
|
+
internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
|
|
1248
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,
|
|
1249
1296
|
compose: Optional[str] = typer.Option(
|
|
1250
1297
|
None,
|
|
1251
1298
|
help="Force compose used to provision test environment.", # noqa
|
|
@@ -1254,6 +1301,8 @@ def restart(
|
|
|
1254
1301
|
None,
|
|
1255
1302
|
help="Force pool to provision.",
|
|
1256
1303
|
),
|
|
1304
|
+
cli_tmt_context: Optional[List[str]] = OPTION_TMT_CONTEXT,
|
|
1305
|
+
variables: Optional[List[str]] = OPTION_VARIABLES,
|
|
1257
1306
|
git_url: Optional[str] = typer.Option(None, help="Force URL of the GIT repository to test."),
|
|
1258
1307
|
git_ref: Optional[str] = typer.Option(None, help="Force GIT ref or branch to test."),
|
|
1259
1308
|
git_merge_sha: Optional[str] = typer.Option(None, help="Force GIT ref or branch into which --ref will be merged."),
|
|
@@ -1277,6 +1326,13 @@ def restart(
|
|
|
1277
1326
|
autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
|
|
1278
1327
|
reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
|
|
1279
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
|
+
),
|
|
1280
1336
|
):
|
|
1281
1337
|
"""
|
|
1282
1338
|
Restart a Testing Farm request.
|
|
@@ -1285,30 +1341,39 @@ def restart(
|
|
|
1285
1341
|
"""
|
|
1286
1342
|
|
|
1287
1343
|
# Accept these arguments only via environment variables
|
|
1288
|
-
check_unexpected_arguments(
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
+
)
|
|
1292
1355
|
|
|
1293
|
-
#
|
|
1294
|
-
|
|
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
|
|
1295
1360
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
|
1299
1364
|
|
|
1300
|
-
# Extract the UUID from the
|
|
1301
|
-
_request_id =
|
|
1365
|
+
# Extract the UUID from the request_id string
|
|
1366
|
+
_request_id = extract_uuid(request_id)
|
|
1302
1367
|
|
|
1303
1368
|
# Construct URL to the internal API
|
|
1304
|
-
get_url = urllib.parse.urljoin(str(
|
|
1369
|
+
get_url = urllib.parse.urljoin(str(effective_internal_source_api_url), f"v0.1/requests/{_request_id}")
|
|
1305
1370
|
|
|
1306
1371
|
# Setting up retries
|
|
1307
1372
|
session = requests.Session()
|
|
1308
1373
|
install_http_retries(session)
|
|
1309
1374
|
|
|
1310
1375
|
# Get the request details
|
|
1311
|
-
response = session.get(get_url, headers=
|
|
1376
|
+
response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
|
|
1312
1377
|
|
|
1313
1378
|
if response.status_code == 401:
|
|
1314
1379
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
@@ -1316,10 +1381,11 @@ def restart(
|
|
|
1316
1381
|
# The API token is valid, but it doesn't own the request
|
|
1317
1382
|
if response.status_code == 403:
|
|
1318
1383
|
console.print(
|
|
1319
|
-
"⚠️ [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]"
|
|
1320
1386
|
)
|
|
1321
|
-
# Construct URL to the
|
|
1322
|
-
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}")
|
|
1323
1389
|
|
|
1324
1390
|
# Get the request details
|
|
1325
1391
|
response = session.get(get_url)
|
|
@@ -1332,9 +1398,9 @@ def restart(
|
|
|
1332
1398
|
# Transform to a request
|
|
1333
1399
|
request['environments'] = request['environments_requested']
|
|
1334
1400
|
|
|
1335
|
-
# Remove all keys except test and
|
|
1401
|
+
# Remove all keys except test, environments and settings
|
|
1336
1402
|
for key in list(request):
|
|
1337
|
-
if key not in ['test', 'environments']:
|
|
1403
|
+
if key not in ['test', 'environments', 'settings']:
|
|
1338
1404
|
del request[key]
|
|
1339
1405
|
|
|
1340
1406
|
test = request['test']
|
|
@@ -1406,6 +1472,14 @@ def restart(
|
|
|
1406
1472
|
for environment in request["environments"]:
|
|
1407
1473
|
environment["tmt"]["extra_args"]["finish"] = tmt_finish
|
|
1408
1474
|
|
|
1475
|
+
if cli_tmt_context:
|
|
1476
|
+
for environment in request["environments"]:
|
|
1477
|
+
environment["tmt"]["context"] = options_to_dict("tmt context", cli_tmt_context)
|
|
1478
|
+
|
|
1479
|
+
if variables:
|
|
1480
|
+
for environment in request["environments"]:
|
|
1481
|
+
environment["variables"] = options_to_dict("environment variables", variables)
|
|
1482
|
+
|
|
1409
1483
|
test_type = "fmf" if "fmf" in request["test"] else "sti"
|
|
1410
1484
|
|
|
1411
1485
|
if tmt_plan_name:
|
|
@@ -1420,7 +1494,7 @@ def restart(
|
|
|
1420
1494
|
|
|
1421
1495
|
if test_type == "fmf":
|
|
1422
1496
|
# The method explained in https://github.com/fastapi/typer/discussions/668
|
|
1423
|
-
if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
|
|
1497
|
+
if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
|
|
1424
1498
|
request["test"][test_type]["path"] = tmt_path
|
|
1425
1499
|
|
|
1426
1500
|
# worker image
|
|
@@ -1460,7 +1534,10 @@ def restart(
|
|
|
1460
1534
|
if len(request["environments"]) > 1:
|
|
1461
1535
|
exit_error("Reservations are currently supported for a single plan, cannot continue")
|
|
1462
1536
|
|
|
1463
|
-
|
|
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
|
+
)
|
|
1464
1541
|
|
|
1465
1542
|
for environment in request["environments"]:
|
|
1466
1543
|
_add_reservation(
|
|
@@ -1478,6 +1555,18 @@ def restart(
|
|
|
1478
1555
|
f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
|
|
1479
1556
|
)
|
|
1480
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
|
+
|
|
1481
1570
|
# dry run
|
|
1482
1571
|
if dry_run:
|
|
1483
1572
|
console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
|
|
@@ -1485,10 +1574,10 @@ def restart(
|
|
|
1485
1574
|
raise typer.Exit()
|
|
1486
1575
|
|
|
1487
1576
|
# submit request to Testing Farm
|
|
1488
|
-
post_url = urllib.parse.urljoin(str(
|
|
1577
|
+
post_url = urllib.parse.urljoin(str(effective_target_api_url), "v0.1/requests")
|
|
1489
1578
|
|
|
1490
1579
|
# handle errors
|
|
1491
|
-
response = session.post(post_url, json=request, headers=
|
|
1580
|
+
response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
|
|
1492
1581
|
if response.status_code == 401:
|
|
1493
1582
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1494
1583
|
|
|
@@ -1505,7 +1594,7 @@ def restart(
|
|
|
1505
1594
|
# watch
|
|
1506
1595
|
watch(
|
|
1507
1596
|
context,
|
|
1508
|
-
str(
|
|
1597
|
+
str(effective_target_api_url),
|
|
1509
1598
|
response.json()['id'],
|
|
1510
1599
|
no_wait,
|
|
1511
1600
|
reserve=reserve,
|
|
@@ -1586,7 +1675,7 @@ def run(
|
|
|
1586
1675
|
raise typer.Exit()
|
|
1587
1676
|
|
|
1588
1677
|
# handle errors
|
|
1589
|
-
response = session.post(post_url, json=request, headers=
|
|
1678
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1590
1679
|
if response.status_code == 401:
|
|
1591
1680
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1592
1681
|
|
|
@@ -1630,7 +1719,7 @@ def run(
|
|
|
1630
1719
|
state = request["state"]
|
|
1631
1720
|
|
|
1632
1721
|
if state == current_state:
|
|
1633
|
-
time.sleep(
|
|
1722
|
+
time.sleep(settings.WATCH_TICK)
|
|
1634
1723
|
continue
|
|
1635
1724
|
|
|
1636
1725
|
current_state = state
|
|
@@ -1642,7 +1731,7 @@ def run(
|
|
|
1642
1731
|
progress.stop()
|
|
1643
1732
|
exit_error("Request canceled.")
|
|
1644
1733
|
|
|
1645
|
-
time.sleep(
|
|
1734
|
+
time.sleep(settings.WATCH_TICK)
|
|
1646
1735
|
|
|
1647
1736
|
# workaround TFT-1690
|
|
1648
1737
|
install_http_retries(session, status_forcelist_extend=[404], timeout=60, retry_backoff_factor=0.1)
|
|
@@ -1786,7 +1875,7 @@ def reserve(
|
|
|
1786
1875
|
|
|
1787
1876
|
if kickstart:
|
|
1788
1877
|
# Typer escapes newlines in options, we need to unescape them
|
|
1789
|
-
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1878
|
+
kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
|
|
1790
1879
|
environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
|
|
1791
1880
|
|
|
1792
1881
|
if redhat_brew_build:
|
|
@@ -1826,7 +1915,10 @@ def reserve(
|
|
|
1826
1915
|
if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
|
|
1827
1916
|
ingress_rules = security_group_rule_ingress or []
|
|
1828
1917
|
if not skip_workstation_access:
|
|
1829
|
-
|
|
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
|
+
)
|
|
1830
1922
|
|
|
1831
1923
|
rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
|
|
1832
1924
|
environment["settings"]["provisioning"].update(rules)
|
|
@@ -1880,7 +1972,7 @@ def reserve(
|
|
|
1880
1972
|
raise typer.Exit()
|
|
1881
1973
|
|
|
1882
1974
|
# handle errors
|
|
1883
|
-
response = session.post(post_url, json=request, headers=
|
|
1975
|
+
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1884
1976
|
if response.status_code == 401:
|
|
1885
1977
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
1886
1978
|
|
|
@@ -1933,7 +2025,7 @@ def reserve(
|
|
|
1933
2025
|
state = request["state"]
|
|
1934
2026
|
|
|
1935
2027
|
if state == current_state:
|
|
1936
|
-
time.sleep(
|
|
2028
|
+
time.sleep(settings.WATCH_TICK)
|
|
1937
2029
|
continue
|
|
1938
2030
|
|
|
1939
2031
|
current_state = state
|
|
@@ -1948,7 +2040,7 @@ def reserve(
|
|
|
1948
2040
|
if not print_only_request_id and task_id is not None:
|
|
1949
2041
|
progress.update(task_id, description=f"Reservation job is [yellow]{current_state}[/yellow]")
|
|
1950
2042
|
|
|
1951
|
-
time.sleep(
|
|
2043
|
+
time.sleep(settings.WATCH_TICK)
|
|
1952
2044
|
|
|
1953
2045
|
while current_state != "ready":
|
|
1954
2046
|
if not print_only_request_id and task_id:
|
|
@@ -2015,7 +2107,7 @@ def reserve(
|
|
|
2015
2107
|
current_state = "ready"
|
|
2016
2108
|
guest = search.group(1)
|
|
2017
2109
|
|
|
2018
|
-
time.sleep(
|
|
2110
|
+
time.sleep(settings.WATCH_TICK)
|
|
2019
2111
|
|
|
2020
2112
|
console.print(f"🌎 ssh root@{guest}")
|
|
2021
2113
|
|
|
@@ -2046,23 +2138,13 @@ def cancel(
|
|
|
2046
2138
|
# Accept these arguments only via environment variables
|
|
2047
2139
|
check_unexpected_arguments(context, "api_url", "api_token")
|
|
2048
2140
|
|
|
2049
|
-
# UUID
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
# Find the UUID in the string
|
|
2053
|
-
uuid_match = uuid_pattern.search(request_id)
|
|
2054
|
-
|
|
2055
|
-
if not uuid_match:
|
|
2056
|
-
exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
|
|
2057
|
-
return
|
|
2141
|
+
# Extract the UUID from the request_id string
|
|
2142
|
+
_request_id = extract_uuid(request_id)
|
|
2058
2143
|
|
|
2059
2144
|
if not api_token:
|
|
2060
2145
|
exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
|
|
2061
2146
|
return
|
|
2062
2147
|
|
|
2063
|
-
# Extract the UUID from the match object
|
|
2064
|
-
_request_id = uuid_match.group()
|
|
2065
|
-
|
|
2066
2148
|
# Construct URL to the internal API
|
|
2067
2149
|
request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
|
|
2068
2150
|
|
|
@@ -2071,11 +2153,17 @@ def cancel(
|
|
|
2071
2153
|
install_http_retries(session)
|
|
2072
2154
|
|
|
2073
2155
|
# Get the request details
|
|
2074
|
-
response = session.delete(request_url, headers=
|
|
2156
|
+
response = session.delete(request_url, headers=authorization_headers(api_token))
|
|
2075
2157
|
|
|
2076
2158
|
if response.status_code == 401:
|
|
2077
2159
|
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
2078
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
|
+
|
|
2079
2167
|
if response.status_code == 404:
|
|
2080
2168
|
exit_error("Request was not found. Verify the request ID is correct.")
|
|
2081
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,11 +10,18 @@ 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
|
-
ONBOARDING_DOCS="https://docs.testing-farm.io/
|
|
22
|
+
ONBOARDING_DOCS="https://docs.testing-farm.io/Testing%20Farm/0.1/onboarding.html",
|
|
16
23
|
CONTAINER_SIGN="/.testing-farm-container",
|
|
17
|
-
WATCH_TICK=
|
|
24
|
+
WATCH_TICK=30,
|
|
18
25
|
DEFAULT_API_TIMEOUT=10,
|
|
19
26
|
DEFAULT_API_RETRIES=7,
|
|
20
27
|
# default reservation duration in minutes
|
|
@@ -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)
|