Merge lp:~vlastimil-holer/cloud-init/opennebula into lp:~cloud-init-dev/cloud-init/trunk

Proposed by Vlastimil Holer
Status: Superseded
Proposed branch: lp:~vlastimil-holer/cloud-init/opennebula
Merge into: lp:~cloud-init-dev/cloud-init/trunk
Diff against target: 759 lines (+691/-2)
7 files modified
cloudinit/settings.py (+1/-0)
cloudinit/sources/DataSourceOpenNebula.py (+367/-0)
cloudinit/sources/__init__.py (+9/-2)
cloudinit/util.py (+7/-0)
doc/rtd/topics/datasources.rst (+6/-0)
doc/sources/opennebula/README.rst (+132/-0)
tests/unittests/test_datasource/test_opennebula.py (+169/-0)
To merge this branch: bzr merge lp:~vlastimil-holer/cloud-init/opennebula
Reviewer Review Type Date Requested Status
Scott Moser Needs Fixing
Joshua Harlow Pending
Review via email: mp+149953@code.launchpad.net

This proposal supersedes a proposal from 2012-10-03.

This proposal has been superseded by a proposal from 2013-09-06.

Description of the change

I'm resubmitting review proposal for OpenNebula support in cloud-init. I have fixed all comments including unit tests. Javi Fontan from OpenNebula.org also added support for static network configuration. All configuration options are now well documented in README.rst. I'm already using previous and current patches on CentOS 6.3 and Debian 6 images, so there is also real environment testing.

---
These patches are modified existing OpenStacks' ConfigDrive datasource to suite requirements of the OpenNebula (http://opennebula.org/). All copyrights are preserved.

In OpenNebula context variables aren't formalized as in OpenStack, people mostly write their own contextualization scripts and recommends image users what context variables should be exported. I have taken variables seen in most examples (SSH_KEY, HOSTNAME, PUBLIC_IP) to control cloud-init's behaviour.

To post a comment you must log in.
Revision history for this message
Joshua Harlow (harlowja) wrote : Posted in a previous version of this proposal

Just a few comments, otherwise seems cool.

Is 'NonConfigDriveDir' (exception afaik) supposed to be brought in somewhere?

Should this ds class be derived from the config drive datasource?
 - This is due to the new overload that was just added there
   for mapping device names to actual devices. I noticed this
   code does not have that function either (without it fstab
   will not be adjusted). So perhaps its better to inherit
   if the functionality is the same?? Perhaps override getdata()
   but still use the rest of the parent?

For capturing 'except subprocess.CalledProcessError as exc:' you might want to catch the one in 'util.py' instead (its not the same exception - although maybe it should be).

Is it possible add more comments as to what the context.sh parsing is doing?
Ie: ''comm -23 <(set | sort -u) <(echo "$VARS") | egrep -v "^(VARS|PIPESTATUS|_)="'' (???)

context_sh[key.lower()]=r.group(1).\
205 + replace('\\\\','\\').\
206 + replace('\\t','\t').\
207 + replace('\\n','\n').\
208 + replace("\\'","'")

Should this just be re.group(1).decode('string_escape')?
>>> print c
\n\tblahblah\n\'
>>> print c.decode('string_escape')

 blahblah
'

review: Needs Fixing
Revision history for this message
Joshua Harlow (harlowja) wrote : Posted in a previous version of this proposal

Also some tests would be super-great :-)

Revision history for this message
Vlastimil Holer (vlastimil-holer) wrote : Posted in a previous version of this proposal

> Just a few comments, otherwise seems cool.
>
> Is 'NonConfigDriveDir' (exception afaik) supposed to be brought in somewhere?

Fixed, leftover from ConfigDrive class.

> Should this ds class be derived from the config drive datasource?
> - This is due to the new overload that was just added there
> for mapping device names to actual devices. I noticed this
> code does not have that function either (without it fstab
> will not be adjusted). So perhaps its better to inherit
> if the functionality is the same?? Perhaps override getdata()
> but still use the rest of the parent?

If I understand right, it's not any problem here. OpenNebula's metadata
aren't strictly specified as in OpenStack or EC2. They all are just user
specified and metadata I currently depend on were just mentioned in
their documentation or example scripts, I haven't seen anything like
block device mappings so far. Since there are no default metadata for
device mapping, I think it's better not to invent this functionality now.

You can check here:
http://opennebula.org/documentation:rel3.8:context_overview
All metadata are just optional and user specified, it's common
to have there SSH public key or static network configuration.

> For capturing 'except subprocess.CalledProcessError as exc:' you might want to
> catch the one in 'util.py' instead (its not the same exception - although
> maybe it should be).

Fixed.

> Is it possible add more comments as to what the context.sh parsing is doing?
> Ie: ''comm -23 <(set | sort -u) <(echo "$VARS") | egrep -v
> "^(VARS|PIPESTATUS|_)="'' (???)

Explanation added into code. I know this construction isn't very nice,
but IMHO it's easiest way how to read context variables. Bash dumps
them just as easy parsable (single line) way.

