Initial version
[ta/remote-installer.git] / src / remoteinstaller / server / server.py
diff --git a/src/remoteinstaller/server/server.py b/src/remoteinstaller/server/server.py
new file mode 100644 (file)
index 0000000..b925dd4
--- /dev/null
@@ -0,0 +1,589 @@
+# Copyright 2019 Nokia
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+import argparse
+import logging
+import os
+from threading import Thread
+import time
+import json
+import urllib
+import urlparse
+import uuid as uuid_module
+import ssl
+
+from wsgiref.simple_server import make_server
+import routes
+
+from remoteinstaller.installer.install import Installer
+from remoteinstaller.installer.install import InstallException
+
+
+class LoggingSSLSocket(ssl.SSLSocket):
+    def accept(self, *args, **kwargs):
+        try:
+            result = super(LoggingSSLSocket, self).accept(*args, **kwargs)
+        except Exception as ex:
+            logging.warn('SSLSocket.accept raised exception: %s', str(ex))
+            raise
+        return result
+
+
+class InstallationWorker(Thread):
+    def __init__(self, server, uuid, admin_passwd, logdir, args=None):
+        super(InstallationWorker, self).__init__(name=uuid)
+        self._server = server
+        self._uuid = uuid
+        self._admin_passwd = admin_passwd
+        self._logdir = logdir
+        self._args = args
+
+    def run(self):
+        access_info = None
+        if self._args:
+            try:
+                installer = Installer(self._args)
+                #access_info = installer.install()
+
+                logging.info('Installation triggered for %s', self._uuid)
+            except InstallException as ex:
+                logging.warn('Installation triggering failed for %s: %s', self._uuid, str(ex))
+                self._server.set_state(self._uuid, 'failed', str(ex), 0)
+                return
+
+        installation_finished = False
+        while not installation_finished:
+            state = self._server.get_state(self._uuid)
+            if not state['status'] == 'ongoing':
+                installation_finished = True
+            else:
+                time.sleep(10)
+
+        logging.info('Installation finished for %s: %s', self._uuid, state)
+        if access_info:
+            logging.info('Login details for installation %s: %s', self._uuid, str(access_info))
+
+        logging.info('Getting logs for installation %s...', uuid)
+        #installer.get_logs(self._logdir, self._admin_passwd)
+        logging.info('Logs retrieved for %s', uuid)
+
+class Server(object):
+    DEFAULT_PATH = '/opt/remoteinstaller'
+    USER_CONFIG_PATH = 'user-configs'
+    ISO_PATH = 'images'
+    CERTIFICATE_PATH = 'certificates'
+    INSTALLATIONS_PATH = 'installations'
+    #CLOUD_ISO_PATH = '{}/rec.iso'.format(ISO_PATH)
+    BOOT_ISO_PATH = '{}/boot.iso'.format(ISO_PATH)
+
+    def __init__(self, host, port, cert=None, key=None, client_cert=None, client_key=None, ca_cert=None, path=None, http_port=None):
+        self._host = host
+        self._port = port
+        self._http_port = http_port
+
+        self._path = path
+        if not self._path:
+            self._path = Server.DEFAULT_PATH
+
+        self._cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, cert)
+        self._key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, key)
+        self._client_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_cert)
+        self._client_key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_key)
+        self._ca_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, ca_cert)
+
+        self._ongoing_installations = {}
+        self._load_states()
+
+    def get_server_keys(self):
+        return {'cert': self._cert, 'key': self._key, 'ca_cert': self._ca_cert}
+
+    def _read_admin_passwd(self, cloud_name):
+        with open('{}/{}/{}/admin_passwd'.format(self._path,
+                                                 Server.USER_CONFIG_PATH,
+                                                 cloud_name)) as pwf:
+            admin_passwd = pwf.readline()
+
+        return admin_passwd
+
+    def _load_states(self):
+        uuid_list = os.listdir('{}/{}'.format(self._path, Server.INSTALLATIONS_PATH))
+        for uuid in uuid_list:
+            state_file_name = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
+            if os.path.exists(state_file_name):
+                with open(state_file_name) as sf:
+                    state_json = sf.readline()
+                    self._ongoing_installations[uuid] = json.loads(state_json)
+
+                if self._ongoing_installations[uuid]['status'] == 'ongoing':
+                    logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
+                    cloud_name = self._ongoing_installations[uuid]['cloud_name']
+                    admin_passwd = self._read_admin_passwd(cloud_name)
+                    worker = InstallationWorker(self, uuid, admin_passwd, logdir)
+                    worker.start()
+
+    def _set_state(self, uuid, status, description, percentage, cloud_name=None):
+        self._ongoing_installations[uuid] = {}
+        self._ongoing_installations[uuid]['status'] = status
+        self._ongoing_installations[uuid]['description'] = description
+        self._ongoing_installations[uuid]['percentage'] = percentage
+        if cloud_name:
+            self._ongoing_installations[uuid]['cloud_name'] = cloud_name
+
+        state_file = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
+        with open(state_file, 'w') as sf:
+            sf.write(json.dumps(self._ongoing_installations[uuid]))
+
+    def set_state(self, uuid, status, description, percentage):
+        logging.info('uuid=%s, status=%s, description=%s, percentage=%s',
+                     uuid, status, description, percentage)
+
+        if not uuid in self._ongoing_installations:
+            raise ServerError('Installation id {} not found'.format(uuid))
+
+        if not status in ['ongoing', 'failed', 'completed']:
+            raise ServerError('Invalid state: {}'.format(status))
+
+        self._set_state(uuid, status, description, percentage)
+
+    def get_state(self, uuid):
+        logging.info('uuid=%s', uuid)
+
+        if not uuid in self._ongoing_installations:
+            raise ServerError('Installation id {} not found'.format(uuid))
+
+        return {'status': self._ongoing_installations[uuid]['status'],
+                'description': self._ongoing_installations[uuid]['description'],
+                'percentage': self._ongoing_installations[uuid]['percentage']}
+
+    def start_installation(self, cloud_name, iso):
+        logging.info('start_installation(%s, %s)', cloud_name, iso)
+
+        uuid = str(uuid_module.uuid4())
+
+        args = argparse.Namespace()
+
+        args.yaml = '{}/{}/{}/user_config.yml'.format(self._path,
+                                                      Server.USER_CONFIG_PATH,
+                                                      cloud_name)
+        if not os.path.isfile(args.yaml):
+            raise ServerError('YAML file {} not found'.format(args.yaml))
+
+        iso_path = '{}/{}/{}'.format(self._path, Server.ISO_PATH, iso)
+        if not os.path.isfile(iso_path):
+            raise ServerError('ISO file {} not found'.format(iso_path))
+
+        http_port_part = ''
+        if self._http_port:
+            http_port_part = ':{}'.format(self._http_port)
+
+        args.iso = 'https://{}{}/{}/{}'.format(self._host, http_port_part, Server.ISO_PATH, iso)
+
+        args.logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
+
+        os.makedirs(args.logdir)
+
+        args.boot_iso = '{}/{}'.format(self._path, Server.BOOT_ISO_PATH)
+
+        args.tag = uuid
+        args.callback_url = 'http://{}:{}/v1/installations/{}/state'.format(self._host,
+                                                                            self._port,
+                                                                            uuid)
+
+        args.client_cert = self._client_cert
+        args.client_key = self._client_key
+        args.ca_cert = self._ca_cert
+        args.host_ip = self._host
+
+        self._set_state(uuid, 'ongoing', '', 0, cloud_name)
+
+        admin_passwd = self._read_admin_passwd(cloud_name)
+        worker = InstallationWorker(self, uuid, admin_passwd, args.logdir, args)
+        worker.start()
+
+        return uuid
+
+
+class ServerError(Exception):
+    pass
+
+
+class HTTPErrors(object):
+    # response for a successful GET, PUT, PATCH, DELETE,
+    # can also be used for POST that does not result in creation.
+    HTTP_OK = 200
+    # response to a POST which results in creation.
+    HTTP_CREATED = 201
+    # response to a successfull request that won't be returning any body like a DELETE request
+    HTTP_NO_CONTENT = 204
+    # used when http caching headers are in play
+    HTTP_NOT_MODIFIED = 304
+    # the request is malformed such as if the body does not parse
+    HTTP_BAD_REQUEST = 400
+    # when no or invalid authentication details are provided.
+    # also useful to trigger an auth popup API is used from a browser
+    HTTP_UNAUTHORIZED_OPERATION = 401
+    # when authentication succeeded but authenticated user doesn't have access to the resource
+    HTTP_FORBIDDEN = 403
+    # when a non-existent resource is requested
+    HTTP_NOT_FOUND = 404
+    # when an http method is being requested that isn't allowed for the authenticated user
+    HTTP_METHOD_NOT_ALLOWED = 405
+    # indicates the resource at this point is no longer available
+    HTTP_GONE = 410
+    # if incorrect content type was provided as part of the request
+    HTTP_UNSUPPORTED_MEDIA_TYPE = 415
+    # used for validation errors
+    HTTP_UNPROCESSABLE_ENTITY = 422
+    # when request is rejected due to rate limiting
+    HTTP_TOO_MANY_REQUESTS = 429
+    # Other errrors
+    HTTP_INTERNAL_ERROR = 500
+
+    @staticmethod
+    def get_ok_status():
+        return '%d OK' % HTTPErrors.HTTP_OK
+
+    @staticmethod
+    def get_object_created_successfully_status():
+        return '%d Created' % HTTPErrors.HTTP_CREATED
+
+    @staticmethod
+    def get_request_not_ok_status():
+        return '%d Bad request' % HTTPErrors.HTTP_BAD_REQUEST
+
+    @staticmethod
+    def get_resource_not_found_status():
+        return '%d Not found' % HTTPErrors.HTTP_NOT_FOUND
+
+    @staticmethod
+    def get_unsupported_content_type_status():
+        return '%d Unsupported content type' % HTTPErrors.HTTP_UNSUPPORTED_MEDIA_TYPE
+
+    @staticmethod
+    def get_validation_error_status():
+        return '%d Validation error' % HTTPErrors.HTTP_UNPROCESSABLE_ENTITY
+
+    @staticmethod
+    def get_internal_error_status():
+        return '%d Internal error' % HTTPErrors.HTTP_INTERNAL_ERROR
+
+
+class HTTPRPC(object):
+    def __init__(self):
+        self.req_body = ''
+        self.req_filter = ''
+        self.req_params = {}
+        self.req_method = ''
+        self.req_content_type = ''
+        self.req_content_size = 0
+        self.req_path = ''
+
+        self.rep_body = ''
+        self.rep_status = ''
+
+    def __str__(self):
+        return str.format('REQ: body:{body} filter:{filter} '
+                          'params:{params} method:{method} path:{path} '
+                          'content_type:{content_type} content_size:{content_size} '
+                          'REP: body:{rep_body} status:{status}',
+                          body=self.req_body, filter=self.req_filter,
+                          params=str(self.req_params), method=self.req_method, path=self.req_path,
+                          content_type=self.req_content_type, content_size=self.req_content_size,
+                          rep_body=self.rep_body, status=self.rep_status)
+
+class WSGIHandler(object):
+    def __init__(self, server):
+        logging.debug('WSGIHandler constructor called')
+
+        self.server = server
+
+        self.mapper = routes.Mapper()
+        self.mapper.connect(None, '/apis', action='get_apis')
+        self.mapper.connect(None, '/{api}/installations', action='handle_installations')
+        self.mapper.connect(None, '/{api}/installations/{uuid}/state', action='handle_state')
+
+    def handle_installations(self, rpc):
+        if rpc.req_method == 'POST':
+            self._start_installation(rpc)
+        else:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            rpc.rep_status += ', only POST are possible to this resource'
+
+    def handle_state(self, rpc):
+        if rpc.req_method == 'GET':
+            self._get_state(rpc)
+        elif rpc.req_method == 'POST':
+            self._set_state(rpc)
+        else:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            rpc.rep_status += ', only GET/POST are possible to this resource'
+
+    def _start_installation(self, rpc):
+        """
+            Request: POST http://<ip:port>/v1/installations
+                {
+                    'cloud-name': <name of the cloud>,
+                    'iso': <iso image name>,
+                }
+            Response: http status set correctly
+                {
+                    'uuid': <operation identifier>
+                }
+        """
+
+        logging.debug('_start_installation called')
+        try:
+            if not rpc.req_body:
+                rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            else:
+                request = json.loads(rpc.req_body)
+                cloud_name = request['cloud-name']
+                iso = request['iso']
+
+                uuid = self.server.start_installation(cloud_name, iso)
+
+                rpc.rep_status = HTTPErrors.get_ok_status()
+                reply = {'uuid': uuid}
+                rpc.rep_body = json.dumps(reply)
+        except KeyError as ex:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            raise ServerError('Missing request parameter: {}'.format(str(ex)))
+        except Exception as exp:  # pylint: disable=broad-except
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            rpc.rep_status += ','
+            rpc.rep_status += str(exp)
+
+    def _get_state(self, rpc):
+        """
+            Request: GET http://<ip:port>/v1/installations/<uuid>/state
+                {
+                }
+            Response: http status set correctly
+                {
+                    'status': <ongoing|completed|failed>,
+                    'description': <description about the progress>,
+                    'percentage': <percentage completed of the installation>
+                }
+        """
+
+        logging.debug('_get_state called')
+        try:
+            if not rpc.req_body:
+                rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            else:
+                uuid = rpc.req_params['uuid']
+
+                reply = self.server.get_state(uuid)
+
+                rpc.rep_status = HTTPErrors.get_ok_status()
+                rpc.rep_body = json.dumps(reply)
+        except KeyError as ex:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            raise ServerError('Missing request parameter: {}'.format(str(ex)))
+        except Exception as exp:  # pylint: disable=broad-except
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            rpc.rep_status += ','
+            rpc.rep_status += str(exp)
+
+    def _set_state(self, rpc):
+        """
+            Request: POST http://<ip:port>/v1/installations/<uuid>/state
+                {
+                    'status': <ongoing|completed|failed>,
+                    'description': <description about the progress>,
+                    'percentage': <percentage completed of the installation>
+                }
+            Response: http status set correctly
+                {
+                }
+        """
+
+        logging.debug('set_state called')
+        try:
+            if not rpc.req_body:
+                rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            else:
+                request = json.loads(rpc.req_body)
+                uuid = rpc.req_params['uuid']
+                status = request['status']
+                description = request['description']
+                percentage = request['percentage']
+
+                self.server.set_state(uuid, status, description, percentage)
+
+                rpc.rep_status = HTTPErrors.get_ok_status()
+                reply = {}
+                rpc.rep_body = json.dumps(reply)
+        except ServerError:
+            raise
+        except KeyError as ex:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            raise ServerError('Missing request parameter: {}'.format(str(ex)))
+        except Exception as exp:  # pylint: disable=broad-except
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            rpc.rep_status += ','
+            rpc.rep_status += str(exp)
+
+    def _read_header(self, rpc, environ):
+        rpc.req_method = environ['REQUEST_METHOD']
+        rpc.req_path = environ['PATH_INFO']
+        try:
+            rpc.req_filter = urlparse.parse_qs(urllib.unquote(environ['QUERY_STRING']))
+        except KeyError:
+            rpc.req_filter = {}
+        rpc.req_content_type = environ['CONTENT_TYPE']
+        try:
+            rpc.req_content_size = int(environ['CONTENT_LENGTH'])
+        except KeyError:
+            rpc.req_content_size = 0
+
+    def _get_action(self, rpc):
+        # get the action to be done
+        action = ''
+        match_result = self.mapper.match(rpc.req_path)
+        if not match_result:
+            rpc.rep_status = HTTPErrors.get_resource_not_found_status()
+            raise ServerError('URL does not match')
+
+        resultdict = {}
+        if isinstance(match_result, dict):
+            resultdict = match_result
+        else:
+            resultdict = match_result[0]
+
+        try:
+            action = resultdict['action']
+            for key, value in resultdict.iteritems():
+                if key != 'action':
+                    rpc.req_params[key] = value
+        except KeyError:
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            raise ServerError('No action found')
+
+        return action
+
+    def _read_body(self, rpc, environ):
+        # get the body if available
+        if rpc.req_content_size:
+            if rpc.req_content_type == 'application/json':
+                rpc.req_body = environ['wsgi.input'].read(rpc.req_content_size)
+            else:
+                rpc.rep_status = HTTPErrors.get_unsupported_content_type_status()
+                raise ServerError('Content type is not json')
+
+    def __call__(self, environ, start_response):
+        logging.debug('Handling request started, environ=%s', str(environ))
+
+        # For request and resonse data
+        rpc = HTTPRPC()
+        rpc.rep_status = HTTPErrors.get_ok_status()
+
+        try:
+            self._read_header(rpc, environ)
+
+            action = self._get_action(rpc)
+
+            self._read_body(rpc, environ)
+
+            logging.info('Calling %s with rpc=%s', action, str(rpc))
+            actionfunc = getattr(self, action)
+            actionfunc(rpc)
+        except ServerError as ex:
+            rpc.rep_status = HTTPErrors.get_request_not_ok_status()
+            rpc.rep_status += ','
+            rpc.rep_status += str(ex)
+        except AttributeError:
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            rpc.rep_status += ','
+            rpc.rep_status += 'Missing action function'
+        except Exception as exp:  # pylint: disable=broad-except
+            rpc.rep_status = HTTPErrors.get_internal_error_status()
+            rpc.rep_status += ','
+            rpc.rep_status += str(exp)
+
+        logging.info('Replying with rpc=%s', str(rpc))
+        response_headers = [('Content-type', 'application/json')]
+        start_response(rpc.rep_status, response_headers)
+        return [rpc.rep_body]
+
+def wrap_socket(sock, keyfile=None, certfile=None,
+                server_side=False, cert_reqs=ssl.CERT_NONE,
+                ssl_version=ssl.PROTOCOL_SSLv23, ca_certs=None,
+                do_handshake_on_connect=True,
+                suppress_ragged_eofs=True,
+                ciphers=None):
+
+    return LoggingSSLSocket(sock=sock, keyfile=keyfile, certfile=certfile,
+                            server_side=server_side, cert_reqs=cert_reqs,
+                            ssl_version=ssl_version, ca_certs=ca_certs,
+                            do_handshake_on_connect=do_handshake_on_connect,
+                            suppress_ragged_eofs=suppress_ragged_eofs,
+                            ciphers=ciphers)
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-H', '--host', required=True, help='binding ip of the server')
+    parser.add_argument('-P', '--listen', required=True, help='binding port of the server')
+    parser.add_argument('-S', '--server', required=False, help='externally visible ip of the server')
+    parser.add_argument('-B', '--port', required=False, help='externally visible port of the server')
+    parser.add_argument('-C', '--cert', required=False, help='server cert file name')
+    parser.add_argument('-K', '--key', required=False, help='server private key file name')
+    parser.add_argument('-c', '--client-cert', required=False, help='client cert file name')
+    parser.add_argument('-k', '--client-key', required=False, help='client key file name')
+    parser.add_argument('-A', '--ca-cert', required=False, help='CA cert file name')
+    parser.add_argument('-p', '--path', required=False, help='path for remote installer files')
+    parser.add_argument('-T', '--http-port', required=False, help='port for HTTPD')
+    parser.add_argument('-d', '--debug', required=False, help='Debug level for logging',
+                        action='store_true')
+
+    args = parser.parse_args()
+
+    if args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.INFO
+
+    format = '%(asctime)s %(threadName)s:%(levelname)s %(message)s'
+    logging.basicConfig(stream=sys.stdout, level=log_level, format=format)
+
+    logging.debug('args: %s', args)
+
+    host = args.server
+    if not host:
+        host = args.host
+
+    port = args.port
+    if not port:
+        port = args.listen
+
+    server = Server(host, port, args.cert, args.key, args.client_cert, args.client_key, args.ca_cert, args.path, args.http_port)
+
+    wsgihandler = WSGIHandler(server)
+
+    wsgi_server = make_server(args.host, int(args.listen), wsgihandler)
+
+    if args.cert:
+        server_keys = server.get_server_keys()
+        wsgi_server.socket = wrap_socket(wsgi_server.socket,
+                                         certfile=server_keys['cert'],
+                                         keyfile=server_keys['key'],
+                                         server_side=True,
+                                         ca_certs=server_keys['ca_cert'],
+                                         cert_reqs=ssl.CERT_REQUIRED)
+
+    wsgi_server.serve_forever()
+
+if __name__ == "__main__":
+    sys.exit(main())