summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Moser <smoser@ubuntu.com>2018-03-19 14:50:54 -0400
committerScott Moser <smoser@brickies.net>2018-03-19 14:50:54 -0400
commite88e35483e373b39b4485f30f7a867f50571027c (patch)
tree9acb68fc91dc94e2f6fd66d741ff6f75f4abe830
parentde34dc7c467b318b2d04d065f8d752c7a530e155 (diff)
downloadvyos-cloud-init-e88e35483e373b39b4485f30f7a867f50571027c.tar.gz
vyos-cloud-init-e88e35483e373b39b4485f30f7a867f50571027c.zip
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/
-rw-r--r--cloudinit/apport.py6
-rw-r--r--cloudinit/settings.py1
-rw-r--r--cloudinit/sources/DataSourceHetzner.py100
-rw-r--r--cloudinit/sources/helpers/hetzner.py26
-rw-r--r--tests/unittests/test_datasource/test_common.py2
-rw-r--r--tests/unittests/test_datasource/test_hetzner.py99
-rw-r--r--tests/unittests/test_ds_identify.py9
-rwxr-xr-xtools/ds-identify7
8 files changed, 246 insertions, 4 deletions
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
index 221f341c..618b0160 100644
--- a/cloudinit/apport.py
+++ b/cloudinit/apport.py
@@ -14,9 +14,9 @@ except ImportError:
KNOWN_CLOUD_NAMES = [
'Amazon - Ec2', 'AliYun', 'AltCloud', 'Azure', 'Bigstep', 'CloudSigma',
- 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine', 'MAAS',
- 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF', 'Scaleway', 'SmartOS',
- 'VMware', 'Other']
+ 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine',
+ 'Hetzner Cloud', 'MAAS', 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF',
+ 'Scaleway', 'SmartOS', 'VMware', 'Other']
# Potentially clear text collected logs
CLOUDINIT_LOG = '/var/log/cloud-init.log'
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index c120498f..5fe749d4 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -36,6 +36,7 @@ CFG_BUILTIN = {
'SmartOS',
'Bigstep',
'Scaleway',
+ 'Hetzner',
# At the end to act as a 'catch' when none of the above work...
'None',
],
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 <jonas.keidel@hetzner.com>
+# Author: Markus Schade <markus.schade@hetzner.com>
+#
+# 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
diff --git a/cloudinit/sources/helpers/hetzner.py b/cloudinit/sources/helpers/hetzner.py
new file mode 100644
index 00000000..2554530d
--- /dev/null
+++ b/cloudinit/sources/helpers/hetzner.py
@@ -0,0 +1,26 @@
+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
+# Author: Markus Schade <markus.schade@hetzner.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit import log as logging
+from cloudinit import url_helper
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+
+def read_metadata(url, timeout=2, sec_between=2, retries=30):
+ response = url_helper.readurl(url, timeout=timeout,
+ sec_between=sec_between, retries=retries)
+ if not response.ok():
+ raise RuntimeError("unable to read metadata at %s" % url)
+ return util.load_yaml(response.contents.decode())
+
+
+def read_userdata(url, timeout=2, sec_between=2, retries=30):
+ response = url_helper.readurl(url, timeout=timeout,
+ sec_between=sec_between, retries=retries)
+ if not response.ok():
+ raise RuntimeError("unable to read userdata at %s" % url)
+ return response.contents
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 80b9c650..6d2dc5b5 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -14,6 +14,7 @@ from cloudinit.sources import (
DataSourceDigitalOcean as DigitalOcean,
DataSourceEc2 as Ec2,
DataSourceGCE as GCE,
+ DataSourceHetzner as Hetzner,
DataSourceMAAS as MAAS,
DataSourceNoCloud as NoCloud,
DataSourceOpenNebula as OpenNebula,
@@ -31,6 +32,7 @@ DEFAULT_LOCAL = [
CloudSigma.DataSourceCloudSigma,
ConfigDrive.DataSourceConfigDrive,
DigitalOcean.DataSourceDigitalOcean,
+ Hetzner.DataSourceHetzner,
NoCloud.DataSourceNoCloud,
OpenNebula.DataSourceOpenNebula,
OVF.DataSourceOVF,
diff --git a/tests/unittests/test_datasource/test_hetzner.py b/tests/unittests/test_datasource/test_hetzner.py
new file mode 100644
index 00000000..f1d1525e
--- /dev/null
+++ b/tests/unittests/test_datasource/test_hetzner.py
@@ -0,0 +1,99 @@
+# Copyright (C) 2018 Jonas Keidel
+#
+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.sources import DataSourceHetzner
+from cloudinit import util, settings, helpers
+
+from cloudinit.tests.helpers import mock, CiTestCase
+
+METADATA = util.load_yaml("""
+hostname: cloudinit-test
+instance-id: 123456
+local-ipv4: ''
+network-config:
+ config:
+ - mac_address: 96:00:00:08:19:da
+ name: eth0
+ subnets:
+ - dns_nameservers:
+ - 213.133.99.99
+ - 213.133.100.100
+ - 213.133.98.98
+ ipv4: true
+ type: dhcp
+ type: physical
+ - name: eth0:0
+ subnets:
+ - address: 2a01:4f8:beef:beef::1/64
+ gateway: fe80::1
+ ipv6: true
+ routes:
+ - gateway: fe80::1%eth0
+ netmask: 0
+ network: '::'
+ type: static
+ type: physical
+ version: 1
+network-sysconfig: "DEVICE='eth0'\nTYPE=Ethernet\nBOOTPROTO=dhcp\n\
+ ONBOOT='yes'\nHWADDR=96:00:00:08:19:da\n\
+ IPV6INIT=yes\nIPV6ADDR=2a01:4f8:beef:beef::1/64\n\
+ IPV6_DEFAULTGW=fe80::1%eth0\nIPV6_AUTOCONF=no\n\
+ DNS1=213.133.99.99\nDNS2=213.133.100.100\n"
+public-ipv4: 192.168.0.1
+public-keys:
+- ssh-ed25519 \
+ AAAAC3Nzac1lZdI1NTE5AaaAIaFrcac0yVITsmRrmueq6MD0qYNKlEvW8O1Ib4nkhmWh \
+ test-key@workstation
+vendor_data: "test"
+""")
+
+USERDATA = b"""#cloud-config
+runcmd:
+- [touch, /root/cloud-init-worked ]
+"""
+
+
+class TestDataSourceHetzner(CiTestCase):
+ """
+ Test reading the meta-data
+ """
+ def setUp(self):
+ super(TestDataSourceHetzner, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ def get_ds(self):
+ ds = DataSourceHetzner.DataSourceHetzner(
+ settings.CFG_BUILTIN, None, helpers.Paths({'run_dir': self.tmp}))
+ return ds
+
+ @mock.patch('cloudinit.net.EphemeralIPv4Network')
+ @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):
+ m_readmd.return_value = METADATA.copy()
+ m_usermd.return_value = USERDATA
+ m_fallback_nic.return_value = 'eth0'
+
+ ds = self.get_ds()
+ ret = ds.get_data()
+ self.assertTrue(ret)
+
+ m_net.assert_called_once_with(
+ 'eth0', '169.254.0.1',
+ 16, '169.254.255.255'
+ )
+
+ self.assertTrue(m_readmd.called)
+
+ self.assertEqual(METADATA.get('hostname'), ds.get_hostname())
+
+ self.assertEqual(METADATA.get('public-keys'),
+ ds.get_public_ssh_keys())
+
+ 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'))
diff --git a/tests/unittests/test_ds_identify.py b/tests/unittests/test_ds_identify.py
index 9be3f964..9c5628e7 100644
--- a/tests/unittests/test_ds_identify.py
+++ b/tests/unittests/test_ds_identify.py
@@ -60,6 +60,7 @@ P_CHASSIS_ASSET_TAG = "sys/class/dmi/id/chassis_asset_tag"
P_PRODUCT_NAME = "sys/class/dmi/id/product_name"
P_PRODUCT_SERIAL = "sys/class/dmi/id/product_serial"
P_PRODUCT_UUID = "sys/class/dmi/id/product_uuid"
+P_SYS_VENDOR = "sys/class/dmi/id/sys_vendor"
P_SEED_DIR = "var/lib/cloud/seed"
P_DSID_CFG = "etc/cloud/ds-identify.cfg"
@@ -379,6 +380,10 @@ class TestDsIdentify(CiTestCase):
"""Nocloud seed directory ubuntu core writable"""
self._test_ds_found('NoCloud-seed-ubuntu-core')
+ def test_hetzner_found(self):
+ """Hetzner cloud is identified in sys_vendor."""
+ self._test_ds_found('Hetzner')
+
def blkid_out(disks=None):
"""Convert a list of disk dictionaries into blkid content."""
@@ -559,6 +564,10 @@ VALID_CFG = {
},
],
},
+ 'Hetzner': {
+ 'ds': 'Hetzner',
+ 'files': {P_SYS_VENDOR: 'Hetzner\n'},
+ },
}
# vi: ts=4 expandtab
diff --git a/tools/ds-identify b/tools/ds-identify
index ec368d58..e3f93c90 100755
--- a/tools/ds-identify
+++ b/tools/ds-identify
@@ -114,7 +114,7 @@ DI_DSNAME=""
# be searched if there is no setting found in config.
DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
-OVF SmartOS Scaleway"
+OVF SmartOS Scaleway Hetzner"
DI_DSLIST=""
DI_MODE=""
DI_ON_FOUND=""
@@ -979,6 +979,11 @@ dscheck_Scaleway() {
return ${DS_NOT_FOUND}
}
+dscheck_Hetzner() {
+ dmi_sys_vendor_is Hetzner && return ${DS_FOUND}
+ return ${DS_NOT_FOUND}
+}
+
collect_info() {
read_virt
read_pid1_product_name