tft-cli 0.0.20__tar.gz → 0.0.22__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tft-cli
3
- Version: 0.0.20
3
+ Version: 0.0.22
4
4
  Summary: Testing Farm CLI tool
5
5
  License: Apache-2.0
6
6
  Author: Miroslav Vadkerti
@@ -15,6 +15,7 @@ Requires-Dist: click (>=8.0.4,<8.1.0)
15
15
  Requires-Dist: colorama (>=0.4.4,<0.5.0)
16
16
  Requires-Dist: dynaconf (>=3.1.7,<4.0.0)
17
17
  Requires-Dist: requests (>=2.27.1,<3.0.0)
18
+ Requires-Dist: rich (>=12,<13)
18
19
  Requires-Dist: ruamel-yaml (>=0.18.6,<0.19.0)
19
20
  Requires-Dist: setuptools
20
21
  Requires-Dist: typer[all] (>=0.7.0,<0.8.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "tft-cli"
3
- version = "0.0.20"
3
+ version = "0.0.22"
4
4
  description = "Testing Farm CLI tool"
5
5
  authors = ["Miroslav Vadkerti <mvadkert@redhat.com>"]
6
6
  license = "Apache-2.0"
@@ -21,6 +21,7 @@ click = "~8.0.4"
21
21
  dynaconf = "^3.1.7"
22
22
  colorama = "^0.4.4"
23
23
  requests = "^2.27.1"
24
+ rich = "^12"
24
25
  ruamel-yaml = "^0.18.6"
25
26
  setuptools = "*"
26
27
 
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import base64
5
+ import ipaddress
5
6
  import json
6
7
  import os
7
8
  import re
@@ -60,6 +61,9 @@ RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
60
61
 
61
62
  DEFAULT_PIPELINE_TIMEOUT = 60 * 12
62
63
 
64
+ # Won't be validating CIDR and 65535 max port range with regex here, not worth it
65
+ SECURITY_GROUP_RULE_FORMAT = re.compile(r"(tcp|ip|icmp|udp|-1|[0-255]):(.*):(\d{1,5}-\d{1,5}|\d{1,5}|-1)")
66
+
63
67
 
64
68
  class WatchFormat(str, Enum):
65
69
  text = 'text'
@@ -134,6 +138,24 @@ OPTION_PIPELINE_TYPE: Optional[PipelineType] = typer.Option(None, help="Force a
134
138
  OPTION_POST_INSTALL_SCRIPT: Optional[str] = typer.Option(
135
139
  None, help="Post-install script to run right after the guest boots for the first time."
136
140
  )
141
+ OPTION_SECURITY_GROUP_RULE_INGRESS: Optional[List[str]] = typer.Option(
142
+ None,
143
+ help=(
144
+ "Additional ingress security group rules to be passed to guest in "
145
+ "PROTOCOL:CIDR:PORT format. Multiple rules can be specified as comma separated, "
146
+ "eg. `tcp:109.81.42.42/32:22,142.0.42.0/24:22`. "
147
+ "Supported by AWS only atm."
148
+ ),
149
+ )
150
+ OPTION_SECURITY_GROUP_RULE_EGRESS: Optional[List[str]] = typer.Option(
151
+ None,
152
+ help=(
153
+ "Additional egress security group rules to be passed to guest in "
154
+ "PROTOCOL:CIDR:PORT format. Multiple rules can be specified as comma separated, "
155
+ "eg. `tcp:109.81.42.42/32:22,142.0.42.0/24:22`. "
156
+ "Supported by AWS only atm."
157
+ ),
158
+ )
137
159
  OPTION_KICKSTART: Optional[List[str]] = typer.Option(
138
160
  None,
139
161
  metavar="key=value|@file",
@@ -212,6 +234,58 @@ OPTION_PARALLEL_LIMIT: Optional[int] = typer.Option(
212
234
  "Red Hat Ranch."
213
235
  ),
214
236
  )
237
+ OPTION_TAGS = typer.Option(
238
+ None,
239
+ "-t",
240
+ "--tag",
241
+ metavar="key=value|@file",
242
+ help="Tag cloud resources with given value. The @ prefix marks a yaml file to load.",
243
+ )
244
+
245
+
246
+ # NOTE(ivasilev) Largely borrowed from artemis-cli
247
+ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str]) -> Dict[str, Any]:
248
+ """
249
+ Returns a dictionary with ingress/egress rules in TFT request friendly format
250
+ """
251
+ security_group_rules = {}
252
+
253
+ def _add_secgroup_rules(sg_type: str, sg_data: List[str]) -> None:
254
+ security_group_rules[sg_type] = []
255
+
256
+ for sg_rule in normalize_multistring_option(sg_data):
257
+ matches = re.match(SECURITY_GROUP_RULE_FORMAT, sg_rule)
258
+ if not matches:
259
+ exit_error(f"Bad format of security group rule '{sg_rule}', should be PROTOCOL:CIDR:PORT") # noqa: E231
260
+
261
+ protocol, cidr, port = matches[1], matches[2], matches[3]
262
+
263
+ # Let's validate cidr
264
+ try:
265
+ # This way a single ip address will be converted to a valid ip/32 cidr.
266
+ cidr = str(ipaddress.ip_network(cidr))
267
+ except ValueError as err:
268
+ exit_error(f'CIDR {cidr} has incorrect format: {err}')
269
+
270
+ # Artemis expectes port_min/port_max, -1 has to be convered to a proper range 0-65535
271
+ port_min = 0 if port == '-1' else int(port.split('-')[0])
272
+ port_max = 65535 if port == '-1' else int(port.split('-')[-1])
273
+
274
+ # Add rule for Artemis API
275
+ security_group_rules[sg_type].append(
276
+ {
277
+ 'type': sg_type.split('_')[-1],
278
+ 'protocol': protocol,
279
+ 'cidr': cidr,
280
+ 'port_min': port_min,
281
+ 'port_max': port_max,
282
+ }
283
+ )
284
+
285
+ _add_secgroup_rules('security_group_rules_ingress', ingress_rules)
286
+ _add_secgroup_rules('security_group_rules_egress', egress_rules)
287
+
288
+ return security_group_rules
215
289
 
