2 # -------------------------------------------------------------------------
3 # Copyright (c) 2015-2017 AT&T Intellectual Property
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 # -------------------------------------------------------------------------
27 from oslo_config import cfg
28 from oslo_log import log
31 from conductor import __file__ as conductor_root
32 from conductor.common.music import messaging as music_messaging
33 from conductor.common import threshold
34 from conductor import messaging
35 from conductor import service
37 LOG = log.getLogger(__name__)
41 VERSIONS = ["2016-11-01", "2017-10-10"]
42 LOCATION_KEYS = ['latitude', 'longitude', 'host_name', 'clli_code']
43 INVENTORY_PROVIDERS = ['aai']
44 INVENTORY_TYPES = ['cloud', 'service', 'transport']
45 DEFAULT_INVENTORY_PROVIDER = INVENTORY_PROVIDERS[0]
46 CANDIDATE_KEYS = ['inventory_type', 'candidate_id', 'location_id',
47 'location_type', 'cost']
48 DEMAND_KEYS = ['inventory_provider', 'inventory_type', 'service_type',
49 'service_id', 'service_resource_id', 'customer_id',
50 'default_cost', 'candidates', 'region', 'complex',
51 'required_candidates', 'excluded_candidates',
52 'existing_placement', 'subdivision', 'flavor', 'attributes']
53 CONSTRAINT_KEYS = ['type', 'demands', 'properties']
56 # split: split into individual constraints, one per demand
57 # required: list of required property names,
58 # optional: list of optional property names,
59 # thresholds: dict of property/base-unit pairs for threshold parsing
60 # allowed: dict of keys and allowed values (if controlled vocab);
61 # only use this for Conductor-controlled values!
65 'required': ['evaluate'],
67 'distance_between_demands': {
68 'required': ['distance'],
70 'distance': 'distance'
73 'distance_to_location': {
75 'required': ['distance', 'location'],
77 'distance': 'distance'
82 'required': ['controller'],
83 'optional': ['request'],
85 'inventory_group': {},
88 'required': ['controller'],
89 'optional': ['request'],
92 'required': ['qualifier', 'category'],
93 'optional': ['location'],
94 'allowed': {'qualifier': ['same', 'different'],
95 'category': ['disaster', 'region', 'complex', 'country',
96 'time', 'maintenance']},
101 class TranslatorException(Exception):
105 class Translator(object):
106 """Template translator.
108 Takes an input template and translates it into
109 something the solver can use. Calls the data service
110 as needed, giving it the inventory provider as context.
111 Presently the only inventory provider is A&AI. Others
112 may be added in the future.
115 def __init__(self, conf, plan_name, plan_id, template):
117 self._template = copy.deepcopy(template)
118 self._plan_name = plan_name
119 self._plan_id = plan_id
120 self._translation = None
124 # Set up the RPC service(s) we want to talk to.
125 self.data_service = self.setup_rpc(self.conf, "data")
127 def setup_rpc(self, conf, topic):
128 """Set up the RPC Client"""
129 # TODO(jdandrea): Put this pattern inside music_messaging?
130 transport = messaging.get_transport(conf=conf)
131 target = music_messaging.Target(topic=topic)
132 client = music_messaging.RPCClient(conf=conf,
137 def create_components(self):
138 # TODO(jdandrea): Make deep copies so the template is untouched
139 self._version = self._template.get("homing_template_version")
140 self._parameters = self._template.get("parameters", {})
141 self._locations = self._template.get("locations", {})
142 self._demands = self._template.get("demands", {})
143 self._constraints = self._template.get("constraints", {})
144 self._optmization = self._template.get("optimization", {})
145 self._reservations = self._template.get("reservation", {})
147 if isinstance(self._version, datetime.date):
148 self._version = str(self._version)
150 def validate_components(self):
151 """Cursory validation of template components.
153 More detailed validation happens while parsing each component.
158 if self._version not in VERSIONS:
159 raise TranslatorException(
160 "conductor_template_version must be one "
161 "of: {}".format(', '.join(VERSIONS)))
163 # Check top level structure
167 "content": self._parameters,
171 "keys": LOCATION_KEYS,
172 "content": self._locations,
176 "content": self._demands,
179 "name": "Constraint",
180 "keys": CONSTRAINT_KEYS,
181 "content": self._constraints,
184 "name": "Optimization",
185 "content": self._optmization,
188 "name": "Reservation",
189 "content": self._reservations,
192 for name, component in components.items():
193 name = component.get('name')
194 keys = component.get('keys', None)
195 content = component.get('content')
197 if type(content) is not dict:
198 raise TranslatorException(
199 "{} section must be a dictionary".format(name))
200 for content_name, content_def in content.items():
204 for key in content_def:
206 raise TranslatorException(
207 "{} {} has an invalid key {}".format(
208 name, content_name, key))
210 demand_keys = self._demands.keys()
211 location_keys = self._locations.keys()
212 for constraint_name, constraint in self._constraints.items():
214 # Require a single demand (string), or a list of one or more.
215 demands = constraint.get('demands')
216 if isinstance(demands, six.string_types):
218 if not isinstance(demands, list) or len(demands) < 1:
219 raise TranslatorException(
220 "Demand list for Constraint {} must be "
221 "a list of names or a string with one name".format(
223 if not set(demands).issubset(demand_keys + location_keys):
224 raise TranslatorException(
225 "Undefined Demand(s) {} in Constraint '{}'".format(
226 list(set(demands).difference(demand_keys)),
229 properties = constraint.get('properties', None)
231 location = properties.get('location', None)
233 if location not in location_keys:
234 raise TranslatorException(
235 "Location {} in Constraint {} is undefined".format(
236 location, constraint_name))
240 def _parse_parameters(self, obj, path=[]):
241 """Recursively parse all {get_param: X} occurrences
243 This modifies obj in-place. If you want to keep the original,
246 # Ok to start with a string ...
247 if isinstance(path, six.string_types):
248 # ... but the breadcrumb trail goes in an array.
252 if type(obj) is list:
253 for idx, val in enumerate(obj, start=0):
254 # Add path to the breadcrumb trail
255 new_path = list(path)
256 new_path[-1] += "[{}]".format(idx)
258 # Look at each element.
259 obj[idx] = self._parse_parameters(val, new_path)
262 elif type(obj) is dict:
263 # Did we find a "{get_param: ...}" intrinsic?
264 if obj.keys() == ['get_param']:
265 param_name = obj['get_param']
267 # The parameter name must be a string.
268 if not isinstance(param_name, six.string_types):
269 path_str = ' > '.join(path)
270 raise TranslatorException(
271 "Parameter name '{}' not a string in path {}".format(
272 param_name, path_str))
274 # Parameter name must be defined.
275 if param_name not in self._parameters:
276 path_str = ' > '.join(path)
277 raise TranslatorException(
278 "Parameter '{}' undefined in path {}".format(
279 param_name, path_str))
281 # Return the value in place of the call.
282 return self._parameters.get(param_name)
284 # Not an intrinsic. Traverse as usual.
285 for key in obj.keys():
286 # Add path to the breadcrumb trail.
287 new_path = list(path)
290 # Look at each key/value pair.
291 obj[key] = self._parse_parameters(obj[key], new_path)
293 # Return whatever we have after unwinding.
296 def parse_parameters(self):
297 """Resolve all parameters references."""
298 locations = copy.deepcopy(self._locations)
299 self._locations = self._parse_parameters(locations, 'locations')
301 demands = copy.deepcopy(self._demands)
302 self._demands = self._parse_parameters(demands, 'demands')
304 constraints = copy.deepcopy(self._constraints)
305 self._constraints = self._parse_parameters(constraints, 'constraints')
307 reservations = copy.deepcopy(self._reservations)
308 self._reservations = self._parse_parameters(reservations,
311 def parse_locations(self, locations):
312 """Prepare the locations for use by the solver."""
314 for location, args in locations.items():
316 'plan_id': self._plan_id,
317 'keyspace': self.conf.keyspace
320 latitude = args.get("latitude")
321 longitude = args.get("longitude")
323 if latitude and longitude:
324 resolved_location = {"latitude": latitude, "longitude": longitude}
327 response = self.data_service.call(
329 method="resolve_location",
332 resolved_location = \
333 response and response.get('resolved_location')
334 if not resolved_location:
335 raise TranslatorException(
336 "Unable to resolve location {}".format(location)
338 parsed[location] = resolved_location
341 def parse_demands(self, demands):
342 """Validate/prepare demands for use by the solver."""
343 if type(demands) is not dict:
344 raise TranslatorException("Demands must be provided in "
347 # Look at each demand
348 demands_copy = copy.deepcopy(demands)
350 for name, requirements in demands_copy.items():
351 inventory_candidates = []
352 for requirement in requirements:
353 for key in requirement:
354 if key not in DEMAND_KEYS:
355 raise TranslatorException(
356 "Demand {} has an invalid key {}".format(
359 if 'candidates' in requirement:
360 # Candidates *must* specify an inventory provider
361 provider = requirement.get("inventory_provider")
362 if provider and provider not in INVENTORY_PROVIDERS:
363 raise TranslatorException(
364 "Unsupported inventory provider {} "
365 "in demand {}".format(provider, name))
367 provider = DEFAULT_INVENTORY_PROVIDER
369 # Check each candidate
370 for candidate in requirement.get('candidates'):
371 # Must be a dictionary
372 if type(candidate) is not dict:
373 raise TranslatorException(
374 "Candidate found in demand {} that is "
375 "not a dictionary".format(name))
377 # Must have only supported keys
378 for key in candidate.keys():
379 if key not in CANDIDATE_KEYS:
380 raise TranslatorException(
381 "Candidate with invalid key {} found "
382 "in demand {}".format(key, name)
385 # TODO(jdandrea): Check required/optional keys
387 # Set the inventory provider if not already
388 candidate['inventory_provider'] = \
389 candidate.get('inventory_provider', provider)
391 # Set cost if not already (default cost is 0?)
392 candidate['cost'] = candidate.get('cost', 0)
394 # Add to our list of parsed candidates
395 inventory_candidates.append(candidate)
397 # candidates are specified through inventory providers
398 # Do the basic sanity checks for inputs
400 # inventory provider MUST be specified
401 provider = requirement.get("inventory_provider")
403 raise TranslatorException(
404 "Inventory provider not specified "
405 "in demand {}".format(name)
407 elif provider and provider not in INVENTORY_PROVIDERS:
408 raise TranslatorException(
409 "Unsupported inventory provider {} "
410 "in demand {}".format(provider, name)
413 provider = DEFAULT_INVENTORY_PROVIDER
414 requirement['provider'] = provider
416 # inventory type MUST be specified
417 inventory_type = requirement.get('inventory_type')
418 if not inventory_type or inventory_type == '':
419 raise TranslatorException(
420 "Inventory type not specified for "
421 "demand {}".format(name)
423 if inventory_type and \
424 inventory_type not in INVENTORY_TYPES:
425 raise TranslatorException(
426 "Unknown inventory type {} specified for "
427 "demand {}".format(inventory_type, name)
430 # For service inventories, customer_id and
431 # service_type MUST be specified
432 if inventory_type == 'service':
433 attributes = requirement.get('attributes')
436 customer_id = attributes.get('customer-id')
437 global_customer_id = attributes.get('global-customer-id')
438 if global_customer_id:
439 customer_id = global_customer_id
441 # for backward compatibility
442 customer_id = requirement.get('customer_id')
443 service_type = requirement.get('service_type')
446 raise TranslatorException(
447 "Customer ID not specified for "
448 "demand {}".format(name)
450 if not attributes and not service_type:
451 raise TranslatorException(
452 "Service Type not specified for "
453 "demand {}".format(name)
456 # TODO(jdandrea): Check required/optional keys for requirement
457 # elif 'inventory_type' in requirement:
458 # # For now this is just a stand-in candidate
460 # 'inventory_provider':
461 # requirement.get('inventory_provider',
462 # DEFAULT_INVENTORY_PROVIDER),
464 # requirement.get('inventory_type', ''),
465 # 'candidate_id': '',
467 # 'location_type': '',
471 # # Add to our list of parsed candidates
472 # inventory_candidates.append(candidate)
474 # Ask conductor-data for one or more candidates.
476 "plan_id": self._plan_id,
477 "plan_name": self._plan_name,
485 # Check if required_candidate and excluded candidate
486 # are mutually exclusive.
487 for requirement in requirements:
488 required_candidates = requirement.get("required_candidates")
489 excluded_candidates = requirement.get("excluded_candidates")
490 if (required_candidates and
491 excluded_candidates and
492 set(map(lambda entry: entry['candidate_id'],
493 required_candidates))
494 & set(map(lambda entry: entry['candidate_id'],
495 excluded_candidates))):
496 raise TranslatorException(
497 "Required candidate list and excluded candidate"
498 " list are not mutually exclusive for demand"
502 response = self.data_service.call(
504 method="resolve_demands",
508 response and response.get('resolved_demands')
510 required_candidates = resolved_demands\
511 .get('required_candidates')
512 if not resolved_demands:
513 raise TranslatorException(
514 "Unable to resolve inventory "
515 "candidates for demand {}"
518 resolved_candidates = resolved_demands.get(name)
519 for candidate in resolved_candidates:
520 inventory_candidates.append(candidate)
521 if len(inventory_candidates) < 1:
522 if not required_candidates:
523 raise TranslatorException(
524 "Unable to find any candidate for "
525 "demand {}".format(name)
528 raise TranslatorException(
529 "Unable to find any required "
530 "candidate for demand {}"
534 "candidates": inventory_candidates,
539 def parse_constraints(self, constraints):
540 """Validate/prepare constraints for use by the solver."""
541 if not isinstance(constraints, dict):
542 raise TranslatorException("Constraints must be provided in "
545 # Look at each constraint. Properties must exist, even if empty.
546 constraints_copy = copy.deepcopy(constraints)
549 for name, constraint in constraints_copy.items():
551 if not constraint.get('properties'):
552 constraint['properties'] = {}
554 constraint_type = constraint.get('type')
555 constraint_def = CONSTRAINTS.get(constraint_type)
557 # Is it a supported type?
558 if constraint_type not in CONSTRAINTS:
559 raise TranslatorException(
560 "Unsupported type '{}' found in constraint "
561 "named '{}'".format(constraint_type, name))
563 # Now walk through the constraint's content
564 for key, value in constraint.items():
565 # Must be a supported key
566 if key not in CONSTRAINT_KEYS:
567 raise TranslatorException(
568 "Invalid key '{}' found in constraint "
569 "named '{}'".format(key, name))
572 if key == 'properties':
573 # Make sure all required properties are present
574 required = constraint_def.get('required', [])
575 for req_prop in required:
576 if req_prop not in value.keys():
577 raise TranslatorException(
578 "Required property '{}' not found in "
579 "constraint named '{}'".format(
581 if not value.get(req_prop) \
582 or value.get(req_prop) == '':
583 raise TranslatorException(
584 "No value specified for property '{}' in "
585 "constraint named '{}'".format(
588 # Make sure there are no unknown properties
589 optional = constraint_def.get('optional', [])
590 for prop_name in value.keys():
591 if prop_name not in required + optional:
592 raise TranslatorException(
593 "Unknown property '{}' in "
594 "constraint named '{}'".format(
597 # If a property has a controlled vocabulary, make
598 # sure its value is one of the allowed ones.
599 allowed = constraint_def.get('allowed', {})
600 for prop_name, allowed_values in allowed.items():
601 if prop_name in value.keys():
602 prop_value = value.get(prop_name, '')
603 if prop_value not in allowed_values:
604 raise TranslatorException(
605 "Property '{}' value '{}' unsupported in "
606 "constraint named '{}' (must be one of "
607 "{})".format(prop_name, prop_value,
608 name, allowed_values))
610 # Break all threshold-formatted values into parts
611 thresholds = constraint_def.get('thresholds', {})
612 for thr_prop, base_units in thresholds.items():
613 if thr_prop in value.keys():
614 expression = value.get(thr_prop)
615 thr = threshold.Threshold(expression, base_units)
616 value[thr_prop] = thr.parts
618 # We already know we have one or more demands due to
619 # validate_components(). We still need to coerce the demands
620 # into a list in case only one demand was provided.
621 constraint_demands = constraint.get('demands')
622 if isinstance(constraint_demands, six.string_types):
623 constraint['demands'] = [constraint_demands]
625 # Either split the constraint into parts, one per demand,
627 if constraint_def.get('split'):
628 for demand in constraint.get('demands', []):
629 constraint_demand = name + '_' + demand
630 parsed[constraint_demand] = copy.deepcopy(constraint)
631 parsed[constraint_demand]['name'] = name
632 parsed[constraint_demand]['demands'] = demand
634 parsed[name] = copy.deepcopy(constraint)
635 parsed[name]['name'] = name
639 def parse_optimization(self, optimization):
640 """Validate/prepare optimization for use by the solver."""
642 # WARNING: The template format for optimization is generalized,
643 # however the solver is very particular about the expected
644 # goal, functions, and operands. Therefore, for the time being,
645 # we are choosing to be highly conservative in what we accept
646 # at the template level. Once the solver can handle the more
647 # general form, we can make the translation pass in this
648 # essentially pre-parsed formula unchanged, or we may allow
649 # optimizations to be written in algebraic form and pre-parsed
650 # with antlr4-python2-runtime. (jdandrea 1 Dec 2016)
653 LOG.debug("No objective function or "
654 "optimzation provided in the template")
657 optimization_copy = copy.deepcopy(optimization)
664 if type(optimization_copy) is not dict:
665 raise TranslatorException("Optimization must be a dictionary.")
667 goals = optimization_copy.keys()
668 if goals != ['minimize']:
669 raise TranslatorException(
670 "Optimization must contain a single goal of 'minimize'.")
672 funcs = optimization_copy['minimize'].keys()
674 raise TranslatorException(
675 "Optimization goal 'minimize' must "
676 "contain a single function of 'sum'.")
678 operands = optimization_copy['minimize']['sum']
679 if type(operands) is not list:
680 # or len(operands) != 2:
681 raise TranslatorException(
682 "Optimization goal 'minimize', function 'sum' "
683 "must be a list of exactly two operands.")
685 def get_distance_between_args(operand):
686 args = operand.get('distance_between')
687 if type(args) is not list and len(args) != 2:
688 raise TranslatorException(
689 "Optimization 'distance_between' arguments must "
690 "be a list of length two.")
695 if not got_demand and arg in self._demands.keys():
697 if not got_location and arg in self._locations.keys():
699 if not got_demand or not got_location:
700 raise TranslatorException(
701 "Optimization 'distance_between' arguments {} must "
702 "include one valid demand name and one valid "
703 "location name.".format(args))
707 for operand in operands:
712 if operand.keys() == ['distance_between']:
713 # Value must be a list of length 2 with one
714 # location and one demand
715 args = get_distance_between_args(operand)
717 elif operand.keys() == ['product']:
718 for product_op in operand['product']:
719 if threshold.is_number(product_op):
721 elif type(product_op) is dict:
722 if product_op.keys() == ['distance_between']:
723 function = 'distance_between'
724 args = get_distance_between_args(product_op)
725 elif product_op.keys() == ['aic_version']:
726 function = 'aic_version'
727 args = product_op.get('aic_version')
728 elif product_op.keys() == ['sum']:
730 nested_operands = product_op.get('sum')
731 for nested_operand in nested_operands:
732 if nested_operand.keys() == ['product']:
733 nested_weight = weight
734 for nested_product_op in nested_operand['product']:
735 if threshold.is_number(nested_product_op):
736 nested_weight = nested_weight * int(nested_product_op)
737 elif type(nested_product_op) is dict:
738 if nested_product_op.keys() == ['distance_between']:
739 function = 'distance_between'
740 args = get_distance_between_args(nested_product_op)
741 parsed['operands'].append(
743 "operation": "product",
744 "weight": nested_weight,
745 "function": function,
746 "function_param": args,
750 elif type(product_op) is unicode:
751 if product_op == 'W1':
752 # get this weight from configuration file
753 weight = self.conf.controller.weight1
754 elif product_op == 'W2':
755 # get this weight from configuration file
756 weight = self.conf.controller.weight2
757 elif product_op == 'cost':
761 raise TranslatorException(
762 "Optimization products must include at least "
763 "one 'distance_between' function call and "
764 "one optional number to be used as a weight.")
766 # We now have our weight/function_param.
768 parsed['operands'].append(
770 "operation": "product",
772 "function": function,
773 "function_param": args,
778 def parse_reservations(self, reservations):
779 demands = self._demands
780 if type(reservations) is not dict:
781 raise TranslatorException("Reservations must be provided in "
786 parsed['counter'] = 0
787 for name, reservation in reservations.items():
788 if not reservation.get('properties'):
789 reservation['properties'] = {}
790 for demand in reservation.get('demands', []):
791 if demand in demands.keys():
792 constraint_demand = name + '_' + demand
793 parsed['demands'] = {}
794 parsed['demands'][constraint_demand] = copy.deepcopy(reservation)
795 parsed['demands'][constraint_demand]['name'] = name
796 parsed['demands'][constraint_demand]['demand'] = demand
800 def do_translation(self):
801 """Perform the translation."""
803 raise TranslatorException("Can't translate an invalid template.")
805 request_type = self._parameters.get("request_type") or ""
807 self._translation = {
808 "conductor_solver": {
809 "version": self._version,
810 "plan_id": self._plan_id,
811 "request_type": request_type,
812 "locations": self.parse_locations(self._locations),
813 "demands": self.parse_demands(self._demands),
814 "objective": self.parse_optimization(self._optmization),
815 "constraints": self.parse_constraints(self._constraints),
816 "objective": self.parse_optimization(self._optmization),
817 "reservations": self.parse_reservations(self._reservations),
822 """Translate the template for the solver."""
825 self.create_components()
826 self.validate_components()
827 self.parse_parameters()
828 self.do_translation()
830 except Exception as exc:
831 self._error_message = exc.message
835 """Returns True if the template has been validated."""
840 """Returns True if the translation was successful."""
844 def translation(self):
845 """Returns the translation if it was successful."""
846 return self._translation
849 def error_message(self):
850 """Returns the last known error message."""
851 return self._error_message
855 template_name = 'some_template'
857 path = os.path.abspath(conductor_root)
858 dir_path = os.path.dirname(path)
860 # Prepare service-wide components (e.g., config)
861 conf = service.prepare_service(
862 [], config_files=[dir_path + '/../etc/conductor/conductor.conf'])
863 # conf.set_override('mock', True, 'music_api')
865 t1 = threshold.Threshold("< 500 ms", "time")
866 t2 = threshold.Threshold("= 120 mi", "distance")
867 t3 = threshold.Threshold("160", "currency")
868 t4 = threshold.Threshold("60-80 Gbps", "throughput")
869 print('t1: {}\nt2: {}\nt3: {}\nt4: {}\n'.format(t1, t2, t3, t4))
871 template_file = dir_path + '/tests/data/' + template_name + '.yaml'
872 fd = open(template_file, "r")
873 template = yaml.load(fd)
875 trns = Translator(conf, template_name, str(uuid.uuid4()), template)
878 print(json.dumps(trns.translation, indent=2))
880 print("TESTING - Translator Error: {}".format(trns.error_message))
883 if __name__ == '__main__':