> context_sh[key.lower()]=r.group(1).\
> 205 + replace('\\\\','\\').\
> 206 + replace('\\t','\t').\
> 207 + replace('\\n','\n').\
> 208 + replace("\\'","'")
>
> Should this just be re.group(1).decode('string_escape')?
> >>> print c
> \n\tblahblah\n\'
> >>> print c.decode('string_escape')
>
> blahblah
> '

Yes, thanks, fixed.

> Also some tests would be super-great :-)

On my TODO list, will do that soon.

Revision history for this message
Joshua Harlow (harlowja) wrote :

Ok, one last comment.

Is the modification of "cloudinit/distros/__init__.py" still needed or is that just leftover code that got picked up in the review?

679. By Vlastimil Holer

Remove commit "Add resolv.conf configuration function"

Revision history for this message
Vlastimil Holer (vlastimil-holer) wrote :

> Ok, one last comment.
>
> Is the modification of "cloudinit/distros/__init__.py" still needed or is that
> just leftover code that got picked up in the review?

You are right, this was leftover after Javi's first version of static network configuration.
Currently we are already generating full Debian style network configuration including
"dns-search|nameservers" interface options. These are on RHEL systems automatically
converted and applied into /etc/resolv.conf inside your code already.

Sorry fot that. Change has been uncommited.

Revision history for this message
Scott Moser (smoser) wrote :

Some thoughts:
 * run ./tools/run-pep8 tests/unittests/test_datasource/test_opennebula.py
   and ./tools/run-pylint tests/unittests/test_datasource/test_opennebula.py
   You'll see lots of (sorry) nit-picky things to fix.

 * how does 'user_dsmode' get set? I guess through 'context.sh' ?

 * I guess I might like to see the reading of context.sh in its own method for
   easier testing.

 * populate_dir (copied to your test) is now in unittests/helpers.py

The one thing I really do not like here is that context.sh is explicitly
executed (by bash), as opposed to being parsed. The issue is that I can
essentially do *anything* here. I could attach a disk labeled CDROM, with
context.sh and execute arbitrary code as root.

This isn't terribly a security issue, since executing code as root is generally
cloud-init's goal, but its very inconsistent with other datasources.

Also, fwiw, I've been wanting to explicitly add a "execute something as root
really early" hook. The goal would be to allow you to tweak an image as early
as possible, even fiddling with or fixing cloud-init.

So, please fix the pep8 and pylint, and, are people actually expecting code to be executed? Ie, do they think they can build/provide a context.sh that executes code?

review: Needs Fixing
680. By Vlastimil Holer

Apply pep8.patch by Javier Fontan <email address hidden>

681. By Vlastimil Holer

PEP8 fixes.

Revision history for this message
Javi Fontan (jfontan) wrote :

"context.sh" is generated by OpenNebula and is a shell script with shell variables. It is meant to be executed by some init scripts in the VM to get that information. It should not contain any "executable" code, at least OpenNebula does not add code to it.

The problem with parsing that file is that some variables can be multiline and and parsing is not that straightforward. For example:

# Context variables generated by OpenNebula
DISK_ID='1'
ETH0_DNS='10.0.0.1'
ETH0_GATEWAY='10.0.0.1'
ETH0_IP='10.0.0.72'
ETH0_MASK='255.255.255.0'
FILES_DS='/var/lib/one/datastores/2/9c92ad910c0b30a411ccdfc141bc7a25:'\''jfopet.sh'\'' '
FILES_URL='http://10.0.0.11/files/installer.tar.gz'
INIT_SCRIPTS='jfopet.sh'
MANIFEST='ubuntu-kvm1'
SSH_PUBLIC_KEY='ssh-dss AAAAB3NzaC1kc3MAAACBANprdUVFRaADZXZaAm2elpRaUGCMETLLuYYJCDUZPb0Dh/V7KeM2/a3rFIDA0s5sVK2XQNqLHyy0U8xA/0R8dplmg7BDckkAzhrDVpEGnQE1fk/xPd1t7u+yeVqpbrfyAmfmyE2P980mhBoWalbeV/f7SmHUP8RiQ2hlAWUxr7I5AAAAFQDLHhFndbcA7svGd/yfY6nU4ubodwAAAIAoXLKlckmZur1pc9TN4XoHa+Fl/6Qpu0XO7Ai/tu8dqHlN+FpVk8BQNnokwE+EZBARLIL0JCjHpT9b5aEvlpRz3TuDa6az8wvJRlScNufmVrf0ls1WCFDHujSLzd3aOkKct35Aamf1amP/NPE2aGne/tPS7HQ3TCf5E2MAQzYVJKSARwAAAIAaVqvU7LfGMEw0hEXr7fuJCMHh4FmPvejiOoSUz2GOU5bceasRitdulCQJbNiVtY6U0S+qQ0X8rvAnG934p4zS9TtgKIhplr146fkbYnNjCaAM0rNVvTh2SzEEKJiG2G1d3wyNuO8wpPhIiJ1OVZrGwkVyWwiNzC2sWXAXldQ9Hg== user1@host
ssh-dss AAAAB3NzaC1kc3MAAACBANBWTQmm4GtkAiPpA20DK1TQd1n7UoC5BymTHlGzFmj+BsQdn6ZZbihV/roNVo1roJhb2hQq/WuQR50D72vMIjrC6t1DkofFBL0iz+mb7JdmNxbE7cnOHMxEr/cd4ds4EwzpBKiQzt8NNcz/zbSadHQtBd0+u1G5nvm4MXHNVYQnAAAAFQDbEN554Or4vd7D+2wzjs3c1nNOowAAAIAdRlTrRG1YmceKHh3urcltniIoo8FrNudwCShbHTCOQbM+KkMUTtw5qwWFuJP6HdaLjmUehqxqDWeWGp8c2y5yMee0JR5cx+iMwhg1Q2o4S6c+zgWdUYIVkuPgYOOR2GMCPdl9mwcxtVvpHD59UFlPh16oLzakUCSxkro5V/LUXxuywAAAIBtYvxfwI5Cl0xq3/KQI7giNef05EXIgK+KwYu3xC6fs+7NxQYFzMsSEQhEJI62J091Kh0RFpFPdGPECIQolt8j4ltymKM9+pfgiE7oBXSxkW44XadBnCWCGU5B4gTnz84VKECWuu2J9Z4cn44hKYt1uj0SxxzExnB1X51kUN+Z5Q== user2@host'

