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
7 # http://www.apache.org/licenses/LICENSE-2.0
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.
17 from configparser import ConfigParser
19 from logging.handlers import RotatingFileHandler
21 from threading import Thread
26 import uuid as uuid_module
29 from wsgiref.simple_server import make_server
32 from remoteinstaller.installer.install import Installer
33 from remoteinstaller.installer.install import InstallException
36 class LoggingSSLSocket(ssl.SSLSocket):
37 def accept(self, *args, **kwargs):
39 result = super(LoggingSSLSocket, self).accept(*args, **kwargs)
40 except Exception as ex:
41 logging.warning('SSLSocket.accept raised exception: %s', str(ex))
46 class InstallationWorker(Thread):
47 def __init__(self, server, uuid, admin_passwd, yaml, logdir, args=None):
48 super(InstallationWorker, self).__init__(name=uuid)
51 self._admin_passwd = admin_passwd
57 installer = Installer(self._server, self._uuid, self._yaml, self._logdir, self._args)
58 access_info = installer.get_access_info()
63 logging.info('Installation triggered for %s', self._uuid)
64 except InstallException as ex:
65 logging.warning('Installation triggering failed for %s: %s', self._uuid, str(ex))
66 self._server.set_state(self._uuid, 'failed', str(ex))
69 installation_finished = False
70 while not installation_finished:
71 state = self._server.get_state(self._uuid)
72 if not state['status'] == 'ongoing':
73 installation_finished = True
75 logging.info('Installation of %s still ongoing (%s%%): %s',
81 logging.info('Installation finished for %s: %s', self._uuid, state)
82 logging.info('Login details for installation %s: %s', self._uuid, str(access_info))
84 logging.info('Getting logs for installation %s...', self._uuid)
85 installer.get_logs(self._admin_passwd)
86 logging.info('Logs retrieved for %s', self._uuid)
89 DEFAULT_PATH = '/opt/remoteinstaller'
90 USER_CONFIG_PATH = 'user-configs'
92 CERTIFICATE_PATH = 'certificates'
93 INSTALLATIONS_PATH = 'installations'
94 USER_CONFIG_NAME = 'user_config.yaml'
95 EXTRA_CONFIG_NAME = 'installation.ini'
109 self._http_port = http_port
113 self._path = Server.DEFAULT_PATH
115 self._cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, cert)
116 self._key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, key)
117 self._client_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_cert)
118 self._client_key = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, client_key)
119 self._ca_cert = '{}/{}/{}'.format(self._path, Server.CERTIFICATE_PATH, ca_cert)
121 self._ongoing_installations = {}
124 def get_server_keys(self):
125 return {'cert': self._cert, 'key': self._key, 'ca_cert': self._ca_cert}
127 def _read_admin_passwd(self, cloud_name):
128 with open('{}/{}/{}/admin_passwd'.format(self._path,
129 Server.USER_CONFIG_PATH,
131 admin_passwd = pwf.readline().strip()
135 def _get_yaml_path_for_cloud(self, cloud_name):
136 yaml = '{}/{}/{}/{}'.format(self._path,
137 Server.USER_CONFIG_PATH,
139 Server.USER_CONFIG_NAME)
140 if not os.path.isfile(yaml):
141 raise ServerError('YAML file {} not found'.format(yaml))
145 def _load_states(self):
146 uuid_list = os.listdir('{}/{}'.format(self._path, Server.INSTALLATIONS_PATH))
147 for uuid in uuid_list:
148 state_file_name = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
149 if os.path.exists(state_file_name):
150 with open(state_file_name) as sf:
151 state_json = sf.readline()
152 self._ongoing_installations[uuid] = json.loads(state_json)
154 if self._ongoing_installations[uuid]['status'] == 'ongoing':
155 logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
156 cloud_name = self._ongoing_installations[uuid]['cloud_name']
157 admin_passwd = self._read_admin_passwd(cloud_name)
158 yaml = self._get_yaml_path_for_cloud(cloud_name)
159 worker = InstallationWorker(self, uuid, admin_passwd, yaml, logdir)
162 def _set_state(self, uuid, status, description, percentage=None, cloud_name=None):
163 if not self._ongoing_installations.get(uuid, None):
164 self._ongoing_installations[uuid] = {}
165 self._ongoing_installations[uuid]['status'] = status
166 self._ongoing_installations[uuid]['description'] = description
167 if percentage is not None:
168 self._ongoing_installations[uuid]['percentage'] = percentage
170 self._ongoing_installations[uuid]['cloud_name'] = cloud_name
172 state_file = '{}/{}/{}.state'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
173 with open(state_file, 'w') as sf:
174 sf.write(json.dumps(self._ongoing_installations[uuid]))
176 def set_state(self, uuid, status, description, percentage=None):
177 logging.debug('set_state called for %s: status=%s, description=%s, percentage=%s',
178 uuid, status, description, percentage)
180 if not uuid in self._ongoing_installations:
181 raise ServerError('Installation id {} not found'.format(uuid))
183 if not status in ['ongoing', 'failed', 'completed']:
184 raise ServerError('Invalid state: {}'.format(status))
186 self._set_state(uuid, status, description, percentage)
188 def get_state(self, uuid):
189 logging.debug('get_state called for %s', uuid)
191 if not uuid in self._ongoing_installations:
192 raise ServerError('Installation id {} not found'.format(uuid))
194 return {'status': self._ongoing_installations[uuid]['status'],
195 'description': self._ongoing_installations[uuid]['description'],
196 'percentage': self._ongoing_installations[uuid]['percentage']}
198 def _read_extra_args(self, cloud_name):
201 extra_config_filename = '{}/{}/{}/{}'.format(self._path,
202 Server.USER_CONFIG_PATH,
204 Server.EXTRA_CONFIG_NAME)
206 if os.path.isfile(extra_config_filename):
207 logging.debug('Read extra installation args from: %s', extra_config_filename)
208 extra_config = ConfigParser()
209 with open(extra_config_filename, 'r') as extra_config_file:
210 extra_config.readfp(extra_config_file)
212 if extra_config.has_section('extra'):
213 for key, value in extra_config.items('extra'):
218 def start_installation(self, cloud_name, iso, boot_iso):
219 logging.debug('start_installation called with args: (%s, %s, %s)', cloud_name, iso, boot_iso)
221 uuid = str(uuid_module.uuid4())[:8]
223 args = argparse.Namespace()
225 extra_args = self._read_extra_args(cloud_name)
226 vars(args).update(extra_args)
228 args.yaml = self._get_yaml_path_for_cloud(cloud_name)
230 iso_path = '{}/{}/{}'.format(self._path, Server.ISO_PATH, iso)
231 if not os.path.isfile(iso_path):
232 raise ServerError('ISO file {} not found'.format(iso_path))
234 boot_iso_path = '{}/{}/{}'.format(self._path, Server.ISO_PATH, boot_iso)
235 if not os.path.isfile(boot_iso_path):
236 raise ServerError('Provisioning ISO file {} not found'.format(boot_iso_path))
240 http_port_part = ':{}'.format(self._http_port)
242 args.iso = 'https://{}{}/{}/{}'.format(self._host, http_port_part, Server.ISO_PATH, iso)
244 args.logdir = '{}/{}/{}'.format(self._path, Server.INSTALLATIONS_PATH, uuid)
246 os.makedirs(args.logdir)
248 args.boot_iso = '{}/{}/{}'.format(self._path, Server.ISO_PATH, boot_iso)
251 args.callback_url = 'https://{}:{}/v1/installations/{}/state'.format(self._host,
255 args.client_cert = self._client_cert
256 args.client_key = self._client_key
257 args.ca_cert = self._ca_cert
258 args.host_ip = self._host
260 self._set_state(uuid, 'ongoing', '', 0, cloud_name)
262 admin_passwd = self._read_admin_passwd(cloud_name)
263 worker = InstallationWorker(self, uuid, admin_passwd, args.yaml, args.logdir, args)
269 class ServerError(Exception):
273 class HTTPErrors(object):
274 # response for a successful GET, PUT, PATCH, DELETE,
275 # can also be used for POST that does not result in creation.
277 # response to a POST which results in creation.
279 # response to a successfull request that won't be returning any body like a DELETE request
280 HTTP_NO_CONTENT = 204
281 # used when http caching headers are in play
282 HTTP_NOT_MODIFIED = 304
283 # the request is malformed such as if the body does not parse
284 HTTP_BAD_REQUEST = 400
285 # when no or invalid authentication details are provided.
286 # also useful to trigger an auth popup API is used from a browser
287 HTTP_UNAUTHORIZED_OPERATION = 401
288 # when authentication succeeded but authenticated user doesn't have access to the resource
290 # when a non-existent resource is requested
292 # when an http method is being requested that isn't allowed for the authenticated user
293 HTTP_METHOD_NOT_ALLOWED = 405
294 # indicates the resource at this point is no longer available
296 # if incorrect content type was provided as part of the request
297 HTTP_UNSUPPORTED_MEDIA_TYPE = 415
298 # used for validation errors
299 HTTP_UNPROCESSABLE_ENTITY = 422
300 # when request is rejected due to rate limiting
301 HTTP_TOO_MANY_REQUESTS = 429
303 HTTP_INTERNAL_ERROR = 500
307 return '%d OK' % HTTPErrors.HTTP_OK
310 def get_object_created_successfully_status():
311 return '%d Created' % HTTPErrors.HTTP_CREATED
314 def get_request_not_ok_status():
315 return '%d Bad request' % HTTPErrors.HTTP_BAD_REQUEST
318 def get_resource_not_found_status():
319 return '%d Not found' % HTTPErrors.HTTP_NOT_FOUND
322 def get_unsupported_content_type_status():
323 return '%d Unsupported content type' % HTTPErrors.HTTP_UNSUPPORTED_MEDIA_TYPE
326 def get_validation_error_status():
327 return '%d Validation error' % HTTPErrors.HTTP_UNPROCESSABLE_ENTITY
330 def get_internal_error_status():
331 return '%d Internal error' % HTTPErrors.HTTP_INTERNAL_ERROR
334 class HTTPRPC(object):
340 self.req_content_type = ''
341 self.req_content_size = 0
348 return str.format('REQ: body:{body} filter:{filter} '
349 'params:{params} method:{method} path:{path} '
350 'content_type:{content_type} content_size:{content_size} '
351 'REP: body:{rep_body} status:{status}',
352 body=self.req_body, filter=self.req_filter,
353 params=str(self.req_params), method=self.req_method, path=self.req_path,
354 content_type=self.req_content_type, content_size=self.req_content_size,
355 rep_body=self.rep_body, status=self.rep_status)
357 class WSGIHandler(object):
358 def __init__(self, server):
359 logging.debug('WSGIHandler constructor called')
363 self.mapper = routes.Mapper()
364 self.mapper.connect(None, '/apis', action='get_apis')
365 self.mapper.connect(None, '/{api}/installations', action='handle_installations')
366 self.mapper.connect(None, '/{api}/installations/{uuid}/state', action='handle_state')
368 def handle_installations(self, rpc):
369 if rpc.req_method == 'POST':
370 self._start_installation(rpc)
372 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
373 rpc.rep_status += ', only POST are possible to this resource'
375 def handle_state(self, rpc):
376 if rpc.req_method == 'GET':
378 elif rpc.req_method == 'POST':
381 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
382 rpc.rep_status += ', only GET/POST are possible to this resource'
384 def _start_installation(self, rpc):
386 Request: POST http://<ip:port>/v1/installations
388 'cloud-name': <name of the cloud>,
389 'iso': <iso image name>,
390 'provisioning-iso': <boot iso image name>
392 Response: http status set correctly
394 'uuid': <operation identifier>
398 logging.debug('_start_installation called')
401 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
403 request = json.loads(rpc.req_body)
404 cloud_name = request['cloud-name']
406 boot_iso = request['provisioning-iso']
408 uuid = self.server.start_installation(cloud_name, iso, boot_iso)
410 rpc.rep_status = HTTPErrors.get_ok_status()
411 reply = {'uuid': uuid}
412 rpc.rep_body = json.dumps(reply)
413 except KeyError as ex:
414 raise ServerError('Missing request parameter: {}'.format(str(ex)))
415 except Exception as exp: # pylint: disable=broad-except
416 rpc.rep_status = HTTPErrors.get_internal_error_status()
417 rpc.rep_status += ','
418 rpc.rep_status += str(exp)
420 def _get_state(self, rpc):
422 Request: GET http://<ip:port>/v1/installations/<uuid>/state
425 Response: http status set correctly
427 'status': <ongoing|completed|failed>,
428 'description': <description about the progress>,
429 'percentage': <percentage completed of the installation>
433 logging.debug('_get_state called')
435 uuid = rpc.req_params['uuid']
437 reply = self.server.get_state(uuid)
439 rpc.rep_status = HTTPErrors.get_ok_status()
440 rpc.rep_body = json.dumps(reply)
441 except KeyError as ex:
442 raise ServerError('Missing request parameter: {}'.format(str(ex)))
443 except Exception as exp: # pylint: disable=broad-except
444 rpc.rep_status = HTTPErrors.get_internal_error_status()
445 rpc.rep_status += ','
446 rpc.rep_status += str(exp)
448 def _set_state(self, rpc):
450 Request: POST http://<ip:port>/v1/installations/<uuid>/state
452 'status': <ongoing|completed|failed>,
453 'description': <description about the progress>,
454 'percentage': <percentage completed of the installation>
456 Response: http status set correctly
461 logging.debug('_set_state called')
464 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
466 request = json.loads(rpc.req_body)
467 uuid = rpc.req_params['uuid']
468 status = request['status']
469 description = request['description']
470 percentage = request.get('percentage', None)
472 self.server.set_state(uuid, status, description, percentage)
474 rpc.rep_status = HTTPErrors.get_ok_status()
476 rpc.rep_body = json.dumps(reply)
477 except KeyError as ex:
478 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
479 raise ServerError('Missing request parameter: {}'.format(str(ex)))
480 except Exception as exp: # pylint: disable=broad-except
481 rpc.rep_status = HTTPErrors.get_internal_error_status()
482 rpc.rep_status += ','
483 rpc.rep_status += str(exp)
485 def _read_header(self, rpc, environ):
486 rpc.req_method = environ['REQUEST_METHOD']
487 rpc.req_path = environ['PATH_INFO']
489 rpc.req_filter = urlparse.parse_qs(urllib.unquote(environ['QUERY_STRING']))
492 rpc.req_content_type = environ['CONTENT_TYPE']
494 content_len = environ['CONTENT_LENGTH']
496 rpc.req_content_size = 0
498 rpc.req_content_size = int(content_len)
500 rpc.req_content_size = 0
502 def _get_action(self, rpc):
503 # get the action to be done
505 match_result = self.mapper.match(rpc.req_path)
507 rpc.rep_status = HTTPErrors.get_resource_not_found_status()
508 raise ServerError('URL does not match')
511 if isinstance(match_result, dict):
512 resultdict = match_result
514 resultdict = match_result[0]
517 action = resultdict['action']
518 for key, value in resultdict.iteritems():
520 rpc.req_params[key] = value
522 rpc.rep_status = HTTPErrors.get_internal_error_status()
523 raise ServerError('No action found')
527 def _read_body(self, rpc, environ):
528 # get the body if available
529 if rpc.req_content_size:
530 if rpc.req_content_type == 'application/json':
531 rpc.req_body = environ['wsgi.input'].read(rpc.req_content_size)
533 rpc.rep_status = HTTPErrors.get_unsupported_content_type_status()
534 raise ServerError('Content type is not json')
536 def __call__(self, environ, start_response):
537 logging.debug('Handling request started, environ=%s', str(environ))
539 # For request and resonse data
541 rpc.rep_status = HTTPErrors.get_ok_status()
544 self._read_header(rpc, environ)
546 action = self._get_action(rpc)
548 self._read_body(rpc, environ)
550 logging.debug('Calling %s with rpc=%s', action, str(rpc))
551 actionfunc = getattr(self, action)
553 except ServerError as ex:
554 rpc.rep_status = HTTPErrors.get_request_not_ok_status()
555 rpc.rep_status += ', '
556 rpc.rep_status += str(ex)
557 except AttributeError:
558 rpc.rep_status = HTTPErrors.get_internal_error_status()
559 rpc.rep_status += ','
560 rpc.rep_status += 'Missing action function'
561 except Exception as exp: # pylint: disable=broad-except
562 rpc.rep_status = HTTPErrors.get_internal_error_status()
563 rpc.rep_status += ', '
564 rpc.rep_status += str(exp)
566 logging.debug('Replying with rpc=%s', str(rpc))
567 response_headers = [('Content-type', 'application/json')]
568 start_response(rpc.rep_status, response_headers)
569 return [rpc.rep_body]
571 def wrap_socket(sock, keyfile=None, certfile=None,
572 server_side=False, cert_reqs=ssl.CERT_NONE,
573 ssl_version=ssl.PROTOCOL_SSLv23, ca_certs=None,
574 do_handshake_on_connect=True,
575 suppress_ragged_eofs=True,
578 return LoggingSSLSocket(sock=sock, keyfile=keyfile, certfile=certfile,
579 server_side=server_side, cert_reqs=cert_reqs,
580 ssl_version=ssl_version, ca_certs=ca_certs,
581 do_handshake_on_connect=do_handshake_on_connect,
582 suppress_ragged_eofs=suppress_ragged_eofs,
586 parser = argparse.ArgumentParser()
587 parser = argparse.ArgumentParser()
588 parser.add_argument('-H', '--host', required=True, metavar='<bind ip>', help='binding ip of the server')
589 parser.add_argument('-P', '--listen', required=True, metavar='<bind port>', help='binding port of the server')
590 parser.add_argument('-S', '--server', required=False, metavar='<external ip>', help='externally visible ip of the server')
591 parser.add_argument('-B', '--port', required=False, metavar='<external port>', help='externally visible port of the server')
592 parser.add_argument('-C', '--cert', required=False, metavar='<server cert file>', help='path to server cert file')
593 parser.add_argument('-K', '--key', required=False, metavar='<server key file>', help='path to server private key file')
594 parser.add_argument('-c', '--client-cert', required=False, metavar='<client cert file>', help='path to client cert file')
595 parser.add_argument('-k', '--client-key', required=False, metavar='<client key file>', help='path to client key file')
596 parser.add_argument('-A', '--ca-cert', required=False, metavar='<CA cert file>', help='path to CA cert file')
597 parser.add_argument('-p', '--path', required=False, metavar='<path to installer files>', help='path to remote installer files')
598 parser.add_argument('-T', '--http-port', required=False, metavar='<HTTPD port>', help='port for HTTPD')
599 parser.add_argument('-d', '--debug', required=False, help='set debug level for logging',
601 parser.add_argument('--log-file', required=False, default='/var/log/remote-installer.log', metavar='<server log file>', help='path to server log file')
602 parser.add_argument('--log-file-max-size', type=int, default=5, required=False, metavar='<max size>', help='server log file max size in MB')
603 parser.add_argument('--log-file-max-count', type=int, default=10, required=False, metavar='<max count>', help='server log file count')
605 args = parser.parse_args()
608 log_level = logging.DEBUG
610 log_level = logging.INFO
612 logformat = '%(asctime)s %(threadName)s:%(levelname)s %(message)s'
613 logging.basicConfig(stream=sys.stdout, level=log_level, format=logformat)
615 log_file_handler = RotatingFileHandler(args.log_file,
616 maxBytes=(args.log_file_max_size*1024*1024),
617 backupCount=args.log_file_max_count)
618 log_file_handler.setFormatter(logging.Formatter(logformat))
619 log_file_handler.setLevel(log_level)
620 logging.getLogger().addHandler(log_file_handler)
622 logging.info('remote-installer started')
623 logging.debug('remote-installer args: %s', args)
633 server = Server(host, port, args.cert, args.key, args.client_cert, args.client_key, args.ca_cert, args.path, args.http_port)
635 wsgihandler = WSGIHandler(server)
637 wsgi_server = make_server(args.host, int(args.listen), wsgihandler)
640 server_keys = server.get_server_keys()
641 wsgi_server.socket = wrap_socket(wsgi_server.socket,
642 certfile=server_keys['cert'],
643 keyfile=server_keys['key'],
645 ca_certs=server_keys['ca_cert'],
646 cert_reqs=ssl.CERT_REQUIRED)
648 wsgi_server.serve_forever()
650 if __name__ == "__main__":