osism 0.20250530.0__py3-none-any.whl → 0.20250605.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.
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from loguru import logger
4
+ import yaml
5
+
6
+ from osism import settings
7
+ from osism.tasks import netbox
8
+
9
+
10
+ def get_nb_device_query_list():
11
+ try:
12
+ supported_nb_device_filters = [
13
+ "site",
14
+ "region",
15
+ "site_group",
16
+ "location",
17
+ "rack",
18
+ "tag",
19
+ "state",
20
+ ]
21
+ nb_device_query_list = yaml.safe_load(settings.NETBOX_FILTER_CONDUCTOR)
22
+ if type(nb_device_query_list) is not list:
23
+ raise TypeError
24
+ for nb_device_query in nb_device_query_list:
25
+ if type(nb_device_query) is not dict:
26
+ raise TypeError
27
+ for key in list(nb_device_query.keys()):
28
+ if key not in supported_nb_device_filters:
29
+ raise ValueError
30
+ # NOTE: Only "location_id" and "rack_id" are supported by netbox
31
+ if key in ["location", "rack"]:
32
+ value_name = nb_device_query.pop(key, "")
33
+ if key == "location":
34
+ value_id = netbox.get_location_id(value_name)
35
+ elif key == "rack":
36
+ value_id = netbox.get_rack_id(value_name)
37
+ if value_id:
38
+ nb_device_query.update({key + "_id": value_id})
39
+ else:
40
+ raise ValueError(f"Invalid name {value_name} for {key}")
41
+ except (yaml.YAMLError, TypeError):
42
+ logger.error(
43
+ f"Setting NETBOX_FILTER_CONDUCTOR needs to be an array of mappings containing supported netbox device filters: {supported_nb_device_filters}"
44
+ )
45
+ nb_device_query_list = []
46
+ except ValueError as exc:
47
+ logger.error(f"Unknown value in NETBOX_FILTER_CONDUCTOR: {exc}")
48
+ nb_device_query_list = []
49
+
50
+ return nb_device_query_list
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from ansible import constants as ansible_constants
4
+ from ansible.parsing.vault import VaultLib, VaultSecret
5
+ from loguru import logger
6
+
7
+ from osism import utils
8
+
9
+
10
+ def deep_compare(a, b, updates):
11
+ """
12
+ Find items in a that do not exist in b or are different.
13
+ Write required changes into updates
14
+ """
15
+ for key, value in a.items():
16
+ if type(value) is not dict:
17
+ if key not in b or b[key] != value:
18
+ updates[key] = value
19
+ else:
20
+ updates[key] = {}
21
+ deep_compare(a[key], b[key], updates[key])
22
+ if not updates[key]:
23
+ updates.pop(key)
24
+
25
+
26
+ def deep_merge(a, b):
27
+ for key, value in b.items():
28
+ if value == "DELETE":
29
+ # NOTE: Use special string to remove keys
30
+ a.pop(key, None)
31
+ elif (
32
+ key not in a.keys()
33
+ or not isinstance(a[key], dict)
34
+ or not isinstance(value, dict)
35
+ ):
36
+ a[key] = value
37
+ else:
38
+ deep_merge(a[key], value)
39
+
40
+
41
+ def deep_decrypt(a, vault):
42
+ if a is None:
43
+ return
44
+ if isinstance(a, dict):
45
+ for key, value in list(a.items()):
46
+ if isinstance(value, (dict, list)):
47
+ deep_decrypt(a[key], vault)
48
+ elif vault.is_encrypted(value):
49
+ try:
50
+ a[key] = vault.decrypt(value).decode()
51
+ except Exception:
52
+ a.pop(key, None)
53
+ elif isinstance(a, list):
54
+ for i, item in enumerate(a):
55
+ if isinstance(item, (dict, list)):
56
+ deep_decrypt(item, vault)
57
+ elif vault.is_encrypted(item):
58
+ try:
59
+ a[i] = vault.decrypt(item).decode()
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def get_vault():
65
+ """Create and return a VaultLib instance for decrypting secrets"""
66
+ try:
67
+ vault_secret = utils.get_ansible_vault_password()
68
+ vault = VaultLib(
69
+ [
70
+ (
71
+ ansible_constants.DEFAULT_VAULT_ID_MATCH,
72
+ VaultSecret(vault_secret.encode()),
73
+ )
74
+ ]
75
+ )
76
+ except Exception:
77
+ logger.error("Unable to get vault secret. Dropping encrypted entries")
78
+ vault = VaultLib()
79
+ return vault
osism/tasks/conductor.py CHANGED
@@ -1,472 +1,15 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
 
