######################################################################## # Copyright (C) 2019 VMWare, Inc. # All Rights Reserved ######################################################################## from copy import deepcopy from datetime import datetime import sys if sys.version >= '3': from abc import ABC, abstractmethod else: # scons build is still using python 2. from abc import ABCMeta as ABC, abstractmethod import json from .Bulletin import ComponentRelation from .ReleaseUnit import NameSpec, TIME_FORMAT, VersionSpec class InvalidConstraint(Exception): """Exception class to signify a bad constraint """ pass class InvalidRange(Exception): """Exception class that is used to signify that a bad range was used as a constraint. i.e. We can't form a closed range with the range operators. """ pass class InvalidRelationToken(Exception): """Exception class that is used to signify a bad range token. i.e. There is no >, >=, <. <= """ pass class EmptyList(Exception): """Exception class to signify that empty list was given as a constraint. """ pass class InvalidSolutionJSON(Exception): """Exception class to bad solution JSON file """ pass class InvalidSolutionArg(Exception): """Exception class for a Bad Solution Argument """ pass class MissingSolutionArg(Exception): """Exception class for a Missing Solution Argument """ pass class Solution(object): """A solution specification represents a set of necessary component constraints for a solution like FDM/HA or NSX. """ # Various JSON attributes which can in a solution JSON file solutionAttrib = 'solution' simpleAttribs = ['description', 'summary', 'vendor', 'docURL'] nameSpecAttrib = 'nameSpec' versionSpecAttrib = 'versionSpec' componentsAttrib = 'components' releaseDateAttrib = 'releaseDate' readOnlyAttribs = ['releaseID', 'releaseType'] mandatoryAttribs = (nameSpecAttrib, versionSpecAttrib, componentsAttrib) def __init__(self, **kwargs): """Creates a solution specification given arguments Params: kwArgs - arguments for creating the Solution """ missingMandatoryArgs = [arg for arg in self.__class__.mandatoryAttribs if arg not in kwargs.keys()] self.componentConstraints = [] if missingMandatoryArgs: raise MissingSolutionArg('The following mandatory Solution kwargs' ' are missing %s' % ' ,'.join(missingMandatoryArgs)) setattr(self, 'releaseType', self.__class__.solutionAttrib) for fieldName, fieldValue in kwargs.items(): if fieldName in self.__class__.simpleAttribs: setattr(self, fieldName, fieldValue) elif fieldName == self.__class__.versionSpecAttrib: versionSpecKeys = ('version', 'uiString') for key in versionSpecKeys: if key not in fieldValue: raise InvalidSolutionArg('Missing argument in VersionSpec: ' ' %s' % key) self.versionSpec = VersionSpec(fieldValue['version'], fieldValue['uiString']) elif fieldName == self.__class__.nameSpecAttrib: nameSpecKeys = ('name', 'uiString') for key in nameSpecKeys: if key not in fieldValue: raise InvalidSolutionArg('Missing argument in' ' NameSpec: %s' % key) self.nameSpec = NameSpec(fieldValue['name'], fieldValue['uiString']) elif fieldName == self.__class__.componentsAttrib: for componentName, constraints in fieldValue.items(): self.componentConstraints.append( ComponentConstraint.Factory(componentName, constraints)) elif (fieldName not in self.__class__.readOnlyAttribs and fieldName != self.__class__.releaseDateAttrib): raise InvalidSolutionArg('Unknown Solution kwarg: %s' % fieldName) if 'releaseID' in kwargs: self.releaseID = kwargs.get('releaseID') else: self.releaseID = self.nameSpec.name + '_' + \ self.versionSpec.version.versionstring if self.__class__.releaseDateAttrib in kwargs: self.releaseDate = datetime.strptime(kwargs.get( self.__class__.releaseDateAttrib), TIME_FORMAT) else: self.releaseDate = datetime.utcnow() def MatchComponents(self, components): """Get components in the component collection that match to this solution. Returns: A dict that has component name as key and component as value. """ solCompDict = dict() for constraint in self.componentConstraints: comps = constraint.MatchComponents(components) if comps: # Component name will not be added the component is not found. solCompDict.setdefault(constraint.componentName, []).extend(comps) return solCompDict def Validate(self, componentCollection): """Validates that a component collection meets the constraints to realize a solution. Params: componentCollection - A collection of components to check Returns: Boolean - True if the component collection meets the component constraints of this solution List - The list of components from the solution whose constraints aren't met from the componentCollection. If all constraints are met an empty list is returned """ failedValidation = [] for componentContraint in self.componentConstraints: if not componentContraint.Validate(componentCollection): failedValidation.append(componentContraint.componentName) return failedValidation == [], failedValidation def __eq__(self, other): """Checks if to solutions are equal Returns: True if the solutions are equal otherwise false """ return self.ToDict() == other.ToDict() def ToDict(self): """Creates a dictionary from the Solution Returns: A dictionary with all the solution attributes """ solDict = {} componentDictItems = {} for componentConstraint in self.componentConstraints: componentDictItems.update(componentConstraint.ToDict()) solDict[self.__class__.componentsAttrib] = componentDictItems solDict[self.__class__.nameSpecAttrib] = self.nameSpec.__dict__ solDict[self.__class__.versionSpecAttrib] = self.versionSpec.ToJSONDict() for attribute in (self.__class__.simpleAttribs + self.__class__.readOnlyAttribs): solDict[attribute] = getattr(self, attribute) solDict[self.__class__.releaseDateAttrib] = self.releaseDate.strftime(TIME_FORMAT) return solDict @classmethod def _FromJSONDict(cls, solutionDict): if ('releaseType' not in solutionDict or solutionDict['releaseType'] != cls.solutionAttrib): raise InvalidSolutionJSON('Invalid release type in solution spec.') return cls(**solutionDict) @classmethod def FromJSONFile(cls, filename, validation=False): """Creates a solution object from a JSON file. Params: filename - The JSON file to create a solution object validation - If True the function will perform schema validation. Returns: A Solution Object created from a JSON file """ with open(filename, 'r') as f: solutionDict = json.load(f) return cls._FromJSONDict(solutionDict) @classmethod def FromJSON(cls, specStr, validation=False): """Creates a solution object from a JSON string. Params: specStr - The JSON string to create a solution object from validation - If True the function will perform schema validation. Returns: A Solution Object created from a JSON file """ solutionDict = json.loads(specStr) return cls._FromJSONDict(solutionDict) def ToJSONFile(self, filename): """Writes a Solution object to a JSON file Params: filename - The JSON file to write the object to """ with open(filename, 'w') as f: json.dump(self.ToDict(), f) def ToJSON(self): """Serialize a Solution object to a JSON string. """ return json.dumps(self.ToDict()) def Copy(self): """Creates a copy of this solution object Returns: A new copied solution object """ return deepcopy(self) class ComponentConstraint(ABC): """A Component Constraint is used to define the necessary versioning requirements for a component to enable a solution. """ @staticmethod def Factory(name, constraints): """This factory method creates a component constraint based upon constraint type. Params: name - The name of the component that has the constraint constraints - Either a list of individual constraints or a dictionary containing a constraint range. """ if type(constraints) == list: return ComponentConstraintList(name, constraints) elif type(constraints) == dict: return ComponentConstraintRange(name, constraints) else: raise InvalidConstraint('Component constrains must be in either list' ' or a range in form a two value dictionary.') @abstractmethod def Validate(self, componentCollection): """Pure Virtual method that validates whether a component collection meets a component constraint. Child class will provide the actual implementation of this metHod Returns: Bool - True if the components in the collection meet the versioning constraints, otherwise false """ pass @abstractmethod def MatchComponents(self, components): """Get the components that meets the constraint from a component collection. Returns: A list of Component object, empty list when not found. """ pass @abstractmethod def ToDict(self): """Turns the name and constraints of this component constraint back into a dictionary. This is useful for JSON serialization of this object. Returns: Name to component constraint dictionary """ pass class ComponentConstraintList(ComponentConstraint): """A Component Constraint List is defines by a list of versions of a component that are necessary to realize a solution. As an example a solution that requires a component constraint for a versions 1.0 and 2.0 of a particular component 'MyComponent', would provide a component constraint definition like this: Name: MyComponent Version: ['1.0', '2.0'] """ def __init__(self, componentName, versionList): """Creates a component constraint based upon a component name and a list of component versions. Params: componentName - The name of the component that has a constraint versionList - The list of versions that we need for this component """ if not versionList: raise EmptyList('Constraing version list is empty') self.componentName = componentName self.versionList = versionList def Validate(self, componentCollection): """Validates that the components in the collection meets one or more of the versioning constraints in the version constraints list. Returns: True if the components in the collection meet the versioning constraints, otherwise False """ for version in self.versionList: if componentCollection.HasComponent(self.componentName, version): return True return False def MatchComponents(self, components): """Get the components that meets the constraint from a component collection. Returns: A list of Component object, empty list when not found. """ try: comps = components.GetComponents(name=self.componentName) except KeyError: # No component with the name. return [] return [c for c in comps if c.compVersionStr in self.versionList] def ToDict(self): """Turns the name and constraints of this component constraint back into a dictionary. This is useful for JSON serialization of this object. Returns: dictionary mapping name to version list """ return {self.componentName: self.versionList} class ComponentConstraintRange(ComponentConstraint): """A Component Constraint Range is used to define a range of versions of a component that a solution requires. An example of a solution that requires a component constraint for a range of components that are greater than version 1.0 and less than version 2.0 Name: MyComponent Version: {">":"1.0", "<":"2.0"} """ def __init__(self, componentName, rangeDict): """Creates a component constraint based upon a version range that is in a dictionary. Params: componentName - The name of the component that has a constraint rangeDict - A dictionary containg two items which describe the range of the component versions. """ self.componentName = componentName self.rangeDict = rangeDict if len(rangeDict) != 2: raise InvalidRange('The range dictionary must contain two item') self.relations = [] operator1, operator2 = rangeDict.keys() version1, version2 = rangeDict.values() self.relations.append(ComponentRelation(componentName, operator1, version1)) self.relations.append(ComponentRelation(componentName, operator2, version2)) greaterOps = ['>=', '>'] lesserOps = ['<=', '<'] if operator1 in greaterOps and operator2 in greaterOps: raise InvalidRange('Range operators must be of opposite' ' types to create a closed range, both operators' ' are greater than.') elif operator1 in lesserOps and operator2 in lesserOps: raise InvalidRange('Range operators must be of opposite ' ' types to create a closed range, both' ' operators are less than.') if operator1 in lesserOps: if version1 < version2: raise InvalidRange('Cannot create a closed bound when the upper' ' bound value: %s, is less than the lower' ' bound value: %s.' % (version1, version2)) elif version1 > version2: raise InvalidRange('Cannot create a closed bound when the lower bound' ' value: %s, is greater than the upper bound value' ' %s' % (version1, version2)) def Validate(self, componentCollection): """Validates that the components in the collection meets the range Returns: Bool - True if the components in the collection meet the versioning constraints, otherwise false """ for _, componentVerDict in componentCollection.items(): for _, component in componentVerDict.items(): isValid = True for relation in self.relations: isValid = relation.Validate(component) and isValid if isValid: return True return False def MatchComponents(self, components): """Get the components that meets the constraint from a component collection. Returns: A list of Component object, empty list when not found. """ try: comps = components.GetComponents(name=self.componentName) except KeyError: # No component with the name. return [] retList = [] for comp in comps: for relation in self.relations: if not relation.Validate(comp): break else: retList.append(comp) return retList def ToDict(self): """Turns the name and constraints of this component constraint back into a dictionary. This is useful for JSON serialization of this object. Returns: dictionary mapping name to version list """ return {self.componentName: self.rangeDict}