tft-cli 0.0.0__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/config.py ADDED
@@ -0,0 +1,39 @@
1
+ # Copyright Contributors to the Testing Farm project.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from dynaconf import LazySettings # type: ignore
5
+
6
+ settings = LazySettings(
7
+ # all environment variables have `TESTING_FARM_` prefix
8
+ ENVVAR_PREFIX_FOR_DYNACONF="TESTING_FARM",
9
+ # defaults
10
+ API_URL="https://api.dev.testing-farm.io/v0.1",
11
+ INTERNAL_API_URL="https://internal.api.dev.testing-farm.io/v0.1",
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,
20
+ ISSUE_TRACKER="https://gitlab.com/testing-farm/general/-/issues/new",
21
+ STATUS_PAGE="https://status.testing-farm.io",
22
+ ONBOARDING_DOCS="https://docs.testing-farm.io/Testing%20Farm/0.1/onboarding.html",
23
+ CONTAINER_SIGN="/.testing-farm-container",
24
+ WATCH_TICK=30,
25
+ DEFAULT_API_TIMEOUT=10,
26
+ DEFAULT_API_RETRIES=7,
27
+ # default reservation duration in minutes
28
+ DEFAULT_RESERVATION_DURATION=30,
29
+ # should lead to delays of 0.5, 1, 2, 4, 8, 16, 32 seconds
30
+ DEFAULT_RETRY_BACKOFF_FACTOR=1,
31
+ # system CA certificates path, default for RHEL variants
32
+ REQUESTS_CA_BUNDLE="/etc/ssl/certs/ca-bundle.crt",
33
+ # Testing Farm sanity test,
34
+ TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
35
+ TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
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,
39
+ )
tft/cli/tool.py ADDED
@@ -0,0 +1,31 @@
1
+ # Copyright Contributors to the Testing Farm project.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import os
5
+
6
+ import typer
7
+
8
+ import tft.cli.commands as commands
9
+ from tft.cli.command.listing import listing
10
+ from tft.cli.config import settings
11
+
12
+ app = typer.Typer()
13
+
14
+ app.command()(commands.cancel)
15
+ app.command(name="list")(listing)
16
+ app.command()(commands.request)
17
+ app.command()(commands.restart)
18
+ app.command()(commands.reserve)
19
+ app.command()(commands.run)
20
+ app.command()(commands.version)
21
+ app.command()(commands.watch)
22
+ app.command()(commands.encrypt)
23
+
24
+ # This command is available only for the container based deployment
25
+ if os.path.exists(settings.CONTAINER_SIGN):
26
+ app.command()(commands.update)
27
+
28
+ # Expose REQUESTS_CA_BUNDLE in the environment for RHEL-like systems
29
+ # This is needed for custom CA certificates to nicely work.
30
+ if "REQUESTS_CA_BUNDLE" not in os.environ and os.path.exists(settings.REQUESTS_CA_BUNDLE):
31
+ os.environ["REQUESTS_CA_BUNDLE"] = settings.REQUESTS_CA_BUNDLE
tft/cli/utils.py ADDED
@@ -0,0 +1,377 @@
1
+ # Copyright Contributors to the Testing Farm project.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import glob
5
+ import itertools
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Any, Dict, List, NoReturn, Optional, Union
15
+
16
+ import pendulum
17
+ import requests
18
+ import requests.adapters
19
+ import typer
20
+ from click.core import ParameterSource
21
+ from rich.console import Console
22
+ from ruamel.yaml import YAML # type: ignore
23
+ from urllib3 import Retry
24
+
25
+ from tft.cli.config import settings
26
+
27
+ console = Console(soft_wrap=True)
28
+ console_stderr = Console(soft_wrap=True, file=sys.stderr)
29
+
30
+
31
+ @dataclass
32
+ class Age:
33
+ value: int
34
+ unit: str
35
+
36
+ _unit_multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}
37
+ _unit_human = {"s": "second", "m": "minute", "h": "hour", "d": "day"}
38
+
39
+ @classmethod
40
+ def from_string(cls, age_string: str) -> 'Age':
41
+ value, unit = age_string[:-1], age_string[-1]
42
+ if unit not in cls._unit_multiplier:
43
+ raise typer.BadParameter(f"Age must end with {', '.join(cls._unit_multiplier.keys())}")
44
+
45
+ if not value.isdigit():
46
+ raise typer.BadParameter(f"Invalid age value {value}")
47
+
48
+ return cls(value=int(age_string[:-1]), unit=age_string[-1])
49
+
50
+ @property
51
+ def birth_date(self) -> pendulum.DateTime:
52
+ now = pendulum.now(tz="UTC")
53
+ return now - pendulum.duration(seconds=self.value * self._unit_multiplier[self.unit])
54
+
55
+ @property
56
+ def human(self) -> str:
57
+ return f"{self.value} {self._unit_human[self.unit]}{'s' if self.value > 1 else ''}"
58
+
59
+ @staticmethod
60
+ def available_units() -> str:
61
+ return "s (seconds), m (minutes), h (hours) or d (days)"
62
+
63
+ def to_string(self, format="%Y-%m-%dT%H:%M:%S") -> str:
64
+ return self.birth_date.strftime(format)
65
+
66
+ def __str__(self):
67
+ return f"{self.value}{self.unit}"
68
+
69
+
70
+ class OutputFormat(str, Enum):
71
+ text = "text"
72
+ json = "json"
73
+ yaml = "yaml"
74
+ table = "table"
75
+
76
+ @staticmethod
77
+ def available_formats():
78
+ return "text, json or table"
79
+
80
+
81
+ def exit_error(error: str) -> NoReturn:
82
+ """Exit with given error message"""
83
+ console.print(f"⛔ {error}", style="red")
84
+ raise typer.Exit(code=255)
85
+
86
+
87
+ def cmd_output_or_exit(command: str, error: str) -> str:
88
+ """Return local command output or exit with given error message"""
89
+ try:
90
+ output = subprocess.check_output(command.split(), stderr=subprocess.STDOUT)
91
+
92
+ except subprocess.CalledProcessError:
93
+ exit_error(error)
94
+
95
+ return output.rstrip().decode("utf-8")
96
+
97
+
98
+ def artifacts(type: str, artifacts_raw: List[str]) -> List[Dict[str, str]]:
99
+ """Return artifacts List for given artifact type"""
100
+ artifacts = []
101
+
102
+ for artifact in artifacts_raw:
103
+ if '=' in artifact:
104
+ artifact_dict = options_to_dict('artifact `{}`'.format(artifact), normalize_multistring_option([artifact]))
105
+ else:
106
+ artifact_dict = {'id': artifact}
107
+
108
+ if 'install' in artifact_dict:
109
+ artifact_dict['install'] = normalize_bool_option(artifact_dict['install']) # pyre-ignore[6]
110
+
111
+ artifacts.append({'type': type, **artifact_dict})
112
+
113
+ return artifacts
114
+
115
+
116
+ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
117
+ """Convert hardware parameters to a dictionary"""
118
+
119
+ constraints: Dict[Any, Any] = {}
120
+
121
+ for raw_constraint in hardware:
122
+ path, value = raw_constraint.split('=', 1)
123
+
124
+ if not path or not value:
125
+ exit_error(f"cannot parse hardware constraint `{raw_constraint}`")
126
+
127
+ path_splitted = path.split('.')
128
+ first_key = path_splitted[0]
129
+
130
+ # Special handling for network and disk as they are lists
131
+ if first_key in ("network", "disk"):
132
+ if first_key not in constraints:
133
+ constraints[first_key] = []
134
+
135
+ if len(path_splitted) > 1:
136
+ new_dict = {}
137
+ current = new_dict
138
+ # Handle all nested levels except the last one
139
+ for key in path_splitted[1:-1]:
140
+ current[key] = {}
141
+ current = current[key]
142
+ # Set the final value
143
+ current[path_splitted[-1]] = value
144
+ constraints[first_key].append(new_dict)
145
+ continue
146
+
147
+ # Special handling for CPU flags as they are also a list
148
+ if first_key == 'cpu' and len(path_splitted) == 2 and path_splitted[1] == 'flag':
149
+ second_key = 'flag'
150
+
151
+ if first_key not in constraints:
152
+ constraints[first_key] = {}
153
+
154
+ if second_key not in constraints[first_key]:
155
+ constraints[first_key][second_key] = []
156
+
157
+ constraints[first_key][second_key].append(value)
158
+
159
+ continue
160
+
161
+ # Walk the path, step by step, and initialize containers along the way. The last step is not
162
+ # a name of another nested container, but actually a name in the last container.
163
+ container: Any = constraints
164
+
165
+ while len(path_splitted) > 1:
166
+ step = path_splitted.pop(0)
167
+
168
+ if step not in container:
169
+ container[step] = {}
170
+
171
+ container = container[step]
172
+
173
+ value_mixed: Union[bool, str] = value
174
+
175
+ if value.lower() in ['true']:
176
+ value_mixed = True
177
+
178
+ elif value.lower() in ['false']:
179
+ value_mixed = False
180
+
181
+ container[path_splitted.pop()] = value_mixed
182
+
183
+ # Only process additional path elements if they exist
184
+ if path_splitted:
185
+ next_path = path_splitted.pop()
186
+
187
+ # handle compatible.distro
188
+ if next_path == 'distro':
189
+ container[next_path] = (
190
+ container[next_path].append(value_mixed) if next_path in container else [value_mixed]
191
+ )
192
+ else:
193
+ container[next_path] = value_mixed
194
+
195
+ return constraints
196
+
197
+
198
+ def options_from_file(filepath) -> Dict[str, str]:
199
+ """Read environment variables from a yaml file."""
200
+
201
+ with open(filepath, 'r') as file:
202
+ try:
203
+ yaml = YAML(typ="safe").load(file.read())
204
+ except Exception:
205
+ exit_error(f"Failed to load variables from yaml file {filepath}.")
206
+
207
+ if not yaml: # pyre-ignore[61] # pyre ignores NoReturn in exit_error
208
+ return {}
209
+
210
+ if not isinstance(yaml, dict): # pyre-ignore[61] # pyre ignores NoReturn in exit_error
211
+ exit_error(f"Environment file {filepath} is not a dict.")
212
+
213
+ if any([isinstance(value, (list, dict)) for value in yaml.values()]):
214
+ exit_error(f"Values of environment file {filepath} are not primitive types.")
215
+
216
+ return yaml # pyre-ignore[61] # pyre ignores NoReturn in exit_error
217
+
218
+
219
+ def options_to_dict(name: str, options: List[str]) -> Dict[str, str]:
220
+ """Create a dictionary from list of `key=value|@file` options"""
221
+
222
+ options_dict = {}
223
+
224
+ # Turn option list such as
225
+ # `['aaa=bbb "foo foo=bar bar"', 'foo=bar']` into
226
+ # `['aaa=bbb', 'foo foo=bar bar', 'foo=bar']`
227
+ options = list(itertools.chain.from_iterable(shlex.split(option) for option in options))
228
+
229
+ for option in options:
230
+ # Option is `@file`
231
+ if option.startswith('@'):
232
+ if not os.path.isfile(option[1:]):
233
+ exit_error(f"Invalid environment file in option `{option}` specified.")
234
+ options_dict.update(options_from_file(option[1:]))
235
+
236
+ # Option is `key=value`
237
+ else:
238
+ try:
239
+ options_dict.update({option.split("=", 1)[0]: option.split("=", 1)[1]})
240
+ except IndexError:
241
+ exit_error(f"Option `{option}` is invalid, must be defined as `key=value|@file`.")
242
+
243
+ return options_dict
244
+
245
+
246
+ def uuid_valid(value: str, version: int = 4) -> bool:
247
+ """
248
+ Validates that given `value` is a valid UUID version 4.
249
+ """
250
+ try:
251
+ uuid.UUID(value, version=version)
252
+ return True
253
+ except ValueError:
254
+ return False
255
+
256
+
257
+ def extract_uuid(value: str) -> str:
258
+ """
259
+ Extracts a UUID from a string. If the string is already a valid UUID, returns it.
260
+ If the string contains a UUID, extracts and returns it.
261
+ Raises typer.Exit with error message if no valid UUID is found.
262
+ """
263
+ # Check if the value is already a valid UUID
264
+ if uuid_valid(value):
265
+ return value
266
+
267
+ # UUID pattern for extracting UUIDs from strings
268
+ 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}')
269
+
270
+ # Try to extract UUID from string
271
+ uuid_match = uuid_pattern.search(value)
272
+ if uuid_match:
273
+ return uuid_match.group()
274
+
275
+ exit_error(f"Could not find a valid Testing Farm request id in '{value}'.")
276
+
277
+
278
+ class TimeoutHTTPAdapter(requests.adapters.HTTPAdapter):
279
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
280
+ self.timeout = kwargs.pop('timeout', settings.DEFAULT_API_TIMEOUT)
281
+
282
+ super().__init__(*args, **kwargs)
283
+
284
+ def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: # type: ignore[override]
285
+ kwargs.setdefault('timeout', self.timeout)
286
+
287
+ return super().send(request, **kwargs)
288
+
289
+
290
+ def install_http_retries(
291
+ session: requests.Session,
292
+ timeout: int = settings.DEFAULT_API_TIMEOUT,
293
+ retries: int = settings.DEFAULT_API_RETRIES,
294
+ retry_backoff_factor: float = settings.DEFAULT_RETRY_BACKOFF_FACTOR,
295
+ status_forcelist_extend: Optional[List[int]] = None,
296
+ ) -> None:
297
+ # urllib3 1.26.0 deprecated method_whitelist, and 2.0.0 removed it:
298
+ # - https://github.com/urllib3/urllib3/commit/382ab32f23795c44faae83b4e8b18a16fb605a0a
299
+ # - https://github.com/urllib3/urllib3/commit/c67c0949e9c91c7621ea718a7f297ecac7c3b79e
300
+ if hasattr(Retry, "DEFAULT_ALLOWED_METHODS"):
301
+ allowed_retry_parameter = "allowed_methods"
302
+ else:
303
+ allowed_retry_parameter = "method_whitelist"
304
+
305
+ status_forcelist_extend = status_forcelist_extend or []
306
+
307
+ from typing import Any, Dict
308
+
309
+ params: Dict[str, Any] = {
310
+ "total": retries,
311
+ "status_forcelist": [
312
+ 429, # Too Many Requests
313
+ 500, # Internal Server Error
314
+ 502, # Bad Gateway
315
+ 503, # Service Unavailable
316
+ 504, # Gateway Timeout
317
+ ]
318
+ + status_forcelist_extend,
319
+ "backoff_factor": retry_backoff_factor,
320
+ }
321
+ params[allowed_retry_parameter] = ['HEAD', 'GET', 'POST', 'DELETE', 'PUT']
322
+ retry_strategy = Retry(**params)
323
+
324
+ timeout_adapter = TimeoutHTTPAdapter(timeout=timeout, max_retries=retry_strategy)
325
+
326
+ session.mount('https://', timeout_adapter)
327
+ session.mount('http://', timeout_adapter)
328
+
329
+
330
+ def normalize_multistring_option(options: List[str], separator: str = ',') -> List[str]:
331
+ return sum([[option.strip() for option in item.split(separator)] for item in options], [])
332
+
333
+
334
+ def normalize_bool_option(option_value: Union[str, bool]) -> bool:
335
+ if str(option_value).strip().lower() in ('yes', 'true', '1', 'y', 'on'):
336
+ return True
337
+ return False
338
+
339
+
340
+ def read_glob_paths(glob_paths: List[str]) -> str:
341
+ paths = [path for glob_path in glob_paths for path in glob.glob(os.path.expanduser(glob_path))]
342
+
343
+ contents: List[str] = []
344
+
345
+ for path in paths:
346
+ if not os.path.isfile(path) or not os.access(path, os.R_OK):
347
+ exit_error(f"Error reading '{path}'.")
348
+ with open(path, 'r') as file:
349
+ contents.append(file.read())
350
+
351
+ return ''.join(contents)
352
+
353
+
354
+ def check_unexpected_arguments(context: typer.Context, *args: str) -> Union[None, NoReturn]:
355
+ for argument in args:
356
+ if context.get_parameter_source(argument) == ParameterSource.COMMANDLINE:
357
+ exit_error(
358
+ f"Unexpected argument '{context.params.get(argument)}'. "
359
+ "Please make sure you are passing the parameters correctly."
360
+ )
361
+
362
+
363
+ def validate_age(value: str) -> Age:
364
+ if value.endswith("m"):
365
+ return Age(int(value[:-1]), "m")
366
+ elif value.endswith("d"):
367
+ return Age(int(value[:-1]), "d")
368
+ else:
369
+ raise ValueError("Age must end with 'm' for months or 'd' for days.")
370
+
371
+
372
+ def authorization_headers(api_key: str) -> Dict[str, str]:
373
+ """
374
+ Return a dict with headers for a request to Testing Farm API.
375
+ Used for authentication.
376
+ """
377
+ return {'Authorization': f'Bearer {api_key}'}
@@ -0,0 +1,13 @@
1
+ Copyright Red Hat, Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.1
2
+ Name: tft-cli
3
+ Version: 0.0.0
4
+ Summary: Testing Farm CLI tool
5
+ License: Apache-2.0
6
+ Author: Miroslav Vadkerti
7
+ Author-email: mvadkert@redhat.com
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Dist: click (>=8.0.4,<9.0.0)
15
+ Requires-Dist: colorama (>=0.4.4,<0.5.0)
16
+ Requires-Dist: dynaconf (>=3.1.2,<4.0.0)
17
+ Requires-Dist: pendulum (>=3.0.0,<4.0.0)
18
+ Requires-Dist: requests (>=2.27.1,<3.0.0)
19
+ Requires-Dist: rich (>=12)
20
+ Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
21
+ Requires-Dist: setuptools
22
+ Requires-Dist: shellingham (>=1.3.0,<2.0.0)
23
+ Requires-Dist: typer (>=0.11)
@@ -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=e3i8zEqDXyMc4Zc-e94A9A2WeXwbb5K9z9aRC3n0nmU,31241
4
+ tft/cli/commands.py,sha256=2Kp6zickQf0sltX4Gp3JVloV1ReW7RZPTdKqaWReFWk,85138
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=eLoldFsYPxTEuVj2zV2gePiIjKtZVYzsVo1fzrU0STA,12369
8
+ tft_cli-0.0.0.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
9
+ tft_cli-0.0.0.dist-info/METADATA,sha256=xRRgGqlWPVBfk9fXsMokmBRjZ8atoJkHWgvFMfxYmEc,829
10
+ tft_cli-0.0.0.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
11
+ tft_cli-0.0.0.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
12
+ tft_cli-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.5.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ testing-farm=tft.cli.tool:app
3
+