Merge ~chad.smith/cloud-init:ubuntu/devel into cloud-init:ubuntu/devel

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: 25198d2af0ece1941edfe1bd5e6476a5c34afe86
Merged at revision: f3fd0af35de9298d716a440cc316c9ed4aff90cf
Proposed branch: ~chad.smith/cloud-init:ubuntu/devel
Merge into: cloud-init:ubuntu/devel
Diff against target: 1052 lines (+551/-128)
17 files modified
cloudinit/apport.py (+3/-3)
cloudinit/net/network_state.py (+10/-0)
cloudinit/settings.py (+1/-0)
cloudinit/sources/DataSourceAliYun.py (+1/-1)
cloudinit/sources/DataSourceCloudSigma.py (+1/-1)
cloudinit/sources/DataSourceGCE.py (+1/-1)
cloudinit/sources/DataSourceHetzner.py (+100/-0)
cloudinit/sources/DataSourceOpenNebula.py (+75/-31)
cloudinit/sources/DataSourceScaleway.py (+1/-1)
cloudinit/sources/helpers/hetzner.py (+26/-0)
cloudinit/sources/tests/test_init.py (+28/-0)
debian/changelog (+11/-0)
tests/unittests/test_datasource/test_common.py (+2/-0)
tests/unittests/test_datasource/test_hetzner.py (+99/-0)
tests/unittests/test_datasource/test_opennebula.py (+177/-89)
tests/unittests/test_ds_identify.py (+9/-0)
tools/ds-identify (+6/-1)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Scott Moser Pending
Review via email: mp+341778@code.launchpad.net

Description of the change

