blob: 227faf076a334dea255ab49dc6a2c77825731914 [file] [log] [blame]
Ales Komarek166cc672016-07-27 14:17:22 +02001# -*- coding: utf-8 -*-
2'''
Ales Komareka4a9f572016-12-03 20:15:50 +01003Module for handling reclass metadata models.
Ales Komarek166cc672016-07-27 14:17:22 +02004
5'''
6
7from __future__ import absolute_import
8
Adam Tengler8a1cf402017-05-16 10:59:35 +00009import io
10import json
Ales Komarek166cc672016-07-27 14:17:22 +020011import logging
12import os
Adam Tengler2b362622017-06-01 14:23:45 +000013import socket
Ales Komarek166cc672016-07-27 14:17:22 +020014import sys
Ales Komareka961df42016-11-21 21:50:24 +010015import six
Ales Komarek166cc672016-07-27 14:17:22 +020016import yaml
17
Adam Tengler2b362622017-06-01 14:23:45 +000018from urlparse import urlparse
Ales Komareka4a9f572016-12-03 20:15:50 +010019from reclass import get_storage, output
Adam Tengler2b362622017-06-01 14:23:45 +000020from reclass.adapters.salt import ext_pillar
Ales Komareka4a9f572016-12-03 20:15:50 +010021from reclass.core import Core
22from reclass.config import find_and_read_configfile
Adam Tengler23d965f2017-05-16 19:14:51 +000023from string import Template
Ales Komarek166cc672016-07-27 14:17:22 +020024
Ales Komareka4a9f572016-12-03 20:15:50 +010025LOG = logging.getLogger(__name__)
Ales Komarek166cc672016-07-27 14:17:22 +020026
Ales Komareka961df42016-11-21 21:50:24 +010027
Ales Komarek166cc672016-07-27 14:17:22 +020028def __virtual__():
29 '''
30 Only load this module if reclass
31 is installed on this minion.
32 '''
33 return 'reclass'
34
35
Ales Komareka4a9f572016-12-03 20:15:50 +010036def _get_nodes_dir():
37 defaults = find_and_read_configfile()
38 return os.path.join(defaults.get('inventory_base_uri'), 'nodes')
39
40
41def _get_classes_dir():
42 defaults = find_and_read_configfile()
43 return os.path.join(defaults.get('inventory_base_uri'), 'classes')
Ales Komarek166cc672016-07-27 14:17:22 +020044
45
Adam Tengler8a1cf402017-05-16 10:59:35 +000046def _get_cluster_dir():
47 classes_dir = _get_classes_dir()
48 return os.path.join(classes_dir, 'cluster')
49
50
Adam Tengler805666d2017-05-15 16:01:13 +000051def _get_node_meta(name, cluster="default", environment="prd", classes=None, parameters=None):
52 host_name = name.split('.')[0]
53 domain_name = '.'.join(name.split('.')[1:])
54
55 if classes == None:
56 meta_classes = []
57 else:
58 if isinstance(classes, six.string_types):
59 meta_classes = json.loads(classes)
60 else:
61 meta_classes = classes
62
63 if parameters == None:
64 meta_parameters = {}
65 else:
66 if isinstance(parameters, six.string_types):
67 meta_parameters = json.loads(parameters)
68 else:
69 # generate dict from OrderedDict
70 meta_parameters = {k: v for (k, v) in parameters.items()}
71
72 node_meta = {
73 'classes': meta_classes,
74 'parameters': {
75 '_param': meta_parameters,
76 'linux': {
77 'system': {
78 'name': host_name,
79 'domain': domain_name,
80 'cluster': cluster,
81 'environment': environment,
82 }
83 }
84 }
85 }
86
87 return node_meta
88
89
Ales Komarek166cc672016-07-27 14:17:22 +020090def node_create(name, path=None, cluster="default", environment="prd", classes=None, parameters=None, **kwargs):
91 '''
92 Create a reclass node
93
94 :param name: new node FQDN
95 :param path: custom path in nodes for new node
96 :param classes: classes given to the new node
97 :param parameters: parameters given to the new node
98 :param environment: node's environment
99 :param cluster: node's cluster
100
101 CLI Examples:
102
103 .. code-block:: bash
104
105 salt '*' reclass.node_create server.domain.com classes=[system.neco1, system.neco2]
106 salt '*' reclass.node_create namespace/test enabled=False
107
108 '''
109 ret = {}
110
111 node = node_get(name=name)
112
113 if node and not "Error" in node:
114 LOG.debug("node {0} exists".format(name))
115 ret[name] = node
116 return ret
117
118 host_name = name.split('.')[0]
119 domain_name = '.'.join(name.split('.')[1:])
120
Adam Tengler805666d2017-05-15 16:01:13 +0000121 node_meta = _get_node_meta(name, cluster, environment, classes, parameters)
Ales Komarek166cc672016-07-27 14:17:22 +0200122 LOG.debug(node_meta)
123
124 if path == None:
Ales Komareka4a9f572016-12-03 20:15:50 +0100125 file_path = os.path.join(_get_nodes_dir(), name + '.yml')
Ales Komarek166cc672016-07-27 14:17:22 +0200126 else:
Ales Komareka4a9f572016-12-03 20:15:50 +0100127 file_path = os.path.join(_get_nodes_dir(), path, name + '.yml')
Ales Komarek166cc672016-07-27 14:17:22 +0200128
129 with open(file_path, 'w') as node_file:
Ales Komareka961df42016-11-21 21:50:24 +0100130 node_file.write(yaml.safe_dump(node_meta, default_flow_style=False))
Ales Komarek71f94b02016-07-27 14:48:57 +0200131
Ales Komarek166cc672016-07-27 14:17:22 +0200132 return node_get(name)
133
Ales Komareka4a9f572016-12-03 20:15:50 +0100134
Ales Komarek166cc672016-07-27 14:17:22 +0200135def node_delete(name, **kwargs):
136 '''
137 Delete a reclass node
138
139 :params node: Node name
140
141 CLI Examples:
142
143 .. code-block:: bash
144
145 salt '*' reclass.node_delete demo01.domain.com
146 salt '*' reclass.node_delete name=demo01.domain.com
147 '''
148
149 node = node_get(name=name)
150
151 if 'Error' in node:
152 return {'Error': 'Unable to retreive node'}
153
154 if node[name]['path'] == '':
Ales Komareka4a9f572016-12-03 20:15:50 +0100155 file_path = os.path.join(_get_nodes_dir(), name + '.yml')
Ales Komarek166cc672016-07-27 14:17:22 +0200156 else:
Ales Komareka4a9f572016-12-03 20:15:50 +0100157 file_path = os.path.join(_get_nodes_dir(), node[name]['path'], name + '.yml')
Ales Komarek166cc672016-07-27 14:17:22 +0200158
159 os.remove(file_path)
160
161 ret = 'Node {0} deleted'.format(name)
162
163 return ret
164
165
166def node_get(name, path=None, **kwargs):
167 '''
168 Return a specific node
169
170 CLI Examples:
171
172 .. code-block:: bash
173
174 salt '*' reclass.node_get host01.domain.com
175 salt '*' reclass.node_get name=host02.domain.com
176 '''
177 ret = {}
178 nodes = node_list(**kwargs)
179
180 if not name in nodes:
181 return {'Error': 'Error in retrieving node'}
182 ret[name] = nodes[name]
183 return ret
184
185
186def node_list(**connection_args):
187 '''
188 Return a list of available nodes
189
190 CLI Example:
191
192 .. code-block:: bash
193
194 salt '*' reclass.node_list
195 '''
196 ret = {}
197
Ales Komareka4a9f572016-12-03 20:15:50 +0100198 for root, sub_folders, files in os.walk(_get_nodes_dir()):
Adam Tengler805666d2017-05-15 16:01:13 +0000199 for fl in files:
200 file_path = os.path.join(root, fl)
201 with open(file_path, 'r') as file_handle:
202 file_read = yaml.load(file_handle.read())
203 file_data = file_read or {}
204 classes = file_data.get('classes', [])
205 parameters = file_data.get('parameters', {}).get('_param', [])
206 name = fl.replace('.yml', '')
Ales Komarek166cc672016-07-27 14:17:22 +0200207 host_name = name.split('.')[0]
208 domain_name = '.'.join(name.split('.')[1:])
Ales Komareka4a9f572016-12-03 20:15:50 +0100209 path = root.replace(_get_nodes_dir()+'/', '')
Ales Komarek166cc672016-07-27 14:17:22 +0200210 ret[name] = {
Ales Komareka4a9f572016-12-03 20:15:50 +0100211 'name': host_name,
212 'domain': domain_name,
213 'cluster': 'default',
214 'environment': 'prd',
215 'path': path,
216 'classes': classes,
217 'parameters': parameters
Ales Komarek166cc672016-07-27 14:17:22 +0200218 }
219
220 return ret
221
Ales Komareka4a9f572016-12-03 20:15:50 +0100222
Adam Tengler2b362622017-06-01 14:23:45 +0000223def _is_valid_ipv4_address(address):
224 try:
225 socket.inet_pton(socket.AF_INET, address)
226 except AttributeError:
227 try:
228 socket.inet_aton(address)
229 except socket.error:
230 return False
231 return address.count('.') == 3
232 except socket.error:
233 return False
234 return True
235
236
237def _is_valid_ipv6_address(address):
238 try:
239 socket.inet_pton(socket.AF_INET6, address)
240 except socket.error:
241 return False
242 return True
243
244
245def _guess_host_from_target(host, domain=None):
246 '''
247 Guess minion ID from given host and domain arguments. Host argument can contain
248 hostname, FQDN, IPv4 or IPv6 addresses.
249 '''
250 if _is_valid_ipv4_address(host):
251 tgt = 'ipv4:%s' % host
252 elif _is_valid_ipv6_address(host):
253 tgt = 'ipv6:%s' % host
254 elif host.endswith(domain):
255 tgt = 'fqdn:%s' % host
256 else:
257 tgt = 'fqdn:%s.%s' % (host, domain)
258
259 res = __salt__['saltutil.cmd'](tgt=tgt,
260 expr_form='grain',
261 fun='grains.item',
262 arg=('id',))
Adam Tengler171c2262017-06-05 18:52:32 +0000263 if res.values():
264 ret = res.values()[0].get('ret', {}).get('id', '')
265 else:
266 ret = host
Adam Tengler2b362622017-06-01 14:23:45 +0000267
Adam Tengler171c2262017-06-05 18:52:32 +0000268 return ret
Adam Tengler2b362622017-06-01 14:23:45 +0000269
270
271def _interpolate_graph_data(graph_data, **kwargs):
272 new_nodes = []
273 for node in graph_data:
Adam Tengler69c7ba92017-06-01 15:59:01 +0000274 if not node.get('relations', []):
275 node['relations'] = []
Adam Tengler2b362622017-06-01 14:23:45 +0000276 for relation in node.get('relations', []):
Adam Tengler12a310d2017-06-05 19:11:29 +0000277 if not relation.get('status', None):
278 relation['status'] = 'unknown'
Adam Tengler2b362622017-06-01 14:23:45 +0000279 if relation.get('host_from_target', None):
280 host = _guess_host_from_target(relation.pop('host_from_target'))
281 relation['host'] = host
282 if relation.get('host_external', None):
Adam Tengler69c7ba92017-06-01 15:59:01 +0000283 parsed_host_external = [urlparse(item).netloc
284 for item
285 in relation.get('host_external', '').split(' ')
286 if urlparse(item).netloc]
287 service = parsed_host_external[0] if parsed_host_external else ''
Adam Tengler2b362622017-06-01 14:23:45 +0000288 host = relation.get('service', '')
289 relation['host'] = host
Adam Tengler69c7ba92017-06-01 15:59:01 +0000290 del relation['host_external']
Adam Tengler2b362622017-06-01 14:23:45 +0000291 relation['service'] = service
Adam Tengler69c7ba92017-06-01 15:59:01 +0000292 host_list = [n.get('host', '') for n in graph_data + new_nodes]
293 service_list = [n.get('service', '') for n in graph_data + new_nodes if host in n.get('host', '')]
294 if host not in host_list or (host in host_list and service not in service_list):
Adam Tengler2b362622017-06-01 14:23:45 +0000295 new_node = {
296 'host': host,
297 'service': service,
298 'type': relation.get('type', ''),
299 'relations': []
300 }
301 new_nodes.append(new_node)
302
303 graph_data = graph_data + new_nodes
304
305 return graph_data
306
307
308def _grain_graph_data(*args, **kwargs):
309 ret = __salt__['saltutil.cmd'](tgt='*',
310 fun='grains.item',
311 arg=('salt:graph',))
312 graph_data = []
313 for minion_ret in ret.values():
314 if minion_ret.get('retcode', 1) == 0:
315 graph_datum = minion_ret.get('ret', {}).get('salt:graph', [])
316 graph_data = graph_data + graph_datum
317
318 graph_nodes = _interpolate_graph_data(graph_data)
319 graph = {}
320
321 for node in graph_nodes:
322 if node.get('host') not in graph:
323 graph[node.get('host')] = {}
324 graph[node.pop('host')][node.pop('service')] = node
325
326 return {'graph': graph}
327
328
329def _pillar_graph_data(*args, **kwargs):
330 graph = {}
331 nodes = inventory()
332 for node, node_data in nodes.items():
333 for role in node_data.get('roles', []):
334 if node not in graph:
335 graph[node] = {}
336 graph[node][role] = {'relations': []}
337
338 return {'graph': graph}
339
340
341def graph_data(*args, **kwargs):
342 '''
343 Returns graph data for visualization app
344
345 CLI Examples:
346
347 .. code-block:: bash
348
349 salt '*' reclass.graph_data
350
351 '''
352 pillar_data = _pillar_graph_data().get('graph')
353 grain_data = _grain_graph_data().get('graph')
354
355 for host, services in pillar_data.items():
356 for service, service_data in services.items():
357 grain_service = grain_data.get(host, {}).get(service, {})
358 service_data.update(grain_service)
359
360 graph = []
361 for host, services in pillar_data.items():
362 for service, service_data in services.items():
363 additional_data = {
364 'host': host,
Adam Tengler12a310d2017-06-05 19:11:29 +0000365 'service': service,
366 'status': 'unknown'
Adam Tengler2b362622017-06-01 14:23:45 +0000367 }
368 service_data.update(additional_data)
369 graph.append(service_data)
370
371 for host, services in grain_data.items():
372 for service, service_data in services.items():
373 additional_data = {
374 'host': host,
Adam Tengler12a310d2017-06-05 19:11:29 +0000375 'service': service,
376 'status': 'success'
Adam Tengler2b362622017-06-01 14:23:45 +0000377 }
378 service_data.update(additional_data)
379 host_list = [g.get('host', '') for g in graph]
380 service_list = [g.get('service', '') for g in graph if g.get('host') == host]
381 if host not in host_list or (host in host_list and service not in service_list):
382 graph.append(service_data)
383
384 return {'graph': graph}
385
386
Ales Komarek166cc672016-07-27 14:17:22 +0200387def node_update(name, classes=None, parameters=None, **connection_args):
388 '''
389 Update a node metadata information, classes and parameters.
390
391 CLI Examples:
392
393 .. code-block:: bash
394
395 salt '*' reclass.node_update name=nodename classes="[clas1, class2]"
396 '''
397 node = node_get(name=name)
398 if not node.has_key('Error'):
Ales Komarek71f94b02016-07-27 14:48:57 +0200399 node = node[name.split("/")[1]]
400 else:
401 return {'Error': 'Error in retrieving node'}
Ales Komareka4a9f572016-12-03 20:15:50 +0100402
403
Adam Tengler23d965f2017-05-16 19:14:51 +0000404def _get_node_classes(node_data, class_mapping_fragment):
405 classes = []
406
407 for value_tmpl_string in class_mapping_fragment.get('value_template', []):
408 value_tmpl = Template(value_tmpl_string.replace('<<', '${').replace('>>', '}'))
409 rendered_value = value_tmpl.safe_substitute(node_data)
410 classes.append(rendered_value)
411
412 for value in class_mapping_fragment.get('value', []):
413 classes.append(value)
414
415 return classes
416
417
418def _get_params(node_data, class_mapping_fragment):
419 params = {}
420
421 for param_name, param in class_mapping_fragment.items():
422 value = param.get('value', None)
423 value_tmpl_string = param.get('value_template', None)
424 if value:
425 params.update({param_name: value})
426 elif value_tmpl_string:
427 value_tmpl = Template(value_tmpl_string.replace('<<', '${').replace('>>', '}'))
428 rendered_value = value_tmpl.safe_substitute(node_data)
429 params.update({param_name: rendered_value})
430
431 return params
432
433
434def _validate_condition(node_data, expression_tmpl_string):
435 expression_tmpl = Template(expression_tmpl_string.replace('<<', '${').replace('>>', '}'))
436 expression = expression_tmpl.safe_substitute(node_data)
437
438 if expression and expression == 'all':
439 return True
440 elif expression:
441 val_a = expression.split('__')[0]
442 val_b = expression.split('__')[2]
443 condition = expression.split('__')[1]
444 if condition == 'startswith':
445 return val_a.startswith(val_b)
446 elif condition == 'equals':
447 return val_a == val_b
448
449 return False
450
451
452def node_classify(node_name, node_data={}, class_mapping={}, **kwargs):
453 '''
454 CLassify node by given class_mapping dictionary
455
456 :param node_name: node FQDN
457 :param node_data: dictionary of known informations about the node
458 :param class_mapping: dictionary of classes and parameters, with conditions
459
460 '''
461 # clean node_data
462 node_data = {k: v for (k, v) in node_data.items() if not k.startswith('__')}
463
464 classes = []
465 node_params = {}
466 cluster_params = {}
467 ret = {'node_create': '', 'cluster_param': {}}
468
469 for type_name, node_type in class_mapping.items():
470 valid = _validate_condition(node_data, node_type.get('expression', ''))
471 if valid:
472 gen_classes = _get_node_classes(node_data, node_type.get('node_class', {}))
473 classes = classes + gen_classes
474 gen_node_params = _get_params(node_data, node_type.get('node_param', {}))
475 node_params.update(gen_node_params)
476 gen_cluster_params = _get_params(node_data, node_type.get('cluster_param', {}))
477 cluster_params.update(gen_cluster_params)
478
479 if classes:
480 create_kwargs = {'name': node_name, 'path': '_generated', 'classes': classes, 'parameters': node_params}
481 ret['node_create'] = node_create(**create_kwargs)
482
483 for name, value in cluster_params.items():
484 ret['cluster_param'][name] = cluster_meta_set(name, value)
485
486 return ret
487
488
Adam Tengler2b362622017-06-01 14:23:45 +0000489def node_pillar(node_name, **kwargs):
490 '''
491 Returns pillar data for given minion from reclass inventory.
492
493 :param node_name: target minion ID
494
495 CLI Examples:
496
497 .. code-block:: bash
498
499 salt-call reclass.node_pillar minion_id
500
501 '''
502 defaults = find_and_read_configfile()
503 pillar = ext_pillar(node_name, {}, defaults['storage_type'], defaults['inventory_base_uri'])
504 output = {node_name: pillar}
505
506 return output
507
508
Ales Komareka4a9f572016-12-03 20:15:50 +0100509def inventory(**connection_args):
510 '''
511 Get all nodes in inventory and their associated services/roles classification.
512
513 CLI Examples:
514
515 .. code-block:: bash
516
517 salt '*' reclass.inventory
518 '''
519 defaults = find_and_read_configfile()
520 storage = get_storage(defaults['storage_type'], _get_nodes_dir(), _get_classes_dir())
521 reclass = Core(storage, None)
522 nodes = reclass.inventory()["nodes"]
523 output = {}
524
525 for node in nodes:
526 service_classification = []
527 role_classification = []
528 for service in nodes[node]['parameters']:
529 if service not in ['_param', 'private_keys', 'public_keys', 'known_hosts']:
530 service_classification.append(service)
531 for role in nodes[node]['parameters'][service]:
532 if role not in ['_support', '_orchestrate', 'common']:
533 role_classification.append('%s.%s' % (service, role))
534 output[node] = {
535 'roles': role_classification,
536 'services': service_classification,
537 }
538 return output
Adam Tengler8a1cf402017-05-16 10:59:35 +0000539
540
541def cluster_meta_list(file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000542 '''
543 List all cluster level overrides
544
545 :param file_name: name of the override file, defaults to: overrides.yml
546
547 CLI Examples:
548
549 .. code-block:: bash
550
551 salt-call reclass.cluster_meta_list
552
553 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000554 path = os.path.join(_get_cluster_dir(), cluster, file_name)
555 try:
556 with io.open(path, 'r') as file_handle:
557 meta_yaml = yaml.safe_load(file_handle.read())
558 meta = meta_yaml or {}
559 except Exception as e:
560 msg = "Unable to load cluster metadata YAML %s: %s" % (path, repr(e))
561 LOG.debug(msg)
562 meta = {'Error': msg}
563 return meta
564
565
566def cluster_meta_delete(name, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000567 '''
568 Delete cluster level override entry
569
570 :param name: name of the override entry (dictionary key)
571 :param file_name: name of the override file, defaults to: overrides.yml
572
573 CLI Examples:
574
575 .. code-block:: bash
576
577 salt-call reclass.cluster_meta_delete foo
578
579 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000580 ret = {}
581 path = os.path.join(_get_cluster_dir(), cluster, file_name)
582 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
583 if 'Error' not in meta:
584 metadata = meta.get('parameters', {}).get('_param', {})
585 if name not in metadata:
586 return ret
587 del metadata[name]
588 try:
589 with io.open(path, 'w') as file_handle:
590 file_handle.write(unicode(yaml.dump(meta, default_flow_style=False)))
591 except Exception as e:
592 msg = "Unable to save cluster metadata YAML: %s" % repr(e)
593 LOG.error(msg)
594 return {'Error': msg}
595 ret = 'Cluster metadata entry {0} deleted'.format(name)
596 return ret
597
598
599def cluster_meta_set(name, value, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000600 '''
601 Create cluster level override entry
602
603 :param name: name of the override entry (dictionary key)
604 :param value: value of the override entry (dictionary value)
605 :param file_name: name of the override file, defaults to: overrides.yml
606
607 CLI Examples:
608
609 .. code-block:: bash
610
611 salt-call reclass.cluster_meta_set foo bar
612
613 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000614 path = os.path.join(_get_cluster_dir(), cluster, file_name)
615 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
616 if 'Error' not in meta:
617 if not meta:
618 meta = {'parameters': {'_param': {}}}
619 metadata = meta.get('parameters', {}).get('_param', {})
620 if name in metadata and metadata[name] == value:
621 return {name: 'Cluster metadata entry %s already exists and is in correct state' % name}
622 metadata.update({name: value})
623 try:
624 with io.open(path, 'w') as file_handle:
625 file_handle.write(unicode(yaml.dump(meta, default_flow_style=False)))
626 except Exception as e:
627 msg = "Unable to save cluster metadata YAML %s: %s" % (path, repr(e))
628 LOG.error(msg)
629 return {'Error': msg}
630 return cluster_meta_get(name, path, **kwargs)
631 return meta
632
633
634def cluster_meta_get(name, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000635 '''
636 Get single cluster level override entry
637
638 :param name: name of the override entry (dictionary key)
639 :param file_name: name of the override file, defaults to: overrides.yml
640
641 CLI Examples:
642
643 .. code-block:: bash
644
645 salt-call reclass.cluster_meta_get foo
646
647 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000648 ret = {}
649 path = os.path.join(_get_cluster_dir(), cluster, file_name)
650 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
651 metadata = meta.get('parameters', {}).get('_param', {})
652 if 'Error' in meta:
653 ret['Error'] = meta['Error']
654 elif name in metadata:
655 ret[name] = metadata.get(name)
656
657 return ret
658