├── requirements.txt ├── group_vars ├── group_1.yml ├── datacenter_1.yml ├── datacenter_2.yml └── group_2.yml ├── host_vars ├── luke-01.example.com.yml └── darth-vader-01.example.com.yml ├── inventory_file_2.yml ├── inventory_file_1.yml ├── LICENSE ├── README.md └── simple-ansible-inventory.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml>=3.13 2 | -------------------------------------------------------------------------------- /group_vars/group_1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | group: Rebels 3 | -------------------------------------------------------------------------------- /group_vars/datacenter_1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | location: Tatooine -------------------------------------------------------------------------------- /group_vars/datacenter_2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | location: Death Star -------------------------------------------------------------------------------- /group_vars/group_2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | group: Galactic Empire 3 | -------------------------------------------------------------------------------- /host_vars/luke-01.example.com.yml: -------------------------------------------------------------------------------- 1 | --- 2 | force_side: Jedi -------------------------------------------------------------------------------- /host_vars/darth-vader-01.example.com.yml: -------------------------------------------------------------------------------- 1 | --- 2 | force_side: Sith -------------------------------------------------------------------------------- /inventory_file_2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | #### YAML inventory file 3 | 4 | # the two first lines above are needed for the script 5 | # to identify the file as an inventory file 6 | 7 | ## Structure 8 | # hosts: 9 | # - host: 10 | # # if needed 11 | # groups: 12 | # - group1 13 | # - group2 14 | 15 | ########################## 16 | ### Hosts definition ### 17 | ########################## 18 | hosts: 19 | - host: stormtrooper-1[0-2,4-5,7].example.com 20 | groups: [group_2, datacenter_2] 21 | 22 | - host: darth-vader-01.example.com 23 | hostvars: 24 | lightsaber: red 25 | groups: [group_2, datacenter_2] -------------------------------------------------------------------------------- /inventory_file_1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | #### YAML inventory file 3 | 4 | # the two first lines above are needed for the script 5 | # to identify the file as an inventory file 6 | 7 | ## Structure 8 | # hosts: 9 | # - host: 10 | # # if needed 11 | # hostvars: 12 | # key: value 13 | # # if needed 14 | # groups: 15 | # - group1 16 | # - group2 17 | 18 | ########################## 19 | ### Hosts definition ### 20 | ########################## 21 | hosts: 22 | - host: luke-01.example.com 23 | groups: [group_1, datacenter_1] 24 | 25 | - host: obi-wan-02.example.com 26 | hostvars: 27 | lightsaber: blue 28 | groups: [group_1, datacenter_1] 29 | 30 | - host: xwing-pilot-0[3-5,8].example.com 31 | groups: [group_1] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 leboncoin 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Ansible Inventory 2 | 3 | The idea is to keep an Ansible inventory simple, clean and easily readable. 4 | Each host will only have to be written one time and you'll not have to define each group before using it. 5 | 6 | ## Prerequisites 7 | 8 | Simple Ansible Inventory works with python2 and python3. 9 | Only the package [`pyyaml`](https://pypi.org/project/PyYAML/) is needed. You can install it using one of the following commands 10 | 11 | `pip install -r requirements.txt` 12 | 13 | or 14 | 15 | `pip install pyyaml` 16 | 17 | ## How to use 18 | 19 | ### Alone 20 | 21 | `./simple-ansible-inventory.py --list` 22 | 23 | ### With ansible 24 | 25 | `ansible-inventory -i simple-ansible-inventory.py --list` 26 | 27 | To work properly, `simple-ansible-inventory.py` needs inventory file(s) to read. 28 | There's two possibilities : 29 | * By default, `simple-ansible-inventory.py` will look in its folder and in all of its subfolder for inventory yaml file(s) 30 | * If the environment variable `ANSIBLE_YAML_INVENTORY` is defined, `simple-ansible-inventory.py` will attempt to read the inventory file in the environment variable and only this one 31 | 32 | ## Directory layout 33 | 34 | The directory layout followed is given by the Ansible best pratices. 35 | https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html#directory-layout 36 | 37 | ## Inventory files 38 | 39 | You can find inventory file examples in [`inventory_file_1.yml`](inventory_file_1.yml) and [`inventory_file_2.yml`](inventory_file_2.yml) 40 | 41 | An inventory file is a yaml file starting with the following header 42 | ```yaml 43 | --- 44 | #### YAML inventory file 45 | ``` 46 | 47 | In this inventory file, you only define hosts and groups associated to this host. 48 | There's no group definition, a group is automatically created when associated to an host. 49 | 50 | Example: 51 | 52 | If you define the following host 53 | ```yaml 54 | hosts: 55 | - host: luke-01.example.com 56 | groups: [group_1, datacenter_1] 57 | ``` 58 | 59 | - the host `luke-01.example.com` will be created 60 | - groups `group_1` and `datacenter_1` will be created 61 | - groups `group_1` and `datacenter_1` will be associated to the host `luke-01.example.com` 62 | 63 | ## Group vars 64 | 65 | Following Ansible best practices, all group vars have to be defined in the `group_vars` folder. 66 | If you want to create the variable `group: Rebels` for the group `group_1`, you have to create the file [`group_vars/group_1.yml`](group_vars/group_1.yml) with the following content: 67 | 68 | ```yaml 69 | --- 70 | group: Rebels 71 | ``` 72 | 73 | ## Host vars 74 | 75 | There's two possibilities to define host vars 76 | 77 | 1. In the inventory file 78 | 2. In the `host_vars` folder (following Ansible best practices) 79 | 80 | 81 | ### In the inventory file 82 | 83 | If you want to create the variable `lightsaber: blue` for the host `obi-wan-02.example.com`, you have to set `hostvars` for the host in the inventory file: 84 | 85 | ```yaml 86 | - host: obi-wan-02.example.com 87 | hostvars: 88 | lightsaber: blue 89 | groups: [group_1, datacenter_1] 90 | ``` 91 | 92 | ### In the `host_vars` folder (following Ansible best practices) 93 | 94 | Following Ansible best practices, host vars have to be defined in the `host_vars` folder. 95 | If you want to create the variable `force_side: Sith` for the host `darth-vader-01.example.com`, you have to create the file [`host_vars/darth-vader-01.example.com.yml`](host_vars/darth-vader-01.example.com.yml) with the following content: 96 | 97 | ```yaml 98 | --- 99 | force_side: Sith 100 | ``` 101 | 102 | 103 | ## And that's it ! -------------------------------------------------------------------------------- /simple-ansible-inventory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import logging 4 | import os 5 | import argparse 6 | import yaml 7 | import json 8 | import re 9 | import copy 10 | import textwrap 11 | 12 | """ 13 | Project repo 14 | https://github.com/leboncoin/simple-ansible-inventory 15 | 16 | For further details about Ansible best practices including directory layout, see 17 | https://docs.ansible.com/ansible/2.5/user_guide/playbooks_best_practices.html 18 | 19 | For further details about developing Ansible inventory, see 20 | http://docs.ansible.com/ansible/latest/dev_guide/developing_inventory.html 21 | """ 22 | 23 | INVENTORY_SCRIPT_NAME = "SimpleAnsibleInventory" 24 | INVENTORY_SCRIPT_VERSION = 1.0 25 | LOGGER = None 26 | INVENTORY_FILE_REGEX_PATTERN = ".*\.y[a]?ml" 27 | INVENTORY_FILE_HEADER_SIZE = 28 28 | INVENTORY_FILE_HEADER = "---\n#### YAML inventory file" 29 | INVENTORY_FILE_ENV_VAR = "ANSIBLE_YAML_INVENTORY" 30 | ACCEPTED_REGEX = r"\[(?:(?:[\d]+-[\d]+|[\d]+)+,?)+\]" 31 | 32 | 33 | def build_meta_header(host, meta_header): 34 | """ 35 | Progressively build the meta header host by host 36 | 37 | :param host: current host to add to meta header 38 | :type host: dict 39 | :param meta_header: meta header to build 40 | :type meta_header: dict 41 | :return: 42 | """ 43 | # If found host doesn't exists in dict, we create it 44 | if host['host'] not in meta_header['hostvars']: 45 | meta_header['hostvars'][host['host']] = dict() 46 | # Browsing and adding all vars found for host 47 | if 'hostvars' in host: 48 | for hostvar in host['hostvars']: 49 | meta_header['hostvars'][host['host']][hostvar] = \ 50 | host['hostvars'][hostvar] 51 | # Return new meta_header version containing new host 52 | return meta_header 53 | 54 | 55 | def build_groups(host, partial_inventory): 56 | """ 57 | Progressively build groups conf host by host 58 | 59 | :param host: current host to add to meta header 60 | :type host: dict 61 | :param partial_inventory: Only contains _meta header 62 | :type partial_inventory: dict 63 | :return: filled inventory 64 | """ 65 | # check if 'all' group exists, if no, create it 66 | if 'all' not in partial_inventory: 67 | partial_inventory['all'] = dict() 68 | partial_inventory['all']['hosts'] = list() 69 | partial_inventory['all']['vars'] = dict() 70 | partial_inventory['all']['children'] = list() 71 | # If groups section doesn't exists return inventory without modification 72 | if 'groups' not in host: 73 | return partial_inventory 74 | # For each group of the host 75 | for group in host['groups']: 76 | # If groups doesn't already exists, creating it 77 | if group not in partial_inventory: 78 | partial_inventory[group] = dict() 79 | partial_inventory[group]['hosts'] = list() 80 | partial_inventory[group]['vars'] = dict() 81 | partial_inventory[group]['children'] = list() 82 | # add group to 'all' group if not already in 83 | if group not in partial_inventory['all']['children']: 84 | partial_inventory['all']['children'].append(group) 85 | partial_inventory[group]['hosts'].append(host['host']) 86 | return partial_inventory 87 | 88 | 89 | def get_int_interval(from_int, to_int): 90 | """ 91 | Return a list of all integers between two integers 92 | 93 | :param from_int: start from 94 | :type from_int: int 95 | :param to_int: end at 96 | :type to_int: int 97 | :return: list(int) 98 | """ 99 | LOGGER.debug("Calculating int interval between " + str(from_int) + 100 | " and " + str(to_int)) 101 | return [str(value) for value in range(from_int, to_int + 1)] 102 | 103 | 104 | def all_string_from_pattern(input_string, matching_part): 105 | """ 106 | Return a list of all string matching the input string containing a pattern 107 | 108 | :param input_string: input string containing pattern 109 | :type input_string: str 110 | :param matching_part: pattern extracted from hostname 111 | :type matching_part: str 112 | :return: str 113 | """ 114 | # Transform matched pattern to a list of ranges 115 | regex_found = matching_part.group(0).replace("[", "").replace("]", "").split(',') 116 | possibilities = list() 117 | # let's fill all ranges 118 | for pattern in regex_found: 119 | split_range = pattern.split('-') 120 | int_1 = int(split_range[0]) 121 | int_possibilities = [int_1] 122 | if len(split_range) == 2: 123 | int_1 = min(int_1, int(split_range[1])) 124 | int_2 = max(int(split_range[0]), int(split_range[1])) 125 | int_possibilities = get_int_interval(int_1, int_2) 126 | LOGGER.debug("Possibilities: " + str(int_possibilities)) 127 | for possibility in int_possibilities: 128 | possibilities.append( 129 | input_string[:matching_part.start(0)] + 130 | str(possibility) + 131 | input_string[matching_part.end(0):] 132 | ) 133 | return possibilities 134 | 135 | 136 | def patterning_hosts(regex_found, host, filled_pattern_host_list): 137 | """ 138 | Function used recursively to fill all patterns in hostname 139 | 140 | :param regex_found: re.match object 141 | :type regex_found: re.match() 142 | :param host: host read in conf 143 | :type host: dict 144 | :param filled_pattern_host_list: list containing all hosts 145 | with all patterns filled 146 | :type filled_pattern_host_list: list 147 | :return: 148 | """ 149 | LOGGER.debug("Processing regex " + str(regex_found.group(0)) + 150 | " found in host name: " + host['host']) 151 | # For each hostname possibility with first pattern 152 | for patterned_host in all_string_from_pattern(host['host'], regex_found): 153 | # Checking if there is still another pattern left in hostname 154 | regex_found = re.search(ACCEPTED_REGEX, patterned_host) 155 | # build a new host with the hostname 156 | new_host = dict(host) 157 | new_host['host'] = patterned_host 158 | # If hostname still containing pattern, call itself 159 | if regex_found: 160 | patterning_hosts(regex_found, new_host, filled_pattern_host_list) 161 | # If no pattern left, append host to list 162 | else: 163 | filled_pattern_host_list.append(new_host) 164 | 165 | 166 | def get_inventory_recursively(raw_conf): 167 | """ 168 | Build and return the inventory 169 | 170 | :param raw_conf: Raw configuration loaded from yml configuration file 171 | :type raw_conf: dict 172 | :return: dict 173 | """ 174 | LOGGER.debug("Building full inventory from loaded YAML(s)") 175 | inventory = dict() 176 | meta_header = dict() 177 | meta_header['hostvars'] = dict() 178 | # Browsing all hosts 179 | for host in raw_conf['hosts']: 180 | LOGGER.debug("Processing host entry " + str(host)) 181 | filled_pattern_host_list = list() 182 | regex_found = re.search(ACCEPTED_REGEX, host['host']) 183 | # If no regex pattern, directly add the host 184 | if not regex_found: 185 | filled_pattern_host_list.append(host) 186 | # Else fill all patterns 187 | else: 188 | patterning_hosts(regex_found, host, filled_pattern_host_list) 189 | LOGGER.debug("Host(s) generated from this host entry: " + 190 | str([hn['host'] for hn in filled_pattern_host_list])) 191 | for filled_pattern_host in filled_pattern_host_list: 192 | # Complete meta header for each host 193 | meta_header = build_meta_header(filled_pattern_host, meta_header) 194 | inventory = build_groups(filled_pattern_host, inventory) 195 | inventory['_meta'] = meta_header 196 | return inventory 197 | 198 | 199 | def find_inventory_files(): 200 | """ 201 | find the inventory file in sub folders 202 | 203 | :return: string 204 | """ 205 | if INVENTORY_FILE_ENV_VAR in os.environ: 206 | LOGGER.debug("env VAR " + INVENTORY_FILE_ENV_VAR + " found") 207 | return [os.environ[INVENTORY_FILE_ENV_VAR]] 208 | inventory_files = list() 209 | LOGGER.debug("Looking for inventory files") 210 | # script py path 211 | script_path = os.path.realpath(__file__) 212 | inventories_path = os.path.dirname(script_path) 213 | # walking through script folder looking for yaml files 214 | for root, dirnames, filenames in os.walk(inventories_path): 215 | LOGGER.debug("All files found: " + str(filenames)) 216 | for file in [f for f in filenames if re.search(INVENTORY_FILE_REGEX_PATTERN, f)]: 217 | # if file beginning match header 218 | with open(os.path.join(root, file), 'r') as fd: 219 | if fd.read(INVENTORY_FILE_HEADER_SIZE) == INVENTORY_FILE_HEADER: 220 | inventory_files.append(os.path.join(root, file)) 221 | return inventory_files 222 | 223 | 224 | def list_all_hosts(): 225 | """ 226 | Build the dictionary containing all hosts 227 | 228 | :return: dict 229 | """ 230 | LOGGER.debug("listing all hosts") 231 | raw_confs_list = list() 232 | # Load all configuration files 233 | inventory_files = find_inventory_files() 234 | LOGGER.debug("Inventory files found: " + str(inventory_files)) 235 | # If no inventory files found, return empty inventory 236 | if not len(inventory_files): 237 | return {"_meta": {"hostvars": {}}, "all": {"children": ["ungrouped"]}} 238 | for inventory_file in inventory_files: 239 | with open(inventory_file, 'r') as fd: 240 | LOGGER.debug("Loading file: " + inventory_file) 241 | raw_confs_list.append(yaml.safe_load(fd)) 242 | # Copy first conf loaded to another object 243 | raw_conf = copy.deepcopy(raw_confs_list[0]) 244 | # Delete first conf loaded 245 | raw_confs_list.pop(0) 246 | # Append all others conf to the first one by merging dictionaries 247 | LOGGER.debug("Merging files if needed") 248 | for conf in raw_confs_list: 249 | for key, value in conf.items(): 250 | raw_conf.setdefault(key, []).extend(value) 251 | inventory = get_inventory_recursively(raw_conf) 252 | LOGGER.debug("Inventory found: " + str(inventory)) 253 | return inventory 254 | 255 | 256 | def create_logger(): 257 | """ 258 | Create a logger instance 259 | 260 | :return: logger instance 261 | """ 262 | logger = logging.getLogger() 263 | handler = logging.StreamHandler() 264 | formatter = logging.Formatter('%(asctime)s - %(message)s') 265 | handler.setFormatter(formatter) 266 | logger.addHandler(handler) 267 | logger.setLevel(logging.INFO) 268 | return logger 269 | 270 | 271 | def parse_arguments(): 272 | """ 273 | Initialize the parser, flags list is mandatory 274 | 275 | :return: parsed arguments 276 | """ 277 | epilog = ''' 278 | By default the script will walk in script folder and in all its subfolders 279 | looking for inventory files. 280 | If a filename match the regex 281 | %s 282 | and if the first %d 283 | %s 284 | the file will be considered as an inventory file 285 | 286 | If the environment variable INVENTORY_FILE_ENV_VAR is found, the only 287 | inventory file read will be the file specified in the environment 288 | variable. 289 | ''' % (str(INVENTORY_FILE_REGEX_PATTERN), 290 | INVENTORY_FILE_HEADER_SIZE, 291 | INVENTORY_FILE_HEADER.replace('\n', '\n\t')) 292 | parser = argparse.ArgumentParser( 293 | formatter_class=argparse.RawDescriptionHelpFormatter, 294 | description="YAML Ansible inventory script loader", 295 | epilog=textwrap.dedent(epilog) 296 | ) 297 | parser.add_argument('--list', 298 | action='store_true', 299 | help="display all loaded inventory") 300 | parser.add_argument('--host', 301 | nargs=1, 302 | help="display vars for specified host") 303 | parser.add_argument('-v', '--verbose', 304 | action='store_true', 305 | help="enable verbose mode") 306 | parser.add_argument('-V', '--version', 307 | action='store_true', 308 | help="display inventory script version and exit") 309 | return parser.parse_args() 310 | 311 | 312 | if __name__ == "__main__": 313 | LOGGER = create_logger() 314 | parsed_arguments = parse_arguments() 315 | if parsed_arguments.verbose: 316 | LOGGER.setLevel(logging.DEBUG) 317 | for hdlr in LOGGER.handlers: 318 | hdlr.setLevel(logging.DEBUG) 319 | if parsed_arguments.version: 320 | LOGGER.debug("version flag found") 321 | print(INVENTORY_SCRIPT_NAME + " v" + str(INVENTORY_SCRIPT_VERSION)) 322 | elif parsed_arguments.list: 323 | LOGGER.debug("list flag found") 324 | print(json.dumps(list_all_hosts())) 325 | elif parsed_arguments.host: 326 | LOGGER.debug("host flag found") 327 | print(json.dumps(dict())) 328 | --------------------------------------------------------------------------------