Initial version
[ta/remote-installer.git] / src / remoteinstaller / server / server.py
1 # Copyright 2019 Nokia
2
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #     http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import sys
16 import argparse
17 import logging
18 import os
19 from threading import Thread
20 import time
21 import json
22 import urllib
23 import urlparse
24 import uuid as uuid_module
25 import ssl
26
27 from wsgiref.simple_server import make_server
28 import routes
29
30 from remoteinstaller.installer.install import Installer
31 from remoteinstaller.installer.install import InstallException
32
33
34 class LoggingSSLSocket(ssl.SSLSocket):
35     def accept(self, *args, **kwargs):
36         try:
37             result = super(LoggingSSLSocket, self).accept(*args, **kwargs)
38         except Exception as ex:
39             logging.warn('SSLSocket.accept raised exception: %s', str(ex))
40             raise
41         return result
42
43
44 class InstallationWorker(Thread):
45     def __init__(self, server, uuid, admin_passwd, logdir, args=None):
46         super(InstallationWorker, self).__init__(name=uuid)
47         self._server = server
48         self._uuid = uuid
49         self._admin_passwd = admin_passwd
50         self._logdir = logdir
51         self._args = args
52
53     def run(self):
54         access_info = None
55         if self._args:
56             try:
57                 installer = Installer(self._args)
58                 #access_info = installer.install()
59
60                 logging.info('Installation triggered for %s', self._uuid)
61             except InstallException as ex:
62                 logging.warn('Installation triggering failed for %s: %s', self._uuid, str(ex))
63                 self._server.set_state(self._uuid, 'failed', str(ex), 0)
64                 return
65
66         installation_finished = False
67         while not installation_finished:
68             state = self._server.get_state(self._uuid)
69             if not state['status'] == 'ongoing':
70                 installation_finished = True
71             else:
72                 time.sleep(10)
73
74         logging.info('Installation finished for %s: %s', self._uuid, state)
75         if access_info:
76             logging.info('Login details for installation %s: %s', self._uuid, str(access_info))
77
78         logging.info('Getting logs for installation %s...', uuid)
79         #installer.get_logs(self._logdir, self._admin_passwd)
80         logging.info('Logs retrieved for %s', uuid)
81
82 class Server(object):
83     DEFAULT_PATH = '/opt/remoteinstaller'
84     USER_CONFIG_PATH = 'user-configs'
85     ISO_PATH = 'images'
86     CERTIFICATE_PATH = 'certificates'
87     INSTALLATIONS_PATH = 'installations'
88     #CLOUD_ISO_PATH = '{}/rec.iso'.format(ISO_PATH)
89     BOOT_ISO_PATH = '{}/boot.iso'.format(ISO_PATH)
90
91     def __init__(self, host, port, cert=None, key=None, client_cert=None, client_key=None, ca_cert=None, path=None, http_port=None):
92         self._host = host
93         self._port = port
94         self._http_port = http_port
95
96         self._path = path
97         if not self._path:
98             self._path = Server.DEFAULT_PATH
99
100         self._cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, cert)
101         self._key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, key)
102         self._client_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_cert)
103         self._client_key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_key)
104         self._ca_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, ca_cert)
105
106         self._ongoing_installations = {}
107         self._load_states()
108
109     def get_server_keys(self):
110         return {'cert': self._cert, 'key': self._key, 'ca_cert': self._ca_cert}
111
112     def _read_admin_passwd(self, cloud_name):
113         with open('{}/{}/{}/admin_passwd'.format(self._path,
114                                                  Server.USER_CONFIG_PATH,
115                                                  cloud_name)) as pwf:
116             admin_passwd = pwf.readline()
117
118         return admin_passwd
119
120     def _load_states(self):
121         uuid_list = os.listdir('{}/{}'.format(self._path, Server.INSTALLATIONS_PATH))
122         for uuid in uuid_list:
123             state_file_name = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
124             if os.path.exists(state_file_name):
125                 with open(state_file_name) as sf:
126                     state_json = sf.readline()
127                     self._ongoing_installations[uuid] = json.loads(state_json)
128
129                 if self._ongoing_installations[uuid]['status'] == 'ongoing':
130                     logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
131                     cloud_name = self._ongoing_installations[uuid]['cloud_name']
132                     admin_passwd = self._read_admin_passwd(cloud_name)
133                     worker = InstallationWorker(self, uuid, admin_passwd, logdir)
134                     worker.start()
135
136     def _set_state(self, uuid, status, description, percentage, cloud_name=None):
137         self._ongoing_installations[uuid] = {}
138         self._ongoing_installations[uuid]['status'] = status
139         self._ongoing_installations[uuid]['description'] = description
140         self._ongoing_installations[uuid]['percentage'] = percentage
141         if cloud_name:
142             self._ongoing_installations[uuid]['cloud_name'] = cloud_name
143
144         state_file = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
145         with open(state_file, 'w') as sf:
146             sf.write(json.dumps(self._ongoing_installations[uuid]))
147
148     def set_state(self, uuid, status, description, percentage):
149         logging.info('uuid=%s, status=%s, description=%s, percentage=%s',
150                      uuid, status, description, percentage)
151
152         if not uuid in self._ongoing_installations:
153             raise ServerError('Installation id {} not found'.format(uuid))
154
155         if not status in ['ongoing', 'failed', 'completed']:
156             raise ServerError('Invalid state: {}'.format(status))
157
158         self._set_state(uuid, status, description, percentage)
159
160     def get_state(self, uuid):
161         logging.info('uuid=%s', uuid)
162
163         if not uuid in self._ongoing_installations:
164             raise ServerError('Installation id {} not found'.format(uuid))
165
166         return {'status': self._ongoing_installations[uuid]['status'],
167                 'description': self._ongoing_installations[uuid]['description'],
168                 'percentage': self._ongoing_installations[uuid]['percentage']}
169
170     def start_installation(self, cloud_name, iso):
171         logging.info('start_installation(%s, %s)', cloud_name, iso)
172
173         uuid = str(uuid_module.uuid4())
174
175         args = argparse.Namespace()
176
177         args.yaml = '{}/{}/{}/user_config.yml'.format(self._path,
178                                                       Server.USER_CONFIG_PATH,
179                                                       cloud_name)
180         if not os.path.isfile(args.yaml):
181             raise ServerError('YAML file {} not found'.format(args.yaml))
182
183         iso_path = '{}/{}/{}'.format(self._path, Server.ISO_PATH, iso)
184         if not os.path.isfile(iso_path):
185             raise ServerError('ISO file {} not found'.format(iso_path))
186
187         http_port_part = ''
188         if self._http_port:
189             http_port_part = ':{}'.format(self._http_port)
190
191         args.iso = 'https://{}{}/{}/{}'.format(self._host, http_port_part, Server.ISO_PATH, iso)
192
193         args.logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
194
195         os.makedirs(args.logdir)
196
197         args.boot_iso = '{}/{}'.format(self._path, Server.BOOT_ISO_PATH)
198
199         args.tag = uuid
200         args.callback_url = 'http://{}:{}/v1/installations/{}/state'.format(self._host,
201                                                                             self._port,
202                                                                             uuid)
203
204         args.client_cert = self._client_cert
205         args.client_key = self._client_key
206         args.ca_cert = self._ca_cert
207         args.host_ip = self._host
208
209         self._set_state(uuid, 'ongoing', '', 0, cloud_name)
210
211         admin_passwd = self._read_admin_passwd(cloud_name)
212         worker = InstallationWorker(self, uuid, admin_passwd, args.logdir, args)
213         worker.start()
214
215         return uuid
216
217
218 class ServerError(Exception):
219     pass
220
221
222 class HTTPErrors(object):
223     # response for a successful GET, PUT, PATCH, DELETE,
224     # can also be used for POST that does not result in creation.
225     HTTP_OK = 200
226     # response to a POST which results in creation.
227     HTTP_CREATED = 201
228     # response to a successfull request that won't be returning any body like a DELETE request
229     HTTP_NO_CONTENT = 204
230     # used when http caching headers are in play
231     HTTP_NOT_MODIFIED = 304
232     # the request is malformed such as if the body does not parse
233     HTTP_BAD_REQUEST = 400
234     # when no or invalid authentication details are provided.
235     # also useful to trigger an auth popup API is used from a browser
236     HTTP_UNAUTHORIZED_OPERATION = 401
237     # when authentication succeeded but authenticated user doesn't have access to the resource
238     HTTP_FORBIDDEN = 403
239     # when a non-existent resource is requested
240     HTTP_NOT_FOUND = 404
241     # when an http method is being requested that isn't allowed for the authenticated user
242     HTTP_METHOD_NOT_ALLOWED = 405
243     # indicates the resource at this point is no longer available
244     HTTP_GONE = 410
245     # if incorrect content type was provided as part of the request
246     HTTP_UNSUPPORTED_MEDIA_TYPE = 415
247     # used for validation errors
248     HTTP_UNPROCESSABLE_ENTITY = 422
249     # when request is rejected due to rate limiting
250     HTTP_TOO_MANY_REQUESTS = 429
251     # Other errrors
252     HTTP_INTERNAL_ERROR = 500
253
254     @staticmethod
255     def get_ok_status():
256         return '%d OK' % HTTPErrors.HTTP_OK
257
258     @staticmethod
259     def get_object_created_successfully_status():
260         return '%d Created' % HTTPErrors.HTTP_CREATED
261
262     @staticmethod
263     def get_request_not_ok_status():
264         return '%d Bad request' % HTTPErrors.HTTP_BAD_REQUEST
265
266     @staticmethod
267     def get_resource_not_found_status():
268         return '%d Not found' % HTTPErrors.HTTP_NOT_FOUND
269
270     @staticmethod
271     def get_unsupported_content_type_status():
272         return '%d Unsupported content type' % HTTPErrors.HTTP_UNSUPPORTED_MEDIA_TYPE
273
274     @staticmethod
275     def get_validation_error_status():
276         return '%d Validation error' % HTTPErrors.HTTP_UNPROCESSABLE_ENTITY
277
278     @staticmethod
279     def get_internal_error_status():
280         return '%d Internal error' % HTTPErrors.HTTP_INTERNAL_ERROR
281
282
283 class HTTPRPC(object):
284     def __init__(self):
285         self.req_body = ''
286         self.req_filter = ''
287         self.req_params = {}
288         self.req_method = ''
289         self.req_content_type = ''
290         self.req_content_size = 0
291         self.req_path = ''
292
293         self.rep_body = ''
294         self.rep_status = ''
295
296     def __str__(self):
297         return str.format('REQ: body:{body} filter:{filter} '
298                           'params:{params} method:{method} path:{path} '
299                           'content_type:{content_type} content_size:{content_size} '
300                           'REP: body:{rep_body} status:{status}',
301                           body=self.req_body, filter=self.req_filter,
302                           params=str(self.req_params), method=self.req_method, path=self.req_path,
303                           content_type=self.req_content_type, content_size=self.req_content_size,
304                           rep_body=self.rep_body, status=self.rep_status)
305
306 class WSGIHandler(object):
307     def __init__(self, server):
308         logging.debug('WSGIHandler constructor called')
309
310         self.server = server
311
312         self.mapper = routes.Mapper()
313         self.mapper.connect(None, '/apis', action='get_apis')
314         self.mapper.connect(None, '/{api}/installations', action='handle_installations')
315         self.mapper.connect(None, '/{api}/installations/{uuid}/state', action='handle_state')
316
317     def handle_installations(self, rpc):
318         if rpc.req_method == 'POST':
319             self._start_installation(rpc)
320         else:
321             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
322             rpc.rep_status += ', only POST are possible to this resource'
323
324     def handle_state(self, rpc):
325         if rpc.req_method == 'GET':
326             self._get_state(rpc)
327         elif rpc.req_method == 'POST':
328             self._set_state(rpc)
329         else:
330             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
331             rpc.rep_status += ', only GET/POST are possible to this resource'
332
333     def _start_installation(self, rpc):
334         """
335             Request: POST http://<ip:port>/v1/installations
336                 {
337                     'cloud-name': <name of the cloud>,
338                     'iso': <iso image name>,
339                 }
340             Response: http status set correctly
341                 {
342                     'uuid': <operation identifier>
343                 }
344         """
345
346         logging.debug('_start_installation called')
347         try:
348             if not rpc.req_body:
349                 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
350             else:
351                 request = json.loads(rpc.req_body)
352                 cloud_name = request['cloud-name']
353                 iso = request['iso']
354
355                 uuid = self.server.start_installation(cloud_name, iso)
356
357                 rpc.rep_status = HTTPErrors.get_ok_status()
358                 reply = {'uuid': uuid}
359                 rpc.rep_body = json.dumps(reply)
360         except KeyError as ex:
361             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
362             raise ServerError('Missing request parameter: {}'.format(str(ex)))
363         except Exception as exp:  # pylint: disable=broad-except
364             rpc.rep_status = HTTPErrors.get_internal_error_status()
365             rpc.rep_status += ','
366             rpc.rep_status += str(exp)
367
368     def _get_state(self, rpc):
369         """
370             Request: GET http://<ip:port>/v1/installations/<uuid>/state
371                 {
372                 }
373             Response: http status set correctly
374                 {
375                     'status': <ongoing|completed|failed>,
376                     'description': <description about the progress>,
377                     'percentage': <percentage completed of the installation>
378                 }
379         """
380
381         logging.debug('_get_state called')
382         try:
383             if not rpc.req_body:
384                 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
385             else:
386                 uuid = rpc.req_params['uuid']
387
388                 reply = self.server.get_state(uuid)
389
390                 rpc.rep_status = HTTPErrors.get_ok_status()
391                 rpc.rep_body = json.dumps(reply)
392         except KeyError as ex:
393             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
394             raise ServerError('Missing request parameter: {}'.format(str(ex)))
395         except Exception as exp:  # pylint: disable=broad-except
396             rpc.rep_status = HTTPErrors.get_internal_error_status()
397             rpc.rep_status += ','
398             rpc.rep_status += str(exp)
399
400     def _set_state(self, rpc):
401         """
402             Request: POST http://<ip:port>/v1/installations/<uuid>/state
403                 {
404                     'status': <ongoing|completed|failed>,
405                     'description': <description about the progress>,
406                     'percentage': <percentage completed of the installation>
407                 }
408             Response: http status set correctly
409                 {
410                 }
411         """
412
413         logging.debug('set_state called')
414         try:
415             if not rpc.req_body:
416                 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
417             else:
418                 request = json.loads(rpc.req_body)
419                 uuid = rpc.req_params['uuid']
420                 status = request['status']
421                 description = request['description']
422                 percentage = request['percentage']
423
424                 self.server.set_state(uuid, status, description, percentage)
425
426                 rpc.rep_status = HTTPErrors.get_ok_status()
427                 reply = {}
428                 rpc.rep_body = json.dumps(reply)
429         except ServerError:
430             raise
431         except KeyError as ex:
432             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
433             raise ServerError('Missing request parameter: {}'.format(str(ex)))
434         except Exception as exp:  # pylint: disable=broad-except
435             rpc.rep_status = HTTPErrors.get_internal_error_status()
436             rpc.rep_status += ','
437             rpc.rep_status += str(exp)
438
439     def _read_header(self, rpc, environ):
440         rpc.req_method = environ['REQUEST_METHOD']
441         rpc.req_path = environ['PATH_INFO']
442         try:
443             rpc.req_filter = urlparse.parse_qs(urllib.unquote(environ['QUERY_STRING']))
444         except KeyError:
445             rpc.req_filter = {}
446         rpc.req_content_type = environ['CONTENT_TYPE']
447         try:
448             rpc.req_content_size = int(environ['CONTENT_LENGTH'])
449         except KeyError:
450             rpc.req_content_size = 0
451
452     def _get_action(self, rpc):
453         # get the action to be done
454         action = ''
455         match_result = self.mapper.match(rpc.req_path)
456         if not match_result:
457             rpc.rep_status = HTTPErrors.get_resource_not_found_status()
458             raise ServerError('URL does not match')
459
460         resultdict = {}
461         if isinstance(match_result, dict):
462             resultdict = match_result
463         else:
464             resultdict = match_result[0]
465
466         try:
467             action = resultdict['action']
468             for key, value in resultdict.iteritems():
469                 if key != 'action':
470                     rpc.req_params[key] = value
471         except KeyError:
472             rpc.rep_status = HTTPErrors.get_internal_error_status()
473             raise ServerError('No action found')
474
475         return action
476
477     def _read_body(self, rpc, environ):
478         # get the body if available
479         if rpc.req_content_size:
480             if rpc.req_content_type == 'application/json':
481                 rpc.req_body = environ['wsgi.input'].read(rpc.req_content_size)
482             else:
483                 rpc.rep_status = HTTPErrors.get_unsupported_content_type_status()
484                 raise ServerError('Content type is not json')
485
486     def __call__(self, environ, start_response):
487         logging.debug('Handling request started, environ=%s', str(environ))
488
489         # For request and resonse data
490         rpc = HTTPRPC()
491         rpc.rep_status = HTTPErrors.get_ok_status()
492
493         try:
494             self._read_header(rpc, environ)
495
496             action = self._get_action(rpc)
497
498             self._read_body(rpc, environ)
499
500             logging.info('Calling %s with rpc=%s', action, str(rpc))
501             actionfunc = getattr(self, action)
502             actionfunc(rpc)
503         except ServerError as ex:
504             rpc.rep_status = HTTPErrors.get_request_not_ok_status()
505             rpc.rep_status += ','
506             rpc.rep_status += str(ex)
507         except AttributeError:
508             rpc.rep_status = HTTPErrors.get_internal_error_status()
509             rpc.rep_status += ','
510             rpc.rep_status += 'Missing action function'
511         except Exception as exp:  # pylint: disable=broad-except
512             rpc.rep_status = HTTPErrors.get_internal_error_status()
513             rpc.rep_status += ','
514             rpc.rep_status += str(exp)
515
516         logging.info('Replying with rpc=%s', str(rpc))
517         response_headers = [('Content-type', 'application/json')]
518         start_response(rpc.rep_status, response_headers)
519         return [rpc.rep_body]
520
521 def wrap_socket(sock, keyfile=None, certfile=None,
522                 server_side=False, cert_reqs=ssl.CERT_NONE,
523                 ssl_version=ssl.PROTOCOL_SSLv23, ca_certs=None,
524                 do_handshake_on_connect=True,
525                 suppress_ragged_eofs=True,
526                 ciphers=None):
527
528     return LoggingSSLSocket(sock=sock, keyfile=keyfile, certfile=certfile,
529                             server_side=server_side, cert_reqs=cert_reqs,
530                             ssl_version=ssl_version, ca_certs=ca_certs,
531                             do_handshake_on_connect=do_handshake_on_connect,
532                             suppress_ragged_eofs=suppress_ragged_eofs,
533                             ciphers=ciphers)
534
535 def main():
536     parser = argparse.ArgumentParser()
537     parser.add_argument('-H', '--host', required=True, help='binding ip of the server')
538     parser.add_argument('-P', '--listen', required=True, help='binding port of the server')
539     parser.add_argument('-S', '--server', required=False, help='externally visible ip of the server')
540     parser.add_argument('-B', '--port', required=False, help='externally visible port of the server')
541     parser.add_argument('-C', '--cert', required=False, help='server cert file name')
542     parser.add_argument('-K', '--key', required=False, help='server private key file name')
543     parser.add_argument('-c', '--client-cert', required=False, help='client cert file name')
544     parser.add_argument('-k', '--client-key', required=False, help='client key file name')
545     parser.add_argument('-A', '--ca-cert', required=False, help='CA cert file name')
546     parser.add_argument('-p', '--path', required=False, help='path for remote installer files')
547     parser.add_argument('-T', '--http-port', required=False, help='port for HTTPD')
548     parser.add_argument('-d', '--debug', required=False, help='Debug level for logging',
549                         action='store_true')
550
551     args = parser.parse_args()
552
553     if args.debug:
554         log_level = logging.DEBUG
555     else:
556         log_level = logging.INFO
557
558     format = '%(asctime)s %(threadName)s:%(levelname)s %(message)s'
559     logging.basicConfig(stream=sys.stdout, level=log_level, format=format)
560
561     logging.debug('args: %s', args)
562
563     host = args.server
564     if not host:
565         host = args.host
566
567     port = args.port
568     if not port:
569         port = args.listen
570
571     server = Server(host, port, args.cert, args.key, args.client_cert, args.client_key, args.ca_cert, args.path, args.http_port)
572
573     wsgihandler = WSGIHandler(server)
574
575     wsgi_server = make_server(args.host, int(args.listen), wsgihandler)
576
577     if args.cert:
578         server_keys = server.get_server_keys()
579         wsgi_server.socket = wrap_socket(wsgi_server.socket,
580                                          certfile=server_keys['cert'],
581                                          keyfile=server_keys['key'],
582                                          server_side=True,
583                                          ca_certs=server_keys['ca_cert'],
584                                          cert_reqs=ssl.CERT_REQUIRED)
585
586     wsgi_server.serve_forever()
587
588 if __name__ == "__main__":
589     sys.exit(main())