From 891cd495268918b7158258225161f53ddb33ae88 Mon Sep 17 00:00:00 2001 From: Cleber Rosa Date: Wed, 22 Aug 2018 14:40:28 -0400 Subject: [PATCH] avocado.utils: introduce cloudinit module This addition to the utilities library facilitates the use of the cloudinit features inside Linux OSs images for the cloud. For now, it eases the creation of "cidata" ISOs, containing configuration to cloud-init, and also a simple "phone home" server. Signed-off-by: Cleber Rosa --- avocado/utils/cloudinit.py | 142 +++++++++++++++++++++++++ selftests/unit/test_utils_cloudinit.py | 91 ++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 avocado/utils/cloudinit.py create mode 100644 selftests/unit/test_utils_cloudinit.py diff --git a/avocado/utils/cloudinit.py b/avocado/utils/cloudinit.py new file mode 100644 index 00000000..7d70021e --- /dev/null +++ b/avocado/utils/cloudinit.py @@ -0,0 +1,142 @@ +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 LICENSE for more details. +# +# Copyright: Red Hat Inc. 2018 +# Author: Cleber Rosa + +""" +cloudinit configuration support + +This module can be easily used with :mod:`avocado.utils.vmimage`, +to configure operating system images via the cloudinit tooling. +""" + +from six.moves import BaseHTTPServer + +from . import astring +from . import iso9660 + + +#: The meta-data file template +#: Positional template variables are: instance-id, hostname +METADATA_TEMPLATE = """instance-id: {0} +hostname: {1} +""" + +#: The header expected to be found at the beginning of the user-data file +USERDATA_HEADER = "#cloud-config" + +#: A password configuration as per cloudinit/config/cc_set_passwords.py +#: Positional template variables are: username, password +PASSWORD_TEMPLATE = """ +ssh_pwauth: True + +system_info: + default_user: + name: {0} + +password: {1} +chpasswd: + expire: False +""" + +#: A phone home configuration that will post just the instance id +#: Positional template variables are: address, port +PHONE_HOME_TEMPLATE = """ +phone_home: + url: http://{0}:{1}/$INSTANCE_ID/ + post: [ instance_id ] +""" + + +def iso(output_path, instance_id, username=None, password=None, + phone_home_host=None, phone_home_port=None): + """ + Generates an ISO image with cloudinit configuration + + The content always include the cloudinit metadata, and optionally + the userdata content. On the userdata file, it may contain a + username/password section (if both parameters are given) and/or a + phone home section (if both host and port are given). + + :param output_path: the location of the resulting (to be created) ISO + image containing the cloudinit configuration + :param instance_id: the ID of the cloud instance, a form of identification + for the dynamically created executing instances + :param username: the username to be used when logging interactively on the + instance + :param password: the password to be used along with username when + authenticating with the login services on the instance + :param phone_home_host: the address of the host the instance + should contact once it has finished + booting + :param phone_home_port: the port acting as an HTTP phone home + server that the instance should contact + once it has finished booting + :raises: RuntimeError if the system can not create ISO images. On such + a case, user is expected to install supporting packages, such as + pycdlib. + """ + out = iso9660.iso9660(output_path, ["create", "write"]) + if out is None: + raise RuntimeError("The system lacks support for creating ISO images") + out.create(flags={"interchange_level": 3, "joliet": 3, "vol_ident": 'cidata'}) + metadata = METADATA_TEMPLATE.format(instance_id, + instance_id).encode(astring.ENCODING) + out.write("/meta-data", metadata) + userdata = USERDATA_HEADER + if username and password: + userdata += PASSWORD_TEMPLATE.format(username, password) + if phone_home_host and phone_home_port: + userdata += PHONE_HOME_TEMPLATE.format(phone_home_host, phone_home_port) + out.write("/user-data", userdata.encode(astring.ENCODING)) + out.close() + + +class PhoneHomeServerHandler(BaseHTTPServer.BaseHTTPRequestHandler): + + def do_POST(self): + path = self.path[1:] + if path[-1] == '/': + path = path[:-1] + if path == self.server.instance_id: + self.server.instance_phoned_back = True + self.send_response(200) + + def log_message(self, format_, *args): + pass + + +class PhoneHomeServer(BaseHTTPServer.HTTPServer): + + def __init__(self, address, instance_id): + BaseHTTPServer.HTTPServer.__init__(self, address, PhoneHomeServerHandler) + self.instance_id = instance_id + self.instance_phoned_back = False + + +def wait_for_phone_home(address, instance_id): + """ + Sets up a phone home server and waits for the given instance to call + + This is a shorthand for setting up a server that will keep handling + requests, until it has heard from the specific instance requested. + + :param address: a hostname or IP address and port, in the same format + given to socket and other servers + :type address: tuple + :param instance_id: the identification for the instance that should be + calling back, and the condition for the wait to end + :type instance_id: str + """ + s = PhoneHomeServer(address, instance_id) + while not s.instance_phoned_back: + s.handle_request() diff --git a/selftests/unit/test_utils_cloudinit.py b/selftests/unit/test_utils_cloudinit.py new file mode 100644 index 00000000..88863fa5 --- /dev/null +++ b/selftests/unit/test_utils_cloudinit.py @@ -0,0 +1,91 @@ +import os +import shutil +import tempfile +import threading +import unittest # pylint: disable=C0411 +try: + from unittest import mock +except ImportError: + import mock + +from six.moves import http_client + +from avocado.utils import cloudinit +from avocado.utils import iso9660 +from avocado.utils import network +from avocado.utils import data_factory + + +def has_iso_create_write(): + return iso9660.iso9660(os.devnull, ["create", "write"]) is not None + + +class CloudInit(unittest.TestCase): + + def test_iso_no_create_write(self): + with mock.patch('avocado.utils.iso9660.iso9660', return_value=None): + self.assertRaises(RuntimeError, cloudinit.iso, os.devnull, "INSTANCE_ID") + + +class CloudInitISO(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="avocado_" + __name__) + + @unittest.skipUnless(has_iso_create_write(), + "system lacks support for creating ISO images") + def test_iso_no_phone_home(self): + path = os.path.join(self.tmpdir, "cloudinit.iso") + instance_id = b"INSTANCE_ID" + username = b"AVOCADO_USER" + password = b"AVOCADO_PASSWORD" + cloudinit.iso(path, instance_id, username, password) + iso = iso9660.iso9660(path) + self.assertIn(instance_id, iso.read("/meta-data")) + user_data = iso.read("/user-data") + self.assertIn(username, user_data) + self.assertIn(password, user_data) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + +class PhoneHome(unittest.TestCase): + + def test_phone_home(self): + instance_id = data_factory.generate_random_string(12) + address = '127.0.0.1' + port = network.find_free_port(address=address) + server = cloudinit.PhoneHomeServer((address, port), instance_id) + self.assertFalse(server.instance_phoned_back) + + server_thread = threading.Thread(target=server.handle_request) + server_thread.start() + conn = http_client.HTTPConnection(address, port) + + # contact the wrong url, and check the server does not + # consider it a call back from the expected caller + conn.request('POST', '/BAD_INSTANCE_ID') + try: + conn.getresponse() + except: + pass + self.assertFalse(server.instance_phoned_back) + conn.close() + + # now the right url + server_thread = threading.Thread(target=server.handle_request) + server_thread.start() + conn = http_client.HTTPConnection(address, port) + conn.request('POST', '/' + instance_id) + try: + conn.getresponse() + except: + pass + self.assertTrue(server.instance_phoned_back) + conn.close() + server.server_close() + + +if __name__ == '__main__': + unittest.main() -- GitLab