blob: 305ac2c0cd4f5709e825eb4f73eaf385cc0f8b3d [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',))
263
264 return res.values()[0].get('ret', {}).get('id', '')
265
266
267def _interpolate_graph_data(graph_data, **kwargs):
268 new_nodes = []
269 for node in graph_data:
Adam Tengler69c7ba92017-06-01 15:59:01 +0000270 if not node.get('relations', []):
271 node['relations'] = []
Adam Tengler2b362622017-06-01 14:23:45 +0000272 for relation in node.get('relations', []):
273 if relation.get('host_from_target', None):
274 host = _guess_host_from_target(relation.pop('host_from_target'))
275 relation['host'] = host
276 if relation.get('host_external', None):
Adam Tengler69c7ba92017-06-01 15:59:01 +0000277 parsed_host_external = [urlparse(item).netloc
278 for item
279 in relation.get('host_external', '').split(' ')
280 if urlparse(item).netloc]
281 service = parsed_host_external[0] if parsed_host_external else ''
Adam Tengler2b362622017-06-01 14:23:45 +0000282 host = relation.get('service', '')
283 relation['host'] = host
Adam Tengler69c7ba92017-06-01 15:59:01 +0000284 del relation['host_external']
Adam Tengler2b362622017-06-01 14:23:45 +0000285 relation['service'] = service
Adam Tengler69c7ba92017-06-01 15:59:01 +0000286 host_list = [n.get('host', '') for n in graph_data + new_nodes]
287 service_list = [n.get('service', '') for n in graph_data + new_nodes if host in n.get('host', '')]
288 if host not in host_list or (host in host_list and service not in service_list):
Adam Tengler2b362622017-06-01 14:23:45 +0000289 new_node = {
290 'host': host,
291 'service': service,
292 'type': relation.get('type', ''),
293 'relations': []
294 }
295 new_nodes.append(new_node)
296
297 graph_data = graph_data + new_nodes
298
299 return graph_data
300
301
302def _grain_graph_data(*args, **kwargs):
303 ret = __salt__['saltutil.cmd'](tgt='*',
304 fun='grains.item',
305 arg=('salt:graph',))
306 graph_data = []
307 for minion_ret in ret.values():
308 if minion_ret.get('retcode', 1) == 0:
309 graph_datum = minion_ret.get('ret', {}).get('salt:graph', [])
310 graph_data = graph_data + graph_datum
311
312 graph_nodes = _interpolate_graph_data(graph_data)
313 graph = {}
314
315 for node in graph_nodes:
316 if node.get('host') not in graph:
317 graph[node.get('host')] = {}
318 graph[node.pop('host')][node.pop('service')] = node
319
320 return {'graph': graph}
321
322
323def _pillar_graph_data(*args, **kwargs):
324 graph = {}
325 nodes = inventory()
326 for node, node_data in nodes.items():
327 for role in node_data.get('roles', []):
328 if node not in graph:
329 graph[node] = {}
330 graph[node][role] = {'relations': []}
331
332 return {'graph': graph}
333
334
335def graph_data(*args, **kwargs):
336 '''
337 Returns graph data for visualization app
338
339 CLI Examples:
340
341 .. code-block:: bash
342
343 salt '*' reclass.graph_data
344
345 '''
346 pillar_data = _pillar_graph_data().get('graph')
347 grain_data = _grain_graph_data().get('graph')
348
349 for host, services in pillar_data.items():
350 for service, service_data in services.items():
351 grain_service = grain_data.get(host, {}).get(service, {})
352 service_data.update(grain_service)
353
354 graph = []
355 for host, services in pillar_data.items():
356 for service, service_data in services.items():
357 additional_data = {
358 'host': host,
359 'service': service
360 }
361 service_data.update(additional_data)
362 graph.append(service_data)
363
364 for host, services in grain_data.items():
365 for service, service_data in services.items():
366 additional_data = {
367 'host': host,
368 'service': service
369 }
370 service_data.update(additional_data)
371 host_list = [g.get('host', '') for g in graph]
372 service_list = [g.get('service', '') for g in graph if g.get('host') == host]
373 if host not in host_list or (host in host_list and service not in service_list):
374 graph.append(service_data)
375
376 return {'graph': graph}
377
378
Ales Komarek166cc672016-07-27 14:17:22 +0200379def node_update(name, classes=None, parameters=None, **connection_args):
380 '''
381 Update a node metadata information, classes and parameters.
382
383 CLI Examples:
384
385 .. code-block:: bash
386
387 salt '*' reclass.node_update name=nodename classes="[clas1, class2]"
388 '''
389 node = node_get(name=name)
390 if not node.has_key('Error'):
Ales Komarek71f94b02016-07-27 14:48:57 +0200391 node = node[name.split("/")[1]]
392 else:
393 return {'Error': 'Error in retrieving node'}
Ales Komareka4a9f572016-12-03 20:15:50 +0100394
395
Adam Tengler23d965f2017-05-16 19:14:51 +0000396def _get_node_classes(node_data, class_mapping_fragment):
397 classes = []
398
399 for value_tmpl_string in class_mapping_fragment.get('value_template', []):
400 value_tmpl = Template(value_tmpl_string.replace('<<', '${').replace('>>', '}'))
401 rendered_value = value_tmpl.safe_substitute(node_data)
402 classes.append(rendered_value)
403
404 for value in class_mapping_fragment.get('value', []):
405 classes.append(value)
406
407 return classes
408
409
410def _get_params(node_data, class_mapping_fragment):
411 params = {}
412
413 for param_name, param in class_mapping_fragment.items():
414 value = param.get('value', None)
415 value_tmpl_string = param.get('value_template', None)
416 if value:
417 params.update({param_name: value})
418 elif value_tmpl_string:
419 value_tmpl = Template(value_tmpl_string.replace('<<', '${').replace('>>', '}'))
420 rendered_value = value_tmpl.safe_substitute(node_data)
421 params.update({param_name: rendered_value})
422
423 return params
424
425
426def _validate_condition(node_data, expression_tmpl_string):
427 expression_tmpl = Template(expression_tmpl_string.replace('<<', '${').replace('>>', '}'))
428 expression = expression_tmpl.safe_substitute(node_data)
429
430 if expression and expression == 'all':
431 return True
432 elif expression:
433 val_a = expression.split('__')[0]
434 val_b = expression.split('__')[2]
435 condition = expression.split('__')[1]
436 if condition == 'startswith':
437 return val_a.startswith(val_b)
438 elif condition == 'equals':
439 return val_a == val_b
440
441 return False
442
443
444def node_classify(node_name, node_data={}, class_mapping={}, **kwargs):
445 '''
446 CLassify node by given class_mapping dictionary
447
448 :param node_name: node FQDN
449 :param node_data: dictionary of known informations about the node
450 :param class_mapping: dictionary of classes and parameters, with conditions
451
452 '''
453 # clean node_data
454 node_data = {k: v for (k, v) in node_data.items() if not k.startswith('__')}
455
456 classes = []
457 node_params = {}
458 cluster_params = {}
459 ret = {'node_create': '', 'cluster_param': {}}
460
461 for type_name, node_type in class_mapping.items():
462 valid = _validate_condition(node_data, node_type.get('expression', ''))
463 if valid:
464 gen_classes = _get_node_classes(node_data, node_type.get('node_class', {}))
465 classes = classes + gen_classes
466 gen_node_params = _get_params(node_data, node_type.get('node_param', {}))
467 node_params.update(gen_node_params)
468 gen_cluster_params = _get_params(node_data, node_type.get('cluster_param', {}))
469 cluster_params.update(gen_cluster_params)
470
471 if classes:
472 create_kwargs = {'name': node_name, 'path': '_generated', 'classes': classes, 'parameters': node_params}
473 ret['node_create'] = node_create(**create_kwargs)
474
475 for name, value in cluster_params.items():
476 ret['cluster_param'][name] = cluster_meta_set(name, value)
477
478 return ret
479
480
Adam Tengler2b362622017-06-01 14:23:45 +0000481def node_pillar(node_name, **kwargs):
482 '''
483 Returns pillar data for given minion from reclass inventory.
484
485 :param node_name: target minion ID
486
487 CLI Examples:
488
489 .. code-block:: bash
490
491 salt-call reclass.node_pillar minion_id
492
493 '''
494 defaults = find_and_read_configfile()
495 pillar = ext_pillar(node_name, {}, defaults['storage_type'], defaults['inventory_base_uri'])
496 output = {node_name: pillar}
497
498 return output
499
500
Ales Komareka4a9f572016-12-03 20:15:50 +0100501def inventory(**connection_args):
502 '''
503 Get all nodes in inventory and their associated services/roles classification.
504
505 CLI Examples:
506
507 .. code-block:: bash
508
509 salt '*' reclass.inventory
510 '''
511 defaults = find_and_read_configfile()
512 storage = get_storage(defaults['storage_type'], _get_nodes_dir(), _get_classes_dir())
513 reclass = Core(storage, None)
514 nodes = reclass.inventory()["nodes"]
515 output = {}
516
517 for node in nodes:
518 service_classification = []
519 role_classification = []
520 for service in nodes[node]['parameters']:
521 if service not in ['_param', 'private_keys', 'public_keys', 'known_hosts']:
522 service_classification.append(service)
523 for role in nodes[node]['parameters'][service]:
524 if role not in ['_support', '_orchestrate', 'common']:
525 role_classification.append('%s.%s' % (service, role))
526 output[node] = {
527 'roles': role_classification,
528 'services': service_classification,
529 }
530 return output
Adam Tengler8a1cf402017-05-16 10:59:35 +0000531
532
533def cluster_meta_list(file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000534 '''
535 List all cluster level overrides
536
537 :param file_name: name of the override file, defaults to: overrides.yml
538
539 CLI Examples:
540
541 .. code-block:: bash
542
543 salt-call reclass.cluster_meta_list
544
545 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000546 path = os.path.join(_get_cluster_dir(), cluster, file_name)
547 try:
548 with io.open(path, 'r') as file_handle:
549 meta_yaml = yaml.safe_load(file_handle.read())
550 meta = meta_yaml or {}
551 except Exception as e:
552 msg = "Unable to load cluster metadata YAML %s: %s" % (path, repr(e))
553 LOG.debug(msg)
554 meta = {'Error': msg}
555 return meta
556
557
558def cluster_meta_delete(name, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000559 '''
560 Delete cluster level override entry
561
562 :param name: name of the override entry (dictionary key)
563 :param file_name: name of the override file, defaults to: overrides.yml
564
565 CLI Examples:
566
567 .. code-block:: bash
568
569 salt-call reclass.cluster_meta_delete foo
570
571 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000572 ret = {}
573 path = os.path.join(_get_cluster_dir(), cluster, file_name)
574 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
575 if 'Error' not in meta:
576 metadata = meta.get('parameters', {}).get('_param', {})
577 if name not in metadata:
578 return ret
579 del metadata[name]
580 try:
581 with io.open(path, 'w') as file_handle:
582 file_handle.write(unicode(yaml.dump(meta, default_flow_style=False)))
583 except Exception as e:
584 msg = "Unable to save cluster metadata YAML: %s" % repr(e)
585 LOG.error(msg)
586 return {'Error': msg}
587 ret = 'Cluster metadata entry {0} deleted'.format(name)
588 return ret
589
590
591def cluster_meta_set(name, value, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000592 '''
593 Create cluster level override entry
594
595 :param name: name of the override entry (dictionary key)
596 :param value: value of the override entry (dictionary value)
597 :param file_name: name of the override file, defaults to: overrides.yml
598
599 CLI Examples:
600
601 .. code-block:: bash
602
603 salt-call reclass.cluster_meta_set foo bar
604
605 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000606 path = os.path.join(_get_cluster_dir(), cluster, file_name)
607 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
608 if 'Error' not in meta:
609 if not meta:
610 meta = {'parameters': {'_param': {}}}
611 metadata = meta.get('parameters', {}).get('_param', {})
612 if name in metadata and metadata[name] == value:
613 return {name: 'Cluster metadata entry %s already exists and is in correct state' % name}
614 metadata.update({name: value})
615 try:
616 with io.open(path, 'w') as file_handle:
617 file_handle.write(unicode(yaml.dump(meta, default_flow_style=False)))
618 except Exception as e:
619 msg = "Unable to save cluster metadata YAML %s: %s" % (path, repr(e))
620 LOG.error(msg)
621 return {'Error': msg}
622 return cluster_meta_get(name, path, **kwargs)
623 return meta
624
625
626def cluster_meta_get(name, file_name="overrides.yml", cluster="", **kwargs):
Adam Tengler2b362622017-06-01 14:23:45 +0000627 '''
628 Get single cluster level override entry
629
630 :param name: name of the override entry (dictionary key)
631 :param file_name: name of the override file, defaults to: overrides.yml
632
633 CLI Examples:
634
635 .. code-block:: bash
636
637 salt-call reclass.cluster_meta_get foo
638
639 '''
Adam Tengler8a1cf402017-05-16 10:59:35 +0000640 ret = {}
641 path = os.path.join(_get_cluster_dir(), cluster, file_name)
642 meta = __salt__['reclass.cluster_meta_list'](path, **kwargs)
643 metadata = meta.get('parameters', {}).get('_param', {})
644 if 'Error' in meta:
645 ret['Error'] = meta['Error']
646 elif name in metadata:
647 ret[name] = metadata.get(name)
648
649 return ret
650