Merge ~oddbloke/cloud-init/+git/cloud-init:ubuntu/devel into cloud-init:ubuntu/devel

Proposed by Dan Watkins
Status: Merged
Merged at revision: ba7b33b48d5dfc9f786f55778877922625cac00a
Proposed branch: ~oddbloke/cloud-init/+git/cloud-init:ubuntu/devel
Merge into: cloud-init:ubuntu/devel
Diff against target: 1177 lines (+863/-34)
18 files modified
cloudinit/apport.py (+1/-0)
cloudinit/config/cc_set_passwords.py (+34/-19)
cloudinit/config/cc_ssh.py (+55/-0)
cloudinit/config/tests/test_ssh.py (+166/-0)
cloudinit/settings.py (+1/-0)
cloudinit/sources/DataSourceExoscale.py (+258/-0)
cloudinit/sources/DataSourceGCE.py (+20/-2)
cloudinit/sources/__init__.py (+10/-0)
cloudinit/url_helper.py (+5/-4)
debian/changelog (+12/-0)
debian/cloud-init.templates (+3/-3)
doc/rtd/conf.py (+0/-5)
doc/rtd/topics/datasources.rst (+1/-0)
doc/rtd/topics/datasources/exoscale.rst (+68/-0)
tests/unittests/test_datasource/test_common.py (+2/-0)
tests/unittests/test_datasource/test_exoscale.py (+203/-0)
tests/unittests/test_datasource/test_gce.py (+18/-0)
tools/ds-identify (+6/-1)
Reviewer Review Type Date Requested Status
Ryan Harper Approve
Review via email: mp+371135@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Ryan Harper (raharper) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
index 22cb7fd..003ff1f 100644
--- a/cloudinit/apport.py
+++ b/cloudinit/apport.py
@@ -23,6 +23,7 @@ KNOWN_CLOUD_NAMES = [
23 'CloudStack',23 'CloudStack',
24 'DigitalOcean',24 'DigitalOcean',
25 'GCE - Google Compute Engine',25 'GCE - Google Compute Engine',
26 'Exoscale',
26 'Hetzner Cloud',27 'Hetzner Cloud',
27 'IBM - (aka SoftLayer or BlueMix)',28 'IBM - (aka SoftLayer or BlueMix)',
28 'LXD',29 'LXD',
diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
index 4585e4d..cf9b5ab 100755
--- a/cloudinit/config/cc_set_passwords.py
+++ b/cloudinit/config/cc_set_passwords.py
@@ -9,27 +9,40 @@
9"""9"""
10Set Passwords10Set Passwords
11-------------11-------------
12**Summary:** Set user passwords12**Summary:** Set user passwords and enable/disable SSH password authentication
1313
14Set system passwords and enable or disable ssh password authentication.14This module consumes three top-level config keys: ``ssh_pwauth``, ``chpasswd``
15The ``chpasswd`` config key accepts a dictionary containing a single one of two15and ``password``.
16keys, either ``expire`` or ``list``. If ``expire`` is specified and is set to16
17``false``, then the ``password`` global config key is used as the password for17The ``ssh_pwauth`` config key determines whether or not sshd will be configured
18all user accounts. If the ``expire`` key is specified and is set to ``true``18to accept password authentication. True values will enable password auth,
19then user passwords will be expired, preventing the default system passwords19false values will disable password auth, and the literal string ``unchanged``
20from being used.20will leave it unchanged. Setting no value will also leave the current setting
2121on-disk unchanged.
22If the ``list`` key is provided, a list of22
23``username:password`` pairs can be specified. The usernames specified23The ``chpasswd`` config key accepts a dictionary containing either or both of
24must already exist on the system, or have been created using the24``expire`` and ``list``.
25``cc_users_groups`` module. A password can be randomly generated using25
26``username:RANDOM`` or ``username:R``. A hashed password can be specified26If the ``list`` key is provided, it should contain a list of
27using ``username:$6$salt$hash``. Password ssh authentication can be27``username:password`` pairs. This can be either a YAML list (of strings), or a
28enabled, disabled, or left to system defaults using ``ssh_pwauth``.28multi-line string with one pair per line. Each user will have the
29corresponding password set. A password can be randomly generated by specifying
30``RANDOM`` or ``R`` as a user's password. A hashed password, created by a tool
31like ``mkpasswd``, can be specified; a regex
32(``r'\\$(1|2a|2y|5|6)(\\$.+){2}'``) is used to determine if a password value
33should be treated as a hash.
2934
30.. note::35.. note::
31 if using ``expire: true`` then a ssh authkey should be specified or it may36 The users specified must already exist on the system. Users will have been
32 not be possible to login to the system37 created by the ``cc_users_groups`` module at this point.
38
39By default, all users on the system will have their passwords expired (meaning
40that they will have to be reset the next time the user logs in). To disable
41this behaviour, set ``expire`` under ``chpasswd`` to a false value.
42
43If a ``list`` of user/password pairs is not specified under ``chpasswd``, then
44the value of the ``password`` config key will be used to set the default user's
45password.
3346
34**Internal name:** ``cc_set_passwords``47**Internal name:** ``cc_set_passwords``
3548
@@ -160,6 +173,8 @@ def handle(_name, cfg, cloud, log, args):
160 hashed_users = []173 hashed_users = []
161 randlist = []174 randlist = []
162 users = []175 users = []
176 # N.B. This regex is included in the documentation (i.e. the module
177 # docstring), so any changes to it should be reflected there.
163 prog = re.compile(r'\$(1|2a|2y|5|6)(\$.+){2}')178 prog = re.compile(r'\$(1|2a|2y|5|6)(\$.+){2}')
164 for line in plist:179 for line in plist:
165 u, p = line.split(':', 1)180 u, p = line.split(':', 1)
diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py
index f8f7cb3..53f6939 100755
--- a/cloudinit/config/cc_ssh.py
+++ b/cloudinit/config/cc_ssh.py
@@ -91,6 +91,9 @@ public keys.
91 ssh_authorized_keys:91 ssh_authorized_keys:
92 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...92 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUU ...
93 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...93 - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZ ...
94 ssh_publish_hostkeys:
95 enabled: <true/false> (Defaults to true)
96 blacklist: <list of key types> (Defaults to [dsa])
94"""97"""
9598
96import glob99import glob
@@ -104,6 +107,10 @@ from cloudinit import util
104107
105GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']108GENERATE_KEY_NAMES = ['rsa', 'dsa', 'ecdsa', 'ed25519']
106KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'109KEY_FILE_TPL = '/etc/ssh/ssh_host_%s_key'
110PUBLISH_HOST_KEYS = True
111# Don't publish the dsa hostkey by default since OpenSSH recommends not using
112# it.
113HOST_KEY_PUBLISH_BLACKLIST = ['dsa']
107114
108CONFIG_KEY_TO_FILE = {}115CONFIG_KEY_TO_FILE = {}
109PRIV_TO_PUB = {}116PRIV_TO_PUB = {}
@@ -176,6 +183,23 @@ def handle(_name, cfg, cloud, log, _args):
176 util.logexc(log, "Failed generating key type %s to "183 util.logexc(log, "Failed generating key type %s to "
177 "file %s", keytype, keyfile)184 "file %s", keytype, keyfile)
178185
186 if "ssh_publish_hostkeys" in cfg:
187 host_key_blacklist = util.get_cfg_option_list(
188 cfg["ssh_publish_hostkeys"], "blacklist",
189 HOST_KEY_PUBLISH_BLACKLIST)
190 publish_hostkeys = util.get_cfg_option_bool(
191 cfg["ssh_publish_hostkeys"], "enabled", PUBLISH_HOST_KEYS)
192 else:
193 host_key_blacklist = HOST_KEY_PUBLISH_BLACKLIST
194 publish_hostkeys = PUBLISH_HOST_KEYS
195
196 if publish_hostkeys:
197 hostkeys = get_public_host_keys(blacklist=host_key_blacklist)
198 try:
199 cloud.datasource.publish_host_keys(hostkeys)
200 except Exception as e:
201 util.logexc(log, "Publishing host keys failed!")
202
179 try:203 try:
180 (users, _groups) = ug_util.normalize_users_groups(cfg, cloud.distro)204 (users, _groups) = ug_util.normalize_users_groups(cfg, cloud.distro)
181 (user, _user_config) = ug_util.extract_default(users)205 (user, _user_config) = ug_util.extract_default(users)
@@ -209,4 +233,35 @@ def apply_credentials(keys, user, disable_root, disable_root_opts):
209233
210 ssh_util.setup_user_keys(keys, 'root', options=key_prefix)234 ssh_util.setup_user_keys(keys, 'root', options=key_prefix)
211235
236
237def get_public_host_keys(blacklist=None):
238 """Read host keys from /etc/ssh/*.pub files and return them as a list.
239
240 @param blacklist: List of key types to ignore. e.g. ['dsa', 'rsa']
241 @returns: List of keys, each formatted as a two-element tuple.
242 e.g. [('ssh-rsa', 'AAAAB3Nz...'), ('ssh-ed25519', 'AAAAC3Nx...')]
243 """
244 public_key_file_tmpl = '%s.pub' % (KEY_FILE_TPL,)
245 key_list = []
246 blacklist_files = []
247 if blacklist:
248 # Convert blacklist to filenames:
249 # 'dsa' -> '/etc/ssh/ssh_host_dsa_key.pub'
250 blacklist_files = [public_key_file_tmpl % (key_type,)
251 for key_type in blacklist]
252 # Get list of public key files and filter out blacklisted files.
253 file_list = [hostfile for hostfile
254 in glob.glob(public_key_file_tmpl % ('*',))
255 if hostfile not in blacklist_files]
256
257 # Read host key files, retrieve first two fields as a tuple and
258 # append that tuple to key_list.
259 for file_name in file_list:
260 file_contents = util.load_file(file_name)
261 key_data = file_contents.split()
262 if key_data and len(key_data) > 1:
263 key_list.append(tuple(key_data[:2]))
264 return key_list
265
266
212# vi: ts=4 expandtab267# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py
index c8a4271..e778984 100644
--- a/cloudinit/config/tests/test_ssh.py
+++ b/cloudinit/config/tests/test_ssh.py
@@ -1,5 +1,6 @@
1# This file is part of cloud-init. See LICENSE file for license information.1# This file is part of cloud-init. See LICENSE file for license information.
22
3import os.path
34
4from cloudinit.config import cc_ssh5from cloudinit.config import cc_ssh
5from cloudinit import ssh_util6from cloudinit import ssh_util
@@ -12,6 +13,25 @@ MODPATH = "cloudinit.config.cc_ssh."
12class TestHandleSsh(CiTestCase):13class TestHandleSsh(CiTestCase):
13 """Test cc_ssh handling of ssh config."""14 """Test cc_ssh handling of ssh config."""
1415
16 def _publish_hostkey_test_setup(self):
17 self.test_hostkeys = {
18 'dsa': ('ssh-dss', 'AAAAB3NzaC1kc3MAAACB'),
19 'ecdsa': ('ecdsa-sha2-nistp256', 'AAAAE2VjZ'),
20 'ed25519': ('ssh-ed25519', 'AAAAC3NzaC1lZDI'),
21 'rsa': ('ssh-rsa', 'AAAAB3NzaC1yc2EAAA'),
22 }
23 self.test_hostkey_files = []
24 hostkey_tmpdir = self.tmp_dir()
25 for key_type in ['dsa', 'ecdsa', 'ed25519', 'rsa']:
26 key_data = self.test_hostkeys[key_type]
27 filename = 'ssh_host_%s_key.pub' % key_type
28 filepath = os.path.join(hostkey_tmpdir, filename)
29 self.test_hostkey_files.append(filepath)
30 with open(filepath, 'w') as f:
31 f.write(' '.join(key_data))
32
33 cc_ssh.KEY_FILE_TPL = os.path.join(hostkey_tmpdir, 'ssh_host_%s_key')
34
15 def test_apply_credentials_with_user(self, m_setup_keys):35 def test_apply_credentials_with_user(self, m_setup_keys):
16 """Apply keys for the given user and root."""36 """Apply keys for the given user and root."""
17 keys = ["key1"]37 keys = ["key1"]
@@ -64,6 +84,7 @@ class TestHandleSsh(CiTestCase):
64 # Mock os.path.exits to True to short-circuit the key writing logic84 # Mock os.path.exits to True to short-circuit the key writing logic
65 m_path_exists.return_value = True85 m_path_exists.return_value = True
66 m_nug.return_value = ([], {})86 m_nug.return_value = ([], {})
87 cc_ssh.PUBLISH_HOST_KEYS = False
67 cloud = self.tmp_cloud(88 cloud = self.tmp_cloud(
68 distro='ubuntu', metadata={'public-keys': keys})89 distro='ubuntu', metadata={'public-keys': keys})
69 cc_ssh.handle("name", cfg, cloud, None, None)90 cc_ssh.handle("name", cfg, cloud, None, None)
@@ -149,3 +170,148 @@ class TestHandleSsh(CiTestCase):
149 self.assertEqual([mock.call(set(keys), user),170 self.assertEqual([mock.call(set(keys), user),
150 mock.call(set(keys), "root", options="")],171 mock.call(set(keys), "root", options="")],
151 m_setup_keys.call_args_list)172 m_setup_keys.call_args_list)
173
174 @mock.patch(MODPATH + "glob.glob")
175 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
176 @mock.patch(MODPATH + "os.path.exists")
177 def test_handle_publish_hostkeys_default(
178 self, m_path_exists, m_nug, m_glob, m_setup_keys):
179 """Test handle with various configs for ssh_publish_hostkeys."""
180 self._publish_hostkey_test_setup()
181 cc_ssh.PUBLISH_HOST_KEYS = True
182 keys = ["key1"]
183 user = "clouduser"
184 # Return no matching keys for first glob, test keys for second.
185 m_glob.side_effect = iter([
186 [],
187 self.test_hostkey_files,
188 ])
189 # Mock os.path.exits to True to short-circuit the key writing logic
190 m_path_exists.return_value = True
191 m_nug.return_value = ({user: {"default": user}}, {})
192 cloud = self.tmp_cloud(
193 distro='ubuntu', metadata={'public-keys': keys})
194 cloud.datasource.publish_host_keys = mock.Mock()
195
196 cfg = {}
197 expected_call = [self.test_hostkeys[key_type] for key_type
198 in ['ecdsa', 'ed25519', 'rsa']]
199 cc_ssh.handle("name", cfg, cloud, None, None)
200 self.assertEqual([mock.call(expected_call)],
201 cloud.datasource.publish_host_keys.call_args_list)
202
203 @mock.patch(MODPATH + "glob.glob")
204 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
205 @mock.patch(MODPATH + "os.path.exists")
206 def test_handle_publish_hostkeys_config_enable(
207 self, m_path_exists, m_nug, m_glob, m_setup_keys):
208 """Test handle with various configs for ssh_publish_hostkeys."""
209 self._publish_hostkey_test_setup()
210 cc_ssh.PUBLISH_HOST_KEYS = False
211 keys = ["key1"]
212 user = "clouduser"
213 # Return no matching keys for first glob, test keys for second.
214 m_glob.side_effect = iter([
215 [],
216 self.test_hostkey_files,
217 ])
218 # Mock os.path.exits to True to short-circuit the key writing logic
219 m_path_exists.return_value = True
220 m_nug.return_value = ({user: {"default": user}}, {})
221 cloud = self.tmp_cloud(
222 distro='ubuntu', metadata={'public-keys': keys})
223 cloud.datasource.publish_host_keys = mock.Mock()
224
225 cfg = {'ssh_publish_hostkeys': {'enabled': True}}
226 expected_call = [self.test_hostkeys[key_type] for key_type
227 in ['ecdsa', 'ed25519', 'rsa']]
228 cc_ssh.handle("name", cfg, cloud, None, None)
229 self.assertEqual([mock.call(expected_call)],
230 cloud.datasource.publish_host_keys.call_args_list)
231
232 @mock.patch(MODPATH + "glob.glob")
233 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
234 @mock.patch(MODPATH + "os.path.exists")
235 def test_handle_publish_hostkeys_config_disable(
236 self, m_path_exists, m_nug, m_glob, m_setup_keys):
237 """Test handle with various configs for ssh_publish_hostkeys."""
238 self._publish_hostkey_test_setup()
239 cc_ssh.PUBLISH_HOST_KEYS = True
240 keys = ["key1"]
241 user = "clouduser"
242 # Return no matching keys for first glob, test keys for second.
243 m_glob.side_effect = iter([
244 [],
245 self.test_hostkey_files,
246 ])
247 # Mock os.path.exits to True to short-circuit the key writing logic
248 m_path_exists.return_value = True
249 m_nug.return_value = ({user: {"default": user}}, {})
250 cloud = self.tmp_cloud(
251 distro='ubuntu', metadata={'public-keys': keys})
252 cloud.datasource.publish_host_keys = mock.Mock()
253
254 cfg = {'ssh_publish_hostkeys': {'enabled': False}}
255 cc_ssh.handle("name", cfg, cloud, None, None)
256 self.assertFalse(cloud.datasource.publish_host_keys.call_args_list)
257 cloud.datasource.publish_host_keys.assert_not_called()
258
259 @mock.patch(MODPATH + "glob.glob")
260 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
261 @mock.patch(MODPATH + "os.path.exists")
262 def test_handle_publish_hostkeys_config_blacklist(
263 self, m_path_exists, m_nug, m_glob, m_setup_keys):
264 """Test handle with various configs for ssh_publish_hostkeys."""
265 self._publish_hostkey_test_setup()
266 cc_ssh.PUBLISH_HOST_KEYS = True
267 keys = ["key1"]
268 user = "clouduser"
269 # Return no matching keys for first glob, test keys for second.
270 m_glob.side_effect = iter([
271 [],
272 self.test_hostkey_files,
273 ])
274 # Mock os.path.exits to True to short-circuit the key writing logic
275 m_path_exists.return_value = True
276 m_nug.return_value = ({user: {"default": user}}, {})
277 cloud = self.tmp_cloud(
278 distro='ubuntu', metadata={'public-keys': keys})
279 cloud.datasource.publish_host_keys = mock.Mock()
280
281 cfg = {'ssh_publish_hostkeys': {'enabled': True,
282 'blacklist': ['dsa', 'rsa']}}
283 expected_call = [self.test_hostkeys[key_type] for key_type
284 in ['ecdsa', 'ed25519']]
285 cc_ssh.handle("name", cfg, cloud, None, None)
286 self.assertEqual([mock.call(expected_call)],
287 cloud.datasource.publish_host_keys.call_args_list)
288
289 @mock.patch(MODPATH + "glob.glob")
290 @mock.patch(MODPATH + "ug_util.normalize_users_groups")
291 @mock.patch(MODPATH + "os.path.exists")
292 def test_handle_publish_hostkeys_empty_blacklist(
293 self, m_path_exists, m_nug, m_glob, m_setup_keys):
294 """Test handle with various configs for ssh_publish_hostkeys."""
295 self._publish_hostkey_test_setup()
296 cc_ssh.PUBLISH_HOST_KEYS = True
297 keys = ["key1"]
298 user = "clouduser"
299 # Return no matching keys for first glob, test keys for second.
300 m_glob.side_effect = iter([
301 [],
302 self.test_hostkey_files,
303 ])
304 # Mock os.path.exits to True to short-circuit the key writing logic
305 m_path_exists.return_value = True
306 m_nug.return_value = ({user: {"default": user}}, {})
307 cloud = self.tmp_cloud(
308 distro='ubuntu', metadata={'public-keys': keys})
309 cloud.datasource.publish_host_keys = mock.Mock()
310
311 cfg = {'ssh_publish_hostkeys': {'enabled': True,
312 'blacklist': []}}
313 expected_call = [self.test_hostkeys[key_type] for key_type
314 in ['dsa', 'ecdsa', 'ed25519', 'rsa']]
315 cc_ssh.handle("name", cfg, cloud, None, None)
316 self.assertEqual([mock.call(expected_call)],
317 cloud.datasource.publish_host_keys.call_args_list)
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index b1ebaad..2060d81 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -39,6 +39,7 @@ CFG_BUILTIN = {
39 'Hetzner',39 'Hetzner',
40 'IBMCloud',40 'IBMCloud',
41 'Oracle',41 'Oracle',
42 'Exoscale',
42 # At the end to act as a 'catch' when none of the above work...43 # At the end to act as a 'catch' when none of the above work...
43 'None',44 'None',
44 ],45 ],
diff --git a/cloudinit/sources/DataSourceExoscale.py b/cloudinit/sources/DataSourceExoscale.py
45new file mode 10064446new file mode 100644
index 0000000..52e7f6f
--- /dev/null
+++ b/cloudinit/sources/DataSourceExoscale.py
@@ -0,0 +1,258 @@
1# Author: Mathieu Corbin <mathieu.corbin@exoscale.com>
2# Author: Christopher Glass <christopher.glass@exoscale.com>
3#
4# This file is part of cloud-init. See LICENSE file for license information.
5
6from cloudinit import ec2_utils as ec2
7from cloudinit import log as logging
8from cloudinit import sources
9from cloudinit import url_helper
10from cloudinit import util
11
12LOG = logging.getLogger(__name__)
13
14METADATA_URL = "http://169.254.169.254"
15API_VERSION = "1.0"
16PASSWORD_SERVER_PORT = 8080
17
18URL_TIMEOUT = 10
19URL_RETRIES = 6
20
21EXOSCALE_DMI_NAME = "Exoscale"
22
23BUILTIN_DS_CONFIG = {
24 # We run the set password config module on every boot in order to enable
25 # resetting the instance's password via the exoscale console (and a
26 # subsequent instance reboot).
27 'cloud_config_modules': [["set-passwords", "always"]]
28}
29
30
31class DataSourceExoscale(sources.DataSource):
32
33 dsname = 'Exoscale'
34
35 def __init__(self, sys_cfg, distro, paths):
36 super(DataSourceExoscale, self).__init__(sys_cfg, distro, paths)
37 LOG.debug("Initializing the Exoscale datasource")
38
39 self.metadata_url = self.ds_cfg.get('metadata_url', METADATA_URL)
40 self.api_version = self.ds_cfg.get('api_version', API_VERSION)
41 self.password_server_port = int(
42 self.ds_cfg.get('password_server_port', PASSWORD_SERVER_PORT))
43 self.url_timeout = self.ds_cfg.get('timeout', URL_TIMEOUT)
44 self.url_retries = self.ds_cfg.get('retries', URL_RETRIES)
45
46 self.extra_config = BUILTIN_DS_CONFIG
47
48 def wait_for_metadata_service(self):
49 """Wait for the metadata service to be reachable."""
50
51 metadata_url = "{}/{}/meta-data/instance-id".format(
52 self.metadata_url, self.api_version)
53
54 url = url_helper.wait_for_url(
55 urls=[metadata_url],
56 max_wait=self.url_max_wait,
57 timeout=self.url_timeout,
58 status_cb=LOG.critical)
59
60 return bool(url)
61
62 def crawl_metadata(self):
63 """
64 Crawl the metadata service when available.
65
66 @returns: Dictionary of crawled metadata content.
67 """
68 metadata_ready = util.log_time(
69 logfunc=LOG.info,
70 msg='waiting for the metadata service',
71 func=self.wait_for_metadata_service)
72
73 if not metadata_ready:
74 return {}
75
76 return read_metadata(self.metadata_url, self.api_version,
77 self.password_server_port, self.url_timeout,
78 self.url_retries)
79
80 def _get_data(self):
81 """Fetch the user data, the metadata and the VM password
82 from the metadata service.
83
84 Please refer to the datasource documentation for details on how the
85 metadata server and password server are crawled.
86 """
87 if not self._is_platform_viable():
88 return False
89
90 data = util.log_time(
91 logfunc=LOG.debug,
92 msg='Crawl of metadata service',
93 func=self.crawl_metadata)
94
95 if not data:
96 return False
97
98 self.userdata_raw = data['user-data']
99 self.metadata = data['meta-data']
100 password = data.get('password')
101
102 password_config = {}
103 if password:
104 # Since we have a password, let's make sure we are allowed to use
105 # it by allowing ssh_pwauth.
106 # The password module's default behavior is to leave the
107 # configuration as-is in this regard, so that means it will either
108 # leave the password always disabled if no password is ever set, or
109 # leave the password login enabled if we set it once.
110 password_config = {
111 'ssh_pwauth': True,
112 'password': password,
113 'chpasswd': {
114 'expire': False,
115 },
116 }
117
118 # builtin extra_config overrides password_config
119 self.extra_config = util.mergemanydict(
120 [self.extra_config, password_config])
121
122 return True
123
124 def get_config_obj(self):
125 return self.extra_config
126
127 def _is_platform_viable(self):
128 return util.read_dmi_data('system-product-name').startswith(
129 EXOSCALE_DMI_NAME)
130
131
132# Used to match classes to dependencies
133datasources = [
134 (DataSourceExoscale, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
135]
136
137
138# Return a list of data sources that match this set of dependencies
139def get_datasource_list(depends):
140 return sources.list_from_depends(depends, datasources)
141
142
143def get_password(metadata_url=METADATA_URL,
144 api_version=API_VERSION,
145 password_server_port=PASSWORD_SERVER_PORT,
146 url_timeout=URL_TIMEOUT,
147 url_retries=URL_RETRIES):
148 """Obtain the VM's password if set.
149
150 Once fetched the password is marked saved. Future calls to this method may
151 return empty string or 'saved_password'."""
152 password_url = "{}:{}/{}/".format(metadata_url, password_server_port,
153 api_version)
154 response = url_helper.read_file_or_url(
155 password_url,
156 ssl_details=None,
157 headers={"DomU_Request": "send_my_password"},
158 timeout=url_timeout,
159 retries=url_retries)
160 password = response.contents.decode('utf-8')
161 # the password is empty or already saved
162 # Note: the original metadata server would answer an additional
163 # 'bad_request' status, but the Exoscale implementation does not.
164 if password in ['', 'saved_password']:
165 return None
166 # save the password
167 url_helper.read_file_or_url(
168 password_url,
169 ssl_details=None,
170 headers={"DomU_Request": "saved_password"},
171 timeout=url_timeout,
172 retries=url_retries)
173 return password
174
175
176def read_metadata(metadata_url=METADATA_URL,
177 api_version=API_VERSION,
178 password_server_port=PASSWORD_SERVER_PORT,
179 url_timeout=URL_TIMEOUT,
180 url_retries=URL_RETRIES):
181 """Query the metadata server and return the retrieved data."""
182 crawled_metadata = {}
183 crawled_metadata['_metadata_api_version'] = api_version
184 try:
185 crawled_metadata['user-data'] = ec2.get_instance_userdata(
186 api_version,
187 metadata_url,
188 timeout=url_timeout,
189 retries=url_retries)
190 crawled_metadata['meta-data'] = ec2.get_instance_metadata(
191 api_version,
192 metadata_url,
193 timeout=url_timeout,
194 retries=url_retries)
195 except Exception as e:
196 util.logexc(LOG, "failed reading from metadata url %s (%s)",
197 metadata_url, e)
198 return {}
199
200 try:
201 crawled_metadata['password'] = get_password(
202 api_version=api_version,
203 metadata_url=metadata_url,
204 password_server_port=password_server_port,
205 url_retries=url_retries,
206 url_timeout=url_timeout)
207 except Exception as e:
208 util.logexc(LOG, "failed to read from password server url %s:%s (%s)",
209 metadata_url, password_server_port, e)
210
211 return crawled_metadata
212
213
214if __name__ == "__main__":
215 import argparse
216
217 parser = argparse.ArgumentParser(description='Query Exoscale Metadata')
218 parser.add_argument(
219 "--endpoint",
220 metavar="URL",
221 help="The url of the metadata service.",
222 default=METADATA_URL)
223 parser.add_argument(
224 "--version",
225 metavar="VERSION",
226 help="The version of the metadata endpoint to query.",
227 default=API_VERSION)
228 parser.add_argument(
229 "--retries",
230 metavar="NUM",
231 type=int,
232 help="The number of retries querying the endpoint.",
233 default=URL_RETRIES)
234 parser.add_argument(
235 "--timeout",
236 metavar="NUM",
237 type=int,
238 help="The time in seconds to wait before timing out.",
239 default=URL_TIMEOUT)
240 parser.add_argument(
241 "--password-port",
242 metavar="PORT",
243 type=int,
244 help="The port on which the password endpoint listens",
245 default=PASSWORD_SERVER_PORT)
246
247 args = parser.parse_args()
248
249 data = read_metadata(
250 metadata_url=args.endpoint,
251 api_version=args.version,
252 password_server_port=args.password_port,
253 url_timeout=args.timeout,
254 url_retries=args.retries)
255
256 print(util.json_dumps(data))
257
258# vi: ts=4 expandtab
diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
index d816262..6cbfbba 100644
--- a/cloudinit/sources/DataSourceGCE.py
+++ b/cloudinit/sources/DataSourceGCE.py
@@ -18,10 +18,13 @@ LOG = logging.getLogger(__name__)
18MD_V1_URL = 'http://metadata.google.internal/computeMetadata/v1/'18MD_V1_URL = 'http://metadata.google.internal/computeMetadata/v1/'
19BUILTIN_DS_CONFIG = {'metadata_url': MD_V1_URL}19BUILTIN_DS_CONFIG = {'metadata_url': MD_V1_URL}
20REQUIRED_FIELDS = ('instance-id', 'availability-zone', 'local-hostname')20REQUIRED_FIELDS = ('instance-id', 'availability-zone', 'local-hostname')
21GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
22 'v1/instance/guest-attributes')
23HOSTKEY_NAMESPACE = 'hostkeys'
24HEADERS = {'Metadata-Flavor': 'Google'}
2125
2226
23class GoogleMetadataFetcher(object):27class GoogleMetadataFetcher(object):
24 headers = {'Metadata-Flavor': 'Google'}
2528
26 def __init__(self, metadata_address):29 def __init__(self, metadata_address):
27 self.metadata_address = metadata_address30 self.metadata_address = metadata_address
@@ -32,7 +35,7 @@ class GoogleMetadataFetcher(object):
32 url = self.metadata_address + path35 url = self.metadata_address + path
33 if is_recursive:36 if is_recursive:
34 url += '/?recursive=True'37 url += '/?recursive=True'
35 resp = url_helper.readurl(url=url, headers=self.headers)38 resp = url_helper.readurl(url=url, headers=HEADERS)
36 except url_helper.UrlError as exc:39 except url_helper.UrlError as exc:
37 msg = "url %s raised exception %s"40 msg = "url %s raised exception %s"
38 LOG.debug(msg, path, exc)41 LOG.debug(msg, path, exc)
@@ -90,6 +93,10 @@ class DataSourceGCE(sources.DataSource):
90 public_keys_data = self.metadata['public-keys-data']93 public_keys_data = self.metadata['public-keys-data']
91 return _parse_public_keys(public_keys_data, self.default_user)94 return _parse_public_keys(public_keys_data, self.default_user)
9295
96 def publish_host_keys(self, hostkeys):
97 for key in hostkeys:
98 _write_host_key_to_guest_attributes(*key)
99
93 def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):100 def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
94 # GCE has long FDQN's and has asked for short hostnames.101 # GCE has long FDQN's and has asked for short hostnames.
95 return self.metadata['local-hostname'].split('.')[0]102 return self.metadata['local-hostname'].split('.')[0]
@@ -103,6 +110,17 @@ class DataSourceGCE(sources.DataSource):
103 return self.availability_zone.rsplit('-', 1)[0]110 return self.availability_zone.rsplit('-', 1)[0]
104111
105112
113def _write_host_key_to_guest_attributes(key_type, key_value):
114 url = '%s/%s/%s' % (GUEST_ATTRIBUTES_URL, HOSTKEY_NAMESPACE, key_type)
115 key_value = key_value.encode('utf-8')
116 resp = url_helper.readurl(url=url, data=key_value, headers=HEADERS,
117 request_method='PUT', check_status=False)
118 if resp.ok():
119 LOG.debug('Wrote %s host key to guest attributes.', key_type)
120 else:
121 LOG.debug('Unable to write %s host key to guest attributes.', key_type)
122
123
106def _has_expired(public_key):124def _has_expired(public_key):
107 # Check whether an SSH key is expired. Public key input is a single SSH125 # Check whether an SSH key is expired. Public key input is a single SSH
108 # public key in the GCE specific key format documented here:126 # public key in the GCE specific key format documented here:
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
index c2baccd..a319322 100644
--- a/cloudinit/sources/__init__.py
+++ b/cloudinit/sources/__init__.py
@@ -491,6 +491,16 @@ class DataSource(object):
491 def get_public_ssh_keys(self):491 def get_public_ssh_keys(self):
492 return normalize_pubkey_data(self.metadata.get('public-keys'))492 return normalize_pubkey_data(self.metadata.get('public-keys'))
493493
494 def publish_host_keys(self, hostkeys):
495 """Publish the public SSH host keys (found in /etc/ssh/*.pub).
496
497 @param hostkeys: List of host key tuples (key_type, key_value),
498 where key_type is the first field in the public key file
499 (e.g. 'ssh-rsa') and key_value is the key itself
500 (e.g. 'AAAAB3NzaC1y...').
501 """
502 pass
503
494 def _remap_device(self, short_name):504 def _remap_device(self, short_name):
495 # LP: #611137505 # LP: #611137
496 # the metadata service may believe that devices are named 'sda'506 # the metadata service may believe that devices are named 'sda'
diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
index 0af0d9e..44ee61d 100644
--- a/cloudinit/url_helper.py
+++ b/cloudinit/url_helper.py
@@ -199,18 +199,19 @@ def _get_ssl_args(url, ssl_details):
199def readurl(url, data=None, timeout=None, retries=0, sec_between=1,199def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
200 headers=None, headers_cb=None, ssl_details=None,200 headers=None, headers_cb=None, ssl_details=None,
201 check_status=True, allow_redirects=True, exception_cb=None,201 check_status=True, allow_redirects=True, exception_cb=None,
202 session=None, infinite=False, log_req_resp=True):202 session=None, infinite=False, log_req_resp=True,
203 request_method=None):
203 url = _cleanurl(url)204 url = _cleanurl(url)
204 req_args = {205 req_args = {
205 'url': url,206 'url': url,
206 }207 }
207 req_args.update(_get_ssl_args(url, ssl_details))208 req_args.update(_get_ssl_args(url, ssl_details))
208 req_args['allow_redirects'] = allow_redirects209 req_args['allow_redirects'] = allow_redirects
209 req_args['method'] = 'GET'210 if not request_method:
211 request_method = 'POST' if data else 'GET'
212 req_args['method'] = request_method
210 if timeout is not None:213 if timeout is not None:
211 req_args['timeout'] = max(float(timeout), 0)214 req_args['timeout'] = max(float(timeout), 0)
212 if data:
213 req_args['method'] = 'POST'
214 # It doesn't seem like config215 # It doesn't seem like config
215 # was added in older library versions (or newer ones either), thus we216 # was added in older library versions (or newer ones either), thus we
216 # need to manually do the retries if it wasn't...217 # need to manually do the retries if it wasn't...
diff --git a/debian/changelog b/debian/changelog
index 671dad7..2cda24c 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,15 @@
1cloud-init (19.2-9-g15584720-0ubuntu1) eoan; urgency=medium
2
3 * New upstream snapshot.
4 - Add support for publishing host keys to GCE guest attributes
5 [Rick Wright]
6 - New data source for the Exoscale.com cloud platform [Chris Glass]
7 - doc: remove intersphinx extension
8 - cc_set_passwords: rewrite documentation (LP: #1838794)
9 * d/cloud-init.templates: add Exoscale data source
10
11 -- Daniel Watkins <oddbloke@ubuntu.com> Fri, 09 Aug 2019 13:57:28 -0400
12
1cloud-init (19.2-5-g496aaa94-0ubuntu1) eoan; urgency=medium13cloud-init (19.2-5-g496aaa94-0ubuntu1) eoan; urgency=medium
214
3 * New upstream snapshot.15 * New upstream snapshot.
diff --git a/debian/cloud-init.templates b/debian/cloud-init.templates
index ece53a0..e5efdad 100644
--- a/debian/cloud-init.templates
+++ b/debian/cloud-init.templates
@@ -1,8 +1,8 @@
1Template: cloud-init/datasources1Template: cloud-init/datasources
2Type: multiselect2Type: multiselect
3Default: NoCloud, ConfigDrive, OpenNebula, DigitalOcean, Azure, AltCloud, OVF, MAAS, GCE, OpenStack, CloudSigma, SmartOS, Bigstep, Scaleway, AliYun, Ec2, CloudStack, Hetzner, IBMCloud, Oracle, None3Default: NoCloud, ConfigDrive, OpenNebula, DigitalOcean, Azure, AltCloud, OVF, MAAS, GCE, OpenStack, CloudSigma, SmartOS, Bigstep, Scaleway, AliYun, Ec2, CloudStack, Hetzner, IBMCloud, Oracle, Exoscale, None
4Choices-C: NoCloud, ConfigDrive, OpenNebula, DigitalOcean, Azure, AltCloud, OVF, MAAS, GCE, OpenStack, CloudSigma, SmartOS, Bigstep, Scaleway, AliYun, Ec2, CloudStack, Hetzner, IBMCloud, Oracle, None4Choices-C: NoCloud, ConfigDrive, OpenNebula, DigitalOcean, Azure, AltCloud, OVF, MAAS, GCE, OpenStack, CloudSigma, SmartOS, Bigstep, Scaleway, AliYun, Ec2, CloudStack, Hetzner, IBMCloud, Oracle, Exoscale, None
5Choices: NoCloud: Reads info from /var/lib/cloud/seed only, ConfigDrive: Reads data from Openstack Config Drive, OpenNebula: read from OpenNebula context disk, DigitalOcean: reads data from Droplet datasource, Azure: read from MS Azure cdrom. Requires walinux-agent, AltCloud: config disks for RHEVm and vSphere, OVF: Reads data from OVF Transports, MAAS: Reads data from Ubuntu MAAS, GCE: google compute metadata service, OpenStack: native openstack metadata service, CloudSigma: metadata over serial for cloudsigma.com, SmartOS: Read from SmartOS metadata service, Bigstep: Bigstep metadata service, Scaleway: Scaleway metadata service, AliYun: Alibaba metadata service, Ec2: reads data from EC2 Metadata service, CloudStack: Read from CloudStack metadata service, Hetzner: Hetzner Cloud, IBMCloud: IBM Cloud. Previously softlayer or bluemix., Oracle: Oracle Compute Infrastructure, None: Failsafe datasource5Choices: NoCloud: Reads info from /var/lib/cloud/seed only, ConfigDrive: Reads data from Openstack Config Drive, OpenNebula: read from OpenNebula context disk, DigitalOcean: reads data from Droplet datasource, Azure: read from MS Azure cdrom. Requires walinux-agent, AltCloud: config disks for RHEVm and vSphere, OVF: Reads data from OVF Transports, MAAS: Reads data from Ubuntu MAAS, GCE: google compute metadata service, OpenStack: native openstack metadata service, CloudSigma: metadata over serial for cloudsigma.com, SmartOS: Read from SmartOS metadata service, Bigstep: Bigstep metadata service, Scaleway: Scaleway metadata service, AliYun: Alibaba metadata service, Ec2: reads data from EC2 Metadata service, CloudStack: Read from CloudStack metadata service, Hetzner: Hetzner Cloud, IBMCloud: IBM Cloud. Previously softlayer or bluemix., Oracle: Oracle Compute Infrastructure, Exoscale: Exoscale, None: Failsafe datasource
6Description: Which data sources should be searched?6Description: Which data sources should be searched?
7 Cloud-init supports searching different "Data Sources" for information7 Cloud-init supports searching different "Data Sources" for information
8 that it uses to configure a cloud instance.8 that it uses to configure a cloud instance.
diff --git a/doc/rtd/conf.py b/doc/rtd/conf.py
index 50eb05c..4174477 100644
--- a/doc/rtd/conf.py
+++ b/doc/rtd/conf.py
@@ -27,16 +27,11 @@ project = 'Cloud-Init'
27# Add any Sphinx extension module names here, as strings. They can be27# Add any Sphinx extension module names here, as strings. They can be
28# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.28# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
29extensions = [29extensions = [
30 'sphinx.ext.intersphinx',
31 'sphinx.ext.autodoc',30 'sphinx.ext.autodoc',
32 'sphinx.ext.autosectionlabel',31 'sphinx.ext.autosectionlabel',
33 'sphinx.ext.viewcode',32 'sphinx.ext.viewcode',
34]33]
3534
36intersphinx_mapping = {
37 'sphinx': ('http://sphinx.pocoo.org', None)
38}
39
40# The suffix of source filenames.35# The suffix of source filenames.
41source_suffix = '.rst'36source_suffix = '.rst'
4237
diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
index 648c606..2148cd5 100644
--- a/doc/rtd/topics/datasources.rst
+++ b/doc/rtd/topics/datasources.rst
@@ -155,6 +155,7 @@ Follow for more information.
155 datasources/configdrive.rst155 datasources/configdrive.rst
156 datasources/digitalocean.rst156 datasources/digitalocean.rst
157 datasources/ec2.rst157 datasources/ec2.rst
158 datasources/exoscale.rst
158 datasources/maas.rst159 datasources/maas.rst
159 datasources/nocloud.rst160 datasources/nocloud.rst
160 datasources/opennebula.rst161 datasources/opennebula.rst
diff --git a/doc/rtd/topics/datasources/exoscale.rst b/doc/rtd/topics/datasources/exoscale.rst
161new file mode 100644162new file mode 100644
index 0000000..27aec9c
--- /dev/null
+++ b/doc/rtd/topics/datasources/exoscale.rst
@@ -0,0 +1,68 @@
1.. _datasource_exoscale:
2
3Exoscale
4========
5
6This datasource supports reading from the metadata server used on the
7`Exoscale platform <https://exoscale.com>`_.
8
9Use of the Exoscale datasource is recommended to benefit from new features of
10the Exoscale platform.
11
12The datasource relies on the availability of a compatible metadata server
13(``http://169.254.169.254`` is used by default) and its companion password
14server, reachable at the same address (by default on port 8080).
15
16Crawling of metadata
17--------------------
18
19The metadata service and password server are crawled slightly differently:
20
21 * The "metadata service" is crawled every boot.
22 * The password server is also crawled every boot (the Exoscale datasource
23 forces the password module to run with "frequency always").
24
25In the password server case, the following rules apply in order to enable the
26"restore instance password" functionality:
27
28 * If a password is returned by the password server, it is then marked "saved"
29 by the cloud-init datasource. Subsequent boots will skip setting the password
30 (the password server will return "saved_password").
31 * When the instance password is reset (via the Exoscale UI), the password
32 server will return the non-empty password at next boot, therefore causing
33 cloud-init to reset the instance's password.
34
35Configuration
36-------------
37
38Users of this datasource are discouraged from changing the default settings
39unless instructed to by Exoscale support.
40
41The following settings are available and can be set for the datasource in system
42configuration (in `/etc/cloud/cloud.cfg.d/`).
43
44The settings available are:
45
46 * **metadata_url**: The URL for the metadata service (defaults to
47 ``http://169.254.169.254``)
48 * **api_version**: The API version path on which to query the instance metadata
49 (defaults to ``1.0``)
50 * **password_server_port**: The port (on the metadata server) on which the
51 password server listens (defaults to ``8080``).
52 * **timeout**: the timeout value provided to urlopen for each individual http
53 request. (defaults to ``10``)
54 * **retries**: The number of retries that should be done for an http request
55 (defaults to ``6``)
56
57
58An example configuration with the default values is provided below:
59
60.. sourcecode:: yaml
61
62 datasource:
63 Exoscale:
64 metadata_url: "http://169.254.169.254"
65 api_version: "1.0"
66 password_server_port: 8080
67 timeout: 10
68 retries: 6
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 2a9cfb2..61a7a76 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -13,6 +13,7 @@ from cloudinit.sources import (
13 DataSourceConfigDrive as ConfigDrive,13 DataSourceConfigDrive as ConfigDrive,
14 DataSourceDigitalOcean as DigitalOcean,14 DataSourceDigitalOcean as DigitalOcean,
15 DataSourceEc2 as Ec2,15 DataSourceEc2 as Ec2,
16 DataSourceExoscale as Exoscale,
16 DataSourceGCE as GCE,17 DataSourceGCE as GCE,
17 DataSourceHetzner as Hetzner,18 DataSourceHetzner as Hetzner,
18 DataSourceIBMCloud as IBMCloud,19 DataSourceIBMCloud as IBMCloud,
@@ -53,6 +54,7 @@ DEFAULT_NETWORK = [
53 CloudStack.DataSourceCloudStack,54 CloudStack.DataSourceCloudStack,
54 DSNone.DataSourceNone,55 DSNone.DataSourceNone,
55 Ec2.DataSourceEc2,56 Ec2.DataSourceEc2,
57 Exoscale.DataSourceExoscale,
56 GCE.DataSourceGCE,58 GCE.DataSourceGCE,
57 MAAS.DataSourceMAAS,59 MAAS.DataSourceMAAS,
58 NoCloud.DataSourceNoCloudNet,60 NoCloud.DataSourceNoCloudNet,
diff --git a/tests/unittests/test_datasource/test_exoscale.py b/tests/unittests/test_datasource/test_exoscale.py
59new file mode 10064461new file mode 100644
index 0000000..350c330
--- /dev/null
+++ b/tests/unittests/test_datasource/test_exoscale.py
@@ -0,0 +1,203 @@
1# Author: Mathieu Corbin <mathieu.corbin@exoscale.com>
2# Author: Christopher Glass <christopher.glass@exoscale.com>
3#
4# This file is part of cloud-init. See LICENSE file for license information.
5from cloudinit import helpers
6from cloudinit.sources.DataSourceExoscale import (
7 API_VERSION,
8 DataSourceExoscale,
9 METADATA_URL,
10 get_password,
11 PASSWORD_SERVER_PORT,
12 read_metadata)
13from cloudinit.tests.helpers import HttprettyTestCase, mock
14
15import httpretty
16import requests
17
18
19TEST_PASSWORD_URL = "{}:{}/{}/".format(METADATA_URL,
20 PASSWORD_SERVER_PORT,
21 API_VERSION)
22
23TEST_METADATA_URL = "{}/{}/meta-data/".format(METADATA_URL,
24 API_VERSION)
25
26TEST_USERDATA_URL = "{}/{}/user-data".format(METADATA_URL,
27 API_VERSION)
28
29
30@httpretty.activate
31class TestDatasourceExoscale(HttprettyTestCase):
32
33 def setUp(self):
34 super(TestDatasourceExoscale, self).setUp()
35 self.tmp = self.tmp_dir()
36 self.password_url = TEST_PASSWORD_URL
37 self.metadata_url = TEST_METADATA_URL
38 self.userdata_url = TEST_USERDATA_URL
39
40 def test_password_saved(self):
41 """The password is not set when it is not found
42 in the metadata service."""
43 httpretty.register_uri(httpretty.GET,
44 self.password_url,
45 body="saved_password")
46 self.assertFalse(get_password())
47
48 def test_password_empty(self):
49 """No password is set if the metadata service returns
50 an empty string."""
51 httpretty.register_uri(httpretty.GET,
52 self.password_url,
53 body="")
54 self.assertFalse(get_password())
55
56 def test_password(self):
57 """The password is set to what is found in the metadata
58 service."""
59 expected_password = "p@ssw0rd"
60 httpretty.register_uri(httpretty.GET,
61 self.password_url,
62 body=expected_password)
63 password = get_password()
64 self.assertEqual(expected_password, password)
65
66 def test_get_data(self):
67 """The datasource conforms to expected behavior when supplied
68 full test data."""
69 path = helpers.Paths({'run_dir': self.tmp})
70 ds = DataSourceExoscale({}, None, path)
71 ds._is_platform_viable = lambda: True
72 expected_password = "p@ssw0rd"
73 expected_id = "12345"
74 expected_hostname = "myname"
75 expected_userdata = "#cloud-config"
76 httpretty.register_uri(httpretty.GET,
77 self.userdata_url,
78 body=expected_userdata)
79 httpretty.register_uri(httpretty.GET,
80 self.password_url,
81 body=expected_password)
82 httpretty.register_uri(httpretty.GET,
83 self.metadata_url,
84 body="instance-id\nlocal-hostname")
85 httpretty.register_uri(httpretty.GET,
86 "{}local-hostname".format(self.metadata_url),
87 body=expected_hostname)
88 httpretty.register_uri(httpretty.GET,
89 "{}instance-id".format(self.metadata_url),
90 body=expected_id)
91 self.assertTrue(ds._get_data())
92 self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
93 self.assertEqual(ds.metadata, {"instance-id": expected_id,
94 "local-hostname": expected_hostname})
95 self.assertEqual(ds.get_config_obj(),
96 {'ssh_pwauth': True,
97 'password': expected_password,
98 'cloud_config_modules': [
99 ["set-passwords", "always"]],
100 'chpasswd': {
101 'expire': False,
102 }})
103
104 def test_get_data_saved_password(self):
105 """The datasource conforms to expected behavior when saved_password is
106 returned by the password server."""
107 path = helpers.Paths({'run_dir': self.tmp})
108 ds = DataSourceExoscale({}, None, path)
109 ds._is_platform_viable = lambda: True
110 expected_answer = "saved_password"
111 expected_id = "12345"
112 expected_hostname = "myname"
113 expected_userdata = "#cloud-config"
114 httpretty.register_uri(httpretty.GET,
115 self.userdata_url,
116 body=expected_userdata)
117 httpretty.register_uri(httpretty.GET,
118 self.password_url,
119 body=expected_answer)
120 httpretty.register_uri(httpretty.GET,
121 self.metadata_url,
122 body="instance-id\nlocal-hostname")
123 httpretty.register_uri(httpretty.GET,
124 "{}local-hostname".format(self.metadata_url),
125 body=expected_hostname)
126 httpretty.register_uri(httpretty.GET,
127 "{}instance-id".format(self.metadata_url),
128 body=expected_id)
129 self.assertTrue(ds._get_data())
130 self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
131 self.assertEqual(ds.metadata, {"instance-id": expected_id,
132 "local-hostname": expected_hostname})
133 self.assertEqual(ds.get_config_obj(),
134 {'cloud_config_modules': [
135 ["set-passwords", "always"]]})
136
137 def test_get_data_no_password(self):
138 """The datasource conforms to expected behavior when no password is
139 returned by the password server."""
140 path = helpers.Paths({'run_dir': self.tmp})
141 ds = DataSourceExoscale({}, None, path)
142 ds._is_platform_viable = lambda: True
143 expected_answer = ""
144 expected_id = "12345"
145 expected_hostname = "myname"
146 expected_userdata = "#cloud-config"
147 httpretty.register_uri(httpretty.GET,
148 self.userdata_url,
149 body=expected_userdata)
150 httpretty.register_uri(httpretty.GET,
151 self.password_url,
152 body=expected_answer)
153 httpretty.register_uri(httpretty.GET,
154 self.metadata_url,
155 body="instance-id\nlocal-hostname")
156 httpretty.register_uri(httpretty.GET,
157 "{}local-hostname".format(self.metadata_url),
158 body=expected_hostname)
159 httpretty.register_uri(httpretty.GET,
160 "{}instance-id".format(self.metadata_url),
161 body=expected_id)
162 self.assertTrue(ds._get_data())
163 self.assertEqual(ds.userdata_raw.decode("utf-8"), "#cloud-config")
164 self.assertEqual(ds.metadata, {"instance-id": expected_id,
165 "local-hostname": expected_hostname})
166 self.assertEqual(ds.get_config_obj(),
167 {'cloud_config_modules': [
168 ["set-passwords", "always"]]})
169
170 @mock.patch('cloudinit.sources.DataSourceExoscale.get_password')
171 def test_read_metadata_when_password_server_unreachable(self, m_password):
172 """The read_metadata function returns partial results in case the
173 password server (only) is unreachable."""
174 expected_id = "12345"
175 expected_hostname = "myname"
176 expected_userdata = "#cloud-config"
177
178 m_password.side_effect = requests.Timeout('Fake Connection Timeout')
179 httpretty.register_uri(httpretty.GET,
180 self.userdata_url,
181 body=expected_userdata)
182 httpretty.register_uri(httpretty.GET,
183 self.metadata_url,
184 body="instance-id\nlocal-hostname")
185 httpretty.register_uri(httpretty.GET,
186 "{}local-hostname".format(self.metadata_url),
187 body=expected_hostname)
188 httpretty.register_uri(httpretty.GET,
189 "{}instance-id".format(self.metadata_url),
190 body=expected_id)
191
192 result = read_metadata()
193
194 self.assertIsNone(result.get("password"))
195 self.assertEqual(result.get("user-data").decode("utf-8"),
196 expected_userdata)
197
198 def test_non_viable_platform(self):
199 """The datasource fails fast when the platform is not viable."""
200 path = helpers.Paths({'run_dir': self.tmp})
201 ds = DataSourceExoscale({}, None, path)
202 ds._is_platform_viable = lambda: False
203 self.assertFalse(ds._get_data())
diff --git a/tests/unittests/test_datasource/test_gce.py b/tests/unittests/test_datasource/test_gce.py
index 41176c6..67744d3 100644
--- a/tests/unittests/test_datasource/test_gce.py
+++ b/tests/unittests/test_datasource/test_gce.py
@@ -55,6 +55,8 @@ GCE_USER_DATA_TEXT = {
55HEADERS = {'Metadata-Flavor': 'Google'}55HEADERS = {'Metadata-Flavor': 'Google'}
56MD_URL_RE = re.compile(56MD_URL_RE = re.compile(
57 r'http://metadata.google.internal/computeMetadata/v1/.*')57 r'http://metadata.google.internal/computeMetadata/v1/.*')
58GUEST_ATTRIBUTES_URL = ('http://metadata.google.internal/computeMetadata/'
59 'v1/instance/guest-attributes/hostkeys/')
5860
5961
60def _set_mock_metadata(gce_meta=None):62def _set_mock_metadata(gce_meta=None):
@@ -341,4 +343,20 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
341 public_key_data, default_user='default')343 public_key_data, default_user='default')
342 self.assertEqual(sorted(found), sorted(expected))344 self.assertEqual(sorted(found), sorted(expected))
343345
346 @mock.patch("cloudinit.url_helper.readurl")
347 def test_publish_host_keys(self, m_readurl):
348 hostkeys = [('ssh-rsa', 'asdfasdf'),
349 ('ssh-ed25519', 'qwerqwer')]
350 readurl_expected_calls = [
351 mock.call(check_status=False, data=b'asdfasdf', headers=HEADERS,
352 request_method='PUT',
353 url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-rsa')),
354 mock.call(check_status=False, data=b'qwerqwer', headers=HEADERS,
355 request_method='PUT',
356 url='%s%s' % (GUEST_ATTRIBUTES_URL, 'ssh-ed25519')),
357 ]
358 self.ds.publish_host_keys(hostkeys)
359 m_readurl.assert_has_calls(readurl_expected_calls, any_order=True)
360
361
344# vi: ts=4 expandtab362# vi: ts=4 expandtab
diff --git a/tools/ds-identify b/tools/ds-identify
index 0305e36..e0d4865 100755
--- a/tools/ds-identify
+++ b/tools/ds-identify
@@ -124,7 +124,7 @@ DI_DSNAME=""
124# be searched if there is no setting found in config.124# be searched if there is no setting found in config.
125DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \125DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
126CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \126CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
127OVF SmartOS Scaleway Hetzner IBMCloud Oracle"127OVF SmartOS Scaleway Hetzner IBMCloud Oracle Exoscale"
128DI_DSLIST=""128DI_DSLIST=""
129DI_MODE=""129DI_MODE=""
130DI_ON_FOUND=""130DI_ON_FOUND=""
@@ -553,6 +553,11 @@ dscheck_CloudStack() {
553 return $DS_NOT_FOUND553 return $DS_NOT_FOUND
554}554}
555555
556dscheck_Exoscale() {
557 dmi_product_name_matches "Exoscale*" && return $DS_FOUND
558 return $DS_NOT_FOUND
559}
560
556dscheck_CloudSigma() {561dscheck_CloudSigma() {
557 # http://paste.ubuntu.com/23624795/562 # http://paste.ubuntu.com/23624795/
558 dmi_product_name_matches "CloudSigma" && return $DS_FOUND563 dmi_product_name_matches "CloudSigma" && return $DS_FOUND

Subscribers

People subscribed via source and target branches