Browse code

Merge pull request #12139 from amousset/rudder_inventory_plugin

Add Rudder inventory plugin

Brian Coca authored on 2015/11/13 01:12:08
Showing 2 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,35 @@
0
+# Rudder external inventory script settings
1
+#
2
+
3
+[rudder]
4
+
5
+# Your Rudder server API URL, typically:
6
+# https://rudder.local/rudder/api
7
+uri = https://rudder.local/rudder/api
8
+
9
+# By default, Rudder uses a self-signed certificate. Set this to True
10
+# to disable certificate validation.
11
+disable_ssl_certificate_validation = True
12
+
13
+# Your Rudder API token, created in the Web interface.
14
+token = aaabbbccc
15
+
16
+# Rudder API version to use, use "latest" for lastest available 
17
+# version.
18
+version = latest
19
+
20
+# Property to use as group name in the output.
21
+# Can generally be "id" or "displayName".
22
+group_name = displayName
23
+
24
+# Fail if there are two groups with the same name or two hosts with the
25
+# same hostname in the output. 
26
+fail_if_name_collision = True
27
+
28
+# We cache the results of Rudder API in a local file
29
+cache_path = /tmp/ansible-rudder.cache
30
+
31
+# The number of seconds a cache file is considered valid. After this many
32
+# seconds, a new API call will be made, and the cache file will be updated.
33
+# Set to 0 to disable cache.
34
+cache_max_age = 500
0 35
new file mode 100755
... ...
@@ -0,0 +1,302 @@
0
+#!/usr/bin/env python
1
+
2
+# Copyright (c) 2015, Normation SAS
3
+#
4
+# Inspired by the EC2 inventory plugin:
5
+# https://github.com/ansible/ansible/blob/devel/contrib/inventory/ec2.py
6
+#
7
+# This file is part of Ansible,
8
+#
9
+# Ansible is free software: you can redistribute it and/or modify
10
+# it under the terms of the GNU General Public License as published by
11
+# the Free Software Foundation, either version 3 of the License, or
12
+# (at your option) any later version.
13
+#
14
+# Ansible is distributed in the hope that it will be useful,
15
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+# GNU General Public License for more details.
18
+#
19
+# You should have received a copy of the GNU General Public License
20
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
21
+
22
+######################################################################
23
+
24
+'''
25
+Rudder external inventory script
26
+=================================
27
+
28
+Generates inventory that Ansible can understand by making API request to
29
+a Rudder server. This script is compatible with Rudder 2.10 or later.
30
+
31
+The output JSON includes all your Rudder groups, containing the hostnames of
32
+their nodes. Groups and nodes have a variable called rudder_group_id and
33
+rudder_node_id, which is the Rudder internal id of the item, allowing to identify
34
+them uniquely. Hosts variables also include your node properties, which are
35
+key => value properties set by the API and specific to each node.
36
+
37
+This script assumes there is an rudder.ini file alongside it. To specify a
38
+different path to rudder.ini, define the RUDDER_INI_PATH environment variable:
39
+
40
+    export RUDDER_INI_PATH=/path/to/my_rudder.ini
41
+
42
+You have to configure your Rudder server information, either in rudder.ini or
43
+by overriding it with environment variables:
44
+
45
+    export RUDDER_API_VERSION='latest'
46
+    export RUDDER_API_TOKEN='my_token'
47
+    export RUDDER_API_URI='https://rudder.local/rudder/api'
48
+'''
49
+
50
+
51
+import sys
52
+import os
53
+import re
54
+import argparse
55
+import six
56
+import httplib2 as http
57
+from time import time
58
+from six.moves import configparser
59
+
60
+try:
61
+    from urlparse import urlparse
62
+except ImportError:
63
+    from urllib.parse import urlparse
64
+
65
+try:
66
+    import json
67
+except ImportError:
68
+    import simplejson as json
69
+
70
+
71
+class RudderInventory(object):
72
+    def __init__(self):
73
+        ''' Main execution path '''
74
+
75
+        # Empty inventory by default
76
+        self.inventory = {}
77
+
78
+        # Read settings and parse CLI arguments
79
+        self.read_settings()
80
+        self.parse_cli_args()
81
+
82
+        # Create connection
83
+        self.conn = http.Http(disable_ssl_certificate_validation=self.disable_ssl_validation)
84
+
85
+        # Cache
86
+        if self.args.refresh_cache:
87
+            self.update_cache()
88
+        elif not self.is_cache_valid():
89
+            self.update_cache()
90
+        else:
91
+            self.load_cache()
92
+
93
+        data_to_print = {}
94
+
95
+        if self.args.host:
96
+            data_to_print = self.get_host_info(self.args.host)
97
+        elif self.args.list:
98
+            data_to_print = self.get_list_info()
99
+
100
+        print(self.json_format_dict(data_to_print, True))
101
+
102
+    def read_settings(self):
103
+        ''' Reads the settings from the rudder.ini file '''
104
+        if six.PY2:
105
+            config = configparser.SafeConfigParser()
106
+        else:
107
+            config = configparser.ConfigParser()
108
+        rudder_default_ini_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'rudder.ini')
109
+        rudder_ini_path = os.path.expanduser(os.path.expandvars(os.environ.get('RUDDER_INI_PATH', rudder_default_ini_path)))
110
+        config.read(rudder_ini_path)
111
+
112
+        self.token = os.environ.get('RUDDER_API_TOKEN', config.get('rudder', 'token'))
113
+        self.version = os.environ.get('RUDDER_API_VERSION', config.get('rudder', 'version'))
114
+        self.uri = os.environ.get('RUDDER_API_URI', config.get('rudder', 'uri'))
115
+
116
+        self.disable_ssl_validation = config.getboolean('rudder', 'disable_ssl_certificate_validation')
117
+        self.group_name = config.get('rudder', 'group_name')
118
+        self.fail_if_name_collision = config.getboolean('rudder', 'fail_if_name_collision')
119
+
120
+        self.cache_path = config.get('rudder', 'cache_path')
121
+        self.cache_max_age = config.getint('rudder', 'cache_max_age')
122
+
123
+    def parse_cli_args(self):
124
+        ''' Command line argument processing '''
125
+
126
+        parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Rudder inventory')
127
+        parser.add_argument('--list', action='store_true', default=True,
128
+                            help='List instances (default: True)')
129
+        parser.add_argument('--host', action='store',
130
+                            help='Get all the variables about a specific instance')
131
+        parser.add_argument('--refresh-cache', action='store_true', default=False,
132
+                            help='Force refresh of cache by making API requests to Rudder (default: False - use cache files)')
133
+        self.args = parser.parse_args()
134
+
135
+    def is_cache_valid(self):
136
+        ''' Determines if the cache files have expired, or if it is still valid '''
137
+
138
+        if os.path.isfile(self.cache_path):
139
+            mod_time = os.path.getmtime(self.cache_path)
140
+            current_time = time()
141
+            if (mod_time + self.cache_max_age) > current_time:
142
+                return True
143
+
144
+        return False
145
+
146
+    def load_cache(self):
147
+        ''' Reads the cache from the cache file sets self.cache '''
148
+
149
+        cache = open(self.cache_path, 'r')
150
+        json_cache = cache.read()
151
+
152
+        try:
153
+            self.inventory = json.loads(json_cache)
154
+        except ValueError, e:
155
+            self.fail_with_error('Could not parse JSON response from local cache', 'parsing local cache')
156
+
157
+    def write_cache(self):
158
+        ''' Writes data in JSON format to a file '''
159
+
160
+        json_data = self.json_format_dict(self.inventory, True)
161
+        cache = open(self.cache_path, 'w')
162
+        cache.write(json_data)
163
+        cache.close()
164
+
165
+    def get_nodes(self):
166
+        ''' Gets the nodes list from Rudder '''
167
+
168
+        path = '/nodes?select=nodeAndPolicyServer'
169
+        result = self.api_call(path)
170
+
171
+        nodes = {}
172
+
173
+        for node in result['data']['nodes']:
174
+            nodes[node['id']] = {}
175
+            nodes[node['id']]['hostname'] = node['hostname']
176
+            if 'properties' in node:
177
+                nodes[node['id']]['properties'] = node['properties']
178
+            else:
179
+                nodes[node['id']]['properties'] = []
180
+
181
+        return nodes
182
+
183
+    def get_groups(self):
184
+        ''' Gets the groups list from Rudder '''
185
+
186
+        path = '/groups'
187
+        result = self.api_call(path)
188
+
189
+        groups = {}
190
+
191
+        for group in result['data']['groups']:
192
+            groups[group['id']] = {'hosts': group['nodeIds'], 'name': self.to_safe(group[self.group_name])}
193
+
194
+        return groups
195
+
196
+    def update_cache(self):
197
+        ''' Fetches the inventory information from Rudder and creates the inventory '''
198
+
199
+        nodes = self.get_nodes()
200
+        groups = self.get_groups()
201
+
202
+        inventory = {}
203
+
204
+        for group in groups:
205
+            # Check for name collision
206
+            if self.fail_if_name_collision:
207
+                if groups[group]['name'] in inventory:
208
+                    self.fail_with_error('Name collision on groups: "%s" appears twice' % groups[group]['name'], 'creating groups')
209
+            # Add group to inventory
210
+            inventory[groups[group]['name']] = {}
211
+            inventory[groups[group]['name']]['hosts'] = []
212
+            inventory[groups[group]['name']]['vars'] = {}
213
+            inventory[groups[group]['name']]['vars']['rudder_group_id'] = group
214
+            for node in groups[group]['hosts']:
215
+                # Add node to group
216
+                inventory[groups[group]['name']]['hosts'].append(nodes[node]['hostname'])
217
+
218
+        properties = {}
219
+
220
+        for node in nodes:
221
+            # Check for name collision
222
+            if self.fail_if_name_collision:
223
+                if nodes[node]['hostname'] in properties:
224
+                    self.fail_with_error('Name collision on hosts: "%s" appears twice' % nodes[node]['hostname'], 'creating hosts')
225
+            # Add node properties to inventory
226
+            properties[nodes[node]['hostname']] = {}
227
+            properties[nodes[node]['hostname']]['rudder_node_id'] = node
228
+            for node_property in nodes[node]['properties']:
229
+                properties[nodes[node]['hostname']][self.to_safe(node_property['name'])] = node_property['value']
230
+
231
+        inventory['_meta'] = {}
232
+        inventory['_meta']['hostvars'] = properties
233
+
234
+        self.inventory = inventory
235
+
236
+        if self.cache_max_age > 0:
237
+            self.write_cache()
238
+
239
+    def get_list_info(self):
240
+        ''' Gets inventory information from local cache '''
241
+
242
+        return self.inventory
243
+
244
+    def get_host_info(self, hostname):
245
+        ''' Gets information about a specific host from local cache '''
246
+
247
+        if hostname in self.inventory['_meta']['hostvars']:
248
+            return self.inventory['_meta']['hostvars'][hostname]
249
+        else:
250
+            return {}
251
+
252
+    def api_call(self, path):
253
+        ''' Performs an API request '''
254
+
255
+        headers = {
256
+            'X-API-Token': self.token,
257
+            'X-API-Version': self.version,
258
+            'Content-Type': 'application/json;charset=utf-8'
259
+        }
260
+
261
+        target = urlparse(self.uri + path)
262
+        method = 'GET'
263
+        body = ''
264
+
265
+        try:
266
+            response, content = self.conn.request(target.geturl(), method, body, headers)
267
+        except:
268
+            self.fail_with_error('Error connecting to Rudder server')
269
+
270
+        try:
271
+            data = json.loads(content)
272
+        except ValueError, e:
273
+            self.fail_with_error('Could not parse JSON response from Rudder API', 'reading API response')
274
+
275
+        return data
276
+
277
+    def fail_with_error(self, err_msg, err_operation=None):
278
+        ''' Logs an error to std err for ansible-playbook to consume and exit '''
279
+        if err_operation:
280
+            err_msg = 'ERROR: "{err_msg}", while: {err_operation}'.format(
281
+                err_msg=err_msg, err_operation=err_operation)
282
+        sys.stderr.write(err_msg)
283
+        sys.exit(1)
284
+
285
+    def json_format_dict(self, data, pretty=False):
286
+        ''' Converts a dict to a JSON object and dumps it as a formatted
287
+        string '''
288
+
289
+        if pretty:
290
+            return json.dumps(data, sort_keys=True, indent=2)
291
+        else:
292
+            return json.dumps(data)
293
+
294
+    def to_safe(self, word):
295
+        ''' Converts 'bad' characters in a string to underscores so they can be
296
+        used as Ansible variable names '''
297
+
298
+        return re.sub('[^A-Za-z0-9\_]', '_', word)
299
+
300
+# Run the script
301
+RudderInventory()