3
- from ansible import constants as ansible_constants
4
- from ansible.parsing.vault import VaultLib, VaultSecret
5
- from celery import Celery
6
- from celery.signals import worker_process_init
7
- import copy
8
- import ipaddress
9
- import jinja2
10
- from loguru import logger
11
- from pottery import Redlock
12
- import yaml
13
- import json
14
-
15
- from osism import settings
16
- from osism import utils
17
- from osism.tasks import Config, netbox, openstack
18
-
19
- app = Celery("conductor")
20
- app.config_from_object(Config)
21
-
22
-
23
- configuration = {}
24
-
25
-
26
- def get_nb_device_query_list():
27
- try:
28
- supported_nb_device_filters = [
29
- "site",
30
- "region",
31
- "site_group",
32
- "location",
33
- "rack",
34
- "tag",
35
- "state",
36
- ]
37
- nb_device_query_list = yaml.safe_load(settings.NETBOX_FILTER_CONDUCTOR)
38
- if type(nb_device_query_list) is not list:
39
- raise TypeError
40
- for nb_device_query in nb_device_query_list:
41
- if type(nb_device_query) is not dict:
42
- raise TypeError
43
- for key in list(nb_device_query.keys()):
44
- if key not in supported_nb_device_filters:
45
- raise ValueError
46
- # NOTE: Only "location_id" and "rack_id" are supported by netbox
47
- if key in ["location", "rack"]:
48
- value_name = nb_device_query.pop(key, "")
49
- if key == "location":
50
- value_id = netbox.get_location_id(value_name)
51
- elif key == "rack":
52
- value_id = netbox.get_rack_id(value_name)
53
- if value_id:
54
- nb_device_query.update({key + "_id": value_id})
55
- else:
56
- raise ValueError(f"Invalid name {value_name} for {key}")
57
- except (yaml.YAMLError, TypeError):
58
- logger.error(
59
- f"Setting NETBOX_FILTER_CONDUCTOR needs to be an array of mappings containing supported netbox device filters: {supported_nb_device_filters}"
60
- )
61
- nb_device_query_list = []
62
- except ValueError as exc:
63
- logger.error(f"Unknown value in NETBOX_FILTER_CONDUCTOR: {exc}")
64
- nb_device_query_list = []
65
-
66
- return nb_device_query_list
67
-
68
-
69
- def get_configuration():
70
- with open("/etc/conductor.yml") as fp:
71
- configuration = yaml.load(fp, Loader=yaml.SafeLoader)
72
-
73
- if not configuration:
74
- logger.warning(
75
- "The conductor configuration is empty. That's probably wrong"
76
- )
77
- return {}
78
-
79
- if Config.enable_ironic.lower() not in ["true", "yes"]:
80
- return configuration
81
-
82
- if "ironic_parameters" not in configuration:
83
- logger.error("ironic_parameters not found in the conductor configuration")
84
- return configuration
85
-
86
- if "driver_info" in configuration["ironic_parameters"]:
87
- if "deploy_kernel" in configuration["ironic_parameters"]["driver_info"]:
88
- result = openstack.image_get(
89
- configuration["ironic_parameters"]["driver_info"]["deploy_kernel"]
90
- )
91
- configuration["ironic_parameters"]["driver_info"][
92
- "deploy_kernel"
93
- ] = result.id
94
-
95
- if "deploy_ramdisk" in configuration["ironic_parameters"]["driver_info"]:
96
- result = openstack.image_get(
97
- configuration["ironic_parameters"]["driver_info"]["deploy_ramdisk"]
98
- )
99
- configuration["ironic_parameters"]["driver_info"][
100
- "deploy_ramdisk"
101
- ] = result.id
102
-
103
- if "cleaning_network" in configuration["ironic_parameters"]["driver_info"]:
104
- result = openstack.network_get(
105
- configuration["ironic_parameters"]["driver_info"][
106
- "cleaning_network"
107
- ]
108
- )
109
- configuration["ironic_parameters"]["driver_info"][
110
- "cleaning_network"
111
- ] = result.id
112
-
113
- if (
114
- "provisioning_network"
115
- in configuration["ironic_parameters"]["driver_info"]
116
- ):
117
- result = openstack.network_get(
118
- configuration["ironic_parameters"]["driver_info"][
119
- "provisioning_network"
120
- ]
121
- )
122
- configuration["ironic_parameters"]["driver_info"][
123
- "provisioning_network"
124
- ] = result.id
125
-
126
- return configuration
127
-
128
-
129
- @worker_process_init.connect
130
- def celery_init_worker(**kwargs):
131
- global configuration
132
- configuration = get_configuration()
133
-
134
-
135
- @app.on_after_configure.connect
136
- def setup_periodic_tasks(sender, **kwargs):
137
- pass
138
-
139
-
140
- @app.task(bind=True, name="osism.tasks.conductor.get_ironic_parameters")
141
- def get_ironic_parameters(self):
142
- if "ironic_parameters" in configuration:
143
- # NOTE: Do not pass by reference, everybody gets their own copy to work with
144
- return copy.deepcopy(configuration["ironic_parameters"])
145
-
146
- return {}
147
-
148
-
149
- @app.task(bind=True, name="osism.tasks.conductor.sync_netbox")
150
- def sync_netbox(self, force_update=False):
151
- logger.info("Not implemented")
152
-
153
-
154
- @app.task(bind=True, name="osism.tasks.conductor.sync_ironic")
155
- def sync_ironic(self, force_update=False):
156
- def deep_compare(a, b, updates):
157
- """
158
- Find items in a that do not exist in b or are different.
159
- Write required changes into updates
160
- """
161
- for key, value in a.items():
162
- if type(value) is not dict:
163
- if key not in b or b[key] != value:
164
- updates[key] = value
165
- else:
166
- updates[key] = {}
167
- deep_compare(a[key], b[key], updates[key])
168
- if not updates[key]:
169
- updates.pop(key)
170
-
171
- def deep_merge(a, b):
172
- for key, value in b.items():
173
- if value == "DELETE":
174
- # NOTE: Use special string to remove keys
175
- a.pop(key, None)
176
- elif (
177
- key not in a.keys()
178
- or not isinstance(a[key], dict)
179
- or not isinstance(value, dict)
180
- ):
181
- a[key] = value
182
- else:
183
- deep_merge(a[key], value)
184
-
185
- def deep_decrypt(a, vault):
186
- for key, value in list(a.items()):
187
- if not isinstance(value, dict):
188
- if vault.is_encrypted(value):
189
- try:
190
- a[key] = vault.decrypt(value).decode()
191
- except Exception:
192
- a.pop(key, None)
193
- else:
194
- deep_decrypt(a[key], vault)
195
-
196
- driver_params = {
197
- "ipmi": {
198
- "address": "ipmi_address",
199
- "port": "ipmi_port",
200
- "password": "ipmi_password",
201
- },
202
- "redfish": {
203
- "address": "redfish_address",
204
- "password": "redfish_password",
205
- },
206
- }
207
-
208
- devices = set()
209
- nb_device_query_list = get_nb_device_query_list()
210
- for nb_device_query in nb_device_query_list:
211
- devices |= set(netbox.get_devices(**nb_device_query))
212
-
213
- # NOTE: Find nodes in Ironic which are no longer present in netbox and remove them
214
- device_names = {dev.name for dev in devices}
215
- nodes = openstack.baremetal_node_list()
216
- for node in nodes:
217
- logger.info(f"Looking for {node['Name']} in netbox")
218
- if node["Name"] not in device_names:
219
- if (
220
- not node["Instance UUID"]
221
- and node["Provisioning State"] in ["enroll", "manageable", "available"]
222
- and node["Power State"] in ["power off", None]
223
- ):
224
- logger.info(
225
- f"Cleaning up baremetal node not found in netbox: {node['Name']}"
226
- )
227
- for port in openstack.baremetal_port_list(
228
- details=False, attributes=dict(node_uuid=node["UUID"])
229
- ):
230
- openstack.baremetal_port_delete(port.id)
231
- openstack.baremetal_node_delete(node["UUID"])
232
- else:
233
- logger.error(
234
- f"Cannot remove baremetal node because it is still provisioned or running: {node}"
235
- )
236
-
237
- # NOTE: Find nodes in netbox which are not present in Ironic and add them
238
- for device in devices:
239
- logger.info(f"Looking for {device.name} in ironic")
240
- logger.info(device)
241
-
242
- node_interfaces = list(netbox.get_interfaces_by_device(device.name))
243
-
244
- node_attributes = get_ironic_parameters()
245
- if (
246
- "ironic_parameters" in device.custom_fields
247
- and device.custom_fields["ironic_parameters"]
248
- ):
249
- # NOTE: Update node attributes with overrides from netbox device
250
- deep_merge(node_attributes, device.custom_fields["ironic_parameters"])
251
- # NOTE: Decrypt ansible vaulted secrets
252
- try:
253
- vault_secret = utils.get_ansible_vault_password()
254
- vault = VaultLib(
255
- [
256
- (
257
- ansible_constants.DEFAULT_VAULT_ID_MATCH,
258
- VaultSecret(vault_secret.encode()),
259
- )
260
- ]
261
- )
262
- except Exception:
263
- logger.error("Unable to get vault secret. Dropping encrypted entries")
264
- vault = VaultLib()
265
- deep_decrypt(node_attributes, vault)
266
- if (
267
- "driver" in node_attributes
268
- and node_attributes["driver"] in driver_params.keys()
269
- ):
270
- if "driver_info" in node_attributes:
271
- # NOTE: Pop all fields belonging to a different driver
272
- unused_drivers = [
273
- driver
274
- for driver in driver_params.keys()
275
- if driver != node_attributes["driver"]
276
- ]
277
- for key in list(node_attributes["driver_info"].keys()):
278
- for driver in unused_drivers:
279
- if key.startswith(driver + "_"):
280
- node_attributes["driver_info"].pop(key, None)
281
- # NOTE: Render driver address field
282
- address_key = driver_params[node_attributes["driver"]]["address"]
283
- if address_key in node_attributes["driver_info"]:
284
- if device.oob_ip and "address" in device.oob_ip:
285
- node_mgmt_address = device.oob_ip["address"]
286
- else:
287
- node_mgmt_addresses = [
288
- interface["address"]
289
- for interface in node_interfaces
290
- if interface.mgmt_only
291
- and "address" in interface
292
- and interface["address"]
293
- ]
294
- if len(node_mgmt_addresses) > 0:
295
- node_mgmt_address = node_mgmt_addresses[0]
296
- else:
297
- node_mgmt_address = None
298
- if node_mgmt_address:
299
- node_attributes["driver_info"][address_key] = (
300
- jinja2.Environment(loader=jinja2.BaseLoader())
301
- .from_string(node_attributes["driver_info"][address_key])
302
- .render(
303
- remote_board_address=str(
304
- ipaddress.ip_interface(node_mgmt_address).ip
305
- )
306
- )
307
- )
308
- node_attributes.update({"resource_class": device.name})
309
- # NOTE: Write metadata used for provisioning into 'extra' field, so that it is available during node deploy without querying the netbox again
310
- if "extra" not in node_attributes:
311
- node_attributes["extra"] = {}
312
- if (
313
- "netplan_parameters" in device.custom_fields
314
- and device.custom_fields["netplan_parameters"]
315
- ):
316
- node_attributes["extra"].update(
317
- {
318
- "netplan_parameters": json.dumps(
319
- device.custom_fields["netplan_parameters"]
320
- )
321
- }
322
- )
323
- if (
324
- "frr_parameters" in device.custom_fields
325
- and device.custom_fields["frr_parameters"]
326
- ):
327
- node_attributes["extra"].update(
328
- {"frr_parameters": json.dumps(device.custom_fields["frr_parameters"])}
329
- )
330
- ports_attributes = [
331
- dict(address=interface.mac_address)
332
- for interface in node_interfaces
333
- if interface.enabled and not interface.mgmt_only and interface.mac_address
334
- ]
335
-
336
- lock = Redlock(
337
- key=f"lock_osism_tasks_conductor_sync_ironic-{device.name}",
338
- masters={utils.redis},
339
- auto_release_time=600,
340
- )
341
- if lock.acquire(timeout=120):
342
- try:
343
- logger.info(f"Processing device {device.name}")
344
- node = openstack.baremetal_node_show(device.name, ignore_missing=True)
345
- if not node:
346
- logger.info(f"Creating baremetal node for {device.name}")
347
- node = openstack.baremetal_node_create(device.name, node_attributes)
348
- else:
349
- # NOTE: The listener service only reacts to changes in the baremetal node. Explicitly sync provision and power state in case updates were missed by the listener.
350
- if (
351
- device.custom_fields["provision_state"]
352
- != node["provision_state"]
353
- ):
354
- netbox.set_provision_state(device.name, node["provision_state"])
355
- if device.custom_fields["power_state"] != node["power_state"]:
356
- netbox.set_power_state(device.name, node["power_state"])
357
- # NOTE: Check whether the baremetal node needs to be updated
358
- node_updates = {}
359
- deep_compare(node_attributes, node, node_updates)
360
- if "driver_info" in node_updates:
361
- # NOTE: The password is not returned by ironic, so we cannot make a comparision and it would always be updated. Therefore we pop it from the dictionary
362
- password_key = driver_params[node_attributes["driver"]][
363
- "password"
364
- ]
365
- if password_key in node_updates["driver_info"]:
366
- node_updates["driver_info"].pop(password_key, None)
367
- if not node_updates["driver_info"]:
368
- node_updates.pop("driver_info", None)
369
- if node_updates or force_update:
370
- logger.info(
371
- f"Updating baremetal node for {device.name} with {node_updates}"
372
- )
373
- # NOTE: Do the actual updates with all values in node_attributes. Otherwise nested dicts like e.g. driver_info will be overwritten as a whole and contain only changed values
374
- node = openstack.baremetal_node_update(
375
- node["uuid"], node_attributes
376
- )
377
-
378
- node_ports = openstack.baremetal_port_list(
379
- details=False, attributes=dict(node_uuid=node["uuid"])
380
- )
381
- # NOTE: Baremetal ports are only required for (i)pxe boot
382
- if node["boot_interface"] in ["pxe", "ipxe"]:
383
- for port_attributes in ports_attributes:
384
- port_attributes.update({"node_id": node["uuid"]})
385
- port = [
386
- port
387
- for port in node_ports
388
- if port_attributes["address"].upper()
389
- == port["address"].upper()
390
- ]
391
- if not port:
392
- logger.info(
393
- f"Creating baremetal port with MAC address {port_attributes['address']} for {device.name}"
394
- )
395
- openstack.baremetal_port_create(port_attributes)
396
- else:
397
- node_ports.remove(port[0])
398
- for node_port in node_ports:
399
- # NOTE: Delete remaining ports not found in netbox
400
- logger.info(
401
- f"Deleting baremetal port with MAC address {node_port['address']} for {device.name}"
402
- )
403
- openstack.baremetal_port_delete(node_port["id"])
404
-
405
- node_validation = openstack.baremetal_node_validate(node["uuid"])
406
- if node_validation["management"].result:
407
- logger.info(
408
- f"Validation of management interface successful for baremetal node for {device.name}"
409
- )
410
- if node["provision_state"] == "enroll":
411
- logger.info(
412
- f"Transitioning baremetal node to manageable state for {device.name}"
413
- )
414
- node = openstack.baremetal_node_set_provision_state(
415
- node["uuid"], "manage"
416
- )
417
- node = openstack.baremetal_node_wait_for_nodes_provision_state(
418
- node["uuid"], "manageable"
419
- )
420
- logger.info(f"Baremetal node for {device.name} is manageable")
421
- if node_validation["boot"].result:
422
- logger.info(
423
- f"Validation of boot interface successful for baremetal node for {device.name}"
424
- )
425
- if node["provision_state"] == "manageable":
426
- logger.info(
427
- f"Transitioning baremetal node to available state for {device.name}"
428
- )
429
- node = openstack.baremetal_node_set_provision_state(
430
- node["uuid"], "provide"
431
- )
432
- node = (
433
- openstack.baremetal_node_wait_for_nodes_provision_state(
434
- node["uuid"], "available"
435
- )
436
- )
437
- logger.info(
438
- f"Baremetal node for {device.name} is available"
439
- )
440
- else:
441
- logger.info(
442
- f"Validation of boot interface failed for baremetal node for {device.name}\nReason: {node_validation['boot'].reason}"
443
- )
444
- if node["provision_state"] == "available":
445
- # NOTE: Demote node to manageable
446
- logger.info(
447
- f"Transitioning baremetal node to manageable state for {device.name}"
448
- )
449
- node = openstack.baremetal_node_set_provision_state(
450
- node["uuid"], "manage"
451
- )
452
- node = (
453
- openstack.baremetal_node_wait_for_nodes_provision_state(
454
- node["uuid"], "manageable"
455
- )
456
- )
457
- logger.info(
458
- f"Baremetal node for {device.name} is manageable"
459
- )
460
- else:
461
- logger.info(
462
- f"Validation of management interface failed for baremetal node for {device.name}\nReason: {node_validation['management'].reason}"
463
- )
464
- except Exception as exc:
465
- logger.info(
466
- f"Could not fully synchronize device {device.name} with ironic: {exc}"
467
- )
468
- finally:
469
- lock.release()
470
-
471
- else:
472
- logger.error("Could not acquire lock for node {device.name}")
3
+ from osism.tasks.conductor import (
4
+ app,
5
+ get_ironic_parameters,
6
+ sync_netbox,
7
+ sync_ironic,
8
+ )
9
+
10
+ __all__ = [
11
+ "app",
12
+ "get_ironic_parameters",
13
+ "sync_netbox",
14
+ "sync_ironic",
15
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: osism
3
- Version: 0.20250530.0
3
+ Version: 0.20250605.0
4
4
  Summary: OSISM manager interface
5
5
  Home-page: https://github.com/osism/python-osism
6
6
  Author: OSISM GmbH
@@ -27,7 +27,7 @@ Requires-Dist: GitPython==3.1.44
27
27
  Requires-Dist: Jinja2==3.1.6
28
28
  Requires-Dist: PyYAML==6.0.2
29
29
  Requires-Dist: ara==1.7.2
30
- Requires-Dist: celery[redis]==5.5.2
30
+ Requires-Dist: celery[redis]==5.5.3
31
31
  Requires-Dist: cliff==4.10.0
32
32
  Requires-Dist: deepdiff==8.5.0
33
33
  Requires-Dist: docker==7.1.0
@@ -37,12 +37,12 @@ Requires-Dist: flower==2.0.1
37
37
  Requires-Dist: hiredis==3.2.1
38
38
  Requires-Dist: jc==1.25.5
39
39
  Requires-Dist: keystoneauth1==5.11.0
40
- Requires-Dist: kombu==5.5.3
40
+ Requires-Dist: kombu==5.5.4
41
41
  Requires-Dist: kubernetes==32.0.1
42
42
  Requires-Dist: loguru==0.7.3
43
43
  Requires-Dist: nbcli==0.10.0.dev2
44
44
  Requires-Dist: netmiko==4.5.0
45
- Requires-Dist: openstacksdk==4.5.0
45
+ Requires-Dist: openstacksdk==4.6.0
46
46
  Requires-Dist: pottery==3.0.1
47
47
  Requires-Dist: prompt-toolkit==3.0.51
48
48
  Requires-Dist: pynetbox==7.5.0
@@ -53,7 +53,7 @@ Requires-Dist: sqlmodel==0.0.24
53
53
  Requires-Dist: sushy==5.6.0
54
54
  Requires-Dist: tabulate==0.9.0
55
55
  Requires-Dist: transitions==0.9.2
56
- Requires-Dist: uvicorn[standard]==0.34.2
56
+ Requires-Dist: uvicorn[standard]==0.34.3
57
57
  Requires-Dist: watchdog==6.0.0
58
58
  Provides-Extra: ansible
59
59
  Requires-Dist: ansible-runner==2.4.1; extra == "ansible"