sync upstream master to Bionic for release (include fix for GCE datasource LP: #1757176)

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:25198d2af0ece1941edfe1bd5e6476a5c34afe86
https://jenkins.ubuntu.com/server/job/cloud-init-ci/894/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/894/rebuild

review: Approve (continuous-integration)

There was an error fetching revisions from git servers. Please try again in a few minutes. If the problem persists, contact Launchpad support.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/apport.py b/cloudinit/apport.py
2index 221f341..618b016 100644
3--- a/cloudinit/apport.py
4+++ b/cloudinit/apport.py
5@@ -14,9 +14,9 @@ except ImportError:
6
7 KNOWN_CLOUD_NAMES = [
8 'Amazon - Ec2', 'AliYun', 'AltCloud', 'Azure', 'Bigstep', 'CloudSigma',
9- 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine', 'MAAS',
10- 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF', 'Scaleway', 'SmartOS',
11- 'VMware', 'Other']
12+ 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine',
13+ 'Hetzner Cloud', 'MAAS', 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF',
14+ 'Scaleway', 'SmartOS', 'VMware', 'Other']
15
16 # Potentially clear text collected logs
17 CLOUDINIT_LOG = '/var/log/cloud-init.log'
18diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
19index 1dd7ded..6d63e5c 100644
20--- a/cloudinit/net/network_state.py
21+++ b/cloudinit/net/network_state.py
22@@ -708,6 +708,7 @@ class NetworkStateInterpreter(object):
23
24 gateway4 = None
25 gateway6 = None
26+ nameservers = {}
27 for address in cfg.get('addresses', []):
28 subnet = {
29 'type': 'static',
30@@ -723,6 +724,15 @@ class NetworkStateInterpreter(object):
31 gateway4 = cfg.get('gateway4')
32 subnet.update({'gateway': gateway4})
33
34+ if 'nameservers' in cfg and not nameservers:
35+ addresses = cfg.get('nameservers').get('addresses')
36+ if addresses:
37+ nameservers['dns_nameservers'] = addresses
38+ search = cfg.get('nameservers').get('search')
39+ if search:
40+ nameservers['dns_search'] = search
41+ subnet.update(nameservers)
42+
43 subnets.append(subnet)
44
45 routes = []
46diff --git a/cloudinit/settings.py b/cloudinit/settings.py
47index c120498..5fe749d 100644
48--- a/cloudinit/settings.py
49+++ b/cloudinit/settings.py
50@@ -36,6 +36,7 @@ CFG_BUILTIN = {
51 'SmartOS',
52 'Bigstep',
53 'Scaleway',
54+ 'Hetzner',
55 # At the end to act as a 'catch' when none of the above work...
56 'None',
57 ],
58diff --git a/cloudinit/sources/DataSourceAliYun.py b/cloudinit/sources/DataSourceAliYun.py
59index 7ac8288..22279d0 100644
60--- a/cloudinit/sources/DataSourceAliYun.py
61+++ b/cloudinit/sources/DataSourceAliYun.py
62@@ -22,7 +22,7 @@ class DataSourceAliYun(EC2.DataSourceEc2):
63 super(DataSourceAliYun, self).__init__(sys_cfg, distro, paths)
64 self.seed_dir = os.path.join(paths.seed_dir, "AliYun")
65
66- def get_hostname(self, fqdn=False, _resolve_ip=False):
67+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
68 return self.metadata.get('hostname', 'localhost.localdomain')
69
70 def get_public_ssh_keys(self):
71diff --git a/cloudinit/sources/DataSourceCloudSigma.py b/cloudinit/sources/DataSourceCloudSigma.py
72index 4eaad47..c816f34 100644
73--- a/cloudinit/sources/DataSourceCloudSigma.py
74+++ b/cloudinit/sources/DataSourceCloudSigma.py
75@@ -84,7 +84,7 @@ class DataSourceCloudSigma(sources.DataSource):
76
77 return True
78
79- def get_hostname(self, fqdn=False, resolve_ip=False):
80+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
81 """
82 Cleans up and uses the server's name if the latter is set. Otherwise
83 the first part from uuid is being used.
84diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
85index bebc991..d816262 100644
86--- a/cloudinit/sources/DataSourceGCE.py
87+++ b/cloudinit/sources/DataSourceGCE.py
88@@ -90,7 +90,7 @@ class DataSourceGCE(sources.DataSource):
89 public_keys_data = self.metadata['public-keys-data']
90 return _parse_public_keys(public_keys_data, self.default_user)
91
92- def get_hostname(self, fqdn=False, resolve_ip=False):
93+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
94 # GCE has long FDQN's and has asked for short hostnames.
95 return self.metadata['local-hostname'].split('.')[0]
96
97diff --git a/cloudinit/sources/DataSourceHetzner.py b/cloudinit/sources/DataSourceHetzner.py
98new file mode 100644
99index 0000000..769fe13
100--- /dev/null
101+++ b/cloudinit/sources/DataSourceHetzner.py
102@@ -0,0 +1,100 @@
103+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
104+# Author: Markus Schade <markus.schade@hetzner.com>
105+#
106+# This file is part of cloud-init. See LICENSE file for license information.
107+#
108+"""Hetzner Cloud API Documentation.
109+ https://docs.hetzner.cloud/"""
110+
111+from cloudinit import log as logging
112+from cloudinit import net as cloudnet
113+from cloudinit import sources
114+from cloudinit import util
115+
116+import cloudinit.sources.helpers.hetzner as hc_helper
117+
118+LOG = logging.getLogger(__name__)
119+
120+BASE_URL_V1 = 'http://169.254.169.254/hetzner/v1'
121+
122+BUILTIN_DS_CONFIG = {
123+ 'metadata_url': BASE_URL_V1 + '/metadata',
124+ 'userdata_url': BASE_URL_V1 + '/userdata',
125+}
126+
127+MD_RETRIES = 60
128+MD_TIMEOUT = 2
129+MD_WAIT_RETRY = 2
130+
131+
132+class DataSourceHetzner(sources.DataSource):
133+ def __init__(self, sys_cfg, distro, paths):
134+ sources.DataSource.__init__(self, sys_cfg, distro, paths)
135+ self.distro = distro
136+ self.metadata = dict()
137+ self.ds_cfg = util.mergemanydict([
138+ util.get_cfg_by_path(sys_cfg, ["datasource", "Hetzner"], {}),
139+ BUILTIN_DS_CONFIG])
140+ self.metadata_address = self.ds_cfg['metadata_url']
141+ self.userdata_address = self.ds_cfg['userdata_url']
142+ self.retries = self.ds_cfg.get('retries', MD_RETRIES)
143+ self.timeout = self.ds_cfg.get('timeout', MD_TIMEOUT)
144+ self.wait_retry = self.ds_cfg.get('wait_retry', MD_WAIT_RETRY)
145+ self._network_config = None
146+ self.dsmode = sources.DSMODE_NETWORK
147+
148+ def get_data(self):
149+ nic = cloudnet.find_fallback_nic()
150+ with cloudnet.EphemeralIPv4Network(nic, "169.254.0.1", 16,
151+ "169.254.255.255"):
152+ md = hc_helper.read_metadata(
153+ self.metadata_address, timeout=self.timeout,
154+ sec_between=self.wait_retry, retries=self.retries)
155+ ud = hc_helper.read_userdata(
156+ self.userdata_address, timeout=self.timeout,
157+ sec_between=self.wait_retry, retries=self.retries)
158+
159+ self.userdata_raw = ud
160+ self.metadata_full = md
161+
162+ """hostname is name provided by user at launch. The API enforces
163+ it is a valid hostname, but it is not guaranteed to be resolvable
164+ in dns or fully qualified."""
165+ self.metadata['instance-id'] = md['instance-id']
166+ self.metadata['local-hostname'] = md['hostname']
167+ self.metadata['network-config'] = md.get('network-config', None)
168+ self.metadata['public-keys'] = md.get('public-keys', None)
169+ self.vendordata_raw = md.get("vendor_data", None)
170+
171+ return True
172+
173+ @property
174+ def network_config(self):
175+ """Configure the networking. This needs to be done each boot, since
176+ the IP information may have changed due to snapshot and/or
177+ migration.
178+ """
179+
180+ if self._network_config:
181+ return self._network_config
182+
183+ _net_config = self.metadata['network-config']
184+ if not _net_config:
185+ raise Exception("Unable to get meta-data from server....")
186+
187+ self._network_config = _net_config
188+
189+ return self._network_config
190+
191+
192+# Used to match classes to dependencies
193+datasources = [
194+ (DataSourceHetzner, (sources.DEP_FILESYSTEM, )),
195+]
196+
197+
198+# Return a list of data sources that match this set of dependencies
199+def get_datasource_list(depends):
200+ return sources.list_from_depends(depends, datasources)
201+
202+# vi: ts=4 expandtab
203diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py
204index 9450835..d4a4111 100644
205--- a/cloudinit/sources/DataSourceOpenNebula.py
206+++ b/cloudinit/sources/DataSourceOpenNebula.py
207@@ -20,7 +20,6 @@ import string
208
209 from cloudinit import log as logging
210 from cloudinit import net
211-from cloudinit.net import eni
212 from cloudinit import sources
213 from cloudinit import util
214
215@@ -91,19 +90,19 @@ class DataSourceOpenNebula(sources.DataSource):
216 return False
217
218 self.seed = seed
219- self.network_eni = results.get('network-interfaces')
220+ self.network = results.get('network-interfaces')
221 self.metadata = md
222 self.userdata_raw = results.get('userdata')
223 return True
224
225 @property
226 def network_config(self):
227- if self.network_eni is not None:
228- return eni.convert_eni_data(self.network_eni)
229+ if self.network is not None:
230+ return self.network
231 else:
232 return None
233
234- def get_hostname(self, fqdn=False, resolve_ip=None):
235+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
236 if resolve_ip is None:
237 if self.dsmode == sources.DSMODE_NETWORK:
238 resolve_ip = True
239@@ -143,18 +142,42 @@ class OpenNebulaNetwork(object):
240 def mac2network(self, mac):
241 return self.mac2ip(mac).rpartition(".")[0] + ".0"
242
243- def get_dns(self, dev):
244- return self.get_field(dev, "dns", "").split()
245+ def get_nameservers(self, dev):
246+ nameservers = {}
247+ dns = self.get_field(dev, "dns", "").split()
248+ dns.extend(self.context.get('DNS', "").split())
249+ if dns:
250+ nameservers['addresses'] = dns
251+ search_domain = self.get_field(dev, "search_domain", "").split()
252+ if search_domain:
253+ nameservers['search'] = search_domain
254+ return nameservers
255
256- def get_domain(self, dev):
257- return self.get_field(dev, "domain")
258+ def get_mtu(self, dev):
259+ return self.get_field(dev, "mtu")
260
261 def get_ip(self, dev, mac):
262 return self.get_field(dev, "ip", self.mac2ip(mac))
263
264+ def get_ip6(self, dev):
265+ addresses6 = []
266+ ip6 = self.get_field(dev, "ip6")
267+ if ip6:
268+ addresses6.append(ip6)
269+ ip6_ula = self.get_field(dev, "ip6_ula")
270+ if ip6_ula:
271+ addresses6.append(ip6_ula)
272+ return addresses6
273+
274+ def get_ip6_prefix(self, dev):
275+ return self.get_field(dev, "ip6_prefix_length", "64")
276+
277 def get_gateway(self, dev):
278 return self.get_field(dev, "gateway")
279
280+ def get_gateway6(self, dev):
281+ return self.get_field(dev, "gateway6")
282+
283 def get_mask(self, dev):
284 return self.get_field(dev, "mask", "255.255.255.0")
285
286@@ -171,10 +194,11 @@ class OpenNebulaNetwork(object):
287 return default if val in (None, "") else val
288
289 def gen_conf(self):
290- global_dns = self.context.get('DNS', "").split()
291-
292- conf = ['auto lo', 'iface lo inet loopback', '']
293+ netconf = {}
294+ netconf['version'] = 2
295+ netconf['ethernets'] = {}
296
297+ ethernets = {}
298 for mac, dev in self.ifaces.items():
299 mac = mac.lower()
300
301@@ -182,29 +206,49 @@ class OpenNebulaNetwork(object):
302 # dev stores the current system name.
303 c_dev = self.context_devname.get(mac, dev)
304
305- conf.append('auto ' + dev)
306- conf.append('iface ' + dev + ' inet static')
307- conf.append(' #hwaddress %s' % mac)
308- conf.append(' address ' + self.get_ip(c_dev, mac))
309- conf.append(' network ' + self.get_network(c_dev, mac))
310- conf.append(' netmask ' + self.get_mask(c_dev))
311+ devconf = {}
312+
313+ # Set MAC address
314+ devconf['match'] = {'macaddress': mac}
315
316+ # Set IPv4 address
317+ devconf['addresses'] = []
318+ mask = self.get_mask(c_dev)
319+ prefix = str(net.mask_to_net_prefix(mask))
320+ devconf['addresses'].append(
321+ self.get_ip(c_dev, mac) + '/' + prefix)
322+
323+ # Set IPv6 Global and ULA address
324+ addresses6 = self.get_ip6(c_dev)
325+ if addresses6:
326+ prefix6 = self.get_ip6_prefix(c_dev)
327+ devconf['addresses'].extend(
328+ [i + '/' + prefix6 for i in addresses6])
329+
330+ # Set IPv4 default gateway
331 gateway = self.get_gateway(c_dev)
332 if gateway:
333- conf.append(' gateway ' + gateway)
334+ devconf['gateway4'] = gateway
335+
336+ # Set IPv6 default gateway
337+ gateway6 = self.get_gateway6(c_dev)
338+ if gateway:
339+ devconf['gateway6'] = gateway6
340
341- domain = self.get_domain(c_dev)
342- if domain:
343- conf.append(' dns-search ' + domain)
344+ # Set DNS servers and search domains
345+ nameservers = self.get_nameservers(c_dev)
346+ if nameservers:
347+ devconf['nameservers'] = nameservers
348
349- # add global DNS servers to all interfaces
350- dns = self.get_dns(c_dev)
351- if global_dns or dns:
352- conf.append(' dns-nameservers ' + ' '.join(global_dns + dns))
353+ # Set MTU size
354+ mtu = self.get_mtu(c_dev)
355+ if mtu:
356+ devconf['mtu'] = mtu
357
358- conf.append('')
359+ ethernets[dev] = devconf
360
361- return "\n".join(conf)
362+ netconf['ethernets'] = ethernets
363+ return(netconf)
364
365
366 def find_candidate_devs():
367@@ -390,10 +434,10 @@ def read_context_disk_dir(source_dir, asuser=None):
368 except TypeError:
369 LOG.warning("Failed base64 decoding of userdata")
370
371- # generate static /etc/network/interfaces
372+ # generate Network Configuration v2
373 # only if there are any required context variables
374- # http://opennebula.org/documentation:rel3.8:cong#network_configuration
375- ipaddr_keys = [k for k in context if re.match(r'^ETH\d+_IP$', k)]
376+ # http://docs.opennebula.org/5.4/operation/references/template.html#context-section
377+ ipaddr_keys = [k for k in context if re.match(r'^ETH\d+_IP.*$', k)]
378 if ipaddr_keys:
379 onet = OpenNebulaNetwork(context)
380 results['network-interfaces'] = onet.gen_conf()
381diff --git a/cloudinit/sources/DataSourceScaleway.py b/cloudinit/sources/DataSourceScaleway.py
382index b0b19c9..9005624 100644
383--- a/cloudinit/sources/DataSourceScaleway.py
384+++ b/cloudinit/sources/DataSourceScaleway.py
385@@ -215,7 +215,7 @@ class DataSourceScaleway(sources.DataSource):
386 def get_public_ssh_keys(self):
387 return [key['key'] for key in self.metadata['ssh_public_keys']]
388
389- def get_hostname(self, fqdn=False, resolve_ip=False):
390+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
391 return self.metadata['hostname']
392
393 @property
394diff --git a/cloudinit/sources/helpers/hetzner.py b/cloudinit/sources/helpers/hetzner.py
395new file mode 100644
396index 0000000..2554530
397--- /dev/null
398+++ b/cloudinit/sources/helpers/hetzner.py
399@@ -0,0 +1,26 @@
400+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
401+# Author: Markus Schade <markus.schade@hetzner.com>
402+#
403+# This file is part of cloud-init. See LICENSE file for license information.
404+
405+from cloudinit import log as logging
406+from cloudinit import url_helper
407+from cloudinit import util
408+
409+LOG = logging.getLogger(__name__)
410+
411+
412+def read_metadata(url, timeout=2, sec_between=2, retries=30):
413+ response = url_helper.readurl(url, timeout=timeout,
414+ sec_between=sec_between, retries=retries)
415+ if not response.ok():
416+ raise RuntimeError("unable to read metadata at %s" % url)
417+ return util.load_yaml(response.contents.decode())
418+
419+
420+def read_userdata(url, timeout=2, sec_between=2, retries=30):
421+ response = url_helper.readurl(url, timeout=timeout,
422+ sec_between=sec_between, retries=retries)
423+ if not response.ok():
424+ raise RuntimeError("unable to read userdata at %s" % url)
425+ return response.contents
426diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
427index 5065083..e7fda22 100644
428--- a/cloudinit/sources/tests/test_init.py
429+++ b/cloudinit/sources/tests/test_init.py
430@@ -1,10 +1,12 @@
431 # This file is part of cloud-init. See LICENSE file for license information.
432
433+import inspect
434 import os
435 import six
436 import stat
437
438 from cloudinit.helpers import Paths
439+from cloudinit import importer
440 from cloudinit.sources import (
441 INSTANCE_JSON_FILE, DataSource)
442 from cloudinit.tests.helpers import CiTestCase, skipIf, mock
443@@ -268,3 +270,29 @@ class TestDataSource(CiTestCase):
444 "WARNING: Error persisting instance-data.json: 'utf8' codec can't"
445 " decode byte 0xaa in position 2: invalid start byte",
446 self.logs.getvalue())
447+
448+ def test_get_hostname_subclass_support(self):
449+ """Validate get_hostname signature on all subclasses of DataSource."""
450+ # Use inspect.getfullargspec when we drop py2.6 and py2.7
451+ get_args = inspect.getargspec # pylint: disable=W1505
452+ base_args = get_args(DataSource.get_hostname) # pylint: disable=W1505
453+ # Import all DataSource subclasses so we can inspect them.
454+ modules = util.find_modules(os.path.dirname(os.path.dirname(__file__)))
455+ for loc, name in modules.items():
456+ mod_locs, _ = importer.find_module(name, ['cloudinit.sources'], [])
457+ if mod_locs:
458+ importer.import_module(mod_locs[0])
459+ for child in DataSource.__subclasses__():
460+ if 'Test' in child.dsname:
461+ continue
462+ self.assertEqual(
463+ base_args,
464+ get_args(child.get_hostname), # pylint: disable=W1505
465+ '%s does not implement DataSource.get_hostname params'
466+ % child)
467+ for grandchild in child.__subclasses__():
468+ self.assertEqual(
469+ base_args,
470+ get_args(grandchild.get_hostname), # pylint: disable=W1505
471+ '%s does not implement DataSource.get_hostname params'
472+ % grandchild)
473diff --git a/debian/changelog b/debian/changelog
474index f225d52..68191a2 100644
475--- a/debian/changelog
476+++ b/debian/changelog
477@@ -1,3 +1,14 @@
478+cloud-init (18.1-26-g685f9901-0ubuntu1) bionic; urgency=medium
479+
480+ * New upstream snapshot.
481+ - datasources: fix DataSource subclass get_hostname method signature
482+ (LP: #1757176)
483+ - OpenNebula: Update network to return v2 config rather than ENI.
484+ [Akihiko Ota]
485+ - Add Hetzner Cloud DataSource [Markus Schade]
486+
487+ -- Chad Smith <chad.smith@canonical.com> Tue, 20 Mar 2018 16:40:54 -0600
488+
489 cloud-init (18.1-23-gde34dc7c-0ubuntu1) bionic; urgency=medium
490
491 * New upstream snapshot.
492diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
493index 80b9c65..6d2dc5b 100644
494--- a/tests/unittests/test_datasource/test_common.py
495+++ b/tests/unittests/test_datasource/test_common.py
496@@ -14,6 +14,7 @@ from cloudinit.sources import (
497 DataSourceDigitalOcean as DigitalOcean,
498 DataSourceEc2 as Ec2,
499 DataSourceGCE as GCE,
500+ DataSourceHetzner as Hetzner,
501 DataSourceMAAS as MAAS,
502 DataSourceNoCloud as NoCloud,
503 DataSourceOpenNebula as OpenNebula,
504@@ -31,6 +32,7 @@ DEFAULT_LOCAL = [
505 CloudSigma.DataSourceCloudSigma,
506 ConfigDrive.DataSourceConfigDrive,
507 DigitalOcean.DataSourceDigitalOcean,
508+ Hetzner.DataSourceHetzner,
509 NoCloud.DataSourceNoCloud,
510 OpenNebula.DataSourceOpenNebula,
511 OVF.DataSourceOVF,
512diff --git a/tests/unittests/test_datasource/test_hetzner.py b/tests/unittests/test_datasource/test_hetzner.py
513new file mode 100644
514index 0000000..f1d1525
515--- /dev/null
516+++ b/tests/unittests/test_datasource/test_hetzner.py
517@@ -0,0 +1,99 @@
518+# Copyright (C) 2018 Jonas Keidel
519+#
520+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
521+#
522+# This file is part of cloud-init. See LICENSE file for license information.
523+
524+from cloudinit.sources import DataSourceHetzner
525+from cloudinit import util, settings, helpers
526+
527+from cloudinit.tests.helpers import mock, CiTestCase
528+
529+METADATA = util.load_yaml("""
530+hostname: cloudinit-test
531+instance-id: 123456
532+local-ipv4: ''
533+network-config:
534+ config:
535+ - mac_address: 96:00:00:08:19:da
536+ name: eth0
537+ subnets:
538+ - dns_nameservers:
539+ - 213.133.99.99
540+ - 213.133.100.100
541+ - 213.133.98.98
542+ ipv4: true
543+ type: dhcp
544+ type: physical
545+ - name: eth0:0
546+ subnets:
547+ - address: 2a01:4f8:beef:beef::1/64
548+ gateway: fe80::1
549+ ipv6: true
550+ routes:
551+ - gateway: fe80::1%eth0
552+ netmask: 0
553+ network: '::'
554+ type: static
555+ type: physical
556+ version: 1
557+network-sysconfig: "DEVICE='eth0'\nTYPE=Ethernet\nBOOTPROTO=dhcp\n\
558+ ONBOOT='yes'\nHWADDR=96:00:00:08:19:da\n\
559+ IPV6INIT=yes\nIPV6ADDR=2a01:4f8:beef:beef::1/64\n\
560+ IPV6_DEFAULTGW=fe80::1%eth0\nIPV6_AUTOCONF=no\n\
561+ DNS1=213.133.99.99\nDNS2=213.133.100.100\n"
562+public-ipv4: 192.168.0.1
563+public-keys:
564+- ssh-ed25519 \
565+ AAAAC3Nzac1lZdI1NTE5AaaAIaFrcac0yVITsmRrmueq6MD0qYNKlEvW8O1Ib4nkhmWh \
566+ test-key@workstation
567+vendor_data: "test"
568+""")
569+
570+USERDATA = b"""#cloud-config
571+runcmd:
572+- [touch, /root/cloud-init-worked ]
573+"""
574+
575+
576+class TestDataSourceHetzner(CiTestCase):
577+ """
578+ Test reading the meta-data
579+ """
580+ def setUp(self):
581+ super(TestDataSourceHetzner, self).setUp()
582+ self.tmp = self.tmp_dir()
583+
584+ def get_ds(self):
585+ ds = DataSourceHetzner.DataSourceHetzner(
586+ settings.CFG_BUILTIN, None, helpers.Paths({'run_dir': self.tmp}))
587+ return ds
588+
589+ @mock.patch('cloudinit.net.EphemeralIPv4Network')
590+ @mock.patch('cloudinit.net.find_fallback_nic')
591+ @mock.patch('cloudinit.sources.helpers.hetzner.read_metadata')
592+ @mock.patch('cloudinit.sources.helpers.hetzner.read_userdata')
593+ def test_read_data(self, m_usermd, m_readmd, m_fallback_nic, m_net):
594+ m_readmd.return_value = METADATA.copy()
595+ m_usermd.return_value = USERDATA
596+ m_fallback_nic.return_value = 'eth0'
597+
598+ ds = self.get_ds()
599+ ret = ds.get_data()
600+ self.assertTrue(ret)
601+
602+ m_net.assert_called_once_with(
603+ 'eth0', '169.254.0.1',
604+ 16, '169.254.255.255'
605+ )
606+
607+ self.assertTrue(m_readmd.called)
608+
609+ self.assertEqual(METADATA.get('hostname'), ds.get_hostname())
610+
611+ self.assertEqual(METADATA.get('public-keys'),
612+ ds.get_public_ssh_keys())
613+
614+ self.assertIsInstance(ds.get_public_ssh_keys(), list)
615+ self.assertEqual(ds.get_userdata_raw(), USERDATA)
616+ self.assertEqual(ds.get_vendordata_raw(), METADATA.get('vendor_data'))
617diff --git a/tests/unittests/test_datasource/test_opennebula.py b/tests/unittests/test_datasource/test_opennebula.py
618index 5c3ba01..ab42f34 100644
619--- a/tests/unittests/test_datasource/test_opennebula.py
620+++ b/tests/unittests/test_datasource/test_opennebula.py
621@@ -4,7 +4,6 @@ from cloudinit import helpers
622 from cloudinit.sources import DataSourceOpenNebula as ds
623 from cloudinit import util
624 from cloudinit.tests.helpers import mock, populate_dir, CiTestCase
625-from textwrap import dedent
626
627 import os
628 import pwd
629@@ -33,6 +32,11 @@ HOSTNAME = 'foo.example.com'
630 PUBLIC_IP = '10.0.0.3'
631 MACADDR = '02:00:0a:12:01:01'
632 IP_BY_MACADDR = '10.18.1.1'
633+IP4_PREFIX = '24'
634+IP6_GLOBAL = '2001:db8:1:0:400:c0ff:fea8:1ba'
635+IP6_ULA = 'fd01:dead:beaf:0:400:c0ff:fea8:1ba'
636+IP6_GW = '2001:db8:1::ffff'
637+IP6_PREFIX = '48'
638
639 DS_PATH = "cloudinit.sources.DataSourceOpenNebula"
640
641@@ -221,7 +225,9 @@ class TestOpenNebulaDataSource(CiTestCase):
642 results = ds.read_context_disk_dir(self.seed_dir)
643
644 self.assertTrue('network-interfaces' in results)
645- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
646+ self.assertTrue(
647+ IP_BY_MACADDR + '/' + IP4_PREFIX in
648+ results['network-interfaces']['ethernets'][dev]['addresses'])
649
650 # ETH0_IP and ETH0_MAC
651 populate_context_dir(
652@@ -229,7 +235,9 @@ class TestOpenNebulaDataSource(CiTestCase):
653 results = ds.read_context_disk_dir(self.seed_dir)
654
655 self.assertTrue('network-interfaces' in results)
656- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
657+ self.assertTrue(
658+ IP_BY_MACADDR + '/' + IP4_PREFIX in
659+ results['network-interfaces']['ethernets'][dev]['addresses'])
660
661 # ETH0_IP with empty string and ETH0_MAC
662 # in the case of using Virtual Network contains
663@@ -239,55 +247,91 @@ class TestOpenNebulaDataSource(CiTestCase):
664 results = ds.read_context_disk_dir(self.seed_dir)
665
666 self.assertTrue('network-interfaces' in results)
667- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
668+ self.assertTrue(
669+ IP_BY_MACADDR + '/' + IP4_PREFIX in
670+ results['network-interfaces']['ethernets'][dev]['addresses'])
671
672- # ETH0_NETWORK
673+ # ETH0_MASK
674 populate_context_dir(
675 self.seed_dir, {
676 'ETH0_IP': IP_BY_MACADDR,
677 'ETH0_MAC': MACADDR,
678- 'ETH0_NETWORK': '10.18.0.0'
679+ 'ETH0_MASK': '255.255.0.0'
680 })
681 results = ds.read_context_disk_dir(self.seed_dir)
682
683 self.assertTrue('network-interfaces' in results)
684- self.assertTrue('10.18.0.0' in results['network-interfaces'])
685+ self.assertTrue(
686+ IP_BY_MACADDR + '/16' in
687+ results['network-interfaces']['ethernets'][dev]['addresses'])
688
689- # ETH0_NETWORK with empty string
690+ # ETH0_MASK with empty string
691 populate_context_dir(
692 self.seed_dir, {
693 'ETH0_IP': IP_BY_MACADDR,
694 'ETH0_MAC': MACADDR,
695- 'ETH0_NETWORK': ''
696+ 'ETH0_MASK': ''
697 })
698 results = ds.read_context_disk_dir(self.seed_dir)
699
700 self.assertTrue('network-interfaces' in results)
701- self.assertTrue('10.18.1.0' in results['network-interfaces'])
702+ self.assertTrue(
703+ IP_BY_MACADDR + '/' + IP4_PREFIX in
704+ results['network-interfaces']['ethernets'][dev]['addresses'])
705
706- # ETH0_MASK
707+ # ETH0_IP6
708 populate_context_dir(
709 self.seed_dir, {
710- 'ETH0_IP': IP_BY_MACADDR,
711+ 'ETH0_IP6': IP6_GLOBAL,
712 'ETH0_MAC': MACADDR,
713- 'ETH0_MASK': '255.255.0.0'
714 })
715 results = ds.read_context_disk_dir(self.seed_dir)
716
717 self.assertTrue('network-interfaces' in results)
718- self.assertTrue('255.255.0.0' in results['network-interfaces'])
719+ self.assertTrue(
720+ IP6_GLOBAL + '/64' in
721+ results['network-interfaces']['ethernets'][dev]['addresses'])
722
723- # ETH0_MASK with empty string
724+ # ETH0_IP6_ULA
725 populate_context_dir(
726 self.seed_dir, {
727- 'ETH0_IP': IP_BY_MACADDR,
728+ 'ETH0_IP6_ULA': IP6_ULA,
729+ 'ETH0_MAC': MACADDR,
730+ })
731+ results = ds.read_context_disk_dir(self.seed_dir)
732+
733+ self.assertTrue('network-interfaces' in results)
734+ self.assertTrue(
735+ IP6_ULA + '/64' in
736+ results['network-interfaces']['ethernets'][dev]['addresses'])
737+
738+ # ETH0_IP6 and ETH0_IP6_PREFIX_LENGTH
739+ populate_context_dir(
740+ self.seed_dir, {
741+ 'ETH0_IP6': IP6_GLOBAL,
742+ 'ETH0_IP6_PREFIX_LENGTH': IP6_PREFIX,
743+ 'ETH0_MAC': MACADDR,
744+ })
745+ results = ds.read_context_disk_dir(self.seed_dir)
746+
747+ self.assertTrue('network-interfaces' in results)
748+ self.assertTrue(
749+ IP6_GLOBAL + '/' + IP6_PREFIX in
750+ results['network-interfaces']['ethernets'][dev]['addresses'])
751+
752+ # ETH0_IP6 and ETH0_IP6_PREFIX_LENGTH with empty string
753+ populate_context_dir(
754+ self.seed_dir, {
755+ 'ETH0_IP6': IP6_GLOBAL,
756+ 'ETH0_IP6_PREFIX_LENGTH': '',
757 'ETH0_MAC': MACADDR,
758- 'ETH0_MASK': ''
759 })
760 results = ds.read_context_disk_dir(self.seed_dir)
761
762 self.assertTrue('network-interfaces' in results)
763- self.assertTrue('255.255.255.0' in results['network-interfaces'])
764+ self.assertTrue(
765+ IP6_GLOBAL + '/64' in
766+ results['network-interfaces']['ethernets'][dev]['addresses'])
767
768 def test_find_candidates(self):
769 def my_devs_with(criteria):
770@@ -310,108 +354,152 @@ class TestOpenNebulaNetwork(unittest.TestCase):
771
772 system_nics = ('eth0', 'ens3')
773
774- def test_lo(self):
775- net = ds.OpenNebulaNetwork(context={}, system_nics_by_mac={})
776- self.assertEqual(net.gen_conf(), u'''\
777-auto lo
778-iface lo inet loopback
779-''')
780-
781 @mock.patch(DS_PATH + ".get_physical_nics_by_mac")
782 def test_eth0(self, m_get_phys_by_mac):
783 for nic in self.system_nics:
784 m_get_phys_by_mac.return_value = {MACADDR: nic}
785 net = ds.OpenNebulaNetwork({})
786- self.assertEqual(net.gen_conf(), dedent("""\
787- auto lo
788- iface lo inet loopback
789-
790- auto {dev}
791- iface {dev} inet static
792- #hwaddress {macaddr}
793- address 10.18.1.1
794- network 10.18.1.0
795- netmask 255.255.255.0
796- """.format(dev=nic, macaddr=MACADDR)))
797+ expected = {
798+ 'version': 2,
799+ 'ethernets': {
800+ nic: {
801+ 'match': {'macaddress': MACADDR},
802+ 'addresses': [IP_BY_MACADDR + '/' + IP4_PREFIX]}}}
803+
804+ self.assertEqual(net.gen_conf(), expected)
805
806 def test_eth0_override(self):
807+ self.maxDiff = None
808 context = {
809 'DNS': '1.2.3.8',
810- 'ETH0_IP': '10.18.1.1',
811- 'ETH0_NETWORK': '10.18.0.0',
812- 'ETH0_MASK': '255.255.0.0',
813+ 'ETH0_DNS': '1.2.3.6 1.2.3.7',
814 'ETH0_GATEWAY': '1.2.3.5',
815- 'ETH0_DOMAIN': 'example.com',
816+ 'ETH0_GATEWAY6': '',
817+ 'ETH0_IP': IP_BY_MACADDR,
818+ 'ETH0_IP6': '',
819+ 'ETH0_IP6_PREFIX_LENGTH': '',
820+ 'ETH0_IP6_ULA': '',
821+ 'ETH0_MAC': '02:00:0a:12:01:01',
822+ 'ETH0_MASK': '255.255.0.0',
823+ 'ETH0_MTU': '',
824+ 'ETH0_NETWORK': '10.18.0.0',
825+ 'ETH0_SEARCH_DOMAIN': '',
826+ }
827+ for nic in self.system_nics:
828+ net = ds.OpenNebulaNetwork(context,
829+ system_nics_by_mac={MACADDR: nic})
830+ expected = {
831+ 'version': 2,
832+ 'ethernets': {
833+ nic: {
834+ 'match': {'macaddress': MACADDR},
835+ 'addresses': [IP_BY_MACADDR + '/16'],
836+ 'gateway4': '1.2.3.5',
837+ 'gateway6': None,
838+ 'nameservers': {
839+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8']}}}}
840+
841+ self.assertEqual(expected, net.gen_conf())
842+
843+ def test_eth0_v4v6_override(self):
844+ self.maxDiff = None
845+ context = {
846+ 'DNS': '1.2.3.8',
847 'ETH0_DNS': '1.2.3.6 1.2.3.7',
848- 'ETH0_MAC': '02:00:0a:12:01:01'
849+ 'ETH0_GATEWAY': '1.2.3.5',
850+ 'ETH0_GATEWAY6': IP6_GW,
851+ 'ETH0_IP': IP_BY_MACADDR,
852+ 'ETH0_IP6': IP6_GLOBAL,
853+ 'ETH0_IP6_PREFIX_LENGTH': IP6_PREFIX,
854+ 'ETH0_IP6_ULA': IP6_ULA,
855+ 'ETH0_MAC': '02:00:0a:12:01:01',
856+ 'ETH0_MASK': '255.255.0.0',
857+ 'ETH0_MTU': '1280',
858+ 'ETH0_NETWORK': '10.18.0.0',
859+ 'ETH0_SEARCH_DOMAIN': 'example.com example.org',
860 }
861 for nic in self.system_nics:
862- expected = dedent("""\
863- auto lo
864- iface lo inet loopback
865-
866- auto {dev}
867- iface {dev} inet static
868- #hwaddress {macaddr}
869- address 10.18.1.1
870- network 10.18.0.0
871- netmask 255.255.0.0
872- gateway 1.2.3.5
873- dns-search example.com
874- dns-nameservers 1.2.3.8 1.2.3.6 1.2.3.7
875- """).format(dev=nic, macaddr=MACADDR)
876 net = ds.OpenNebulaNetwork(context,
877 system_nics_by_mac={MACADDR: nic})
878+
879+ expected = {
880+ 'version': 2,
881+ 'ethernets': {
882+ nic: {
883+ 'match': {'macaddress': MACADDR},
884+ 'addresses': [
885+ IP_BY_MACADDR + '/16',
886+ IP6_GLOBAL + '/' + IP6_PREFIX,
887+ IP6_ULA + '/' + IP6_PREFIX],
888+ 'gateway4': '1.2.3.5',
889+ 'gateway6': IP6_GW,
890+ 'nameservers': {
891+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8'],
892+ 'search': ['example.com', 'example.org']},
893+ 'mtu': '1280'}}}
894+
895 self.assertEqual(expected, net.gen_conf())
896
897 def test_multiple_nics(self):
898 """Test rendering multiple nics with names that differ from context."""
899+ self.maxDiff = None
900 MAC_1 = "02:00:0a:12:01:01"
901 MAC_2 = "02:00:0a:12:01:02"
902 context = {
903 'DNS': '1.2.3.8',
904- 'ETH0_IP': '10.18.1.1',
905- 'ETH0_NETWORK': '10.18.0.0',
906- 'ETH0_MASK': '255.255.0.0',
907- 'ETH0_GATEWAY': '1.2.3.5',
908- 'ETH0_DOMAIN': 'example.com',
909 'ETH0_DNS': '1.2.3.6 1.2.3.7',
910+ 'ETH0_GATEWAY': '1.2.3.5',
911+ 'ETH0_GATEWAY6': IP6_GW,
912+ 'ETH0_IP': '10.18.1.1',
913+ 'ETH0_IP6': IP6_GLOBAL,
914+ 'ETH0_IP6_PREFIX_LENGTH': '',
915+ 'ETH0_IP6_ULA': IP6_ULA,
916 'ETH0_MAC': MAC_2,
917- 'ETH3_IP': '10.3.1.3',
918- 'ETH3_NETWORK': '10.3.0.0',
919- 'ETH3_MASK': '255.255.0.0',
920- 'ETH3_GATEWAY': '10.3.0.1',
921- 'ETH3_DOMAIN': 'third.example.com',
922+ 'ETH0_MASK': '255.255.0.0',
923+ 'ETH0_MTU': '1280',
924+ 'ETH0_NETWORK': '10.18.0.0',
925+ 'ETH0_SEARCH_DOMAIN': 'example.com',
926 'ETH3_DNS': '10.3.1.2',
927+ 'ETH3_GATEWAY': '10.3.0.1',
928+ 'ETH3_GATEWAY6': '',
929+ 'ETH3_IP': '10.3.1.3',
930+ 'ETH3_IP6': '',
931+ 'ETH3_IP6_PREFIX_LENGTH': '',
932+ 'ETH3_IP6_ULA': '',
933 'ETH3_MAC': MAC_1,
934+ 'ETH3_MASK': '255.255.0.0',
935+ 'ETH3_MTU': '',
936+ 'ETH3_NETWORK': '10.3.0.0',
937+ 'ETH3_SEARCH_DOMAIN': 'third.example.com third.example.org',
938 }
939 net = ds.OpenNebulaNetwork(
940 context, system_nics_by_mac={MAC_1: 'enp0s25', MAC_2: 'enp1s2'})
941
942- expected = dedent("""\
943- auto lo
944- iface lo inet loopback
945-
946- auto enp0s25
947- iface enp0s25 inet static
948- #hwaddress 02:00:0a:12:01:01
949- address 10.3.1.3
950- network 10.3.0.0
951- netmask 255.255.0.0
952- gateway 10.3.0.1
953- dns-search third.example.com
954- dns-nameservers 1.2.3.8 10.3.1.2
955-
956- auto enp1s2
957- iface enp1s2 inet static
958- #hwaddress 02:00:0a:12:01:02
959- address 10.18.1.1
960- network 10.18.0.0
961- netmask 255.255.0.0
962- gateway 1.2.3.5
963- dns-search example.com
964- dns-nameservers 1.2.3.8 1.2.3.6 1.2.3.7
965- """)
966+ expected = {
967+ 'version': 2,
968+ 'ethernets': {
969+ 'enp1s2': {
970+ 'match': {'macaddress': MAC_2},
971+ 'addresses': [
972+ '10.18.1.1/16',
973+ IP6_GLOBAL + '/64',
974+ IP6_ULA + '/64'],
975+ 'gateway4': '1.2.3.5',
976+ 'gateway6': IP6_GW,
977+ 'nameservers': {
978+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8'],
979+ 'search': ['example.com']},
980+ 'mtu': '1280'},
981+ 'enp0s25': {
982+ 'match': {'macaddress': MAC_1},
983+ 'addresses': ['10.3.1.3/16'],
984+ 'gateway4': '10.3.0.1',
985+ 'gateway6': None,
986+ 'nameservers': {
987+ 'addresses': ['10.3.1.2', '1.2.3.8'],
988+ 'search': [
989+ 'third.example.com',
990+ 'third.example.org']}}}}
991
992 self.assertEqual(expected, net.gen_conf())
993
994diff --git a/tests/unittests/test_ds_identify.py b/tests/unittests/test_ds_identify.py
995index 9be3f96..9c5628e 100644
996--- a/tests/unittests/test_ds_identify.py
997+++ b/tests/unittests/test_ds_identify.py
998@@ -60,6 +60,7 @@ P_CHASSIS_ASSET_TAG = "sys/class/dmi/id/chassis_asset_tag"
999 P_PRODUCT_NAME = "sys/class/dmi/id/product_name"
1000 P_PRODUCT_SERIAL = "sys/class/dmi/id/product_serial"
1001 P_PRODUCT_UUID = "sys/class/dmi/id/product_uuid"
1002+P_SYS_VENDOR = "sys/class/dmi/id/sys_vendor"
1003 P_SEED_DIR = "var/lib/cloud/seed"
1004 P_DSID_CFG = "etc/cloud/ds-identify.cfg"
1005
1006@@ -379,6 +380,10 @@ class TestDsIdentify(CiTestCase):
1007 """Nocloud seed directory ubuntu core writable"""
1008 self._test_ds_found('NoCloud-seed-ubuntu-core')
1009
1010+ def test_hetzner_found(self):
1011+ """Hetzner cloud is identified in sys_vendor."""
1012+ self._test_ds_found('Hetzner')
1013+
1014
1015 def blkid_out(disks=None):
1016 """Convert a list of disk dictionaries into blkid content."""
1017@@ -559,6 +564,10 @@ VALID_CFG = {
1018 },
1019 ],
1020 },
1021+ 'Hetzner': {
1022+ 'ds': 'Hetzner',
1023+ 'files': {P_SYS_VENDOR: 'Hetzner\n'},
1024+ },
1025 }
1026
1027 # vi: ts=4 expandtab
1028diff --git a/tools/ds-identify b/tools/ds-identify
1029index ec368d5..e3f93c9 100755
1030--- a/tools/ds-identify
1031+++ b/tools/ds-identify
1032@@ -114,7 +114,7 @@ DI_DSNAME=""
1033 # be searched if there is no setting found in config.
1034 DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
1035 CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
1036-OVF SmartOS Scaleway"
1037+OVF SmartOS Scaleway Hetzner"
1038 DI_DSLIST=""
1039 DI_MODE=""
1040 DI_ON_FOUND=""
1041@@ -979,6 +979,11 @@ dscheck_Scaleway() {
1042 return ${DS_NOT_FOUND}
1043 }
1044
1045+dscheck_Hetzner() {
1046+ dmi_sys_vendor_is Hetzner && return ${DS_FOUND}
1047+ return ${DS_NOT_FOUND}
1048+}
1049+
1050 collect_info() {
1051 read_virt
1052 read_pid1_product_name

Subscribers

People subscribed via source and target branches