From e88e35483e373b39b4485f30f7a867f50571027c Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Mon, 19 Mar 2018 14:50:54 -0400 Subject: Add Hetzner Cloud DataSource The Hetzner Cloud metadata service is an AWS-style service available over HTTP via the link local address 169.254.169.254. https://hetzner.com/cloud https://docs.hetzner.cloud/ --- cloudinit/sources/DataSourceHetzner.py | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 cloudinit/sources/DataSourceHetzner.py (limited to 'cloudinit/sources/DataSourceHetzner.py') diff --git a/cloudinit/sources/DataSourceHetzner.py b/cloudinit/sources/DataSourceHetzner.py new file mode 100644 index 00000000..769fe131 --- /dev/null +++ b/cloudinit/sources/DataSourceHetzner.py @@ -0,0 +1,100 @@ +# Author: Jonas Keidel +# Author: Markus Schade +# +# This file is part of cloud-init. See LICENSE file for license information. +# +"""Hetzner Cloud API Documentation. + https://docs.hetzner.cloud/""" + +from cloudinit import log as logging +from cloudinit import net as cloudnet +from cloudinit import sources +from cloudinit import util + +import cloudinit.sources.helpers.hetzner as hc_helper + +LOG = logging.getLogger(__name__) + +BASE_URL_V1 = 'http://169.254.169.254/hetzner/v1' + +BUILTIN_DS_CONFIG = { + 'metadata_url': BASE_URL_V1 + '/metadata', + 'userdata_url': BASE_URL_V1 + '/userdata', +} + +MD_RETRIES = 60 +MD_TIMEOUT = 2 +MD_WAIT_RETRY = 2 + + +class DataSourceHetzner(sources.DataSource): + def __init__(self, sys_cfg, distro, paths): + sources.DataSource.__init__(self, sys_cfg, distro, paths) + self.distro = distro + self.metadata = dict() + self.ds_cfg = util.mergemanydict([ + util.get_cfg_by_path(sys_cfg, ["datasource", "Hetzner"], {}), + BUILTIN_DS_CONFIG]) + self.metadata_address = self.ds_cfg['metadata_url'] + self.userdata_address = self.ds_cfg['userdata_url'] + self.retries = self.ds_cfg.get('retries', MD_RETRIES) + self.timeout = self.ds_cfg.get('timeout', MD_TIMEOUT) + self.wait_retry = self.ds_cfg.get('wait_retry', MD_WAIT_RETRY) + self._network_config = None + self.dsmode = sources.DSMODE_NETWORK + + def get_data(self): + nic = cloudnet.find_fallback_nic() + with cloudnet.EphemeralIPv4Network(nic, "169.254.0.1", 16, + "169.254.255.255"): + md = hc_helper.read_metadata( + self.metadata_address, timeout=self.timeout, + sec_between=self.wait_retry, retries=self.retries) + ud = hc_helper.read_userdata( + self.userdata_address, timeout=self.timeout, + sec_between=self.wait_retry, retries=self.retries) + + self.userdata_raw = ud + self.metadata_full = md + + """hostname is name provided by user at launch. The API enforces + it is a valid hostname, but it is not guaranteed to be resolvable + in dns or fully qualified.""" + self.metadata['instance-id'] = md['instance-id'] + self.metadata['local-hostname'] = md['hostname'] + self.metadata['network-config'] = md.get('network-config', None) + self.metadata['public-keys'] = md.get('public-keys', None) + self.vendordata_raw = md.get("vendor_data", None) + + return True + + @property + def network_config(self): + """Configure the networking. This needs to be done each boot, since + the IP information may have changed due to snapshot and/or + migration. + """ + + if self._network_config: + return self._network_config + + _net_config = self.metadata['network-config'] + if not _net_config: + raise Exception("Unable to get meta-data from server....") + + self._network_config = _net_config + + return self._network_config + + +# Used to match classes to dependencies +datasources = [ + (DataSourceHetzner, (sources.DEP_FILESYSTEM, )), +] + + +# Return a list of data sources that match this set of dependencies +def get_datasource_list(depends): + return sources.list_from_depends(depends, datasources) + +# vi: ts=4 expandtab -- cgit v1.2.3 From 5e4641a3cd5eac5cbf8ffd13d32514fbb9424077 Mon Sep 17 00:00:00 2001 From: Scott Moser Date: Tue, 27 Mar 2018 12:03:39 -0600 Subject: Hetzner: Exit early if dmi system-manufacturer is not Hetzner. This takes the same basic check that is in ds-identify. If the DMI system manufacturer (aka sys_vendor) is not 'Hetzner', then exit out of the datasource's get_data quickly. --- cloudinit/sources/DataSourceHetzner.py | 6 ++++++ tests/unittests/test_datasource/test_hetzner.py | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) (limited to 'cloudinit/sources/DataSourceHetzner.py') diff --git a/cloudinit/sources/DataSourceHetzner.py b/cloudinit/sources/DataSourceHetzner.py index 769fe131..5c75b65b 100644 --- a/cloudinit/sources/DataSourceHetzner.py +++ b/cloudinit/sources/DataSourceHetzner.py @@ -44,6 +44,8 @@ class DataSourceHetzner(sources.DataSource): self.dsmode = sources.DSMODE_NETWORK def get_data(self): + if not on_hetzner(): + return False nic = cloudnet.find_fallback_nic() with cloudnet.EphemeralIPv4Network(nic, "169.254.0.1", 16, "169.254.255.255"): @@ -87,6 +89,10 @@ class DataSourceHetzner(sources.DataSource): return self._network_config +def on_hetzner(): + return util.read_dmi_data('system-manufacturer') == "Hetzner" + + # Used to match classes to dependencies datasources = [ (DataSourceHetzner, (sources.DEP_FILESYSTEM, )), diff --git a/tests/unittests/test_datasource/test_hetzner.py b/tests/unittests/test_datasource/test_hetzner.py index f1d1525e..a9c12597 100644 --- a/tests/unittests/test_datasource/test_hetzner.py +++ b/tests/unittests/test_datasource/test_hetzner.py @@ -73,7 +73,10 @@ class TestDataSourceHetzner(CiTestCase): @mock.patch('cloudinit.net.find_fallback_nic') @mock.patch('cloudinit.sources.helpers.hetzner.read_metadata') @mock.patch('cloudinit.sources.helpers.hetzner.read_userdata') - def test_read_data(self, m_usermd, m_readmd, m_fallback_nic, m_net): + @mock.patch('cloudinit.sources.DataSourceHetzner.on_hetzner') + def test_read_data(self, m_on_hetzner, m_usermd, m_readmd, m_fallback_nic, + m_net): + m_on_hetzner.return_value = True m_readmd.return_value = METADATA.copy() m_usermd.return_value = USERDATA m_fallback_nic.return_value = 'eth0' @@ -97,3 +100,18 @@ class TestDataSourceHetzner(CiTestCase): self.assertIsInstance(ds.get_public_ssh_keys(), list) self.assertEqual(ds.get_userdata_raw(), USERDATA) self.assertEqual(ds.get_vendordata_raw(), METADATA.get('vendor_data')) + + @mock.patch('cloudinit.sources.helpers.hetzner.read_metadata') + @mock.patch('cloudinit.net.find_fallback_nic') + @mock.patch('cloudinit.sources.DataSourceHetzner.on_hetzner') + def test_not_on_hetzner_returns_false(self, m_on_hetzner, m_find_fallback, + m_read_md): + """If helper 'on_hetzner' returns False, return False from get_data.""" + m_on_hetzner.return_value = False + ds = self.get_ds() + ret = ds.get_data() + + self.assertFalse(ret) + # These are a white box attempt to ensure it did not search. + m_find_fallback.assert_not_called() + m_read_md.assert_not_called() -- cgit v1.2.3