Merge lp:~smoser/cloud-init/trunk.net1-cmdline-ip into lp:~smoser/cloud-init/trunk.net1

Proposed by Scott Moser on 2016-03-23
Status: Merged
Merged at revision: 1212
Proposed branch: lp:~smoser/cloud-init/trunk.net1-cmdline-ip
Merge into: lp:~smoser/cloud-init/trunk.net1
Diff against target: 385 lines (+298/-13)
2 files modified
cloudinit/net/__init__.py (+171/-13)
tests/unittests/test_net.py (+127/-0)
To merge this branch: bzr merge lp:~smoser/cloud-init/trunk.net1-cmdline-ip
Reviewer Review Type Date Requested Status
Scott Moser Pending
Review via email: mp+289968@code.launchpad.net

Commit Message

support reading network config from kernel command line

This adds support for suppling network configuration on the
kernel command line in 2 ways:
 a.) kernel command line includes 'network-config=<base64>'
     value of that parameter is base64 encoded json (or yaml)
     it is taken as network config yaml.
     In order to save space on kernel command line, it can be
     base64 encoded gzipped json also.

 b.) ip= paired with files authored by klibc's ipconfig tool
     When network devices are brought up in the initramfs, klibc's
     ipconfig tool writes files are named /run/net-<DEVNAME>.conf.
     The best documentation available on that tool is
     /usr/share/doc/libklibc/README.ipconfig.gz.

Also changes util.get_cmdline() to return the command line of
pid 1 if it is in a container.

To post a comment you must log in.
1207. By Scott Moser on 2016-03-24

add suport for base64 encoded gzipped text on command line

add tests to show this functional.

1208. By Scott Moser on 2016-03-24

improve comment

Ryan Harper (raharper) wrote :

Looks good. COmments below, needs commit description which captures what we're adding here.

Scott Moser (smoser) wrote :

