Coverage for melissa/launcher/monitoring/rest.py: 34%

67 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-09-22 10:36 +0000

1#!/usr/bin/python3 

2 

3# Copyright (c) 2022, Institut National de Recherche en Informatique et en Automatique (Inria), 

4# Poznan Supercomputing and Networking Center (PSNC) 

5# All rights reserved. 

6# 

7# Redistribution and use in source and binary forms, with or without 

8# modification, are permitted provided that the following conditions are met: 

9# 

10# * Redistributions of source code must retain the above copyright notice, this 

11# list of conditions and the following disclaimer. 

12# 

13# * Redistributions in binary form must reproduce the above copyright notice, 

14# this list of conditions and the following disclaimer in the documentation 

15# and/or other materials provided with the distribution. 

16# 

17# * Neither the name of the copyright holder nor the names of its 

18# contributors may be used to endorse or promote products derived from 

19# this software without specific prior written permission. 

20# 

21# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 

22# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 

23# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 

24# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 

25# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 

26# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 

27# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 

28# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 

29# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 

30# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 

31 

32from collections import namedtuple 

33import rapidjson 

34import logging 

35import re 

36from http import HTTPStatus 

37from http.server import BaseHTTPRequestHandler, HTTPServer 

38from typing import Any, Dict, List, Tuple 

39 

40from melissa.launcher import __version__ as version 

41 

42from melissa.launcher import event 

43from melissa.launcher.action import Action 

44from melissa.launcher.event import Event 

45from melissa.launcher.state_machine import State 

46 

47logger = logging.getLogger(__name__) 

48 

49 

50Job = namedtuple("Job", ["id", "unique_id", "state"]) 

51 

52 

53class RestHttpHandler(BaseHTTPRequestHandler): 

54 """Incoming request handler for REST API""" 

55 

56 def __init__(self, request, client_address, server: HTTPServer) -> None: 

57 # ensure the base class constructor is called AFTER initializing all 

58 # properties 

59 self.server_version = "melissa-launcher/%s" % version 

60 super().__init__(request, client_address, server) 

61 

62 def log_error(self, format: str, *args, **kwargs) -> None: 

63 logger.error(format, *args, **kwargs) 

64 

65 def log_message(self, format: str, *args) -> None: 

66 logger.debug(format, *args) 

67 

68 def _respond(self, data: Dict[str, Any]) -> None: 

69 response_str = rapidjson.dumps(data).encode() + b"\n" 

70 self.send_response(HTTPStatus.OK) 

71 self.send_header("content-type", "application/json") 

72 self.send_header("content-length", str(len(response_str))) 

73 self.end_headers() 

74 self.wfile.write(response_str) 

75 

76 def do_GET(self) -> None: 

77 """Callable triggered everytime HTTP GET 

78 request hits the server. 

79 """ 

80 forbidden = True 

81 if "token" not in self.headers: 

82 logger.debug("GET refused: no token in header") 

83 elif self.headers["token"] != self.server._token: # type: ignore 

84 logger.debug("GET refused: wrong token") 

85 else: 

86 forbidden = False 

87 

88 if forbidden: 

89 self.send_response(HTTPStatus.FORBIDDEN) 

90 self.end_headers() 

91 return 

92 

93 m = re.fullmatch("/jobs", self.path) 

94 if m is not None: 

95 self._respond({"jobs": [j for j in self.server._jobs.keys()]}) # type: ignore 

96 return 

97 

98 m = re.fullmatch("/jobs/([0-9]+)", self.path) 

99 if m is not None: 

100 uid = int(m.group(1)) 

101 jobs = [j for j in self.server._jobs.keys() if j == uid] # type: ignore 

102 assert len(jobs) <= 1 

103 if len(jobs) == 1: 

104 self._respond(self.server._jobs[jobs[0]]) # type: ignore 

105 return 

106 

107 self.send_response(HTTPStatus.NOT_FOUND) 

108 self.end_headers() 

109 

110 

111class RestHttpServer(HTTPServer): 

112 def __init__( 

113 self, server_address: Tuple[str, int], token: str, state: State 

114 ) -> None: 

115 super().__init__(server_address, RestHttpHandler) 

116 self._token = token 

117 self._state = state 

118 self._jobs: Dict[int, Dict[str, Any]] = {} 

119 

120 def notify( 

121 self, ev: Event, new_state: State, actions: List[Action] 

122 ) -> None: 

123 self._state = new_state 

124 

125 if isinstance(ev, event.JobSubmission): 

126 self._jobs[ev.job.unique_id()] = { 

127 'id': int(ev.job.id()), 

128 'unique_id': int(ev.job.unique_id()), 

129 'state': str(ev.job.state()) 

130 } 

131 

132 if isinstance(ev, event.JobUpdate): 

133 for job in ev.jobs: 

134 self._jobs[job.unique_id()]['state'] = str(job.state())