xref: /OpenGrok/tools/src/main/python/opengrok_tools/utils/commandsequence.py (revision cff95066cb05b52120fe4f3cd315b963de174813)
1#
2# CDDL HEADER START
3#
4# The contents of this file are subject to the terms of the
5# Common Development and Distribution License (the "License").
6# You may not use this file except in compliance with the License.
7#
8# See LICENSE.txt included in this distribution for the specific
9# language governing permissions and limitations under the License.
10#
11# When distributing Covered Code, include this CDDL HEADER in each
12# file and include the License file at LICENSE.txt.
13# If applicable, add the following below this CDDL HEADER, with the
14# fields enclosed by brackets "[]" replaced with your own identifying
15# information: Portions Copyright [yyyy] [name of copyright owner]
16#
17# CDDL HEADER END
18#
19
20#
21# Copyright (c) 2017, 2022, Oracle and/or its affiliates. All rights reserved.
22#
23
24import logging
25
26from requests.exceptions import RequestException
27
28from .command import Command
29from .exitvals import (
30    CONTINUE_EXITVAL,
31    SUCCESS_EXITVAL,
32    FAILURE_EXITVAL
33)
34from .restful import call_rest_api
35from .patterns import PROJECT_SUBST, COMMAND_PROPERTY, CALL_PROPERTY, URL_SUBST
36import re
37
38
39API_TIMEOUT_PROPERTY = "api_timeout"
40ASYNC_API_TIMEOUT_PROPERTY = "async_api_timeout"
41HEADERS_PROPERTY = "headers"
42METHOD_PROPERTY = "method"
43URI_PROPERTY = "uri"
44
45
46class CommandConfigurationException(Exception):
47    pass
48
49
50def check_call_config(call):
51    """
52    :param call: dictionary with API call configuration
53    """
54    if not isinstance(call, dict):
55        raise CommandConfigurationException("call value not a dictionary: {}".
56                                            format(call))
57
58    uri = call.get(URI_PROPERTY)
59    if not uri:
60        raise CommandConfigurationException(f"no '{URI_PROPERTY}' key present in {call}")
61
62    method = call.get(METHOD_PROPERTY)
63    if method and method.upper() not in ['GET', 'POST', 'PUT', 'DELETE']:
64        raise CommandConfigurationException(f"invalid HTTP method: {method}")
65
66    headers = call.get(HEADERS_PROPERTY)
67    if headers and not isinstance(headers, dict):
68        raise CommandConfigurationException("headers must be a dictionary")
69
70    call_timeout = call.get(API_TIMEOUT_PROPERTY)
71    if call_timeout:
72        try:
73            int(call_timeout)
74        except ValueError as exc:
75            raise CommandConfigurationException(f"{API_TIMEOUT_PROPERTY} not an integer", exc)
76
77    call_api_timeout = call.get(ASYNC_API_TIMEOUT_PROPERTY)
78    if call_api_timeout:
79        try:
80            int(call_api_timeout)
81        except ValueError as exc:
82            raise CommandConfigurationException(f"{ASYNC_API_TIMEOUT_PROPERTY} not an integer", exc)
83
84
85def check_command_property(command):
86    """
87    Check if the 'commands' parameter of CommandSequenceBase() has the right structure
88    w.r.t. individual commands.
89    :param command: command element
90    """
91
92    if not isinstance(command, dict):
93        raise CommandConfigurationException("command '{}' is not a dictionary".format(command))
94
95    command_value = command.get(COMMAND_PROPERTY)
96    call_value = command.get(CALL_PROPERTY)
97    if command_value is None and call_value is None:
98        raise CommandConfigurationException(f"command dictionary has unknown key: {command}")
99
100    if command_value and not isinstance(command_value, list):
101        raise CommandConfigurationException("command value not a list: {}".
102                                            format(command_value))
103    if call_value:
104        check_call_config(call_value)
105
106
107class ApiCall:
108    """
109    Container class to store properties of API call.
110    """
111    def __init__(self, call_dict):
112        """
113        Initialize the object from a dictionary.
114        :param call_dict: dictionary
115        """
116        if not isinstance(call_dict, dict):
117            raise CommandConfigurationException(f"not a dictionary: {call_dict}")
118
119        self.uri = call_dict.get(URI_PROPERTY)
120        self.method = call_dict.get(METHOD_PROPERTY)
121        if not self.method:
122            self.method = "GET"
123
124        self.data = call_dict.get("data")
125
126        self.headers = call_dict.get(HEADERS_PROPERTY)
127        if not self.headers:
128            self.headers = {}
129
130        self.api_timeout = None
131        call_timeout = call_dict.get(API_TIMEOUT_PROPERTY)
132        if call_timeout:
133            self.api_timeout = call_timeout
134
135        self.async_api_timeout = None
136        call_api_timeout = call_dict.get(ASYNC_API_TIMEOUT_PROPERTY)
137        if call_api_timeout:
138            self.async_api_timeout = call_api_timeout
139
140
141class CommandSequenceBase:
142    """
143    Wrap the run of a set of Command instances.
144
145    This class intentionally does not contain any logging
146    so that it can be passed through Pool.map().
147    """
148
149    def __init__(self, name, commands, loglevel=logging.INFO, cleanup=None,
150                 driveon=False, url=None, env=None, http_headers=None,
151                 api_timeout=None, async_api_timeout=None):
152        self.name = name
153
154        if commands is None:
155            raise CommandConfigurationException("commands is None")
156        if not isinstance(commands, list):
157            raise CommandConfigurationException("commands is not a list")
158        self.commands = commands
159        for command in self.commands:
160            check_command_property(command)
161
162        self.failed = False
163        self.retcodes = {}
164        self.outputs = {}
165
166        if cleanup and not isinstance(cleanup, list):
167            raise CommandConfigurationException("cleanup is not a list of commands")
168        self.cleanup = cleanup
169        if self.cleanup:
170            for command in self.cleanup:
171                check_command_property(command)
172
173        self.loglevel = loglevel
174        self.driveon = driveon
175        self.env = env
176        self.http_headers = http_headers
177        self.api_timeout = api_timeout
178        self.async_api_timeout = async_api_timeout
179
180        self.url = url
181
182    def __str__(self):
183        return str(self.name)
184
185    def get_cmd_output(self, cmd, indent=""):
186        """
187        :param cmd: command
188        :param indent: prefix for each line
189        :return: command output as string
190        """
191
192        str_out = ""
193        for line in self.outputs.get(cmd, []):
194            str_out += '{}{}'.format(indent, line)
195
196        return str_out
197
198    def fill(self, retcodes, outputs, failed):
199        self.retcodes = retcodes
200        self.outputs = outputs
201        self.failed = failed
202
203
204class CommandSequence(CommandSequenceBase):
205    re_program = re.compile('ERROR[:]*\\s+')
206
207    def __init__(self, base):
208        super().__init__(base.name, base.commands, loglevel=base.loglevel,
209                         cleanup=base.cleanup, driveon=base.driveon,
210                         url=base.url, env=base.env,
211                         http_headers=base.http_headers,
212                         api_timeout=base.api_timeout,
213                         async_api_timeout=base.async_api_timeout)
214
215        self.logger = logging.getLogger(__name__)
216        self.logger.setLevel(base.loglevel)
217
218    def run_command(self, cmd):
219        """
220        Execute a command and return its return code.
221        """
222        cmd.execute()
223        self.retcodes[str(cmd)] = cmd.getretcode()
224        self.outputs[str(cmd)] = cmd.getoutput()
225
226        return cmd.getretcode()
227
228    def run(self):
229        """
230        Run the sequence of commands and capture their output and return code.
231        First command that returns code other than 0 terminates the sequence.
232        If the command has return code 2, the sequence will be terminated
233        however it will not be treated as error (unless the 'driveon' parameter
234        is True).
235
236        If a command contains PROJECT_SUBST pattern, it will be replaced
237        by project name, otherwise project name will be appended to the
238        argument list of the command.
239
240        Any command entry that is a URI, will be used to submit REST API
241        request.
242        """
243
244        for command in self.commands:
245            if command.get(CALL_PROPERTY):
246                try:
247                    call_rest_api(ApiCall(command.get(CALL_PROPERTY)),
248                                  {PROJECT_SUBST: self.name,
249                                   URL_SUBST: self.url},
250                                  self.http_headers,
251                                  self.api_timeout,
252                                  self.async_api_timeout)
253                except RequestException as e:
254                    self.logger.error("REST API call {} failed: {}".
255                                      format(command, e))
256                    self.failed = True
257                    self.retcodes[str(command)] = FAILURE_EXITVAL
258
259                    break
260            elif command.get(COMMAND_PROPERTY):
261                command_args = command.get(COMMAND_PROPERTY)
262                command = Command(command_args,
263                                  env_vars=command.get("env"),
264                                  logger=self.logger,
265                                  resource_limits=command.get("limits"),
266                                  args_subst={PROJECT_SUBST: self.name,
267                                              URL_SUBST: self.url},
268                                  args_append=[self.name], excl_subst=True)
269                ret_code = self.run_command(command)
270
271                # If a command exits with non-zero return code,
272                # terminate the sequence of commands.
273                if ret_code != SUCCESS_EXITVAL:
274                    if ret_code == CONTINUE_EXITVAL:
275                        if not self.driveon:
276                            self.logger.debug("command '{}' for project {} "
277                                              "requested break".
278                                              format(command, self.name))
279                            self.run_cleanup()
280                        else:
281                            self.logger.debug("command '{}' for project {} "
282                                              "requested break however "
283                                              "the 'driveon' option is set "
284                                              "so driving on.".
285                                              format(command, self.name))
286                            continue
287                    else:
288                        self.logger.error("command '{}' for project {} failed "
289                                          "with code {}, breaking".
290                                          format(command, self.name, ret_code))
291                        self.failed = True
292                        self.run_cleanup()
293
294                    break
295            else:
296                raise Exception(f"unknown command: {command}")
297
298    def run_cleanup(self):
299        """
300        Call cleanup sequence in case the command sequence failed
301        or termination was requested.
302        """
303        if self.cleanup is None:
304            return
305
306        for cleanup_cmd in self.cleanup:
307            if cleanup_cmd.get(CALL_PROPERTY):
308                try:
309                    call_rest_api(ApiCall(cleanup_cmd.get(CALL_PROPERTY)),
310                                  {PROJECT_SUBST: self.name,
311                                   URL_SUBST: self.url},
312                                  self.http_headers,
313                                  self.api_timeout,
314                                  self.async_api_timeout)
315                except RequestException as e:
316                    self.logger.error("API call {} failed: {}".
317                                      format(cleanup_cmd, e))
318            elif cleanup_cmd.get(COMMAND_PROPERTY):
319                command_args = cleanup_cmd.get(COMMAND_PROPERTY)
320                self.logger.debug("Running cleanup command '{}'".
321                                  format(command_args))
322                cmd = Command(command_args,
323                              logger=self.logger,
324                              args_subst={PROJECT_SUBST: self.name,
325                                          URL_SUBST: self.url},
326                              args_append=[self.name], excl_subst=True)
327                cmd.execute()
328                if cmd.getretcode() != SUCCESS_EXITVAL:
329                    self.logger.error("cleanup command '{}' failed with "
330                                      "code {}".
331                                      format(cmd.cmd, cmd.getretcode()))
332                    self.logger.info('output: {}'.format(cmd.getoutputstr()))
333            else:
334                raise Exception(f"unknown type of action: {cleanup_cmd}")
335
336    def print_outputs(self, logger, loglevel=logging.INFO, lines=False):
337        """
338        Print command outputs.
339        """
340
341        logger.debug("Output for project '{}':".format(self.name))
342        for cmd in self.outputs.keys():
343            if self.outputs[cmd] and len(self.outputs[cmd]) > 0:
344                if lines:
345                    logger.log(loglevel, "Output from '{}':".format(cmd))
346                    logger.log(loglevel, '{}'.format(self.get_cmd_output(cmd)))
347                else:
348                    logger.log(loglevel, "'{}': {}".
349                               format(cmd, self.outputs[cmd]))
350
351    def check(self, ignore_errors):
352        """
353        Check the output of the commands and perform logging.
354
355        Return SUCCESS_EXITVAL on success, 1 if error was detected.
356        """
357
358        ret = SUCCESS_EXITVAL
359        self.print_outputs(self.logger, loglevel=logging.DEBUG)
360
361        if ignore_errors and self.name in ignore_errors:
362            self.logger.debug("errors of project '{}' ignored".
363                              format(self.name))
364            return
365
366        self.logger.debug("retcodes = {}".format(self.retcodes))
367        if any(rv != SUCCESS_EXITVAL and rv != CONTINUE_EXITVAL
368               for rv in self.retcodes.values()):
369            ret = 1
370            self.logger.error("processing of project '{}' failed".
371                              format(self))
372            indent = "  "
373            self.logger.error("{}failed commands:".format(indent))
374            failed_cmds = {k: v for k, v in
375                           self.retcodes.items() if v != SUCCESS_EXITVAL}
376            indent = "    "
377            for cmd in failed_cmds.keys():
378                self.logger.error("{}'{}': {}".
379                                  format(indent, cmd, failed_cmds[cmd]))
380                out = self.get_cmd_output(cmd,
381                                          indent=indent + "  ")
382                if out:
383                    self.logger.error(out)
384            self.logger.error("")
385
386        errored_cmds = {k: v for k, v in self.outputs.items()
387                        if self.re_program.match(str(v))}
388        if len(errored_cmds) > 0:
389            ret = 1
390            self.logger.error("Command output in project '{}'"
391                              " contains errors:".format(self.name))
392            indent = "  "
393            for cmd in errored_cmds.keys():
394                self.logger.error("{}{}".format(indent, cmd))
395                out = self.get_cmd_output(cmd,
396                                          indent=indent + "  ")
397                if out:
398                    self.logger.error(out)
399                self.logger.error("")
400
401        return ret
402