will address comments and psuh

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'cloudinit/net/__init__.py'
2--- cloudinit/net/__init__.py 2016-03-22 09:40:05 +0000
3+++ cloudinit/net/__init__.py 2016-03-24 15:51:15 +0000
4@@ -16,10 +16,14 @@
5 # You should have received a copy of the GNU Affero General Public License
6 # along with Curtin. If not, see <http://www.gnu.org/licenses/>.
7
8+import base64
9 import errno
10 import glob
11+import gzip
12+import io
13 import os
14 import re
15+import shlex
16
17 from cloudinit import log as logging
18 from cloudinit import util
19@@ -283,6 +287,105 @@
20 return ns
21
22
23+def _load_shell_content(content, add_empty=False, empty_val=None):
24+ """Given shell like syntax (key=value\nkey2=value2\n) in content
25+ return the data in dictionary form. If 'add_empty' is True
26+ then add entries in to the returned dictionary for 'VAR='
27+ variables. Set their value to empty_val."""
28+ data = {}
29+ for line in shlex.split(content):
30+ key, value = line.split("=", 1)
31+ if not value:
32+ value = empty_val
33+ if add_empty or value:
34+ data[key] = value
35+
36+ return data
37+
38+
39+def _klibc_to_config_entry(content, mac_addrs=None):
40+ if mac_addrs is None:
41+ mac_addrs = {}
42+
43+ data = _load_shell_content(content)
44+ try:
45+ name = data['DEVICE']
46+ except KeyError:
47+ raise ValueError("no 'DEVICE' entry in data")
48+
49+ # ipconfig on precise does not write PROTO
50+ proto = data.get('PROTO')
51+ if not proto:
52+ if data.get('filename'):
53+ proto = 'dhcp'
54+ else:
55+ proto = 'static'
56+
57+ if proto not in ('static', 'dhcp'):
58+ raise ValueError("Unexpected value for PROTO: %s" % proto)
59+
60+ iface = {
61+ 'type': 'physical',
62+ 'name': name,
63+ 'subnets': [],
64+ }
65+
66+ if name in mac_addrs:
67+ iface['mac_address'] = mac_addrs[name]
68+
69+ # originally believed there might be IPV6* values
70+ for v, pre in (('ipv4', 'IPV4'),):
71+ # if no IPV4ADDR or IPV6ADDR, then go on.
72+ if pre + "ADDR" not in data:
73+ continue
74+ subnet = {'type': proto}
75+
76+ # these fields go right on the subnet
77+ for key in ('NETMASK', 'BROADCAST', 'GATEWAY'):
78+ if pre + key in data:
79+ subnet[key.lower()] = data[pre + key]
80+
81+ dns = []
82+ # handle IPV4DNS0 or IPV6DNS0
83+ for nskey in ('DNS0', 'DNS1'):
84+ ns = data.get(pre + nskey)
85+ # verify it has something other than 0.0.0.0 (or ipv6)
86+ if ns and len(ns.strip(":.0")):
87+ dns.append(data[pre + nskey])
88+ if dns:
89+ subnet['dns_nameservers'] = dns
90+ # add search to both ipv4 and ipv6, as it has no namespace
91+ search = data.get('DOMAINSEARCH')
92+ if search:
93+ if ',' in search:
94+ subnet['dns_search'] = search.split(",")
95+ else:
96+ subnet['dns_search'] = search.split()
97+
98+ iface['subnets'].append(subnet)
99+
100+ return name, iface
101+
102+
103+def config_from_klibc_net_cfg(files=None, mac_addrs=None):
104+ if files is None:
105+ files = glob.glob('/run/net*.conf')
106+
107+ entries = []
108+ names = {}
109+ for cfg_file in files:
110+ name, entry = _klibc_to_config_entry(util.load_file(cfg_file),
111+ mac_addrs=mac_addrs)
112+ if name in names:
113+ raise ValueError(
114+ "device '%s' defined multiple times: %s and %s" % (
115+ name, names[name], cfg_file))
116+
117+ names[name] = cfg_file
118+ entries.append(entry)
119+ return {'config': entries, 'version': 1}
120+
121+
122 def render_persistent_net(network_state):
123 ''' Given state, emit udev rules to map
124 mac to ifname
125@@ -470,6 +573,19 @@
126 return cfg.get('config') == "disabled"
127
128
129+def sys_netdev_info(name, field):
130+ if not os.path.exists(os.path.join(SYS_CLASS_NET, name)):
131+ raise OSError("%s: interface does not exist in /sys" % name)
132+
133+ fname = os.path.join(SYS_CLASS_NET, name, field)
134+ if not os.path.exists(fname):
135+ raise OSError("%s: %s does not exist in /sys" % (name, fname))
136+ data = util.load_file(fname)
137+ if data[-1] == '\n':
138+ data = data[:-1]
139+ return data
140+
141+
142 def generate_fallback_config():
143 """Determine which attached net dev is most likely to have a connection and
144 generate network state to run dhcp on that interface"""
145@@ -478,7 +594,7 @@
146
147 # get list of interfaces that could have connections
148 invalid_interfaces = set(['lo'])
149- potential_interfaces = set(os.listdir(SYS_CLASS_NET))
150+ potential_interfaces = set(get_devicelist())
151 potential_interfaces = potential_interfaces.difference(invalid_interfaces)
152 # sort into interfaces with carrier, interfaces which could have carrier,
153 # and ignore interfaces that are definitely disconnected
154@@ -486,8 +602,7 @@
155 possibly_connected = []
156 for interface in potential_interfaces:
157 try:
158- sysfs_carrier = os.path.join(SYS_CLASS_NET, interface, 'carrier')
159- carrier = int(util.load_file(sysfs_carrier).strip())
160+ carrier = int(sys_netdev_info(interface, 'carrier'))
161 if carrier:
162 connected.append(interface)
163 continue
164@@ -497,17 +612,14 @@
165 # not have a carrier even though it could acquire one when brought
166 # online by dhclient
167 try:
168- sysfs_dormant = os.path.join(SYS_CLASS_NET, interface, 'dormant')
169- dormant = int(util.load_file(sysfs_dormant).strip())
170+ dormant = int(sys_netdev_info(interface, 'dormant'))
171 if dormant:
172 possibly_connected.append(interface)
173 continue
174 except OSError:
175 pass
176 try:
177- sysfs_operstate = os.path.join(SYS_CLASS_NET, interface,
178- 'operstate')
179- operstate = util.load_file(sysfs_operstate).strip()
180+ operstate = sys_netdev_info(interface, 'operstate')
181 if operstate in ['dormant', 'down', 'lowerlayerdown', 'unknown']:
182 possibly_connected.append(interface)
183 continue
184@@ -530,8 +642,7 @@
185 else:
186 name = sorted(potential_interfaces)[0]
187
188- sysfs_mac = os.path.join(SYS_CLASS_NET, name, 'address')
189- mac = util.load_file(sysfs_mac).strip()
190+ mac = sys_netdev_info(name, 'address')
191 target_name = name
192
193 nconf['config'].append(
194@@ -540,9 +651,56 @@
195 return nconf
196
197
198-def read_kernel_cmdline_config():
199- # FIXME: add implementation here
200- return None
201+def _decomp_gzip(blob, strict=True):
202+ # decompress blob. raise exception if not compressed unless strict=False.
203+ with io.BytesIO(blob) as iobuf:
204+ gzfp = None
205+ try:
206+ gzfp = gzip.GzipFile(mode="rb", fileobj=iobuf)
207+ return gzfp.read()
208+ except IOError:
209+ if strict:
210+ raise
211+ return blob
212+ finally:
213+ if gzfp:
214+ gzfp.close()
215+
216+
217+def _b64dgz(b64str, gzipped="try"):
218+ # decode a base64 string. If gzipped is true, transparently uncompresss
219+ # if gzipped is 'try', then try gunzip, returning the original on fail.
220+ try:
221+ blob = base64.b64decode(b64str)
222+ except TypeError:
223+ raise ValueError("Invalid base64 text: %s" % b64str)
224+
225+ if not gzipped:
226+ return blob
227+
228+ return _decomp_gzip(blob, strict=gzipped != "try")
229+
230+
231+def read_kernel_cmdline_config(files=None, mac_addrs=None, cmdline=None):
232+ if cmdline is None:
233+ cmdline = util.get_cmdline()
234+
235+ if 'network-config=' in cmdline:
236+ data64 = None
237+ for tok in cmdline.split():
238+ if tok.startswith("network-config="):
239+ data64 = tok.split("=", 1)[1]
240+ if data64:
241+ return util.load_yaml(_b64dgz(data64))
242+
243+ if 'ip=' not in cmdline:
244+ return None
245+
246+ if mac_addrs is None:
247+ mac_addrs = {k: sys_netdev_info(k, 'address')
248+ for k in get_devicelist()}
249+
250+ return config_from_klibc_net_cfg(files=files, mac_addrs=mac_addrs)
251
252
253 # vi: ts=4 expandtab syntax=python
254
255=== added file 'tests/unittests/test_net.py'
256--- tests/unittests/test_net.py 1970-01-01 00:00:00 +0000
257+++ tests/unittests/test_net.py 2016-03-24 15:51:15 +0000
258@@ -0,0 +1,127 @@
259+from cloudinit import util
260+from cloudinit import net
261+from .helpers import TestCase
262+
263+import base64
264+import copy
265+import io
266+import gzip
267+import json
268+import os
269+
270+DHCP_CONTENT_1 = """
271+DEVICE='eth0'
272+PROTO='dhcp'
273+IPV4ADDR='192.168.122.89'
274+IPV4BROADCAST='192.168.122.255'
275+IPV4NETMASK='255.255.255.0'
276+IPV4GATEWAY='192.168.122.1'
277+IPV4DNS0='192.168.122.1'
278+IPV4DNS1='0.0.0.0'
279+HOSTNAME='foohost'
280+DNSDOMAIN=''
281+NISDOMAIN=''
282+ROOTSERVER='192.168.122.1'
283+ROOTPATH=''
284+filename=''
285+UPTIME='21'
286+DHCPLEASETIME='3600'
287+DOMAINSEARCH='foo.com'
288+"""
289+
290+DHCP_EXPECTED_1 = {
291+ 'name': 'eth0',
292+ 'type': 'physical',
293+ 'subnets': [{'broadcast': '192.168.122.255',
294+ 'gateway': '192.168.122.1',
295+ 'dns_search': ['foo.com'],
296+ 'type': 'dhcp',
297+ 'netmask': '255.255.255.0',
298+ 'dns_nameservers': ['192.168.122.1']}],
299+}
300+
301+
302+STATIC_CONTENT_1 = """
303+DEVICE='eth1'
304+PROTO='static'
305+IPV4ADDR='10.0.0.2'
306+IPV4BROADCAST='10.0.0.255'
307+IPV4NETMASK='255.255.255.0'
308+IPV4GATEWAY='10.0.0.1'
309+IPV4DNS0='10.0.1.1'
310+IPV4DNS1='0.0.0.0'
311+HOSTNAME='foohost'
312+UPTIME='21'
313+DHCPLEASETIME='3600'
314+DOMAINSEARCH='foo.com'
315+"""
316+
317+STATIC_EXPECTED_1 = {
318+ 'name': 'eth1',
319+ 'type': 'physical',
320+ 'subnets': [{'broadcast': '10.0.0.255', 'gateway': '10.0.0.1',
321+ 'dns_search': ['foo.com'], 'type': 'static',
322+ 'netmask': '255.255.255.0',
323+ 'dns_nameservers': ['10.0.1.1']}],
324+}
325+
326+
327+class TestNetConfigParsing(TestCase):
328+ simple_cfg = {
329+ 'config': [{"type": "physical", "name": "eth0",
330+ "mac_address": "c0:d6:9f:2c:e8:80",
331+ "subnets": [{"type": "dhcp4"}]}]}
332+
333+ def test_klibc_convert_dhcp(self):
334+ found = net._klibc_to_config_entry(DHCP_CONTENT_1)
335+ self.assertEqual(found, ('eth0', DHCP_EXPECTED_1))
336+
337+ def test_klibc_convert_static(self):
338+ found = net._klibc_to_config_entry(STATIC_CONTENT_1)
339+ self.assertEqual(found, ('eth1', STATIC_EXPECTED_1))
340+
341+ def test_config_from_klibc_net_cfg(self):
342+ files = []
343+ pairs = (('net-eth0.cfg', DHCP_CONTENT_1),
344+ ('net-eth1.cfg', STATIC_CONTENT_1))
345+
346+ macs = {'eth1': 'b8:ae:ed:75:ff:2b',
347+ 'eth0': 'b8:ae:ed:75:ff:2a'}
348+
349+ dhcp = copy.deepcopy(DHCP_EXPECTED_1)
350+ dhcp['mac_address'] = macs['eth0']
351+
352+ static = copy.deepcopy(STATIC_EXPECTED_1)
353+ static['mac_address'] = macs['eth1']
354+
355+ expected = {'version': 1, 'config': [dhcp, static]}
356+ with util.tempdir() as tmpd:
357+ for fname, content in pairs:
358+ fp = os.path.join(tmpd, fname)
359+ files.append(fp)
360+ util.write_file(fp, content)
361+
362+ found = net.config_from_klibc_net_cfg(files=files, mac_addrs=macs)
363+ self.assertEqual(found, expected)
364+
365+ def test_cmdline_with_b64(self):
366+ data = base64.b64encode(json.dumps(self.simple_cfg).encode())
367+ encoded_text = data.decode()
368+ cmdline = 'ro network-config=' + encoded_text + ' root=foo'
369+ found = net.read_kernel_cmdline_config(cmdline=cmdline)
370+ self.assertEqual(found, self.simple_cfg)
371+
372+ def test_cmdline_with_b64_gz(self):
373+ data = _gzip_data(json.dumps(self.simple_cfg).encode())
374+ encoded_text = base64.b64encode(data).decode()
375+ cmdline = 'ro network-config=' + encoded_text + ' root=foo'
376+ found = net.read_kernel_cmdline_config(cmdline=cmdline)
377+ self.assertEqual(found, self.simple_cfg)
378+
379+
380+def _gzip_data(data):
381+ with io.BytesIO() as iobuf:
382+ gzfp = gzip.GzipFile(mode="wb", fileobj=iobuf)
383+ gzfp.write(data)
384+ gzfp.close()
385+ return iobuf.getvalue()

Subscribers

People subscribed via source and target branches

to all changes: