diff --git a/avocado/utils/cloudinit.py b/avocado/utils/cloudinit.py new file mode 100644 index 0000000000000000000000000000000000000000..7d70021e8f92a2e4281a78d3b7d55192a40b6e03 --- /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 0000000000000000000000000000000000000000..88863fa51e8e7a3eacbe4b1d71272545153ef7f3 --- /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()