I don't think creating a parser for these files is worth the job.

682. By Vlastimil Holer

Apply parse.diff by Javier Fontan <email address hidden>

683. By Vlastimil Holer

Fix RE matching context variables. Test cleanups.

684. By Vlastimil Holer

Search for contextualization CDROM by LABEL=CONTEXT

685. By Vlastimil Holer

Merged trunk lp:cloud-init

686. By Vlastimil Holer

PEP8 and Pylint fixes. Move context.sh "parser" into separate
function. Fix fetching user specified dsmode (from context).
Rename context_sh->context. Reuse unittests.helpers.populate_dir.

687. By Vlastimil Holer

Replace RE context.sh parser with Scott's rewrite of bash dumper. Upper case context variable names.

688. By Vlastimil Holer

Fix pylint complain on toks.split('.')

689. By Vlastimil Holer

Configurable OpenNebula::parseuser. Seed search dir+dev merge.
Eat shell parser error output. Few tests for tests for get_data.

690. By Vlastimil Holer

Update OpenNebula documentation (parseuser, more fs. labels, K-V single quotes)

691. By Vlastimil Holer

Detect invalid system user for test

692. By Vlastimil Holer

Fix detection of ETHx_IP context variable, add test.

693. By Vlastimil Holer

Merged lp:~smoser/cloud-init/opennebula

694. By Vlastimil Holer

