#!/usr/bin/env python # coding: utf-8 -*- # (c) 2016, Ariel Opincaru <aopincar@redhat.com> # # This module is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This software is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this software. If not, see <http://www.gnu.org/licenses/>. import json import time from urlparse import urljoin from xml.dom import minidom import requests import requests.auth import requests.cookies DOCUMENTATION = ''' --- module: beaker_provisioner version_added: "0.1" short_description: Provision servers via Beaker description: - Provision servers via Beaker options: url: description: - Base URL of Beaker server required: true username: description: - Login username to authenticate to Beaker required: false default: admin password: description: - Password of login user required: true host: description: - Fully qualified domain name of a system required: true action: description: - Action to perform required: true choices: 'provision', 'release' distro_tree_id: description: - Distro Tree ID default: 71576 required: false web_service: description - Web service protocol default: 'rest' choices: 'rest', 'rpc' ca_cert: description: - CA certificate (python 2.7.9+ only) required: false custom_loan_comment: description: - Message stored as "Loan Comment" in Beaker's history Default value is used if not specified Comment can be max 60 lines long (Beaker requirement!), otherwise it will be cut required: false ''' DEFAULT_LOAN_COMMENT = "Loaned by InfraRed Module For Beaker" SYS_FQDN_PATH = '/systems/{fqdn}/' WAIT_BETWEEN_PROVISION_CHECKS = 10 class BeakerMachine(object): REQ_URL = dict( ALL='', FREE='free/', LOAN='loans/', VIEW='view/', RETURN='loans/+current', RESERVE='reservations/', RELEASE='reservations/+current', PROVISION='installations/', SYSTEMS='systems/', ) def __init__(self, url, username, password, fqdn, web_service, ca_cert, custom_loan_comment, disable_warnings=True): """ BeakerMachine initializer. :param url: Base URL of the Beaker server :param username: Authentication username :param password: Authentication password :param fqdn: Fully qualified domain name of a system :param disable_warnings: Hide unverified HTTPS requests warnings ( default = False) """ self.base_url = url self.auth = requests.auth.HTTPBasicAuth(username, password) self.fqdn = fqdn self.web_service = web_service self.ca_cert = ca_cert self.loan_comment = custom_loan_comment or DEFAULT_LOAN_COMMENT self.disable_warnings = disable_warnings self.changed = False self.session = None self.details = {} def create_session(self): """ Creates a session and get some details on the machine. """ self.session = requests.Session() self.session.auth = self.auth # whether verify the SSL certificate or not self.session.verify = self.ca_cert or False if self.disable_warnings: requests.packages.urllib3.disable_warnings() if self.web_service == 'rpc': self.session.cookies = requests.cookies.cookiejar_from_dict( self._xml_rpc_auth()) else: # cookie workaround - consider changing with _get_last_activity() self.list_systems(limit=1) self.details = self.get_system_details() def get_system_details(self): """ Get system's details :return: dict containing details about the system """ headers = {'Content-Type': 'application/json'} url = urljoin(self.base_url, SYS_FQDN_PATH.format(fqdn=self.fqdn)) resp = self.session.get(url, headers=headers) assert resp.status_code == requests.codes.OK, \ "Failed to get system's details: %s" % resp.text return json.loads(resp.text) def list_systems(self, limit=0, filters=None, req='FREE'): """ Get a list of free systems :param limit: Limit the number of free systems to return (0 = no limit) :param filters: A dictionary for system filtering :param req: A String representing the wanted request (Default = 'FREE') :return: List of free systems """ if filters is None: filters = {} url = self._build_url(req) params = dict(tg_format='atom', list_tgp_limit=limit) cnt = 0 for f_name, f_val in filters.items(): params['systemsearch-{0}.table'.format(cnt)] = f_name params['systemsearch-{0}.operation'.format(cnt)] = 'is' params['systemsearch-{0}.value'.format(cnt)] = f_val cnt += 1 # save the session params before changes saved_params = self.session.params self.session.params = params resp = self.session.get(url) assert resp.status_code == requests.codes.OK, \ str(resp.status_code) + " - " + resp.reason # restore session params self.session.params = saved_params parsed_xml = minidom.parseString(resp.content) feed = parsed_xml.getElementsByTagName('feed')[0] entries = feed.getElementsByTagName('entry') systems = set() for entry in entries: systems.add(entry.getElementsByTagName('title')[0].firstChild.data) systems = list(systems) systems.sort() return systems def release(self): """ Release the system :raises AssertionError: If fails to release the system """ if self.details['current_reservation']: self._release_system() self.changed = True if self.details['current_loan']: self._return_system() self.changed = True def reserve(self): """ Reserve the system :raises AssertionError: If fails to reserve the system try to return """ self._loan_system() try: self._reserve_system() except AssertionError: self._return_system() raise def _loan_system(self): """ Loan a system :raises AssertionError: If fails to loan the system """ headers = {'Content-Type': 'application/json'} url = self._build_url('LOAN', self.fqdn) resp = self.session.post(url, headers=headers, data=json.dumps({'comment': self.loan_comment})) assert resp.status_code == requests.codes.OK, "Failed to loan system: %s" % resp.text def _return_system(self): """ Return a loaned system :raises AssertionError: If fails to return the system """ headers = {'Content-Type': 'application/json'} url = self._build_url('RETURN', self.fqdn) resp = self.session.patch(url, headers=headers, data=json.dumps({'finish': 'now'})) assert resp.status_code == requests.codes.OK, "Failed to return system: %s" % resp.text def _reserve_system(self): """ Reserve a system :raises AssertionError: If fails to reserve the system """ headers = {'Content-Type': 'application/json'} url = self._build_url('RESERVE', self.fqdn) resp = self.session.post(url, headers=headers) assert resp.status_code == requests.codes.OK, \ "Failed to reserve system: %s" % resp.text def _release_system(self): """ Release a reserved system :raises AssertionError: If fails to release the system """ headers = {'Content-Type': 'application/json'} url = self._build_url('RELEASE', self.fqdn) resp = self.session.patch(url, headers=headers, data=json.dumps({'finish_time': 'now'})) assert resp.status_code == requests.codes.OK, \ "Failed to release system: %s" % resp.text def provision(self, distro_id, ks_meta=None, koptions=None, koptions_post=None, reboot=False, wait_for_host=True, provision_timeout=1200): """ Provision a system :param distro_id: an ID identifying the distro tree to be provisioned :param ks_meta: Kickstart metadata variables (string) :param koptions: Kernel options to be passed to the installer (string) :param koptions_post: Kernel options to be configured after installation (string) :param reboot: If true, the system will be rebooted immediately after the installer netboot configuration has been set up (boolean) :param wait_for_host: Whether or not wait for host to finish provisioning :param provision_timeout: Max time to wait for provisioning process (wait_for_ssh) to be done :raises AssertionError: If fails to provision the system """ if wait_for_host: last_pre_provison_activity = self._get_last_system_activity() headers = {'Content-Type': 'application/json'} url = self._build_url('PROVISION', self.fqdn) params = json.dumps({ 'distro_tree': {'id': str(distro_id)}, 'ks_meta': str(ks_meta), 'koptions': str(koptions), 'koptions_post': str(koptions_post), 'reboot': str(reboot) }) resp = self.session.post(url, data=params, headers=headers) assert resp.status_code in [requests.codes.CREATED, requests.codes.OK], \ ', '.join((str(resp.status_code), resp.reason, resp.text)) self.changed = True if wait_for_host: self._wait_for_provision( last_pre_provison_activity, provision_timeout) def _get_last_system_activity(self): """ Return the ID of the last system's activity """ headers = {'accept': 'application/json'} url = self.base_url + '/activity/system' self.session.params = {'q': 'system:' + self.fqdn} resp = self.session.get(url, headers=headers) return json.loads(resp.text)['entries'][0] def _wait_for_provision(self, pre_provision_activity_id, timeout): """ Waits until the provisioning process is done. :param pre_provision_activity_id: Last system's activity ID. :param timeout: Max time (in seconds) to wait for provisioning process. :raises RuntimeError: If waiting timeout exceeded """ start_time = time.time() # Provision waiter while (time.time() - start_time) < timeout: time.sleep(WAIT_BETWEEN_PROVISION_CHECKS) last_activity = self._get_last_system_activity() if all((last_activity['id'] != pre_provision_activity_id['id'], last_activity['action'] == 'clear_netboot', last_activity['service'] == 'XMLRPC')): break else: raise RuntimeError( "Provisioning waiting time exceeded ({} seconds)".format( timeout)) def _build_url(self, action, fqdn=None): """ Build URL by concatenating the base URL of the beaker server with the specific action string :param action: An action from the class variable ACTIONS :param fqdn: The system's fully qualified domain name :return: String of the absolute URL """ url = self.REQ_URL.get(action) if fqdn: url = urljoin(SYS_FQDN_PATH.format(fqdn=fqdn), url) return urljoin(self.base_url, url) def _xml_rpc_auth(self): """ Authenticates with the server using XML-RPC and returns the cookie's name and ID. """ # TODO: This method should be replaced with SafeCookieTransfer class!!! import re import ssl import tempfile import xmlrpclib try: ssl_context = ssl.create_default_context(cafile=self.ca_cert) transport = xmlrpclib.SafeTransport(context=ssl_context) except TypeError: # py < 2.7.9 transport = xmlrpclib.SafeTransport() hub = xmlrpclib.ServerProxy( urljoin(self.base_url, 'client'), allow_none=True, transport=transport, verbose=True) stdout = sys.stdout tmp_file = tempfile.TemporaryFile() try: sys.stdout = tmp_file hub.auth.login_password(self.auth.username, self.auth.password) tmp_file.seek(0) stdout_content = tmp_file.read() except xmlrpclib.Fault: raise RuntimeError('Failed to authenticate with the server') finally: sys.stdout = stdout tmp_file.close() pattern = re.compile('beaker_auth_token=[\w.-]*') results = pattern.findall(stdout_content) if not results: raise RuntimeError("Cookie not found") return {'beaker_auth_token': results[0].split('=')[1]} def main(): module = AnsibleModule( argument_spec=dict( url=dict(required=True), username=dict(default='admin'), password=dict(required=True), host=dict(required=True), action=dict(required=True, choices=['provision', 'release']), distro_tree_id=dict(default=71576), web_service=dict(default='rest', choices=['rest', 'rpc']), ca_cert=dict(required=False), custom_loan_comment=dict(default="",required=False), )) if module.params['ca_cert'] and not os.path.exists(os.path.expanduser(module.params['ca_cert'])): module.fail_json(msg="CA cert file doesn't exist", ca_cert=module.params['ca_cert']) beaker_client = BeakerMachine(url=module.params['url'], username=module.params['username'], password=module.params['password'], fqdn=module.params['host'], web_service=module.params['web_service'], ca_cert=module.params['ca_cert'], custom_loan_comment=module.params['custom_loan_comment']) try: beaker_client.create_session() # Reserve & provision if module.params['action'] == 'provision': # Reserve the system beaker_client.reserve() # Provision the system beaker_client.provision( reboot=True, distro_id=module.params['distro_tree_id'], ) # Release else: beaker_client.release() module.exit_json( changed=beaker_client.changed, host=beaker_client.fqdn) except (AssertionError, RuntimeError) as exc: module.fail_json( msg=exc.message + ", host: {}".format(beaker_client.fqdn)) from ansible.module_utils.basic import * main()