tft-cli 0.0.28__py3-none-any.whl → 0.0.31__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/composes.py +200 -0
- tft/cli/command/listing.py +14 -22
- tft/cli/commands.py +148 -76
- tft/cli/tool.py +2 -0
- tft/cli/utils.py +93 -27
- {tft_cli-0.0.28.dist-info → tft_cli-0.0.31.dist-info}/METADATA +3 -1
- tft_cli-0.0.31.dist-info/RECORD +13 -0
- tft_cli-0.0.28.dist-info/RECORD +0 -12
- {tft_cli-0.0.28.dist-info → tft_cli-0.0.31.dist-info}/LICENSE +0 -0
- {tft_cli-0.0.28.dist-info → tft_cli-0.0.31.dist-info}/WHEEL +0 -0
- {tft_cli-0.0.28.dist-info → tft_cli-0.0.31.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright Contributors to the Testing Farm project.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from typing import Any, List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
import typer
|
|
12
|
+
from rich.progress import Progress, SpinnerColumn
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.table import Table # type: ignore
|
|
15
|
+
from ruamel.yaml import YAML # type: ignore
|
|
16
|
+
|
|
17
|
+
from tft.cli.commands import ARGUMENT_API_TOKEN, ARGUMENT_API_URL
|
|
18
|
+
from tft.cli.config import settings
|
|
19
|
+
from tft.cli.utils import (
|
|
20
|
+
OutputFormat,
|
|
21
|
+
StrEnum,
|
|
22
|
+
authorization_headers,
|
|
23
|
+
check_unexpected_arguments,
|
|
24
|
+
console,
|
|
25
|
+
exit_error,
|
|
26
|
+
handle_response_errors,
|
|
27
|
+
install_http_retries,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Ranch(StrEnum):
|
|
32
|
+
public = "public"
|
|
33
|
+
redhat = "redhat"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_text(composes_json: Any, show_regex: bool) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Show list of composes as a text.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
lines: list[str] = []
|
|
42
|
+
|
|
43
|
+
for compose in sorted(composes_json, key=lambda compose: compose["name"]):
|
|
44
|
+
text = f"{compose['name']}"
|
|
45
|
+
|
|
46
|
+
if show_regex:
|
|
47
|
+
if compose["type"] == "regex":
|
|
48
|
+
text += " [bold][green]regex[/green][/bold]"
|
|
49
|
+
else:
|
|
50
|
+
text += " [bold][green]compose[/green][/bold]"
|
|
51
|
+
|
|
52
|
+
lines.append(text)
|
|
53
|
+
|
|
54
|
+
console.print("\n".join(lines))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_table(composes_json: Any, show_regex: bool) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Show list of composes as a table.
|
|
60
|
+
"""
|
|
61
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
62
|
+
|
|
63
|
+
table.add_column("name", justify="left")
|
|
64
|
+
|
|
65
|
+
if show_regex:
|
|
66
|
+
table.add_column("type", justify="left")
|
|
67
|
+
|
|
68
|
+
for compose in sorted(composes_json, key=lambda compose: compose["name"]):
|
|
69
|
+
row = [compose["name"]]
|
|
70
|
+
|
|
71
|
+
if show_regex:
|
|
72
|
+
row.append(compose["type"])
|
|
73
|
+
|
|
74
|
+
table.add_row(*row)
|
|
75
|
+
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def composes(
|
|
80
|
+
context: typer.Context,
|
|
81
|
+
api_token: str = ARGUMENT_API_TOKEN,
|
|
82
|
+
api_url: str = ARGUMENT_API_URL,
|
|
83
|
+
ranch: Optional[Ranch] = typer.Option(
|
|
84
|
+
None, help="List composes for this ranch, instead of the ranch of your token."
|
|
85
|
+
),
|
|
86
|
+
search: Optional[str] = typer.Option(
|
|
87
|
+
None,
|
|
88
|
+
"-s",
|
|
89
|
+
"--search",
|
|
90
|
+
help="Search for composes based on the given regular expression. For searching `re.search` is used.",
|
|
91
|
+
),
|
|
92
|
+
show_regex: bool = typer.Option(
|
|
93
|
+
False,
|
|
94
|
+
help="Show also regular expressions used to accept additional composes.",
|
|
95
|
+
),
|
|
96
|
+
validate: Optional[List[str]] = typer.Option(
|
|
97
|
+
None,
|
|
98
|
+
"-v",
|
|
99
|
+
"--validate",
|
|
100
|
+
help="Verify that given compose would be accepted by Testing Farm. Can be specified multiple times.",
|
|
101
|
+
),
|
|
102
|
+
format: OutputFormat = typer.Option(
|
|
103
|
+
"text", help=f"Output format to use. Possible formats: {OutputFormat.available_formats()}"
|
|
104
|
+
),
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
List composes accepted by Testing Farm.
|
|
108
|
+
|
|
109
|
+
When Testing Farm token is provided, the command uses the ranch corresponding to your token.
|
|
110
|
+
To force listing of composes for a specific ranch, use the `--ranch` option.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
# Accept these arguments only via environment variables
|
|
114
|
+
check_unexpected_arguments(context, "api_url", "api_token")
|
|
115
|
+
|
|
116
|
+
# Setting up HTTP retries
|
|
117
|
+
session = requests.Session()
|
|
118
|
+
install_http_retries(session)
|
|
119
|
+
|
|
120
|
+
# check for token
|
|
121
|
+
if not api_token and not ranch:
|
|
122
|
+
exit_error("No API token found and no ranch specified. Cannot determine ranch.")
|
|
123
|
+
|
|
124
|
+
# Validate token if provided
|
|
125
|
+
if api_token and not ranch:
|
|
126
|
+
whoami_url = urllib.parse.urljoin(api_url, "v0.1/whoami")
|
|
127
|
+
try:
|
|
128
|
+
with Progress(SpinnerColumn(), transient=True) as progress:
|
|
129
|
+
progress.add_task(description="")
|
|
130
|
+
|
|
131
|
+
response = session.get(whoami_url, headers=authorization_headers(api_token))
|
|
132
|
+
handle_response_errors(response)
|
|
133
|
+
|
|
134
|
+
ranch = response.json()['token']['ranch']
|
|
135
|
+
|
|
136
|
+
except requests.RequestException as e:
|
|
137
|
+
exit_error(f"Failed to validate token: {e}")
|
|
138
|
+
|
|
139
|
+
# Compile the search regular expression
|
|
140
|
+
search_pattern = re.compile(search) if search else None
|
|
141
|
+
|
|
142
|
+
# Fetch composes
|
|
143
|
+
with Progress(SpinnerColumn(), transient=True) as progress:
|
|
144
|
+
progress.add_task(description="")
|
|
145
|
+
|
|
146
|
+
composes_url = urllib.parse.urljoin(api_url, f"v0.2/composes/{ranch}")
|
|
147
|
+
|
|
148
|
+
response = session.get(composes_url)
|
|
149
|
+
handle_response_errors(response)
|
|
150
|
+
|
|
151
|
+
composes_json = response.json().get("composes") or []
|
|
152
|
+
|
|
153
|
+
if not composes_json:
|
|
154
|
+
exit_error(f"No composes found in Testing Farm. Please file an issue to {settings.ISSUE_TRACKER}")
|
|
155
|
+
|
|
156
|
+
if search_pattern:
|
|
157
|
+
composes_json = [compose for compose in composes_json if search_pattern.search(compose["name"])]
|
|
158
|
+
|
|
159
|
+
if not composes_json:
|
|
160
|
+
exit_error(f"No composes found for '{search_pattern.pattern}'.")
|
|
161
|
+
|
|
162
|
+
if validate:
|
|
163
|
+
|
|
164
|
+
def _compose_accepted(compose: str):
|
|
165
|
+
console.print(f"✅ Compose '{compose}' is valid")
|
|
166
|
+
|
|
167
|
+
for validated_compose in validate:
|
|
168
|
+
for compose in composes_json:
|
|
169
|
+
if compose["type"] == "compose" and validated_compose == compose["name"]:
|
|
170
|
+
_compose_accepted(validated_compose)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
if compose["type"] == "regex" and re.match(compose["name"], validated_compose):
|
|
174
|
+
_compose_accepted(validated_compose)
|
|
175
|
+
break
|
|
176
|
+
else:
|
|
177
|
+
console.print(f"❌ Compose '{validated_compose}' is invalid")
|
|
178
|
+
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if not show_regex:
|
|
182
|
+
composes_json = [compose for compose in composes_json if compose["type"] != "regex"]
|
|
183
|
+
|
|
184
|
+
if format == OutputFormat.json:
|
|
185
|
+
json_dump = json.dumps(composes_json) or '[]'
|
|
186
|
+
console.print_json(json_dump)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if format == OutputFormat.yaml:
|
|
190
|
+
yaml_dump = io.StringIO()
|
|
191
|
+
YAML().dump(composes_json, yaml_dump)
|
|
192
|
+
syntax = Syntax(yaml_dump.getvalue(), "yaml")
|
|
193
|
+
console.print(syntax)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if format == OutputFormat.table:
|
|
197
|
+
render_table(composes_json, show_regex)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
render_text(composes_json, show_regex)
|
tft/cli/command/listing.py
CHANGED
|
@@ -7,7 +7,6 @@ import re
|
|
|
7
7
|
import sys
|
|
8
8
|
import urllib.parse
|
|
9
9
|
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
-
from enum import Enum
|
|
11
10
|
from typing import Any, List, Optional, Tuple
|
|
12
11
|
|
|
13
12
|
import pendulum
|
|
@@ -24,16 +23,20 @@ from tft.cli.commands import (
|
|
|
24
23
|
ARGUMENT_API_URL,
|
|
25
24
|
ARGUMENT_INTERNAL_API_URL,
|
|
26
25
|
PipelineState,
|
|
26
|
+
check_token,
|
|
27
27
|
)
|
|
28
28
|
from tft.cli.config import settings
|
|
29
29
|
from tft.cli.utils import (
|
|
30
30
|
Age,
|
|
31
31
|
OutputFormat,
|
|
32
|
+
StrEnum,
|
|
32
33
|
authorization_headers,
|
|
33
34
|
check_unexpected_arguments,
|
|
34
35
|
console,
|
|
35
36
|
exit_error,
|
|
36
37
|
extract_uuid,
|
|
38
|
+
handle_401_response,
|
|
39
|
+
handle_response_errors,
|
|
37
40
|
install_http_retries,
|
|
38
41
|
uuid_valid,
|
|
39
42
|
)
|
|
@@ -42,7 +45,7 @@ from tft.cli.utils import (
|
|
|
42
45
|
MAX_COMPOSE_LENGTH = 30
|
|
43
46
|
|
|
44
47
|
|
|
45
|
-
class Ranch(
|
|
48
|
+
class Ranch(StrEnum):
|
|
46
49
|
public = "public"
|
|
47
50
|
redhat = "redhat"
|
|
48
51
|
|
|
@@ -201,11 +204,11 @@ def render_reservation_table(requests_json: Any, show_utc: bool) -> None:
|
|
|
201
204
|
os_compose = os_info.get('compose')
|
|
202
205
|
if os_compose:
|
|
203
206
|
if len(os_compose) > MAX_COMPOSE_LENGTH:
|
|
204
|
-
envs.append(f"{arch:>7} (<too-long>)")
|
|
207
|
+
envs.append(f"{arch:>7} (<too-long>)") # noqa: E231
|
|
205
208
|
else:
|
|
206
|
-
envs.append(f"{arch:>7} ({os_compose})")
|
|
209
|
+
envs.append(f"{arch:>7} ({os_compose})") # noqa: E231
|
|
207
210
|
else:
|
|
208
|
-
envs.append(f"{arch:>7} (container)")
|
|
211
|
+
envs.append(f"{arch:>7} (container)") # noqa: E231
|
|
209
212
|
envs = list(dict.fromkeys(envs))
|
|
210
213
|
|
|
211
214
|
# Get time info
|
|
@@ -311,11 +314,11 @@ def render_table(
|
|
|
311
314
|
if os_compose:
|
|
312
315
|
# Check if compose contains disk_image or boot_image and display <hidden-flasher-image> instead
|
|
313
316
|
if len(os_compose) > 20:
|
|
314
|
-
envs.append(f"{arch:>7} (<too-long>)")
|
|
317
|
+
envs.append(f"{arch:>7} (<too-long>)") # noqa: E231
|
|
315
318
|
else:
|
|
316
|
-
envs.append(f"{arch:>7} ({os_compose})")
|
|
319
|
+
envs.append(f"{arch:>7} ({os_compose})") # noqa: E231
|
|
317
320
|
else:
|
|
318
|
-
envs.append(f"{arch:>7} (container)")
|
|
321
|
+
envs.append(f"{arch:>7} (container)") # noqa: E231
|
|
319
322
|
envs = list(dict.fromkeys(envs)) # Remove duplicates while preserving order
|
|
320
323
|
|
|
321
324
|
git_type, git_url = shorten_git_url(url)
|
|
@@ -395,7 +398,7 @@ def _format_time(seconds):
|
|
|
395
398
|
try:
|
|
396
399
|
seconds = float(seconds)
|
|
397
400
|
minutes, seconds = divmod(seconds, 60)
|
|
398
|
-
return f"{int(minutes)}m {seconds:.2f}s"
|
|
401
|
+
return f"{int(minutes)}m {seconds:.2f}s" # noqa: E231
|
|
399
402
|
except (ValueError, TypeError):
|
|
400
403
|
return "N/A"
|
|
401
404
|
|
|
@@ -697,24 +700,13 @@ def listing(
|
|
|
697
700
|
session = requests.Session()
|
|
698
701
|
install_http_retries(session)
|
|
699
702
|
|
|
700
|
-
def handle_response_errors(response: requests.Response) -> None:
|
|
701
|
-
if response.status_code == 401:
|
|
702
|
-
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
703
|
-
|
|
704
|
-
if response.status_code != 200:
|
|
705
|
-
exit_error(
|
|
706
|
-
f"Unexpected error {response.text}. "
|
|
707
|
-
f"Check {settings.STATUS_PAGE}. "
|
|
708
|
-
f"File an issue to {settings.ISSUE_TRACKER} if needed."
|
|
709
|
-
)
|
|
710
|
-
|
|
711
703
|
# Handle minimum age
|
|
712
704
|
if min_age:
|
|
713
705
|
base_request_url = f"{base_request_url}&created_before={min_age.to_string()}"
|
|
714
706
|
|
|
715
707
|
# check for token
|
|
716
708
|
if not api_token and (mine or show_secrets):
|
|
717
|
-
|
|
709
|
+
api_token = check_token(api_url, api_token)
|
|
718
710
|
|
|
719
711
|
# Validate token if provided
|
|
720
712
|
if api_token:
|
|
@@ -722,7 +714,7 @@ def listing(
|
|
|
722
714
|
try:
|
|
723
715
|
response = session.get(whoami_url, headers=authorization_headers(api_token))
|
|
724
716
|
if response.status_code == 401:
|
|
725
|
-
|
|
717
|
+
handle_401_response(response)
|
|
726
718
|
elif response.status_code != 200:
|
|
727
719
|
exit_error(
|
|
728
720
|
f"Token validation failed with status {response.status_code}. "
|
tft/cli/commands.py
CHANGED
|
@@ -15,7 +15,6 @@ import textwrap
|
|
|
15
15
|
import time
|
|
16
16
|
import urllib.parse
|
|
17
17
|
import xml.etree.ElementTree as ET
|
|
18
|
-
from enum import Enum
|
|
19
18
|
from typing import Any, Dict, List, Optional
|
|
20
19
|
|
|
21
20
|
import requests
|
|
@@ -27,6 +26,7 @@ from rich.table import Table # type: ignore
|
|
|
27
26
|
|
|
28
27
|
from tft.cli.config import settings
|
|
29
28
|
from tft.cli.utils import (
|
|
29
|
+
StrEnum,
|
|
30
30
|
artifacts,
|
|
31
31
|
authorization_headers,
|
|
32
32
|
check_unexpected_arguments,
|
|
@@ -36,6 +36,7 @@ from tft.cli.utils import (
|
|
|
36
36
|
edit_with_editor,
|
|
37
37
|
exit_error,
|
|
38
38
|
extract_uuid,
|
|
39
|
+
handle_401_response,
|
|
39
40
|
hw_constraints,
|
|
40
41
|
install_http_retries,
|
|
41
42
|
normalize_multistring_option,
|
|
@@ -68,7 +69,10 @@ RESERVE_URL = os.getenv("TESTING_FARM_RESERVE_URL", "https://gitlab.com/testing-
|
|
|
68
69
|
RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
|
|
69
70
|
RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
|
|
70
71
|
|
|
72
|
+
# NOTE(mvadkert): note that reservation duration is different per ranch,
|
|
73
|
+
# ignore this fact for now here for reservations
|
|
71
74
|
DEFAULT_PIPELINE_TIMEOUT = 60 * 12
|
|
75
|
+
|
|
72
76
|
DEFAULT_AGE = "7d"
|
|
73
77
|
|
|
74
78
|
# SSH command options for reservation connections
|
|
@@ -80,16 +84,16 @@ SSH_RESERVATION_OPTIONS = (
|
|
|
80
84
|
SECURITY_GROUP_RULE_FORMAT = re.compile(r"(tcp|ip|icmp|udp|-1|[0-255]):(.*):(\d{1,5}-\d{1,5}|\d{1,5}|-1)")
|
|
81
85
|
|
|
82
86
|
|
|
83
|
-
class WatchFormat(
|
|
87
|
+
class WatchFormat(StrEnum):
|
|
84
88
|
text = 'text'
|
|
85
89
|
json = 'json'
|
|
86
90
|
|
|
87
91
|
|
|
88
|
-
class PipelineType(
|
|
92
|
+
class PipelineType(StrEnum):
|
|
89
93
|
tmt_multihost = "tmt-multihost"
|
|
90
94
|
|
|
91
95
|
|
|
92
|
-
class PipelineState(
|
|
96
|
+
class PipelineState(StrEnum):
|
|
93
97
|
new = "new"
|
|
94
98
|
queued = "queued"
|
|
95
99
|
running = "running"
|
|
@@ -98,7 +102,7 @@ class PipelineState(str, Enum):
|
|
|
98
102
|
canceled = "canceled"
|
|
99
103
|
|
|
100
104
|
|
|
101
|
-
class Ranch(
|
|
105
|
+
class Ranch(StrEnum):
|
|
102
106
|
public = "public"
|
|
103
107
|
redhat = "redhat"
|
|
104
108
|
|
|
@@ -160,43 +164,32 @@ ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
|
|
|
160
164
|
OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
|
|
161
165
|
None,
|
|
162
166
|
"--plan",
|
|
163
|
-
help=(
|
|
164
|
-
'Select plans to be executed. '
|
|
165
|
-
'Passed as `--name` option to the `tmt plan` command. '
|
|
166
|
-
'Can be a regular expression.'
|
|
167
|
-
),
|
|
167
|
+
help=('A regular expression to select plans to be executed. Passed to the `tmt plan ls` command. '),
|
|
168
168
|
rich_help_panel=REQUEST_PANEL_TMT,
|
|
169
169
|
)
|
|
170
170
|
OPTION_TMT_PLAN_FILTER: Optional[str] = typer.Option(
|
|
171
171
|
None,
|
|
172
172
|
"--plan-filter",
|
|
173
173
|
help=(
|
|
174
|
-
'
|
|
175
|
-
'Passed as `--filter` option to the `tmt plan` command. '
|
|
176
|
-
'
|
|
177
|
-
'Plan filtering is similar to test filtering, '
|
|
178
|
-
'see https://tmt.readthedocs.io/en/stable/examples.html#filter-tests for more information.'
|
|
174
|
+
'Apply an advanced filter using key:value pairs and logical operators to filter plans. '
|
|
175
|
+
'Passed as `--filter` option to the `tmt plan ls` command. '
|
|
176
|
+
'See `pydoc fmf.filter` for detailed documentation on the syntax.'
|
|
179
177
|
),
|
|
180
178
|
rich_help_panel=REQUEST_PANEL_TMT,
|
|
181
179
|
)
|
|
182
180
|
OPTION_TMT_TEST_NAME: Optional[str] = typer.Option(
|
|
183
181
|
None,
|
|
184
182
|
"--test",
|
|
185
|
-
help=(
|
|
186
|
-
'Select tests to be executed. '
|
|
187
|
-
'Passed as `--name` option to the `tmt test` command. '
|
|
188
|
-
'Can be a regular expression.'
|
|
189
|
-
),
|
|
183
|
+
help=('Regular expression to select tests to be executed. Passed to the `tmt test ls` command. '),
|
|
190
184
|
rich_help_panel=REQUEST_PANEL_TMT,
|
|
191
185
|
)
|
|
192
186
|
OPTION_TMT_TEST_FILTER: Optional[str] = typer.Option(
|
|
193
187
|
None,
|
|
194
188
|
"--test-filter",
|
|
195
189
|
help=(
|
|
196
|
-
'
|
|
197
|
-
'Passed as `--filter` option to the `tmt test` command. '
|
|
198
|
-
'
|
|
199
|
-
'See https://tmt.readthedocs.io/en/stable/examples.html#filter-tests for more information.'
|
|
190
|
+
'Apply an advanced filter using key:value pairs and logical operators to filter tests. '
|
|
191
|
+
'Passed as `--filter` option to the `tmt test ls` command. '
|
|
192
|
+
'See `pydoc fmf.filter` for detailed documentation on the syntax.'
|
|
200
193
|
),
|
|
201
194
|
rich_help_panel=REQUEST_PANEL_TMT,
|
|
202
195
|
)
|
|
@@ -271,6 +264,7 @@ OPTION_REPOSITORY: List[str] = typer.Option(
|
|
|
271
264
|
OPTION_REPOSITORY_FILE: List[str] = typer.Option(
|
|
272
265
|
None,
|
|
273
266
|
help="URL to a repository file which should be added to /etc/yum.repos.d, e.g. https://example.com/repository.repo", # noqa
|
|
267
|
+
rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
|
|
274
268
|
)
|
|
275
269
|
OPTION_DRY_RUN: bool = typer.Option(
|
|
276
270
|
False, help="Do not submit a request to Testing Farm, just print it.", rich_help_panel=RESERVE_PANEL_GENERAL
|
|
@@ -280,14 +274,14 @@ OPTION_VARIABLES: Optional[List[str]] = typer.Option(
|
|
|
280
274
|
"-e",
|
|
281
275
|
"--environment",
|
|
282
276
|
metavar="key=value|@file",
|
|
283
|
-
help="Variables to pass to the test environment. The @ prefix marks a yaml file to load.",
|
|
277
|
+
help="Variables to pass to the test environment. The @ prefix marks a yaml or dotenv file to load.",
|
|
284
278
|
)
|
|
285
279
|
OPTION_SECRETS: Optional[List[str]] = typer.Option(
|
|
286
280
|
None,
|
|
287
281
|
"-s",
|
|
288
282
|
"--secret",
|
|
289
283
|
metavar="key=value|@file",
|
|
290
|
-
help="Secret variables to pass to the test environment. The @ prefix marks a yaml file to load.",
|
|
284
|
+
help="Secret variables to pass to the test environment. The @ prefix marks a yaml or dotenv file to load.",
|
|
291
285
|
)
|
|
292
286
|
OPTION_HARDWARE: List[str] = typer.Option(
|
|
293
287
|
None,
|
|
@@ -329,6 +323,17 @@ OPTION_TMT_CONTEXT: Optional[List[str]] = typer.Option(
|
|
|
329
323
|
metavar="key=value|@file",
|
|
330
324
|
help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
|
|
331
325
|
)
|
|
326
|
+
OPTION_TMT_ENVIRONMENT: Optional[List[str]] = typer.Option(
|
|
327
|
+
None,
|
|
328
|
+
"-T",
|
|
329
|
+
"--tmt-environment",
|
|
330
|
+
metavar="key=value|@file",
|
|
331
|
+
help=(
|
|
332
|
+
"Environment variables to pass to the tmt process. "
|
|
333
|
+
"Used to configure tmt report plugins like reportportal or polarion. "
|
|
334
|
+
"The @ prefix marks a yaml file to load."
|
|
335
|
+
),
|
|
336
|
+
)
|
|
332
337
|
|
|
333
338
|
|
|
334
339
|
def _option_autoconnect(panel: str) -> bool:
|
|
@@ -478,6 +483,24 @@ def _localhost_ingress_rule(session: requests.Session) -> str:
|
|
|
478
483
|
exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
|
|
479
484
|
|
|
480
485
|
|
|
486
|
+
def _extend_test_name_for_reservation(tmt_test_name: Optional[str]) -> Optional[str]:
|
|
487
|
+
"""
|
|
488
|
+
Extend test name to include the reservation test when --reserve is used.
|
|
489
|
+
"""
|
|
490
|
+
if tmt_test_name:
|
|
491
|
+
return f"{tmt_test_name}|{RESERVE_TEST}"
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _extend_test_filter_for_reservation(tmt_test_filter: Optional[str]) -> Optional[str]:
|
|
496
|
+
"""
|
|
497
|
+
Extend test filter to include the reservation test when --reserve is used.
|
|
498
|
+
"""
|
|
499
|
+
if tmt_test_filter:
|
|
500
|
+
return f"{tmt_test_filter} | name:{RESERVE_TEST}" # noqa: E231
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
481
504
|
def _add_reservation(
|
|
482
505
|
ssh_public_keys: List[str],
|
|
483
506
|
rules: Dict[str, Any],
|
|
@@ -585,7 +608,7 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
|
|
|
585
608
|
return security_group_rules
|
|
586
609
|
|
|
587
610
|
|
|
588
|
-
def _parse_xunit(xunit: str):
|
|
611
|
+
def _parse_xunit(xunit: str, multihost: bool = False):
|
|
589
612
|
"""
|
|
590
613
|
A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
|
|
591
614
|
|
|
@@ -609,9 +632,12 @@ def _parse_xunit(xunit: str):
|
|
|
609
632
|
|
|
610
633
|
results_root = ET.fromstring(xunit)
|
|
611
634
|
for plan in results_root.findall('./testsuite'):
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
635
|
+
testing_environment = plan.find(
|
|
636
|
+
'./testing-environment[@name="requested"]'
|
|
637
|
+
if not multihost
|
|
638
|
+
else './guest/testing-environment[@name="provisioned"]'
|
|
639
|
+
)
|
|
640
|
+
|
|
615
641
|
if not testing_environment:
|
|
616
642
|
console_stderr.print(
|
|
617
643
|
f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
|
|
@@ -646,6 +672,7 @@ def _get_request_summary(request: dict, session: requests.Session):
|
|
|
646
672
|
artifacts_url = (request.get('run') or {}).get('artifacts')
|
|
647
673
|
xpath_url = f'{artifacts_url}/results.xml' if artifacts_url else ''
|
|
648
674
|
xunit = (request.get('result') or {}).get('xunit') or '<testsuites></testsuites>'
|
|
675
|
+
multihost = ((request.get('settings') or {}).get('pipeline') or {}).get('type') == 'tmt-multihost'
|
|
649
676
|
if state not in ['queued', 'running'] and artifacts_url:
|
|
650
677
|
# NOTE(ivasilev) xunit can be None (ex. in case of timed out requests) so let's fetch results.xml and use it
|
|
651
678
|
# as source of truth
|
|
@@ -655,7 +682,7 @@ def _get_request_summary(request: dict, session: requests.Session):
|
|
|
655
682
|
xunit = response.text
|
|
656
683
|
except requests.exceptions.ConnectionError:
|
|
657
684
|
console_stderr.print("Could not get xunit results")
|
|
658
|
-
passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit)
|
|
685
|
+
passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit, multihost=multihost)
|
|
659
686
|
overall = (request.get("result") or {}).get("overall")
|
|
660
687
|
arches_requested = [env['arch'] for env in request['environments_requested']]
|
|
661
688
|
|
|
@@ -874,13 +901,43 @@ def version():
|
|
|
874
901
|
console.print(f"{cli_version}")
|
|
875
902
|
|
|
876
903
|
|
|
904
|
+
def check_token(api_url: str, api_token: Optional[str]):
|
|
905
|
+
"""Check for API token, falling back to keyring if not provided. Exits on failure."""
|
|
906
|
+
|
|
907
|
+
running_in_container = os.path.exists(settings.CONTAINER_SIGN)
|
|
908
|
+
|
|
909
|
+
# Keyring is not supported inside container environment
|
|
910
|
+
if not api_token and not running_in_container:
|
|
911
|
+
try:
|
|
912
|
+
import keyring
|
|
913
|
+
|
|
914
|
+
api_token = keyring.get_password(api_url, 'api-token')
|
|
915
|
+
except ImportError as e:
|
|
916
|
+
console.print(f"⚠️ keyring import error: {e}", style="yellow")
|
|
917
|
+
except Exception as e:
|
|
918
|
+
console.print(f"⚠️ keyring error: {e}", style="yellow")
|
|
919
|
+
|
|
920
|
+
if not api_token:
|
|
921
|
+
message = "No API token found, export `TESTING_FARM_API_TOKEN` environment variable."
|
|
922
|
+
if not running_in_container:
|
|
923
|
+
message += f" Or store it in keyring using `keyring set {api_url} api-token`."
|
|
924
|
+
exit_error(message)
|
|
925
|
+
|
|
926
|
+
return api_token
|
|
927
|
+
|
|
928
|
+
|
|
877
929
|
def request(
|
|
878
930
|
context: typer.Context,
|
|
879
931
|
api_url: str = ARGUMENT_API_URL,
|
|
880
932
|
api_token: str = ARGUMENT_API_TOKEN,
|
|
881
|
-
timeout: int = typer.Option(
|
|
882
|
-
|
|
883
|
-
help=
|
|
933
|
+
timeout: Optional[int] = typer.Option(
|
|
934
|
+
None,
|
|
935
|
+
help=(
|
|
936
|
+
"Set the timeout for the request in minutes. "
|
|
937
|
+
"If the request takes longer than this, it will be terminated. "
|
|
938
|
+
"The timeout is counted from the time the request is switched to the running state."
|
|
939
|
+
"For default timeout see https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html."
|
|
940
|
+
),
|
|
884
941
|
),
|
|
885
942
|
test_type: str = typer.Option("fmf", help="Test type to use, if not set autodetected."),
|
|
886
943
|
tmt_plan_name: Optional[str] = OPTION_TMT_PLAN_NAME,
|
|
@@ -914,17 +971,7 @@ def request(
|
|
|
914
971
|
cli_tmt_context: Optional[List[str]] = OPTION_TMT_CONTEXT,
|
|
915
972
|
variables: Optional[List[str]] = OPTION_VARIABLES,
|
|
916
973
|
secrets: Optional[List[str]] = OPTION_SECRETS,
|
|
917
|
-
tmt_environment: Optional[List[str]] =
|
|
918
|
-
None,
|
|
919
|
-
"-T",
|
|
920
|
-
"--tmt-environment",
|
|
921
|
-
metavar="key=value|@file",
|
|
922
|
-
help=(
|
|
923
|
-
"Environment variables to pass to the tmt process. "
|
|
924
|
-
"Used to configure tmt report plugins like reportportal or polarion. "
|
|
925
|
-
"The @ prefix marks a yaml file to load."
|
|
926
|
-
),
|
|
927
|
-
),
|
|
974
|
+
tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
|
|
928
975
|
no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
|
|
929
976
|
worker_image: Optional[str] = OPTION_WORKER_IMAGE,
|
|
930
977
|
redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
|
|
@@ -959,6 +1006,7 @@ def request(
|
|
|
959
1006
|
parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
|
|
960
1007
|
tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
|
|
961
1008
|
tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
|
|
1009
|
+
tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
|
|
962
1010
|
tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
|
|
963
1011
|
reserve: bool = OPTION_RESERVE,
|
|
964
1012
|
ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
|
|
@@ -978,9 +1026,7 @@ def request(
|
|
|
978
1026
|
|
|
979
1027
|
git_available = bool(shutil.which("git"))
|
|
980
1028
|
|
|
981
|
-
|
|
982
|
-
if not api_token:
|
|
983
|
-
exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
|
|
1029
|
+
api_token = check_token(api_url, api_token)
|
|
984
1030
|
|
|
985
1031
|
if not compose and arches != ['x86_64']:
|
|
986
1032
|
exit_error(
|
|
@@ -1070,10 +1116,16 @@ def request(
|
|
|
1070
1116
|
test["plan_filter"] = tmt_plan_filter
|
|
1071
1117
|
|
|
1072
1118
|
if tmt_test_name:
|
|
1073
|
-
|
|
1119
|
+
if reserve:
|
|
1120
|
+
test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
|
|
1121
|
+
else:
|
|
1122
|
+
test["test_name"] = tmt_test_name
|
|
1074
1123
|
|
|
1075
1124
|
if tmt_test_filter:
|
|
1076
|
-
|
|
1125
|
+
if reserve:
|
|
1126
|
+
test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
|
|
1127
|
+
else:
|
|
1128
|
+
test["test_filter"] = tmt_test_filter
|
|
1077
1129
|
|
|
1078
1130
|
if sti_playbooks:
|
|
1079
1131
|
test["playbooks"] = sti_playbooks
|
|
@@ -1130,7 +1182,7 @@ def request(
|
|
|
1130
1182
|
if tmt_environment:
|
|
1131
1183
|
environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
|
|
1132
1184
|
|
|
1133
|
-
if tmt_discover or tmt_prepare or tmt_finish:
|
|
1185
|
+
if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
|
|
1134
1186
|
if "extra_args" not in environment["tmt"]:
|
|
1135
1187
|
environment["tmt"]["extra_args"] = {}
|
|
1136
1188
|
|
|
@@ -1140,6 +1192,9 @@ def request(
|
|
|
1140
1192
|
if tmt_prepare:
|
|
1141
1193
|
environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
|
|
1142
1194
|
|
|
1195
|
+
if tmt_report:
|
|
1196
|
+
environment["tmt"]["extra_args"]["report"] = tmt_report
|
|
1197
|
+
|
|
1143
1198
|
if tmt_finish:
|
|
1144
1199
|
environment["tmt"]["extra_args"]["finish"] = tmt_finish
|
|
1145
1200
|
|
|
@@ -1217,11 +1272,14 @@ def request(
|
|
|
1217
1272
|
request["environments"] = environments
|
|
1218
1273
|
request["settings"] = {}
|
|
1219
1274
|
|
|
1220
|
-
|
|
1275
|
+
forced_pipeline_timeout = context.get_parameter_source("timeout") == ParameterSource.COMMANDLINE
|
|
1276
|
+
|
|
1277
|
+
if reserve or pipeline_type or parallel_limit or forced_pipeline_timeout:
|
|
1221
1278
|
request["settings"]["pipeline"] = {}
|
|
1222
1279
|
|
|
1223
1280
|
# in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
|
|
1224
1281
|
if reserve:
|
|
1282
|
+
timeout = timeout or DEFAULT_PIPELINE_TIMEOUT
|
|
1225
1283
|
if reservation_duration > timeout:
|
|
1226
1284
|
request["settings"]["pipeline"] = {"timeout": reservation_duration}
|
|
1227
1285
|
console.print(f"⏳ Maximum reservation time is {reservation_duration} minutes")
|
|
@@ -1230,7 +1288,7 @@ def request(
|
|
|
1230
1288
|
console.print(f"⏳ Maximum reservation time is {timeout} minutes")
|
|
1231
1289
|
|
|
1232
1290
|
# forced pipeline timeout
|
|
1233
|
-
elif
|
|
1291
|
+
elif forced_pipeline_timeout:
|
|
1234
1292
|
console.print(f"⏳ Pipeline timeout forced to {timeout} minutes")
|
|
1235
1293
|
request["settings"]["pipeline"] = {"timeout": timeout}
|
|
1236
1294
|
|
|
@@ -1264,7 +1322,7 @@ def request(
|
|
|
1264
1322
|
# handle errors
|
|
1265
1323
|
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1266
1324
|
if response.status_code == 401:
|
|
1267
|
-
|
|
1325
|
+
handle_401_response(response)
|
|
1268
1326
|
|
|
1269
1327
|
if response.status_code == 400:
|
|
1270
1328
|
exit_error(
|
|
@@ -1315,6 +1373,7 @@ def restart(
|
|
|
1315
1373
|
tmt_path: Optional[str] = OPTION_TMT_PATH,
|
|
1316
1374
|
tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
|
|
1317
1375
|
tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
|
|
1376
|
+
tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
|
|
1318
1377
|
tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
|
|
1319
1378
|
worker_image: Optional[str] = OPTION_WORKER_IMAGE,
|
|
1320
1379
|
no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
|
|
@@ -1376,7 +1435,7 @@ def restart(
|
|
|
1376
1435
|
response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
|
|
1377
1436
|
|
|
1378
1437
|
if response.status_code == 401:
|
|
1379
|
-
|
|
1438
|
+
handle_401_response(response)
|
|
1380
1439
|
|
|
1381
1440
|
# The API token is valid, but it doesn't own the request
|
|
1382
1441
|
if response.status_code == 403:
|
|
@@ -1423,10 +1482,16 @@ def restart(
|
|
|
1423
1482
|
test["ref"] = git_ref
|
|
1424
1483
|
|
|
1425
1484
|
if tmt_test_name:
|
|
1426
|
-
|
|
1485
|
+
if reserve:
|
|
1486
|
+
test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
|
|
1487
|
+
else:
|
|
1488
|
+
test["test_name"] = tmt_test_name
|
|
1427
1489
|
|
|
1428
1490
|
if tmt_test_filter:
|
|
1429
|
-
|
|
1491
|
+
if reserve:
|
|
1492
|
+
test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
|
|
1493
|
+
else:
|
|
1494
|
+
test["test_filter"] = tmt_test_filter
|
|
1430
1495
|
|
|
1431
1496
|
merge_sha_info = ""
|
|
1432
1497
|
if git_merge_sha:
|
|
@@ -1453,7 +1518,7 @@ def restart(
|
|
|
1453
1518
|
for environment in request['environments']:
|
|
1454
1519
|
environment["pool"] = pool
|
|
1455
1520
|
|
|
1456
|
-
if tmt_discover or tmt_prepare or tmt_finish:
|
|
1521
|
+
if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
|
|
1457
1522
|
for environment in request["environments"]:
|
|
1458
1523
|
if "tmt" not in environment:
|
|
1459
1524
|
environment["tmt"] = {"extra_args": {}}
|
|
@@ -1468,6 +1533,10 @@ def restart(
|
|
|
1468
1533
|
for environment in request["environments"]:
|
|
1469
1534
|
environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
|
|
1470
1535
|
|
|
1536
|
+
if tmt_report:
|
|
1537
|
+
for environment in request["environments"]:
|
|
1538
|
+
environment["tmt"]["extra_args"]["report"] = tmt_report
|
|
1539
|
+
|
|
1471
1540
|
if tmt_finish:
|
|
1472
1541
|
for environment in request["environments"]:
|
|
1473
1542
|
environment["tmt"]["extra_args"]["finish"] = tmt_finish
|
|
@@ -1579,7 +1648,7 @@ def restart(
|
|
|
1579
1648
|
# handle errors
|
|
1580
1649
|
response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
|
|
1581
1650
|
if response.status_code == 401:
|
|
1582
|
-
|
|
1651
|
+
handle_401_response(response)
|
|
1583
1652
|
|
|
1584
1653
|
if response.status_code == 400:
|
|
1585
1654
|
exit_error(
|
|
@@ -1626,9 +1695,7 @@ def run(
|
|
|
1626
1695
|
Run an arbitrary script via Testing Farm.
|
|
1627
1696
|
"""
|
|
1628
1697
|
|
|
1629
|
-
|
|
1630
|
-
if not api_token:
|
|
1631
|
-
exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
|
|
1698
|
+
api_token = check_token(api_url, api_token)
|
|
1632
1699
|
|
|
1633
1700
|
# create request
|
|
1634
1701
|
request = TestingFarmRequestV1
|
|
@@ -1677,7 +1744,7 @@ def run(
|
|
|
1677
1744
|
# handle errors
|
|
1678
1745
|
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1679
1746
|
if response.status_code == 401:
|
|
1680
|
-
|
|
1747
|
+
handle_401_response(response)
|
|
1681
1748
|
|
|
1682
1749
|
if response.status_code == 400:
|
|
1683
1750
|
exit_error(f"Request is invalid. Please file an issue to {settings.ISSUE_TRACKER}")
|
|
@@ -1793,8 +1860,10 @@ def reserve(
|
|
|
1793
1860
|
repository: List[str] = OPTION_REPOSITORY,
|
|
1794
1861
|
repository_file: List[str] = OPTION_REPOSITORY_FILE,
|
|
1795
1862
|
redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
|
|
1863
|
+
tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
|
|
1796
1864
|
tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
|
|
1797
1865
|
tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
|
|
1866
|
+
tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
|
|
1798
1867
|
tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
|
|
1799
1868
|
dry_run: bool = OPTION_DRY_RUN,
|
|
1800
1869
|
post_install_script: Optional[str] = OPTION_POST_INSTALL_SCRIPT,
|
|
@@ -1828,9 +1897,7 @@ def reserve(
|
|
|
1828
1897
|
# Accept these arguments only via environment variables
|
|
1829
1898
|
check_unexpected_arguments(context, "api_url", "api_token")
|
|
1830
1899
|
|
|
1831
|
-
|
|
1832
|
-
if not settings.API_TOKEN:
|
|
1833
|
-
exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
|
|
1900
|
+
api_token = check_token(api_url, api_token)
|
|
1834
1901
|
|
|
1835
1902
|
pool_info = f"via pool [blue]{pool}[/blue]" if pool else ""
|
|
1836
1903
|
console.print(f"💻 [blue]{compose}[/blue] on [blue]{arch}[/blue] {pool_info}")
|
|
@@ -1896,7 +1963,7 @@ def reserve(
|
|
|
1896
1963
|
if post_install_script:
|
|
1897
1964
|
environment["settings"]["provisioning"]["post_install_script"] = post_install_script
|
|
1898
1965
|
|
|
1899
|
-
if tmt_discover or tmt_prepare or tmt_finish:
|
|
1966
|
+
if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
|
|
1900
1967
|
environment["tmt"] = {"extra_args": {}}
|
|
1901
1968
|
|
|
1902
1969
|
if tmt_discover:
|
|
@@ -1905,9 +1972,18 @@ def reserve(
|
|
|
1905
1972
|
if tmt_prepare:
|
|
1906
1973
|
environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
|
|
1907
1974
|
|
|
1975
|
+
if tmt_report:
|
|
1976
|
+
environment["tmt"]["extra_args"]["report"] = tmt_report
|
|
1977
|
+
|
|
1908
1978
|
if tmt_finish:
|
|
1909
1979
|
environment["tmt"]["extra_args"]["finish"] = tmt_finish
|
|
1910
1980
|
|
|
1981
|
+
if tmt_environment:
|
|
1982
|
+
if "tmt" not in environment:
|
|
1983
|
+
environment["tmt"] = {}
|
|
1984
|
+
|
|
1985
|
+
environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
|
|
1986
|
+
|
|
1911
1987
|
# Setting up retries
|
|
1912
1988
|
session = requests.Session()
|
|
1913
1989
|
install_http_retries(session)
|
|
@@ -1974,7 +2050,7 @@ def reserve(
|
|
|
1974
2050
|
# handle errors
|
|
1975
2051
|
response = session.post(post_url, json=request, headers=authorization_headers(api_token))
|
|
1976
2052
|
if response.status_code == 401:
|
|
1977
|
-
|
|
2053
|
+
handle_401_response(response)
|
|
1978
2054
|
|
|
1979
2055
|
if response.status_code == 400:
|
|
1980
2056
|
exit_error(
|
|
@@ -2141,9 +2217,7 @@ def cancel(
|
|
|
2141
2217
|
# Extract the UUID from the request_id string
|
|
2142
2218
|
_request_id = extract_uuid(request_id)
|
|
2143
2219
|
|
|
2144
|
-
|
|
2145
|
-
exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
|
|
2146
|
-
return
|
|
2220
|
+
api_token = check_token(api_url, api_token)
|
|
2147
2221
|
|
|
2148
2222
|
# Construct URL to the internal API
|
|
2149
2223
|
request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
|
|
@@ -2156,7 +2230,7 @@ def cancel(
|
|
|
2156
2230
|
response = session.delete(request_url, headers=authorization_headers(api_token))
|
|
2157
2231
|
|
|
2158
2232
|
if response.status_code == 401:
|
|
2159
|
-
|
|
2233
|
+
handle_401_response(response)
|
|
2160
2234
|
|
|
2161
2235
|
if response.status_code == 403:
|
|
2162
2236
|
exit_error(
|
|
@@ -2201,9 +2275,7 @@ def encrypt(
|
|
|
2201
2275
|
# Accept these arguments only via environment variables
|
|
2202
2276
|
check_unexpected_arguments(context, "api_url", "api_token")
|
|
2203
2277
|
|
|
2204
|
-
|
|
2205
|
-
if not api_token:
|
|
2206
|
-
exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
|
|
2278
|
+
api_token = check_token(api_url, api_token)
|
|
2207
2279
|
|
|
2208
2280
|
git_available = bool(shutil.which("git"))
|
|
2209
2281
|
|
|
@@ -2235,7 +2307,7 @@ def encrypt(
|
|
|
2235
2307
|
|
|
2236
2308
|
# handle errors
|
|
2237
2309
|
if response.status_code == 401:
|
|
2238
|
-
|
|
2310
|
+
handle_401_response(response)
|
|
2239
2311
|
|
|
2240
2312
|
if response.status_code == 400:
|
|
2241
2313
|
exit_error(
|
tft/cli/tool.py
CHANGED
|
@@ -6,12 +6,14 @@ import os
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
import tft.cli.commands as commands
|
|
9
|
+
from tft.cli.command.composes import composes
|
|
9
10
|
from tft.cli.command.listing import listing
|
|
10
11
|
from tft.cli.config import settings
|
|
11
12
|
|
|
12
13
|
app = typer.Typer()
|
|
13
14
|
|
|
14
15
|
app.command()(commands.cancel)
|
|
16
|
+
app.command()(composes)
|
|
15
17
|
app.command(name="list")(listing)
|
|
16
18
|
app.command()(commands.request)
|
|
17
19
|
app.command()(commands.restart)
|
tft/cli/utils.py
CHANGED
|
@@ -20,6 +20,7 @@ import requests
|
|
|
20
20
|
import requests.adapters
|
|
21
21
|
import typer
|
|
22
22
|
from click.core import ParameterSource
|
|
23
|
+
from dotenv import dotenv_values
|
|
23
24
|
from rich.console import Console
|
|
24
25
|
from ruamel.yaml import YAML # type: ignore
|
|
25
26
|
from urllib3 import Retry
|
|
@@ -69,7 +70,16 @@ class Age:
|
|
|
69
70
|
return f"{self.value}{self.unit}"
|
|
70
71
|
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
# Use built-in StrEnum for Python 3.11+, otherwise define a compatible version
|
|
74
|
+
if sys.version_info >= (3, 11):
|
|
75
|
+
from enum import StrEnum
|
|
76
|
+
else:
|
|
77
|
+
|
|
78
|
+
class StrEnum(str, Enum):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OutputFormat(StrEnum):
|
|
73
83
|
text = "text"
|
|
74
84
|
json = "json"
|
|
75
85
|
yaml = "yaml"
|
|
@@ -77,7 +87,7 @@ class OutputFormat(str, Enum):
|
|
|
77
87
|
|
|
78
88
|
@staticmethod
|
|
79
89
|
def available_formats():
|
|
80
|
-
return "text, json or table"
|
|
90
|
+
return "text, json, yaml or table"
|
|
81
91
|
|
|
82
92
|
|
|
83
93
|
def exit_error(error: str) -> NoReturn:
|
|
@@ -86,6 +96,24 @@ def exit_error(error: str) -> NoReturn:
|
|
|
86
96
|
raise typer.Exit(code=255)
|
|
87
97
|
|
|
88
98
|
|
|
99
|
+
def handle_401_response(response: requests.Response) -> NoReturn:
|
|
100
|
+
"""Handle 401 Unauthorized responses with appropriate error message.
|
|
101
|
+
|
|
102
|
+
Differentiates between expired tokens and invalid tokens based on
|
|
103
|
+
the API response message.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
error_msg = response.json().get('message', '')
|
|
107
|
+
if error_msg == "Token has expired":
|
|
108
|
+
exit_error(
|
|
109
|
+
f"API token has expired. Please generate a new token at "
|
|
110
|
+
f"{settings.ONBOARDING_DOCS} and update your TESTING_FARM_API_TOKEN."
|
|
111
|
+
)
|
|
112
|
+
except requests.exceptions.JSONDecodeError:
|
|
113
|
+
pass
|
|
114
|
+
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
115
|
+
|
|
116
|
+
|
|
89
117
|
def cmd_output_or_exit(command: str, error: str) -> str:
|
|
90
118
|
"""Return local command output or exit with given error message"""
|
|
91
119
|
try:
|
|
@@ -180,42 +208,68 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
|
|
|
180
208
|
elif value.lower() in ['false']:
|
|
181
209
|
value_mixed = False
|
|
182
210
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
# Only process additional path elements if they exist
|
|
186
|
-
if path_splitted:
|
|
187
|
-
next_path = path_splitted.pop()
|
|
211
|
+
final_key = path_splitted.pop()
|
|
188
212
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
# Handle compatible.distro as a list
|
|
214
|
+
if final_key == 'distro':
|
|
215
|
+
if final_key not in container:
|
|
216
|
+
container[final_key] = []
|
|
217
|
+
container[final_key].append(value_mixed)
|
|
218
|
+
else:
|
|
219
|
+
container[final_key] = value_mixed
|
|
196
220
|
|
|
197
221
|
return constraints
|
|
198
222
|
|
|
199
223
|
|
|
200
|
-
def
|
|
201
|
-
"""Read environment variables from
|
|
224
|
+
def options_from_yaml(filepath: str) -> Optional[Dict[str, Optional[str]]]:
|
|
225
|
+
"""Read environment variables from yaml content.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If the file cannot be parsed as YAML or has invalid structure.
|
|
229
|
+
"""
|
|
202
230
|
|
|
203
231
|
with open(filepath, 'r') as file:
|
|
204
|
-
|
|
205
|
-
yaml = YAML(typ="safe").load(file.read())
|
|
206
|
-
except Exception:
|
|
207
|
-
exit_error(f"Failed to load variables from yaml file {filepath}.")
|
|
232
|
+
content = file.read()
|
|
208
233
|
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
try:
|
|
235
|
+
yaml = YAML(typ="safe").load(content)
|
|
236
|
+
except Exception:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if not yaml:
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
if not isinstance(yaml, dict):
|
|
243
|
+
exit_error(f"Environment file {filepath} is not a dict.")
|
|
244
|
+
|
|
245
|
+
if any([isinstance(value, (list, dict)) for value in yaml.values()]):
|
|
246
|
+
exit_error(f"Values of environment file {filepath} are not primitive types.")
|
|
247
|
+
|
|
248
|
+
return yaml
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def options_from_dotenv(filepath: str) -> Dict[str, Optional[str]]:
|
|
252
|
+
"""Read environment variables from dotenv file.
|
|
211
253
|
|
|
212
|
-
|
|
213
|
-
|
|
254
|
+
Raises:
|
|
255
|
+
Exception: If the file cannot be parsed as dotenv.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
return dotenv_values(filepath)
|
|
259
|
+
except Exception:
|
|
260
|
+
exit_error(f"Failed to load variables from file {filepath}.")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def options_from_file(filepath) -> Dict[str, Optional[str]]:
|
|
264
|
+
"""Read environment variables from a yaml or dotenv file."""
|
|
265
|
+
# Try to load from yaml first
|
|
266
|
+
# If that fails, try to load from dotenv
|
|
267
|
+
yaml = options_from_yaml(filepath)
|
|
214
268
|
|
|
215
|
-
|
|
216
|
-
|
|
269
|
+
if yaml is None:
|
|
270
|
+
return options_from_dotenv(filepath)
|
|
217
271
|
|
|
218
|
-
|
|
272
|
+
return yaml
|
|
219
273
|
|
|
220
274
|
|
|
221
275
|
def options_to_dict(name: str, options: List[str]) -> Dict[str, str]:
|
|
@@ -412,3 +466,15 @@ def edit_with_editor(data: Any, description: Optional[str]) -> Any:
|
|
|
412
466
|
# Read the modified content
|
|
413
467
|
with open(temp_file.name, 'r') as modified_file:
|
|
414
468
|
return modified_file.read()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def handle_response_errors(response: requests.Response) -> None:
|
|
472
|
+
if response.status_code == 401:
|
|
473
|
+
exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
|
|
474
|
+
|
|
475
|
+
if response.status_code != 200:
|
|
476
|
+
exit_error(
|
|
477
|
+
f"Unexpected error {response.text}. "
|
|
478
|
+
f"Check {settings.STATUS_PAGE}. "
|
|
479
|
+
f"File an issue to {settings.ISSUE_TRACKER} if needed."
|
|
480
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tft-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.31
|
|
4
4
|
Summary: Testing Farm CLI tool
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: Miroslav Vadkerti
|
|
@@ -14,7 +14,9 @@ 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: keyring (>=25.7.0,<26.0.0)
|
|
17
18
|
Requires-Dist: pendulum (>=3.0.0,<4.0.0)
|
|
19
|
+
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
18
20
|
Requires-Dist: requests (>=2.27.1,<3.0.0)
|
|
19
21
|
Requires-Dist: rich (>=12)
|
|
20
22
|
Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
2
|
+
tft/cli/command/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
|
|
3
|
+
tft/cli/command/composes.py,sha256=85xdTqgCIINl4XDhOAZq2EWI_KhitvGGMlneyK7xpc4,6126
|
|
4
|
+
tft/cli/command/listing.py,sha256=a0DDdXQ84qL5v6lpOEuF-UiBkD6Zr7yr02Oa3t1cd6I,30863
|
|
5
|
+
tft/cli/commands.py,sha256=fX08gLXDosZC06YBt5igiFsprJBOHAxclR44ptCwxu0,88374
|
|
6
|
+
tft/cli/config.py,sha256=9JWjEwBiS4_Eq1B53lxxCIceERJ7AeryUyqxj9fm9c4,1714
|
|
7
|
+
tft/cli/tool.py,sha256=xGBqfk8te63cijhhWtP1SopiHugTHPLp2crGx-LoLXg,1045
|
|
8
|
+
tft/cli/utils.py,sha256=9LRNTfG1zTD65_foF6IGD9H3JoYJVeJHzhI7siNv20I,15449
|
|
9
|
+
tft_cli-0.0.31.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
|
|
10
|
+
tft_cli-0.0.31.dist-info/METADATA,sha256=kpgBJALq0mpq8v1WntORVmdoNQYRD6mB_YQRADNf0ck,918
|
|
11
|
+
tft_cli-0.0.31.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
|
12
|
+
tft_cli-0.0.31.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
|
|
13
|
+
tft_cli-0.0.31.dist-info/RECORD,,
|
tft_cli-0.0.28.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|