All fake util.find_devs_with set before try-finally section

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'cloudinit/settings.py'
2--- cloudinit/settings.py 2013-07-18 21:37:18 +0000
3+++ cloudinit/settings.py 2013-09-05 16:03:43 +0000
4@@ -31,6 +31,7 @@
5 'datasource_list': [
6 'NoCloud',
7 'ConfigDrive',
8+ 'OpenNebula',
9 'Azure',
10 'AltCloud',
11 'OVF',
12
13=== added file 'cloudinit/sources/DataSourceOpenNebula.py'
14--- cloudinit/sources/DataSourceOpenNebula.py 1970-01-01 00:00:00 +0000
15+++ cloudinit/sources/DataSourceOpenNebula.py 2013-09-05 16:03:43 +0000
16@@ -0,0 +1,367 @@
17+# vi: ts=4 expandtab
18+#
19+# Copyright (C) 2012 Canonical Ltd.
20+# Copyright (C) 2012 Yahoo! Inc.
21+# Copyright (C) 2012-2013 CERIT Scientific Cloud
22+# Copyright (C) 2012-2013 OpenNebula.org
23+#
24+# Author: Scott Moser <scott.moser@canonical.com>
25+# Author: Joshua Harlow <harlowja@yahoo-inc.com>
26+# Author: Vlastimil Holer <xholer@mail.muni.cz>
27+# Author: Javier Fontan <jfontan@opennebula.org>
28+#
29+# This program is free software: you can redistribute it and/or modify
30+# it under the terms of the GNU General Public License version 3, as
31+# published by the Free Software Foundation.
32+#
33+# This program is distributed in the hope that it will be useful,
34+# but WITHOUT ANY WARRANTY; without even the implied warranty of
35+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
36+# GNU General Public License for more details.
37+#
38+# You should have received a copy of the GNU General Public License
39+# along with this program. If not, see <http://www.gnu.org/licenses/>.
40+
41+import os
42+import re
43+
44+from cloudinit import log as logging
45+from cloudinit import sources
46+from cloudinit import util
47+
48+LOG = logging.getLogger(__name__)
49+
50+DEFAULT_IID = "iid-dsopennebula"
51+DEFAULT_MODE = 'net'
52+CONTEXT_DISK_FILES = ["context.sh"]
53+VALID_DSMODES = ("local", "net", "disabled")
54+
55+
56+class DataSourceOpenNebula(sources.DataSource):
57+ def __init__(self, sys_cfg, distro, paths):
58+ sources.DataSource.__init__(self, sys_cfg, distro, paths)
59+ self.dsmode = 'local'
60+ self.seed = None
61+ self.seed_dir = os.path.join(paths.seed_dir, 'opennebula')
62+
63+ def __str__(self):
64+ root = sources.DataSource.__str__(self)
65+ return "%s [seed=%s][dsmode=%s]" % (root, self.seed, self.dsmode)
66+
67+ def get_data(self):
68+ defaults = {
69+ "instance-id": DEFAULT_IID,
70+ "dsmode": self.dsmode
71+ }
72+
73+ seed = None
74+ results = {}
75+
76+ # first try to read local seed_dir
77+ if os.path.isdir(self.seed_dir):
78+ try:
79+ results = read_context_disk_dir(self.seed_dir)
80+ seed = self.seed_dir
81+ except NonContextDiskDir:
82+ util.logexc(LOG, "Failed reading context from %s",
83+ self.seed_dir)
84+
85+ if not seed:
86+ # then try to detect and mount candidate devices and
87+ # read contextualization if present
88+ for dev in find_candidate_devs():
89+ try:
90+ results = util.mount_cb(dev, read_context_disk_dir)
91+ seed = dev
92+ break
93+ except (NonContextDiskDir, util.MountFailedError):
94+ pass
95+
96+ if not seed:
97+ return False
98+
99+ # merge fetched metadata with datasource defaults
100+ md = results['metadata']
101+ md = util.mergemanydict([md, defaults])
102+
103+ # check for valid user specified dsmode
104+ user_dsmode = results['metadata'].get('dsmode', None)
105+ if user_dsmode not in VALID_DSMODES + (None,):
106+ LOG.warn("user specified invalid mode: %s", user_dsmode)
107+ user_dsmode = None
108+
109+ # decide dsmode
110+ if user_dsmode:
111+ dsmode = user_dsmode
112+ elif self.ds_cfg.get('dsmode'):
113+ dsmode = self.ds_cfg.get('dsmode')
114+ else:
115+ dsmode = DEFAULT_MODE
116+
117+ if dsmode == "disabled":
118+ # most likely user specified
119+ return False
120+
121+ # apply static network configuration only in 'local' dsmode
122+ if ('network-interfaces' in results and self.dsmode == "local"):
123+ LOG.debug("Updating network interfaces from %s", self)
124+ self.distro.apply_network(results['network-interfaces'])
125+
126+ if dsmode != self.dsmode:
127+ LOG.debug("%s: not claiming datasource, dsmode=%s", self, dsmode)
128+ return False
129+
130+ self.seed = seed
131+ self.metadata = md
132+ self.userdata_raw = results.get('userdata')
133+ return True
134+
135+ def get_hostname(self, fqdn=False, resolve_ip=None):
136+ if resolve_ip is None:
137+ if self.dsmode == 'net':
138+ resolve_ip = True
139+ else:
140+ resolve_ip = False
141+ return sources.DataSource.get_hostname(self, fqdn, resolve_ip)
142+
143+
144+class DataSourceOpenNebulaNet(DataSourceOpenNebula):
145+ def __init__(self, sys_cfg, distro, paths):
146+ DataSourceOpenNebula.__init__(self, sys_cfg, distro, paths)
147+ self.dsmode = 'net'
148+
149+
150+class NonContextDiskDir(Exception):
151+ pass
152+
153+
154+class OpenNebulaNetwork(object):
155+ REG_DEV_MAC = re.compile(
156+ r'^\d+: (eth\d+):.*?link\/ether (..:..:..:..:..:..) ?',
157+ re.MULTILINE | re.DOTALL)
158+
159+ def __init__(self, ip, context):
160+ self.ip = ip
161+ self.context = context
162+ self.ifaces = self.get_ifaces()
163+
164+ def get_ifaces(self):
165+ return self.REG_DEV_MAC.findall(self.ip)
166+
167+ def mac2ip(self, mac):
168+ components = mac.split(':')[2:]
169+ return [str(int(c, 16)) for c in components]
170+
171+ def get_ip(self, dev, components):
172+ var_name = dev + '_ip'
173+ if var_name in self.context:
174+ return self.context[var_name]
175+ else:
176+ return '.'.join(components)
177+
178+ def get_mask(self, dev):
179+ var_name = dev + '_mask'
180+ if var_name in self.context:
181+ return self.context[var_name]
182+ else:
183+ return '255.255.255.0'
184+
185+ def get_network(self, dev, components):
186+ var_name = dev + '_network'
187+ if var_name in self.context:
188+ return self.context[var_name]
189+ else:
190+ return '.'.join(components[:-1]) + '.0'
191+
192+ def get_gateway(self, dev):
193+ var_name = dev + '_gateway'
194+ if var_name in self.context:
195+ return self.context[var_name]
196+ else:
197+ return None
198+
199+ def get_dns(self, dev):
200+ var_name = dev + '_dns'
201+ if var_name in self.context:
202+ return self.context[var_name]
203+ else:
204+ return None
205+
206+ def get_domain(self, dev):
207+ var_name = dev + '_domain'
208+ if var_name in self.context:
209+ return self.context[var_name]
210+ else:
211+ return None
212+
213+ def gen_conf(self):
214+ global_dns = []
215+ if 'dns' in self.context:
216+ global_dns.append(self.context['dns'])
217+
218+ conf = []
219+ conf.append('auto lo')
220+ conf.append('iface lo inet loopback')
221+ conf.append('')
222+
223+ for i in self.ifaces:
224+ dev = i[0]
225+ mac = i[1]
226+ ip_components = self.mac2ip(mac)
227+
228+ conf.append('auto ' + dev)
229+ conf.append('iface ' + dev + ' inet static')
230+ conf.append(' address ' + self.get_ip(dev, ip_components))
231+ conf.append(' network ' + self.get_network(dev, ip_components))
232+ conf.append(' netmask ' + self.get_mask(dev))
233+
234+ gateway = self.get_gateway(dev)
235+ if gateway:
236+ conf.append(' gateway ' + gateway)
237+
238+ domain = self.get_domain(dev)
239+ if domain:
240+ conf.append(' dns-search ' + domain)
241+
242+ # add global DNS servers to all interfaces
243+ dns = self.get_dns(dev)
244+ if global_dns or dns:
245+ all_dns = global_dns
246+ if dns:
247+ all_dns.append(dns)
248+ conf.append(' dns-nameservers ' + ' '.join(all_dns))
249+
250+ conf.append('')
251+
252+ return "\n".join(conf)
253+
254+
255+def find_candidate_devs():
256+ """
257+ Return a list of devices that may contain the context disk.
258+ """
259+ combined = []
260+ for f in ('LABEL=CONTEXT', 'LABEL=CDROM', 'TYPE=iso9660'):
261+ devs = util.find_devs_with(f)
262+ devs.sort()
263+ for d in devs:
264+ if d not in combined:
265+ combined.append(d)
266+
267+ return combined
268+
269+
270+def parse_context_data(data):
271+ """
272+ parse_context_data(data)
273+ parse context.sh variables provided as a single string. Uses
274+ very simple matching RE. Returns None if nothing is matched.
275+ """
276+ # RE groups:
277+ # 1: key
278+ # 2: single quoted value, respect '\''
279+ # 3: old double quoted value, but doesn't end with \"
280+ context_reg = re.compile(
281+ r"^([\w_]+)=(?:'((?:[^']|'\\'')*?)'|\"(.*?[^\\])\")$",
282+ re.MULTILINE | re.DOTALL)
283+
284+ found = context_reg.findall(data)
285+ if not found:
286+ return None
287+
288+ variables = {}
289+ for k, v1, v2 in found:
290+ k = k.lower()
291+ if v1:
292+ # take single quoted variable 'xyz'
293+ # (ON>=4) and unquote '\'' -> '
294+ variables[k] = v1.replace(r"'\''", r"'")
295+ elif v2:
296+ # take double quoted variable "xyz"
297+ # (old ON<4) and unquote \" -> "
298+ variables[k] = v2.replace(r'\"', r'"')
299+
300+ return variables
301+
302+
303+def read_context_disk_dir(source_dir):
304+ """
305+ read_context_disk_dir(source_dir):
306+ read source_dir and return a tuple with metadata dict and user-data
307+ string populated. If not a valid dir, raise a NonContextDiskDir
308+ """
309+ found = {}
310+ for af in CONTEXT_DISK_FILES:
311+ fn = os.path.join(source_dir, af)
312+ if os.path.isfile(fn):
313+ found[af] = fn
314+
315+ if not found:
316+ raise NonContextDiskDir("%s: %s" % (source_dir, "no files found"))
317+
318+ results = {'userdata': None, 'metadata': {}}
319+ context = {}
320+
321+ if "context.sh" in found:
322+ try:
323+ with open(os.path.join(source_dir, 'context.sh'), 'r') as f:
324+ context = parse_context_data(f.read())
325+ f.close()
326+ if not context:
327+ raise NonContextDiskDir("No variables in context")
328+
329+ except (IOError, NonContextDiskDir) as e:
330+ raise NonContextDiskDir("Error reading context.sh: %s" % (e))
331+
332+ results['metadata'] = context
333+ else:
334+ raise NonContextDiskDir("Missing context.sh")
335+
336+ # process single or multiple SSH keys
337+ ssh_key_var = None
338+ if "ssh_key" in context:
339+ ssh_key_var = "ssh_key"
340+ elif "ssh_public_key" in context:
341+ ssh_key_var = "ssh_public_key"
342+
343+ if ssh_key_var:
344+ lines = context.get(ssh_key_var).splitlines()
345+ results['metadata']['public-keys'] = [l for l in lines
346+ if len(l) and not l.startswith("#")]
347+
348+ # custom hostname -- try hostname or leave cloud-init
349+ # itself create hostname from IP address later
350+ for k in ('hostname', 'public_ip', 'ip_public', 'eth0_ip'):
351+ if k in context:
352+ results['metadata']['local-hostname'] = context[k]
353+ break
354+
355+ # raw user data
356+ if "user_data" in context:
357+ results['userdata'] = context["user_data"]
358+ elif "userdata" in context:
359+ results['userdata'] = context["userdata"]
360+
361+ # generate static /etc/network/interfaces
362+ # only if there are any required context variables
363+ # http://opennebula.org/documentation:rel3.8:cong#network_configuration
364+ for k in context.keys():
365+ if re.match(r'^eth\d+_ip$', k):
366+ (out, _) = util.subp(['/sbin/ip', 'link'])
367+ net = OpenNebulaNetwork(out, context)
368+ results['network-interfaces'] = net.gen_conf()
369+ break
370+
371+ return results
372+
373+
374+# Used to match classes to dependencies
375+datasources = [
376+ (DataSourceOpenNebula, (sources.DEP_FILESYSTEM, )),
377+ (DataSourceOpenNebulaNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
378+]
379+
380+
381+# Return a list of data sources that match this set of dependencies
382+def get_datasource_list(depends):
383+ return sources.list_from_depends(depends, datasources)
384
385=== modified file 'cloudinit/sources/__init__.py'
386--- cloudinit/sources/__init__.py 2013-07-23 17:10:33 +0000
387+++ cloudinit/sources/__init__.py 2013-09-05 16:03:43 +0000
388@@ -144,7 +144,7 @@
389 return "iid-datasource"
390 return str(self.metadata['instance-id'])
391
392- def get_hostname(self, fqdn=False):
393+ def get_hostname(self, fqdn=False, resolve_ip=False):
394 defdomain = "localdomain"
395 defhost = "localhost"
396 domain = defdomain
397@@ -168,7 +168,14 @@
398 # make up a hostname (LP: #475354) in format ip-xx.xx.xx.xx
399 lhost = self.metadata['local-hostname']
400 if util.is_ipv4(lhost):
401- toks = ["ip-%s" % lhost.replace(".", "-")]
402+ toks = []
403+ if resolve_ip:
404+ toks = util.gethostbyaddr(lhost)
405+
406+ if toks:
407+ toks = toks.split('.')
408+ else:
409+ toks = ["ip-%s" % lhost.replace(".", "-")]
410 else:
411 toks = lhost.split(".")
412
413
414=== modified file 'cloudinit/util.py'
415--- cloudinit/util.py 2013-07-30 18:28:09 +0000
416+++ cloudinit/util.py 2013-09-05 16:03:43 +0000
417@@ -955,6 +955,13 @@
418 return hostname
419
420
421+def gethostbyaddr(ip):
422+ try:
423+ return socket.gethostbyaddr(ip)[0]
424+ except socket.herror:
425+ return None
426+
427+
428 def is_resolvable_url(url):
429 """determine if this url is resolvable (existing or ip)."""
430 return (is_resolvable(urlparse.urlparse(url).hostname))
431
432=== modified file 'doc/rtd/topics/datasources.rst'
433--- doc/rtd/topics/datasources.rst 2013-02-06 07:58:49 +0000
434+++ doc/rtd/topics/datasources.rst 2013-09-05 16:03:43 +0000
435@@ -141,6 +141,12 @@
436 .. include:: ../../sources/configdrive/README.rst
437
438 ---------------------------
439+OpenNebula
440+---------------------------
441+
442+.. include:: ../../sources/opennebula/README.rst
443+
444+---------------------------
445 Alt cloud
446 ---------------------------
447
448
449=== added directory 'doc/sources/opennebula'
450=== added file 'doc/sources/opennebula/README.rst'
451--- doc/sources/opennebula/README.rst 1970-01-01 00:00:00 +0000
452+++ doc/sources/opennebula/README.rst 2013-09-05 16:03:43 +0000
453@@ -0,0 +1,132 @@
454+The `OpenNebula`_ (ON) datasource supports the contextualization disk.
455+
456+ See `contextualization overview`_, `contextualizing VMs`_ and
457+ `network configuration`_ in the public documentation for
458+ more information.
459+
460+OpenNebula's virtual machines are contextualized (parametrized) by
461+CD-ROM image, which contains a shell script *context.sh* with
462+custom variables defined on virtual machine start. There are no
463+fixed contextualization variables, but the datasource accepts
464+many used and recommended across the documentation.
465+
466+Datasource configuration
467+~~~~~~~~~~~~~~~~~~~~~~~~~
468+
469+Datasource accepts following configuration options.
470+
471+::
472+
473+ dsmode:
474+ values: local, net, disabled
475+ default: net
476+
477+Tells if this datasource will be processed in 'local' (pre-networking) or
478+'net' (post-networking) stage or even completely 'disabled'.
479+
480+Contextualization disk
481+~~~~~~~~~~~~~~~~~~~~~~
482+
483+The following criteria are required:
484+
485+1. Must be formatted with `iso9660`_ fs. or have fs. label of **CDROM**
486+2. Must contain file *context.sh* with contextualization variables.
487+ File is generated by OpenNebula, it has a KEY="VALUE" format and
488+ can be easily read (via *source*) by shell
489+
490+Contextualization variables
491+~~~~~~~~~~~~~~~~~~~~~~~~~~~
492+
493+There are no fixed contextualization variables in OpenNebula, no standard.
494+Following variables were found on various places and revisions of
495+the OpenNebula documentation. Where multiple similar variables are
496+specified, only first found is taken.
497+
498+::
499+
500+ DSMODE
501+
502+Datasource mode configuration override. Values: local, net, disabled.
503+
504+::
505+
506+ DNS
507+ ETH<x>_IP
508+ ETH<x>_NETWORK
509+ ETH<x>_MASK
510+ ETH<x>_GATEWAY
511+ ETH<x>_DOMAIN
512+ ETH<x>_DNS
513+
514+Static `network configuration`_.
515+
516+::
517+
518+ HOSTNAME
519+
520+Instance hostname.
521+
522+::
523+
524+ PUBLIC_IP
525+ IP_PUBLIC
526+ ETH0_IP
527+
528+If no hostname has been specified, cloud-init will try to create hostname
529+from instance's IP address in 'local' dsmode. In 'net' dsmode, cloud-init
530+tries to resolve one of its IP addresses to get hostname.
531+
532+::
533+
534+ SSH_KEY
535+ SSH_PUBLIC_KEY
536+
537+One or multiple SSH keys (separated by newlines) can be specified.
538+
539+::
540+
541+ USER_DATA
542+ USERDATA
543+
544+cloud-init user data.
545+
546+Example configuration
547+~~~~~~~~~~~~~~~~~~~~~
548+
549+This example cloud-init configuration (*cloud.cfg*) enables
550+OpenNebula datasource only in 'net' mode.
551+
552+::
553+
554+ disable_ec2_metadata: True
555+ datasource_list: ['OpenNebula']
556+ datasource:
557+ OpenNebula:
558+ dsmode: net
559+
560+Example VM's context section
561+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
562+
563+::
564+
565+ CONTEXT=[
566+ PUBLIC_IP="$NIC[IP]",
567+ SSH_KEY="$USER[SSH_KEY]
568+ $USER[SSH_KEY1]
569+ $USER[SSH_KEY2] ",
570+ USER_DATA="#cloud-config
571+ # see https://help.ubuntu.com/community/CloudInit
572+
573+ packages: []
574+
575+ mounts:
576+ - [vdc,none,swap,sw,0,0]
577+ runcmd:
578+ - echo 'Instance has been configured by cloud-init.' | wall
579+ " ]
580+
581+.. _OpenNebula: http://opennebula.org/
582+.. _contextualization overview: http://opennebula.org/documentation:documentation:context_overview
583+.. _contextualizing VMs: http://opennebula.org/documentation:documentation:cong
584+.. _network configuration: http://opennebula.org/documentation:documentation:cong#network_configuration
585+.. _iso9660: https://en.wikipedia.org/wiki/ISO_9660
586
587=== added file 'tests/unittests/test_datasource/test_opennebula.py'
588--- tests/unittests/test_datasource/test_opennebula.py 1970-01-01 00:00:00 +0000
589+++ tests/unittests/test_datasource/test_opennebula.py 2013-09-05 16:03:43 +0000
590@@ -0,0 +1,169 @@
591+from cloudinit.sources import DataSourceOpenNebula as ds
592+from cloudinit import util
593+from mocker import MockerTestCase
594+from tests.unittests.helpers import populate_dir
595+
596+import os
597+
598+TEST_VARS = {
599+ 'var1': 'single',
600+ 'var2': 'double word',
601+ 'var3': 'multi\nline\n',
602+ 'var4': "'single'",
603+ 'var5': "'double word'",
604+ 'var6': "'multi\nline\n'",
605+ 'var7': 'single\\t',
606+ 'var8': 'double\\tword',
607+ 'var9': 'multi\\t\nline\n',
608+ 'var10': '\\', # expect \
609+ 'var11': '\'', # expect '
610+ 'var12': '$', # expect $
611+}
612+
613+USER_DATA = '#cloud-config\napt_upgrade: true'
614+SSH_KEY = 'ssh-rsa AAAAB3NzaC1....sIkJhq8wdX+4I3A4cYbYP ubuntu@server-460-%i'
615+HOSTNAME = 'foo.example.com'
616+PUBLIC_IP = '10.0.0.3'
617+
618+CMD_IP_OUT = '''\
619+1: lo: <LOOPBACK,UP,LOWER_UP> mtu 16436 qdisc noqueue state UNKNOWN
620+ link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
621+2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 1000
622+ link/ether 02:00:0a:12:01:01 brd ff:ff:ff:ff:ff:ff
623+'''
624+
625+
626+class TestOpenNebulaDataSource(MockerTestCase):
627+
628+ def setUp(self):
629+ super(TestOpenNebulaDataSource, self).setUp()
630+ self.tmp = self.makeDir()
631+
632+ def test_seed_dir_non_contextdisk(self):
633+ my_d = os.path.join(self.tmp, 'non-contextdisk')
634+ self.assertRaises(ds.NonContextDiskDir, ds.read_context_disk_dir, my_d)
635+
636+ def test_seed_dir_bad_context(self):
637+ my_d = os.path.join(self.tmp, 'bad-context')
638+ os.mkdir(my_d)
639+ open(os.path.join(my_d, "context.sh"), "w").close()
640+ self.assertRaises(ds.NonContextDiskDir, ds.read_context_disk_dir, my_d)
641+
642+ def test_context_parser(self):
643+ my_d = os.path.join(self.tmp, 'context-parser')
644+ populate_context_dir(my_d, TEST_VARS)
645+ results = ds.read_context_disk_dir(my_d)
646+
647+ self.assertTrue('metadata' in results)
648+ self.assertEqual(TEST_VARS, results['metadata'])
649+
650+ def test_ssh_key(self):
651+ public_keys = ['first key', 'second key']
652+ for c in range(4):
653+ for k in ('SSH_KEY', 'SSH_PUBLIC_KEY'):
654+ my_d = os.path.join(self.tmp, "%s-%i" % (k, c))
655+ populate_context_dir(my_d, {k: '\n'.join(public_keys)})
656+ results = ds.read_context_disk_dir(my_d)
657+
658+ self.assertTrue('metadata' in results)
659+ self.assertTrue('public-keys' in results['metadata'])
660+ self.assertEqual(public_keys,
661+ results['metadata']['public-keys'])
662+
663+ public_keys.append(SSH_KEY % (c + 1,))
664+
665+ def test_user_data(self):
666+ for k in ('USER_DATA', 'USERDATA'):
667+ my_d = os.path.join(self.tmp, k)
668+ populate_context_dir(my_d, {k: USER_DATA})
669+ results = ds.read_context_disk_dir(my_d)
670+
671+ self.assertTrue('userdata' in results)
672+ self.assertEqual(USER_DATA, results['userdata'])
673+
674+ def test_hostname(self):
675+ for k in ('HOSTNAME', 'PUBLIC_IP', 'IP_PUBLIC', 'ETH0_IP'):
676+ my_d = os.path.join(self.tmp, k)
677+ populate_context_dir(my_d, {k: PUBLIC_IP})
678+ results = ds.read_context_disk_dir(my_d)
679+
680+ self.assertTrue('metadata' in results)
681+ self.assertTrue('local-hostname' in results['metadata'])
682+ self.assertEqual(PUBLIC_IP, results['metadata']['local-hostname'])
683+
684+ def test_find_candidates(self):
685+ def my_devs_with(criteria):
686+ return {
687+ "LABEL=CONTEXT": ["/dev/sdb"],
688+ "LABEL=CDROM": ["/dev/sr0"],
689+ "TYPE=iso9660": ["/dev/vdb"],
690+ }.get(criteria, [])
691+
692+ try:
693+ orig_find_devs_with = util.find_devs_with
694+ util.find_devs_with = my_devs_with
695+ self.assertEqual(["/dev/sdb", "/dev/sr0", "/dev/vdb"],
696+ ds.find_candidate_devs())
697+ finally:
698+ util.find_devs_with = orig_find_devs_with
699+
700+
701+class TestOpenNebulaNetwork(MockerTestCase):
702+
703+ def setUp(self):
704+ super(TestOpenNebulaNetwork, self).setUp()
705+
706+ def test_lo(self):
707+ net = ds.OpenNebulaNetwork('', {})
708+ self.assertEqual(net.gen_conf(), u'''\
709+auto lo
710+iface lo inet loopback
711+''')
712+
713+ def test_eth0(self):
714+ net = ds.OpenNebulaNetwork(CMD_IP_OUT, {})
715+ self.assertEqual(net.gen_conf(), u'''\
716+auto lo
717+iface lo inet loopback
718+
719+auto eth0
720+iface eth0 inet static
721+ address 10.18.1.1
722+ network 10.18.1.0
723+ netmask 255.255.255.0
724+''')
725+
726+ def test_eth0_override(self):
727+ context = {
728+ 'dns': '1.2.3.8',
729+ 'eth0_ip': '1.2.3.4',
730+ 'eth0_network': '1.2.3.0',
731+ 'eth0_mask': '255.255.0.0',
732+ 'eth0_gateway': '1.2.3.5',
733+ 'eth0_domain': 'example.com',
734+ 'eth0_dns': '1.2.3.6 1.2.3.7'
735+ }
736+
737+ net = ds.OpenNebulaNetwork(CMD_IP_OUT, context)
738+ self.assertEqual(net.gen_conf(), u'''\
739+auto lo
740+iface lo inet loopback
741+
742+auto eth0
743+iface eth0 inet static
744+ address 1.2.3.4
745+ network 1.2.3.0
746+ netmask 255.255.0.0
747+ gateway 1.2.3.5
748+ dns-search example.com
749+ dns-nameservers 1.2.3.8 1.2.3.6 1.2.3.7
750+''')
751+
752+
753+def populate_context_dir(path, variables):
754+ data = "# Context variables generated by OpenNebula\n"
755+ for (k, v) in variables.iteritems():
756+ data += ("%s='%s'\n" % (k.upper(), v.replace(r"'", r"'\''")))
757+ populate_dir(path, {'context.sh': data})
758+
759+# vi: ts=4 expandtab