├── requirements.txt ├── .gitignore ├── .travis.yml ├── tox.ini ├── Pipfile ├── consul_awx.ini ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── consul_awx_test.py ├── consul_awx.py └── Pipfile.lock /requirements.txt: -------------------------------------------------------------------------------- 1 | python-consul2>=0.0.16 2 | requests 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox/ 3 | __pycache__/ 4 | htmlcov/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | install: 8 | - pip install -q tox pre-commit 9 | script: 10 | - pre-commit 11 | - tox 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py3 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | pytest-cov 9 | -rrequirements.txt 10 | commands = pytest --cov=consul_awx --cov-report html consul_awx_test.py 11 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | python-consul2 = ">=0.0.16" 8 | requests = "*" 9 | tox = "*" 10 | 11 | [dev-packages] 12 | 13 | [requires] 14 | python_version = "3.9" 15 | -------------------------------------------------------------------------------- /consul_awx.ini: -------------------------------------------------------------------------------- 1 | [consul] 2 | host: 127.0.0.1 3 | port: 8500 4 | scheme: http 5 | # *verify* is whether to verify the SSL certificate for HTTPS requests 6 | verify: true 7 | # *token* is an optional `ACL token`. 8 | # token: 9 | # *dc* is the datacenter that this agent will communicate with. 10 | # dc: 11 | 12 | # [consul_node_meta] 13 | # k1: v1 14 | # k2: v2 15 | 16 | # [consul_node_meta_types] 17 | # cluster: str 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.3.0 5 | hooks: 6 | - id: check-executables-have-shebangs 7 | - id: check-merge-conflict 8 | - id: end-of-file-fixer 9 | - id: fix-encoding-pragma 10 | args: ['--remove'] 11 | - id: requirements-txt-fixer 12 | - id: trailing-whitespace 13 | - repo: https://github.com/FalconSocial/pre-commit-python-sorter 14 | rev: master 15 | hooks: 16 | - id: python-import-sorter 17 | args: ['--silent-overwrite'] 18 | - repo: https://github.com/psf/black 19 | rev: 22.10.0 20 | hooks: 21 | - id: black 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 wilfriedroset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Consul inventory source for AWX/Tower 2 | [![Build Status](https://travis-ci.org/wilfriedroset/consul-awx.svg?branch=master)](https://travis-ci.org/wilfriedroset/consul-awx) 3 | 4 | Aims to provide an inventory script for 5 | [AWX/Tower](https://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html) 6 | as first target but also usable by vanilla Ansible with Consul catalog as data 7 | source. 8 | 9 | Standalone Ansible inventory scripts can rely on INI configuration file where 10 | AWX/Tower rely only on environment variables. All data are pulled from the catalog 11 | API. 12 | 13 | ## Usage 14 | 15 | ### AWX 16 | Simply add this script as inventory within AWX/Tower following the [official 17 | documentation](https://docs.ansible.com/ansible-tower/latest/html/userguide/inventories.html). 18 | 19 | ### Standalone Ansible 20 | Following [Working with dynamic inventory](https://docs.ansible.com/ansible/latest/user_guide/intro_dynamic_inventory.html) documentation, the simplest method to use this script as inventory is the implicit method: 21 | 22 | ``` 23 | wget https://raw.githubusercontent.com/wilfriedroset/consul-awx/master/consul_awx.py -o /etc/ansible/hosts 24 | chmod +x /etc/ansible/hosts 25 | # Configure it 26 | cat << EOF > /etc/ansible/consul_awx.ini 27 | [consul] 28 | host: 127.0.0.1 29 | port: 8500 30 | scheme: http 31 | verify: true 32 | EOF 33 | # Test it 34 | /etc/ansible/hosts --list 35 | ``` 36 | 37 | ## Credits 38 | 39 | This script was mostly inspired by [consul_io.py](https://github.com/ansible/ansible/tree/devel/contrib/inventory) 40 | -------------------------------------------------------------------------------- /consul_awx_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from pprint import pprint as print 4 | from unittest import mock 5 | 6 | import pytest 7 | from consul_awx import ConsulInventory, get_node_meta, get_node_meta_types 8 | 9 | 10 | @mock.patch("consul.base.Consul.Catalog.node") 11 | @mock.patch("consul.base.Consul.Catalog.nodes") 12 | def test_mock(mocked_catalog_nodes, mocked_catalog_node): 13 | for tagged_address, ip_prefix in [("lan", "10"), ("wan", "20")]: 14 | mocked_catalog_nodes.return_value = ( 15 | "2513", 16 | [ 17 | { 18 | "Address": "10.0.0.0", 19 | "CreateIndex": 7, 20 | "Datacenter": "dc1", 21 | "ID": "517ef51b-7ac0-91ff-76f3-8e2ca17e714e", 22 | "Meta": { 23 | "consul-network-segment": "", 24 | "cluster": "94", 25 | "server_type": "postgresql", 26 | }, 27 | "ModifyIndex": 12, 28 | "Node": "node1", 29 | "TaggedAddresses": {"lan": "10.0.0.0", "wan": "20.0.0.0"}, 30 | }, 31 | { 32 | "Address": "10.0.0.1", 33 | "CreateIndex": 9, 34 | "Datacenter": "dc1", 35 | "ID": "465baa62-8aec-8148-b7fc-2e5c942c9f26", 36 | "Meta": { 37 | "consul-network-segment": "", 38 | "pseudo_bool": "true", 39 | "server_type": "nginx", 40 | }, 41 | "ModifyIndex": 11, 42 | "Node": "node2", 43 | "TaggedAddresses": {"lan": "10.0.0.1", "wan": "20.0.0.1"}, 44 | }, 45 | { 46 | "Address": "10.0.0.2", 47 | "CreateIndex": 8, 48 | "Datacenter": "dc1", 49 | "ID": "eb71d55b-a688-cd45-321b-565a318e2600", 50 | "Meta": { 51 | "consul-network-segment": "", 52 | "pseudo_bool": "false", 53 | "server_type": "web-server", 54 | }, 55 | "ModifyIndex": 10, 56 | "Node": "node3", 57 | "TaggedAddresses": {"lan": "10.0.0.2", "wan": "20.0.0.2"}, 58 | }, 59 | { 60 | "Address": "10.0.0.3", 61 | "CreateIndex": 14, 62 | "Datacenter": "dc1", 63 | "ID": "97649a1d-281d-4455-a215-020e7d77eedb", 64 | "Meta": { 65 | "consul-network-segment": "", 66 | "cluster": "one-two", 67 | "server_type": "postgresql", 68 | }, 69 | "ModifyIndex": 15, 70 | "Node": "node4", 71 | "TaggedAddresses": {"lan": "10.0.0.3", "wan": "20.0.0.3"}, 72 | }, 73 | { 74 | "Address": "10.0.0.4", 75 | "CreateIndex": 16, 76 | "Datacenter": "dc1", 77 | "ID": "0f81eddd-3a0f-4168-8493-a06d2b2de2e8", 78 | "Meta": { 79 | "consul-network-segment": "", 80 | "cluster": "1", 81 | "server_type": "postgresql", 82 | }, 83 | "ModifyIndex": 17, 84 | "Node": "node5", 85 | "TaggedAddresses": {"lan": "10.0.0.4", "wan": "20.0.0.4"}, 86 | }, 87 | ], 88 | ) 89 | mocked_catalog_node.side_effect = [ 90 | ("2345", {"Node": {}, "Services": {"a": {"Meta": {}, "Tags": ["aa"]}}}), 91 | ("3456", {"Node": {}, "Services": {"b": {"Meta": {}, "Tags": ["bb"]}}}), 92 | ("4567", {"Node": {}, "Services": {"c": {"Meta": {}, "Tags": ["cc"]}}}), 93 | ("5678", {"Node": {}, "Services": {"d-d": {"Meta": {}, "Tags": ["dd"]}}}), 94 | ("6789", {"Node": {}, "Services": {"e": {"Meta": {}, "Tags": ["ee"]}}}), 95 | ] 96 | node_meta_types = {"cluster": "str"} 97 | c = ConsulInventory() 98 | c.build_full_inventory( 99 | tagged_address=tagged_address, node_meta_types=node_meta_types 100 | ) 101 | print(c.inventory) 102 | 103 | mocked_catalog_nodes.call_count == 1 104 | mocked_catalog_node.call_count == 3 105 | assert c.inventory == { 106 | "_meta": { 107 | "hostvars": { 108 | "node1": { 109 | "ansible_host": "{}.0.0.0".format(ip_prefix), 110 | "datacenter": "dc1", 111 | "server_type": "postgresql", 112 | "cluster": "94", 113 | }, 114 | "node2": { 115 | "ansible_host": "{}.0.0.1".format(ip_prefix), 116 | "datacenter": "dc1", 117 | "server_type": "nginx", 118 | "pseudo_bool": True, 119 | }, 120 | "node3": { 121 | "ansible_host": "{}.0.0.2".format(ip_prefix), 122 | "datacenter": "dc1", 123 | "server_type": "web-server", 124 | "pseudo_bool": False, 125 | }, 126 | "node4": { 127 | "ansible_host": "{}.0.0.3".format(ip_prefix), 128 | "datacenter": "dc1", 129 | "server_type": "postgresql", 130 | "cluster": "one-two", 131 | }, 132 | "node5": { 133 | "ansible_host": "{}.0.0.4".format(ip_prefix), 134 | "datacenter": "dc1", 135 | "server_type": "postgresql", 136 | "cluster": "1", 137 | }, 138 | } 139 | }, 140 | "a": {"children": ["a_aa"], "hosts": ["node1"]}, 141 | "a_aa": {"children": [], "hosts": ["node1"]}, 142 | "all": { 143 | "children": sorted( 144 | [ 145 | "a", 146 | "a_aa", 147 | "b", 148 | "b_bb", 149 | "c", 150 | "c_cc", 151 | "cluster_1", 152 | "cluster_94", 153 | "cluster_one_two", 154 | "d_d", 155 | "d_d_dd", 156 | "dc1", 157 | "e", 158 | "e_ee", 159 | "pseudo_bool", 160 | "server_type_nginx", 161 | "server_type_postgresql", 162 | "server_type_web_server", 163 | "ungrouped", 164 | ] 165 | ), 166 | "hosts": [], 167 | }, 168 | "b": {"children": ["b_bb"], "hosts": ["node2"]}, 169 | "b_bb": {"children": [], "hosts": ["node2"]}, 170 | "c": {"children": ["c_cc"], "hosts": ["node3"]}, 171 | "c_cc": {"children": [], "hosts": ["node3"]}, 172 | "cluster_94": {"children": [], "hosts": ["node1"]}, 173 | "cluster_one_two": {"children": [], "hosts": ["node4"]}, 174 | "cluster_1": {"children": [], "hosts": ["node5"]}, 175 | "d_d": {"children": ["d_d_dd"], "hosts": ["node4"]}, 176 | "d_d_dd": {"children": [], "hosts": ["node4"]}, 177 | "dc1": { 178 | "children": [], 179 | "hosts": ["node1", "node2", "node3", "node4", "node5"], 180 | }, 181 | "e": {"children": ["e_ee"], "hosts": ["node5"]}, 182 | "e_ee": {"children": [], "hosts": ["node5"]}, 183 | "pseudo_bool": {"children": [], "hosts": ["node2"]}, 184 | "server_type_web_server": {"children": [], "hosts": ["node3"]}, 185 | "server_type_nginx": {"children": [], "hosts": ["node2"]}, 186 | "server_type_postgresql": { 187 | "children": [], 188 | "hosts": ["node1", "node4", "node5"], 189 | }, 190 | "ungrouped": {"children": [], "hosts": []}, 191 | } 192 | 193 | 194 | def test_get_node_meta_envvar(): 195 | assert get_node_meta() is None 196 | os.environ["CONSUL_NODE_META"] = '{"foo": "bar"}' 197 | assert get_node_meta() == {"foo": "bar"} 198 | for wrong_value in ['{"foo": 1}', '{1: "bar"}', "not a dict"]: 199 | with pytest.raises(Exception): 200 | os.environ["CONSUL_NODE_META"] = wrong_value 201 | get_node_meta() 202 | del os.environ["CONSUL_NODE_META"] 203 | 204 | 205 | def test_get_node_meta_types_envvar(): 206 | assert get_node_meta_types() is None 207 | os.environ["CONSUL_NODE_META_TYPES"] = '{"foo": "int"}' 208 | assert get_node_meta_types() == {"foo": "int"} 209 | for wrong_value in ['{"foo": 1}', '{1: "int"}', "not a dict"]: 210 | with pytest.raises(Exception): 211 | os.environ["CONSUL_NODE_META_TYPES"] = wrong_value 212 | get_node_meta_types() 213 | del os.environ["CONSUL_NODE_META_TYPES"] 214 | 215 | 216 | def test_get_node_meta_configfile(): 217 | with tempfile.NamedTemporaryFile() as fp: 218 | fp.write(b"[consul_node_meta]\nk1:v1") 219 | fp.seek(0) 220 | path = fp.name 221 | assert get_node_meta(path) == {"k1": "v1"} 222 | 223 | 224 | def test_get_node_meta_types_configfile(): 225 | with tempfile.NamedTemporaryFile() as fp: 226 | fp.write(b"[consul_node_meta_types]\ncluster:str") 227 | fp.seek(0) 228 | path = fp.name 229 | assert get_node_meta_types(path) == {"cluster": "str"} 230 | 231 | -------------------------------------------------------------------------------- /consul_awx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import configparser 4 | import copy 5 | import json 6 | import logging 7 | import os 8 | import re 9 | import sys 10 | import time 11 | from urllib.parse import urlparse 12 | 13 | import urllib3 14 | from requests.exceptions import ConnectionError 15 | 16 | try: 17 | import consul 18 | except ImportError: 19 | sys.exit( 20 | """failed=True msg='python-consul2 required for this module. 21 | See https://python-consul2.readthedocs.io/en/latest/'""" 22 | ) 23 | 24 | CONFIG = "consul_awx.ini" 25 | DEFAULT_CONFIG_DIR = os.path.dirname(os.path.realpath(__file__)) 26 | DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_CONFIG_DIR, CONFIG) 27 | CONSUL_EXPECTED_TAGGED_ADDRESS = ["wan", "wan_ipv4", "lan", "lan_ipv4"] 28 | 29 | EMPTY_GROUP = {"hosts": [], "children": []} 30 | 31 | EMPTY_INVENTORY = { 32 | "_meta": {"hostvars": {}}, 33 | "all": {"hosts": [], "children": ["ungrouped"]}, 34 | "ungrouped": copy.deepcopy(EMPTY_GROUP), 35 | } 36 | 37 | 38 | class ConsulInventory: 39 | def __init__( 40 | self, 41 | host="127.0.0.1", 42 | port=8500, 43 | token=None, 44 | scheme="http", 45 | verify=True, 46 | dc=None, 47 | cert=None, 48 | ): 49 | 50 | if not str2bool(verify): 51 | verify = False 52 | # If the user disable SSL verification no need to bother him with 53 | # warning 54 | urllib3.disable_warnings() 55 | 56 | # if user specified the param in the configuration file, it will be a Str and not managed later by requests 57 | # ex: verify: true 58 | if not isinstance(verify, bool): 59 | verify = str2bool(verify) 60 | 61 | self.consul_api = consul.Consul( 62 | host=host, 63 | port=port, 64 | token=token, 65 | scheme=scheme, 66 | verify=verify, 67 | dc=dc, 68 | cert=cert, 69 | ) 70 | 71 | self.inventory = copy.deepcopy(EMPTY_INVENTORY) 72 | 73 | def build_full_inventory( 74 | self, node_meta=None, node_meta_types=None, tagged_address="lan" 75 | ): 76 | for node in self.get_nodes(node_meta=node_meta): 77 | self.inventory["_meta"]["hostvars"][node["Node"]] = get_node_vars( 78 | node, tagged_address=tagged_address, node_meta_types=node_meta_types 79 | ) 80 | self.add_to_group(node["Datacenter"], node["Node"]) 81 | meta = node.get("Meta", {}) 82 | if meta is None: 83 | meta = {} 84 | for key, value in meta.items(): 85 | 86 | if not value: 87 | continue 88 | 89 | # Keep from converting some values to boolean 90 | # So a valid value of 1 or 0 is kept as is 91 | if not (node_meta_types and key in node_meta_types): 92 | try: 93 | value = str2bool(value.strip()) 94 | except ValueError: 95 | pass 96 | # Meta can only be string but we can pseudo support bool 97 | # We don't want groups named _false because by convention 98 | # this means the host is *not* in the group 99 | if value is False: 100 | continue 101 | elif value is True: 102 | group = key 103 | # Otherwise we want a group name by concatening key/value 104 | else: 105 | group = f"{key}_{value}" 106 | 107 | self.add_to_group(group, node["Node"]) 108 | 109 | # Build node services by using the service's name as group name 110 | services = self.get_node_services(node["Node"]) 111 | for service, data in services.items(): 112 | service = sanitize(service) 113 | self.add_to_group(service, node["Node"]) 114 | for tag in data["Tags"]: 115 | self.add_to_group(f"{service}_{tag}", node["Node"]) 116 | # We want to define group nesting 117 | if f"{service}_{tag}" not in self.inventory[service]["children"]: 118 | self.inventory[service]["children"].append(f"{service}_{tag}") 119 | 120 | all_groups = [ 121 | k for k in self.inventory.keys() if k not in ["_meta", "all", "ungrouped"] 122 | ] 123 | self.inventory["all"]["children"].extend(all_groups) 124 | # Better for humanreadable 125 | self.inventory["all"]["children"].sort() 126 | 127 | def add_to_group(self, group, host, parent=None): 128 | group = sanitize(group) 129 | if group not in self.inventory: 130 | self.inventory[group] = copy.deepcopy(EMPTY_GROUP) 131 | self.inventory[group]["hosts"].append(host) 132 | 133 | def get_nodes(self, datacenter=None, node_meta=None): 134 | logging.debug( 135 | "getting all nodes for datacenter: %s, with node_meta: %s", 136 | datacenter, 137 | node_meta, 138 | ) 139 | return self.consul_api.catalog.nodes(dc=datacenter, node_meta=node_meta)[1] 140 | 141 | def get_node(self, node): 142 | logging.debug("getting node info for node: %s", node) 143 | return self.consul_api.catalog.node(node)[1] 144 | 145 | def get_node_services(self, node): 146 | logging.debug("getting services for node: %s", node) 147 | return self.get_node(node)["Services"] 148 | 149 | 150 | def sanitize(string): 151 | # Sanitize string for ansible: 152 | # https://docs.ansible.com/ansible/latest/network/getting_started/first_inventory.html 153 | # Avoid spaces, hyphens, and preceding numbers (use floor_19, not 154 | # 19th_floor) in your group names. Group names are case sensitive. 155 | return re.sub(r"[^A-Za-z0-9]", "_", string) 156 | 157 | 158 | def get_node_vars(node, tagged_address, node_meta_types=None): 159 | node_vars = { 160 | "ansible_host": node["TaggedAddresses"][tagged_address], 161 | "datacenter": node["Datacenter"], 162 | } 163 | meta = node.get("Meta", {}) 164 | if meta is None: 165 | meta = {} 166 | for k, v in meta.items(): 167 | # Meta are all strings in consul 168 | if not v: 169 | continue 170 | v = v.strip() 171 | 172 | if not (node_meta_types and k in node_meta_types): 173 | if v.isdigit(): 174 | node_vars[k] = int(v) 175 | elif v.lower() == "true": 176 | node_vars[k] = True 177 | elif v.lower() == "false": 178 | node_vars[k] = False 179 | else: 180 | node_vars[k] = v 181 | else: 182 | node_vars[k] = v 183 | 184 | return node_vars 185 | 186 | 187 | def cmdline_parser(): 188 | parser = argparse.ArgumentParser( 189 | description="Produce an Ansible Inventory file based nodes in a Consul cluster" 190 | ) 191 | 192 | command_group = parser.add_mutually_exclusive_group(required=True) 193 | 194 | command_group.add_argument( 195 | "--list", 196 | action="store_true", 197 | dest="list", 198 | help="Get all inventory variables from all nodes in the consul cluster", 199 | ) 200 | command_group.add_argument( 201 | "--host", 202 | action="store", 203 | dest="host", 204 | help="Get all inventory variables about a specific consul node," 205 | "requires datacenter set in consul.ini.", 206 | ) 207 | 208 | parser.add_argument( 209 | "--path", help="path to configuration file", default=DEFAULT_CONFIG_PATH 210 | ) 211 | parser.add_argument( 212 | "--datacenter", 213 | action="store", 214 | help="Get all inventory about a specific consul datacenter", 215 | ) 216 | parser.add_argument( 217 | "--tagged-address", 218 | action="store", 219 | choices=CONSUL_EXPECTED_TAGGED_ADDRESS, 220 | # Let's not define an default value this will be handled in the main 221 | help="Which tagged address to use as ansible_host", 222 | ) 223 | 224 | parser.add_argument("--indent", type=int, default=4) 225 | parser.add_argument( 226 | "-d", 227 | "--debug", 228 | help="Print lots of debugging statements", 229 | action="store_const", 230 | dest="loglevel", 231 | const=logging.DEBUG, 232 | default=logging.WARNING, 233 | ) # mind the default value 234 | 235 | parser.add_argument( 236 | "-v", 237 | "--verbose", 238 | help="Be verbose", 239 | action="store_const", 240 | dest="loglevel", 241 | const=logging.INFO, 242 | ) 243 | 244 | parser.add_argument( 245 | "-q", 246 | "--quiet", 247 | help="Be quiet", 248 | action="store_const", 249 | const=logging.CRITICAL, 250 | ) 251 | 252 | parser.add_argument( 253 | "-r", 254 | "--retry-count", 255 | help="Retry count", 256 | type=int, 257 | default=3, 258 | ) 259 | 260 | parser.add_argument( 261 | "--retry-delay", 262 | help="Retry delay in seconds", 263 | type=int, 264 | default=10, 265 | ) 266 | 267 | args = parser.parse_args() 268 | logging.basicConfig(level=args.loglevel) 269 | return args 270 | 271 | 272 | def str2bool(v): 273 | if isinstance(v, bool): 274 | return v 275 | elif v.lower() in ["true", "1", "yes"]: 276 | return True 277 | elif v.lower() in ["false", "0", "no"]: 278 | return False 279 | else: 280 | raise ValueError 281 | 282 | 283 | def get_client_configuration(config_path=DEFAULT_CONFIG_PATH): 284 | consul_config = {} 285 | if "CONSUL_URL" in os.environ: 286 | consul_url = os.environ["CONSUL_URL"] 287 | url = urlparse(consul_url) 288 | consul_config = { 289 | "host": url.hostname, 290 | "port": url.port, 291 | "scheme": url.scheme, 292 | "verify": str2bool(os.environ.get("CONSUL_SSL_VERIFY", True)), 293 | "token": os.environ.get("CONSUL_TOKEN"), 294 | "dc": os.environ.get("CONSUL_DC"), 295 | "cert": os.environ.get("CONSUL_CERT"), 296 | } 297 | elif os.path.isfile(config_path): 298 | config = configparser.ConfigParser() 299 | config.read(config_path) 300 | if config.has_section("consul"): 301 | consul_config = dict(config.items("consul")) 302 | else: 303 | logging.debug("No envvar nor configuration file, will use default values") 304 | return consul_config 305 | 306 | 307 | def get_node_meta(config_path=None): 308 | node_meta = None 309 | if "CONSUL_NODE_META" in os.environ: 310 | try: 311 | node_meta = json.loads(os.environ["CONSUL_NODE_META"]) 312 | 313 | assert isinstance(node_meta, dict) # node_meta must be dict 314 | assert all( 315 | isinstance(x, str) for x in node_meta.keys() 316 | ) # all keys must be string 317 | assert all( 318 | isinstance(x, str) for x in node_meta.values() 319 | ) # all values must be string 320 | 321 | except (json.decoder.JSONDecodeError) as err: 322 | logging.fatal(str(err)) 323 | raise json.decoder.JSONDecodeError("failed to load CONSUL_NODE_META") 324 | except AssertionError: 325 | raise Exception( 326 | "Invalid node_meta filter. Content must be dict with keys and values as string" 327 | ) 328 | elif config_path and os.path.isfile(config_path): 329 | config = configparser.ConfigParser() 330 | config.read(config_path) 331 | if config.has_section("consul_node_meta"): 332 | node_meta = dict(config.items("consul_node_meta")) 333 | else: 334 | logging.debug( 335 | "No envvar nor configuration file, will not use node_meta to filter" 336 | ) 337 | return node_meta 338 | 339 | 340 | def get_node_meta_types(config_path=None): 341 | node_meta_types = None 342 | if "CONSUL_NODE_META_TYPES" in os.environ: 343 | try: 344 | node_meta_types = json.loads(os.environ["CONSUL_NODE_META_TYPES"]) 345 | 346 | assert isinstance(node_meta_types, dict) # node_meta_types must be dict 347 | assert all( 348 | isinstance(x, str) for x in node_meta_types.keys() 349 | ) # all keys must be string 350 | assert all( 351 | isinstance(x, str) for x in node_meta_types.values() 352 | ) # all values must be string 353 | 354 | except (json.decoder.JSONDecodeError) as err: 355 | logging.fatal(str(err)) 356 | raise json.decoder.JSONDecodeError("failed to load CONSUL_NODE_META_TYPES") 357 | except AssertionError: 358 | raise Exception( 359 | "Invalid node_meta_types filter. Content must be dict with keys and values as string" 360 | ) 361 | elif config_path and os.path.isfile(config_path): 362 | config = configparser.ConfigParser() 363 | config.read(config_path) 364 | if config.has_section("consul_node_meta_types"): 365 | node_meta_types = dict(config.items("consul_node_meta_types")) 366 | else: 367 | logging.debug( 368 | "No envvar nor configuration file, will not use node_meta_types to filter" 369 | ) 370 | return node_meta_types 371 | 372 | 373 | def main(): 374 | args = cmdline_parser() 375 | consul_config = get_client_configuration(args.path) 376 | 377 | c = ConsulInventory(**consul_config) 378 | tagged_address = args.tagged_address or os.environ.get( 379 | "CONSUL_TAGGED_ADDRESS", "lan" 380 | ) 381 | if tagged_address not in CONSUL_EXPECTED_TAGGED_ADDRESS: 382 | logging.debug("Got %s as consul tagged address", tagged_address) 383 | logging.fatal( 384 | "Invalid tagged_address provided must be in: %s", 385 | ", ".join(CONSUL_EXPECTED_TAGGED_ADDRESS), 386 | ) 387 | sys.exit(1) 388 | 389 | for i in range(0, args.retry_count): 390 | try: 391 | if args.host: 392 | result = get_node_vars(c.get_node(args.host)["Node"], tagged_address) 393 | else: 394 | node_meta = get_node_meta(args.path) 395 | node_meta_types = get_node_meta_types(args.path) 396 | c.build_full_inventory(node_meta, node_meta_types, tagged_address) 397 | result = c.inventory 398 | except ConnectionError as err: 399 | logging.error("Failed to connect to consul: %s", str(err)) 400 | logging.error("Waiting %ds before retry %d/%d", args.retry_delay, i, args.retry_count) 401 | time.sleep(args.retry_delay) 402 | continue 403 | break 404 | else: 405 | logging.fatal("Number of retries exhausted") 406 | sys.exit(1) 407 | 408 | print(json.dumps(result, sort_keys=True, indent=args.indent)) 409 | 410 | 411 | if __name__ == "__main__": 412 | main() 413 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "1da4bc4baf24910bc5fc3f0af85ee8f80bf7c07bc509a107d2bd97f81722c169" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "cachetools": { 20 | "hashes": [ 21 | "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474", 22 | "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==5.4.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", 30 | "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" 31 | ], 32 | "markers": "python_version >= '3.6'", 33 | "version": "==2025.4.26" 34 | }, 35 | "chardet": { 36 | "hashes": [ 37 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 38 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 39 | ], 40 | "markers": "python_version >= '3.7'", 41 | "version": "==5.2.0" 42 | }, 43 | "charset-normalizer": { 44 | "hashes": [ 45 | "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", 46 | "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45", 47 | "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", 48 | "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", 49 | "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", 50 | "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", 51 | "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d", 52 | "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", 53 | "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184", 54 | "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", 55 | "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b", 56 | "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64", 57 | "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", 58 | "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", 59 | "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", 60 | "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344", 61 | "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58", 62 | "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", 63 | "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", 64 | "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", 65 | "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", 66 | "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", 67 | "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", 68 | "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", 69 | "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", 70 | "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1", 71 | "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01", 72 | "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", 73 | "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58", 74 | "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", 75 | "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", 76 | "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2", 77 | "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a", 78 | "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", 79 | "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", 80 | "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5", 81 | "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb", 82 | "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f", 83 | "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", 84 | "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", 85 | "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", 86 | "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", 87 | "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7", 88 | "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", 89 | "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455", 90 | "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", 91 | "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4", 92 | "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", 93 | "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", 94 | "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", 95 | "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", 96 | "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", 97 | "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", 98 | "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", 99 | "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", 100 | "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", 101 | "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", 102 | "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa", 103 | "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", 104 | "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", 105 | "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", 106 | "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", 107 | "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", 108 | "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", 109 | "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02", 110 | "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", 111 | "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", 112 | "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", 113 | "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", 114 | "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", 115 | "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", 116 | "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", 117 | "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681", 118 | "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", 119 | "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", 120 | "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a", 121 | "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", 122 | "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", 123 | "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", 124 | "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", 125 | "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027", 126 | "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", 127 | "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", 128 | "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", 129 | "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", 130 | "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", 131 | "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", 132 | "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da", 133 | "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", 134 | "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f", 135 | "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", 136 | "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f" 137 | ], 138 | "markers": "python_version >= '3.7'", 139 | "version": "==3.4.2" 140 | }, 141 | "colorama": { 142 | "hashes": [ 143 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 144 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 145 | ], 146 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 147 | "version": "==0.4.6" 148 | }, 149 | "distlib": { 150 | "hashes": [ 151 | "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", 152 | "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" 153 | ], 154 | "version": "==0.3.9" 155 | }, 156 | "filelock": { 157 | "hashes": [ 158 | "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", 159 | "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" 160 | ], 161 | "markers": "python_version >= '3.8'", 162 | "version": "==3.16.1" 163 | }, 164 | "idna": { 165 | "hashes": [ 166 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 167 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 168 | ], 169 | "markers": "python_version >= '3.6'", 170 | "version": "==3.10" 171 | }, 172 | "packaging": { 173 | "hashes": [ 174 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 175 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 176 | ], 177 | "markers": "python_version >= '3.8'", 178 | "version": "==24.1" 179 | }, 180 | "platformdirs": { 181 | "hashes": [ 182 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", 183 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" 184 | ], 185 | "markers": "python_version >= '3.8'", 186 | "version": "==4.3.6" 187 | }, 188 | "pluggy": { 189 | "hashes": [ 190 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 191 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 192 | ], 193 | "markers": "python_version >= '3.8'", 194 | "version": "==1.5.0" 195 | }, 196 | "pyproject-api": { 197 | "hashes": [ 198 | "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb", 199 | "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827" 200 | ], 201 | "markers": "python_version >= '3.8'", 202 | "version": "==1.7.1" 203 | }, 204 | "python-consul2": { 205 | "hashes": [ 206 | "sha256:29c859de73e17f36ab99be831fdc1d924f16e8772233277a28ef92b7b99995cd", 207 | "sha256:ff8c6642c5a8d5f13a072e90adbad44cde824fc46fb2ab76b40dd1c77d6c0c41" 208 | ], 209 | "index": "pypi", 210 | "version": "==0.1.5" 211 | }, 212 | "requests": { 213 | "hashes": [ 214 | "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", 215 | "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" 216 | ], 217 | "index": "pypi", 218 | "markers": "python_version >= '3.8'", 219 | "version": "==2.32.4" 220 | }, 221 | "six": { 222 | "hashes": [ 223 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 224 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 225 | ], 226 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 227 | "version": "==1.16.0" 228 | }, 229 | "tomli": { 230 | "hashes": [ 231 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 232 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 233 | ], 234 | "markers": "python_version < '3.11'", 235 | "version": "==2.0.1" 236 | }, 237 | "tox": { 238 | "hashes": [ 239 | "sha256:2974597c0353577126ab014f52d1a399fb761049e165ff34427f84e8cfe6c990", 240 | "sha256:2c41565a571e34480bd401d668a4899806169a4633e972ac296c54406d2ded8a" 241 | ], 242 | "index": "pypi", 243 | "markers": "python_version >= '3.8'", 244 | "version": "==4.17.1" 245 | }, 246 | "urllib3": { 247 | "hashes": [ 248 | "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", 249 | "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1" 250 | ], 251 | "index": "pypi", 252 | "markers": "python_version >= '3.9'", 253 | "version": "==2.6.0" 254 | }, 255 | "virtualenv": { 256 | "hashes": [ 257 | "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", 258 | "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2" 259 | ], 260 | "index": "pypi", 261 | "markers": "python_version >= '3.7'", 262 | "version": "==20.26.6" 263 | } 264 | }, 265 | "develop": {} 266 | } 267 | --------------------------------------------------------------------------------