216
290
 
217
291
  def _parse_xunit(xunit: str):
@@ -441,7 +515,12 @@ def watch(
441
515
  raise typer.Exit(code=1)
442
516
 
443
517
  elif state == "error":
444
- _console_print(f"📛 pipeline error\n{request['result']['summary']}", style="red")
518
+ msg = (
519
+ request['result'].get('summary')
520
+ if request['result']
521
+ else '\n'.join(note['message'] for note in request['notes'])
522
+ )
523
+ _console_print(f"📛 pipeline error\n{msg}", style="red")
445
524
  _print_summary_table(request_summary, format)
446
525
  raise typer.Exit(code=2)
447
526
 
@@ -521,13 +600,7 @@ def request(
521
600
  repository: List[str] = OPTION_REPOSITORY,
522
601
  repository_file: List[str] = OPTION_REPOSITORY_FILE,
523
602
  sanity: bool = typer.Option(False, help="Run Testing Farm sanity test.", rich_help_panel=RESERVE_PANEL_GENERAL),
524
- tags: Optional[List[str]] = typer.Option(
525
- None,
526
- "-t",
527
- "--tag",
528
- metavar="key=value|@file",
529
- help="Tag cloud resources with given value. The @ prefix marks a yaml file to load.",
530
- ),
603
+ tags: Optional[List[str]] = OPTION_TAGS,
531
604
  watchdog_dispatch_delay: Optional[int] = typer.Option(
532
605
  None,
533
606
  help="How long (seconds) before the guest \"is-alive\" watchdog is dispatched. Note that this is implemented only in Artemis service.", # noqa
@@ -539,6 +612,8 @@ def request(
539
612
  dry_run: bool = OPTION_DRY_RUN,
540
613
  pipeline_type: Optional[PipelineType] = OPTION_PIPELINE_TYPE,
541
614
  post_install_script: Optional[str] = OPTION_POST_INSTALL_SCRIPT,
615
+ security_group_rule_ingress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_INGRESS,
616
+ security_group_rule_egress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_EGRESS,
542
617
  user_webpage: Optional[str] = typer.Option(
543
618
  None, help="URL to the user's webpage. The link will be shown in the results viewer."
544
619
  ),
@@ -707,7 +782,17 @@ def request(
707
782
 
708
783
  environments.append(environment)
709
784
 
710
- if tags or watchdog_dispatch_delay or watchdog_period_delay or post_install_script:
785
+ if any(
786
+ provisioning_detail
787
+ for provisioning_detail in [
788
+ tags,
789
+ watchdog_dispatch_delay,
790
+ watchdog_period_delay,
791
+ post_install_script,
792
+ security_group_rule_ingress,
793
+ security_group_rule_egress,
794
+ ]
795
+ ):
711
796
  if "settings" not in environments[0]:
712
797
  environments[0]["settings"] = {}
713
798
 
@@ -726,6 +811,10 @@ def request(
726
811
  if post_install_script:
727
812
  environments[0]["settings"]["provisioning"]["post_install_script"] = post_install_script
728
813
 
814
+ if security_group_rule_ingress or security_group_rule_egress:
815
+ rules = _parse_security_group_rules(security_group_rule_ingress or [], security_group_rule_egress or [])
816
+ environments[0]["settings"]["provisioning"].update(rules)
817
+
729
818
  # create final request
730
819
  request = TestingFarmRequestV1
731
820
  request["api_key"] = api_token
@@ -747,6 +836,7 @@ def request(
747
836
 
748
837
  # worker image
749
838
  if worker_image:
839
+ console.print(f"👷 Forcing worker image [blue]{worker_image}[/blue]")
750
840
  request["settings"]["worker"] = {"image": worker_image}
751
841
 
752
842
  if not user_webpage and (user_webpage_name or user_webpage_icon):
@@ -810,6 +900,7 @@ def restart(
810
900
  git_ref: Optional[str] = typer.Option(None, help="Force GIT ref or branch to test."),
811
901
  git_merge_sha: Optional[str] = typer.Option(None, help="Force GIT ref or branch into which --ref will be merged."),
812
902
  hardware: List[str] = OPTION_HARDWARE,
903
+ tags: Optional[List[str]] = OPTION_TAGS,
813
904
  tmt_plan_name: Optional[str] = OPTION_TMT_PLAN_NAME,
814
905
  tmt_plan_filter: Optional[str] = OPTION_TMT_PLAN_FILTER,
815
906
  tmt_test_name: Optional[str] = OPTION_TMT_TEST_NAME,
@@ -853,6 +944,17 @@ def restart(
853
944
  if response.status_code == 401:
854
945
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
855
946
 
947
+ # The API token is valid, but it doesn't own the request
948
+ if response.status_code == 403:
949
+ console.print(
950
+ "⚠️ [yellow] You are not the owner of this request. Any secrets associated with the request will not be included on the restart.[/yellow]" # noqa: E501
951
+ )
952
+ # Construct URL to the internal API
953
+ get_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
954
+
955
+ # Get the request details
956
+ response = session.get(get_url)
957
+
856
958
  if response.status_code != 200:
857
959
  exit_error(f"Unexpected error. Please file an issue to {settings.ISSUE_TRACKER}.")
858
960
 
@@ -933,7 +1035,7 @@ def restart(
933
1035
 
934
1036
  # worker image
935
1037
  if worker_image:
936
- console.print(f"👷 forcing worker image [blue]{worker_image}[/blue]")
1038
+ console.print(f"👷 Forcing worker image [blue]{worker_image}[/blue]")
937
1039
  request["settings"] = request["settings"] if request.get("settings") else {}
938
1040
  request["settings"]["worker"] = {"image": worker_image}
939
1041
  # it is required to have also pipeline key set, otherwise API will fail
@@ -954,6 +1056,16 @@ def restart(
954
1056
  if parallel_limit:
955
1057
  request["settings"]["pipeline"]["parallel-limit"] = parallel_limit
956
1058
 
1059
+ if tags:
1060
+ for environment in request["environments"]:
1061
+ if "settings" not in environment or not environment["settings"]:
1062
+ environment["settings"] = {}
1063
+
1064
+ if 'provisioning' not in environment["settings"]:
1065
+ environment["settings"]["provisioning"] = {}
1066
+
1067
+ environment["settings"]["provisioning"]["tags"] = options_to_dict("tags", tags)
1068
+
957
1069
  # dry run
958
1070
  if dry_run:
959
1071
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
@@ -1163,6 +1275,7 @@ def reserve(
1163
1275
  rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
1164
1276
  ),
1165
1277
  hardware: List[str] = OPTION_HARDWARE,
1278
+ tags: Optional[List[str]] = OPTION_TAGS,
1166
1279
  kickstart: Optional[List[str]] = OPTION_KICKSTART,
1167
1280
  pool: Optional[str] = OPTION_POOL,
1168
1281
  fedora_koji_build: List[str] = OPTION_FEDORA_KOJI_BUILD,
@@ -1180,6 +1293,12 @@ def reserve(
1180
1293
  autoconnect: bool = typer.Option(
1181
1294
  True, help="Automatically connect to the guest via SSH.", rich_help_panel=RESERVE_PANEL_GENERAL
1182
1295
  ),
1296
+ worker_image: Optional[str] = OPTION_WORKER_IMAGE,
1297
+ security_group_rule_ingress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_INGRESS,
1298
+ security_group_rule_egress: Optional[List[str]] = OPTION_SECURITY_GROUP_RULE_EGRESS,
1299
+ skip_workstation_access: bool = typer.Option(
1300
+ False, help="Do not allow ingress traffic from this workstation's ip to the reserved machine"
1301
+ ),
1183
1302
  ):
1184
1303
  """
1185
1304
  Reserve a system in Testing Farm.
@@ -1230,12 +1349,22 @@ def reserve(
1230
1349
  environment["pool"] = pool
1231
1350
  environment["artifacts"] = []
1232
1351
 
1233
- if post_install_script:
1352
+ if "settings" not in environment:
1353
+ environment["settings"] = {}
1354
+
1355
+ if post_install_script or security_group_rule_ingress or security_group_rule_egress or tags:
1234
1356
  if "settings" not in environment:
1235
1357
  environment["settings"] = {}
1236
1358
 
1237
- if 'provisioning' not in environment["settings"]:
1238
- environment["settings"]["provisioning"] = {}
1359
+ if "provisioning" not in environment["settings"]:
1360
+ environment["settings"]["provisioning"] = {}
1361
+
1362
+ if "tags" not in environment["settings"]["provisioning"]:
1363
+ environment["settings"]["provisioning"]["tags"] = {}
1364
+
1365
+ # reserve command is for interacting with the guest, and so non-spot instances
1366
+ # would be nicer for the user than them getting shocked when they loose their work.
1367
+ environment["settings"]["provisioning"]["tags"]["ArtemisUseSpot"] = "false"
1239
1368
 
1240
1369
  if compose:
1241
1370
  environment["os"] = {"compose": compose}
@@ -1243,6 +1372,9 @@ def reserve(
1243
1372
  if hardware:
1244
1373
  environment["hardware"] = hw_constraints(hardware)
1245
1374
 
1375
+ if tags:
1376
+ environment["settings"]["provisioning"]["tags"] = options_to_dict("tags", tags)
1377
+
1246
1378
  if kickstart:
1247
1379
  environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
1248
1380
 
@@ -1264,6 +1396,24 @@ def reserve(
1264
1396
  if post_install_script:
1265
1397
  environment["settings"]["provisioning"]["post_install_script"] = post_install_script
1266
1398
 
1399
+ if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
1400
+ ingress_rules = security_group_rule_ingress or []
1401
+ if not skip_workstation_access:
1402
+ try:
1403
+ get_ip = requests.get(settings.PUBLIC_IP_CHECKER_URL)
1404
+ except requests.exceptions.RequestException as err:
1405
+ exit_error(f"Could not get workstation ip to form a security group rule: {err}")
1406
+
1407
+ if get_ip.ok:
1408
+ ip = get_ip.text.strip()
1409
+ ingress_rules.append(f'-1:{ip}:-1') # noqa: E231
1410
+
1411
+ else:
1412
+ exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
1413
+
1414
+ rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
1415
+ environment["settings"]["provisioning"].update(rules)
1416
+
1267
1417
  console.print(f"🕗 Reserved for [blue]{str(reservation_duration)}[/blue] minutes")
1268
1418
  environment["variables"] = {"TF_RESERVATION_DURATION": str(reservation_duration)}
1269
1419
 
@@ -1283,6 +1433,12 @@ def reserve(
1283
1433
  request["api_key"] = settings.API_TOKEN
1284
1434
  request["test"]["fmf"] = test
1285
1435
 
1436
+ # worker image
1437
+ if worker_image:
1438
+ console.print(f"👷 Forcing worker image [blue]{worker_image}[/blue]")
1439
+ request["settings"] = request["settings"] if request.get("settings") else {}
1440
+ request["settings"]["worker"] = {"image": worker_image}
1441
+
1286
1442
  request["environments"] = [environment]
1287
1443
 
1288
1444
  # in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
@@ -1438,27 +1594,10 @@ def reserve(
1438
1594
 
1439
1595
  time.sleep(1)
1440
1596
 
1441
- sshproxy_url = urllib.parse.urljoin(str(settings.API_URL), f"v0.1/sshproxy?api_key={settings.API_TOKEN}")
1442
- response = session.get(sshproxy_url)
1443
-
1444
- content = response.json()
1445
-
1446
- ssh_private_key = ""
1447
- if content.get('ssh_private_key_base_64'):
1448
- ssh_private_key = base64.b64decode(content['ssh_private_key_base_64']).decode()
1449
-
1450
- ssh_proxy_option = f" -J {content['ssh_proxy']}" if content.get('ssh_proxy') else ""
1451
-
1452
- if ssh_private_key:
1453
- console.print("🔑 [blue]Adding SSH proxy key[/blue]")
1454
- subprocess.run(["ssh-add", "-"], input=ssh_private_key.encode())
1455
-
1456
- console.print(f"🌎 ssh{ssh_proxy_option} root@{guest}")
1597
+ console.print(f"🌎 ssh root@{guest}")
1457
1598
 
1458
1599
  if autoconnect:
1459
- os.system(
1460
- f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null{ssh_proxy_option} root@{guest}" # noqa: E501
1461
- )
1600
+ os.system(f"ssh -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null root@{guest}") # noqa: E501
1462
1601
 
1463
1602
 
1464
1603
  def update():
@@ -24,4 +24,5 @@ settings = LazySettings(
24
24
  # Testing Farm sanity test,
25
25
  TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
26
26
  TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
27
+ PUBLIC_IP_CHECKER_URL="http://icanhazip.com",
27
28
  )
@@ -2,7 +2,9 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import glob
5
+ import itertools
5
6
  import os
7
+ import shlex
6
8
  import subprocess
7
9
  import sys
8
10
  import uuid
@@ -119,6 +121,12 @@ def options_to_dict(name: str, options: List[str]) -> Dict[str, str]:
119
121
  """Create a dictionary from list of `key=value|@file` options"""
120
122
 
121
123
  options_dict = {}
124
+
125
+ # Turn option list such as
126
+ # `['aaa=bbb "foo foo=bar bar"', 'foo=bar']` into
127
+ # `['aaa=bbb', 'foo foo=bar bar', 'foo=bar']`
128
+ options = list(itertools.chain.from_iterable(shlex.split(option) for option in options))
129
+
122
130
  for option in options:
123
131
  # Option is `@file`
124
132
  if option.startswith('@'):
File without changes
File without changes
File without changes