wrk_traffic_profile_parser.py revision b55e324d
1# Copyright (c) 2019 Cisco and / or its affiliates.
2# Licensed under the Apache License, Version 2.0 (the "License"); you may not
3# use this file except in compliance with the License. You may obtain a copy
4# of the License at:
5#
6#     http://www.apache.org/licenses/LICENSE-2.0
7#
8# Unless required by applicable law or agreed to in writing, software
9# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11# See the License for the specific language governing permissions
12# and limitations under the License.
13
14"""wrk traffic profile parser.
15
16See LLD for the structure of a wrk traffic profile.
17"""
18
19
20from os.path import isfile
21from pprint import pformat
22
23from yaml import safe_load, YAMLError
24from robot.api import logger
25
26from resources.tools.wrk.wrk_errors import WrkError
27
28
29class WrkTrafficProfile:
30    """The wrk traffic profile.
31    """
32
33    MANDATORY_PARAMS = (
34        u"urls",
35        u"first-cpu",
36        u"cpus",
37        u"duration",
38        u"nr-of-threads",
39        u"nr-of-connections"
40    )
41
42    INTEGER_PARAMS = (
43        (u"cpus", 1),
44        (u"first-cpu", 0),
45        (u"duration", 1),
46        (u"nr-of-threads", 1),
47        (u"nr-of-connections", 1)
48    )
49
50    def __init__(self, profile_name):
51        """Read the traffic profile from the yaml file.
52
53        :param profile_name: Path to the yaml file with the profile.
54        :type profile_name: str
55        :raises: WrkError if it is not possible to parse the profile.
56        """
57
58        self._profile_name = None
59        self._traffic_profile = None
60
61        self.profile_name = profile_name
62
63        try:
64            with open(self.profile_name, u"rt") as profile_file:
65                self.traffic_profile = safe_load(profile_file)
66        except IOError as err:
67            raise WrkError(
68                msg=f"An error occurred while opening the file "
69                f"'{self.profile_name}'.", details=str(err)
70            )
71        except YAMLError as err:
72            raise WrkError(
73                msg=f"An error occurred while parsing the traffic profile "
74                f"'{self.profile_name}'.", details=str(err)
75            )
76
77        self._validate_traffic_profile()
78
79        if self.traffic_profile:
80            logger.debug(
81                f"\nThe wrk traffic profile '{self.profile_name}' is valid.\n"
82            )
83            logger.debug(f"wrk traffic profile '{self.profile_name}':")
84            logger.debug(pformat(self.traffic_profile))
85        else:
86            logger.debug(
87                f"\nThe wrk traffic profile '{self.profile_name}' is invalid.\n"
88            )
89            raise WrkError(
90                f"\nThe wrk traffic profile '{self.profile_name}' is invalid.\n"
91            )
92
93    def __repr__(self):
94        return pformat(self.traffic_profile)
95
96    def __str__(self):
97        return pformat(self.traffic_profile)
98
99    def _validate_traffic_profile(self):
100        """Validate the traffic profile.
101
102        The specification, the structure and the rules are described in
103        doc/wrk_lld.rst
104        """
105
106        logger.debug(
107            f"\nValidating the wrk traffic profile '{self.profile_name}'...\n"
108        )
109        if not (self._validate_mandatory_structure()
110                and self._validate_mandatory_values()
111                and self._validate_optional_values()
112                and self._validate_dependencies()):
113            self.traffic_profile = None
114
115    def _validate_mandatory_structure(self):
116        """Validate presence of mandatory parameters in trafic profile dict
117
118        :returns: whether mandatory structure is followed by the profile
119        :rtype: bool
120        """
121        # Level 1: Check if the profile is a dictionary:
122        if not isinstance(self.traffic_profile, dict):
123            logger.error(u"The wrk traffic profile must be a dictionary.")
124            return False
125
126        # Level 2: Check if all mandatory parameters are present:
127        is_valid = True
128        for param in self.MANDATORY_PARAMS:
129            if self.traffic_profile.get(param, None) is None:
130                logger.error(f"The parameter '{param}' in mandatory.")
131                is_valid = False
132        return is_valid
133
134    def _validate_mandatory_values(self):
135        """Validate that mandatory profile values satisfy their constraints
136
137        :returns: whether mandatory values are acceptable
138        :rtype: bool
139        """
140        # Level 3: Mandatory params: Check if urls is a list:
141        is_valid = True
142        if not isinstance(self.traffic_profile[u"urls"], list):
143            logger.error(u"The parameter 'urls' must be a list.")
144            is_valid = False
145
146        # Level 3: Mandatory params: Check if integers are not below minimum
147        for param, minimum in self.INTEGER_PARAMS:
148            if not self._validate_int_param(param, minimum):
149                is_valid = False
150        return is_valid
151
152    def _validate_optional_values(self):
153        """Validate values for optional parameters, if present
154
155        :returns: whether present optional values are acceptable
156        :rtype: bool
157        """
158        is_valid = True
159        # Level 4: Optional params: Check if script is present:
160        script = self.traffic_profile.get(u"script", None)
161        if script is not None:
162            if not isinstance(script, str):
163                logger.error(u"The path to LuaJIT script in invalid")
164                is_valid = False
165            else:
166                if not isfile(script):
167                    logger.error(f"The file '{script}' does not exist.")
168                    is_valid = False
169        else:
170            self.traffic_profile[u"script"] = None
171            logger.debug(
172                u"The optional parameter 'LuaJIT script' is not defined. "
173                u"No problem."
174            )
175
176        # Level 4: Optional params: Check if header is present:
177        header = self.traffic_profile.get(u"header", None)
178        if header is not None:
179            if isinstance(header, dict):
180                header = u", ".join(
181                    f"{0}: {1}".format(*item) for item in header.items()
182                )
183                self.traffic_profile[u"header"] = header
184            elif not isinstance(header, str):
185                logger.error(u"The parameter 'header' type is not valid.")
186                is_valid = False
187
188            if not header:
189                logger.error(u"The parameter 'header' is defined but empty.")
190                is_valid = False
191        else:
192            self.traffic_profile[u"header"] = None
193            logger.debug(
194                u"The optional parameter 'header' is not defined. No problem."
195            )
196
197        # Level 4: Optional params: Check if latency is present:
198        latency = self.traffic_profile.get(u"latency", None)
199        if latency is not None:
200            if not isinstance(latency, bool):
201                logger.error(u"The parameter 'latency' must be boolean.")
202                is_valid = False
203        else:
204            self.traffic_profile[u"latency"] = False
205            logger.debug(
206                u"The optional parameter 'latency' is not defined. No problem."
207            )
208
209        # Level 4: Optional params: Check if timeout is present:
210        if u"timeout" in self.traffic_profile:
211            if not self._validate_int_param(u"timeout", 1):
212                is_valid = False
213        else:
214            self.traffic_profile[u"timeout"] = None
215            logger.debug(
216                u"The optional parameter 'timeout' is not defined. No problem."
217            )
218
219        return is_valid
220
221    def _validate_dependencies(self):
222        """Validate dependencies between parameters
223
224        :returns: whether dependencies between parameters are acceptable
225        :rtype: bool
226        """
227        # Level 5: Check urls and cpus:
228        if self.traffic_profile[u"cpus"] % len(self.traffic_profile[u"urls"]):
229            logger.error(
230                u"The number of CPUs must be a multiple of the number of URLs."
231            )
232            return False
233        return True
234
235    def _validate_int_param(self, param, minimum):
236        """Validate that an int parameter is set acceptably
237        If it is not an int already but a string, convert and store it as int.
238
239        :param param: Name of a traffic profile parameter
240        :param minimum: The minimum value for the named parameter
241        :type param: str
242        :type minimum: int
243        :returns: whether param is set to an int of at least minimum value
244        :rtype: bool
245        """
246        value = self._traffic_profile[param]
247        if isinstance(value, str):
248            if value.isdigit():
249                value = int(value)
250            else:
251                value = minimum - 1
252        if isinstance(value, int) and value >= minimum:
253            self.traffic_profile[param] = value
254            return True
255        logger.error(
256            f"The parameter '{param}' must be an integer and at least {minimum}"
257        )
258        return False
259
260    @property
261    def profile_name(self):
262        """Getter - Profile name.
263
264        :returns: The traffic profile file path
265        :rtype: str
266        """
267        return self._profile_name
268
269    @profile_name.setter
270    def profile_name(self, profile_name):
271        """
272
273        :param profile_name:
274        :type profile_name: str
275        """
276        self._profile_name = profile_name
277
278    @property
279    def traffic_profile(self):
280        """Getter: Traffic profile.
281
282        :returns: The traffic profile.
283        :rtype: dict
284        """
285        return self._traffic_profile
286
287    @traffic_profile.setter
288    def traffic_profile(self, profile):
289        """Setter - Traffic profile.
290
291        :param profile: The new traffic profile.
292        :type profile: dict
293        """
294        self._traffic_profile = profile
295