Merge lp:~raharper/curtin/iodo_network_rebase_v2 into lp:~curtin-dev/curtin/trunk

Proposed by Ryan Harper
Status: Merged
Merged at revision: 257
Proposed branch: lp:~raharper/curtin/iodo_network_rebase_v2
Merge into: lp:~curtin-dev/curtin/trunk
Diff against target: 2046 lines (+1307/-455)
20 files modified
curtin/commands/apply_net.py (+96/-0)
curtin/commands/block_meta.py (+1/-1)
curtin/commands/curthooks.py (+29/-1)
curtin/commands/install.py (+8/-1)
curtin/commands/main.py (+3/-3)
curtin/commands/net_meta.py (+7/-256)
curtin/config.py (+4/-0)
curtin/net/__init__.py (+166/-0)
curtin/net/network_state.py (+360/-0)
curtin/udev.py (+54/-0)
curtin/util.py (+3/-1)
examples/network-all.yaml (+85/-83)
examples/network-bond.yaml (+42/-40)
examples/network-bridge.yaml (+24/-22)
examples/network-simple.yaml (+25/-20)
examples/network-vlan.yaml (+18/-16)
examples/tests/basic_network.yaml (+22/-0)
tests/unittests/test_net.py (+74/-0)
tests/vmtests/__init__.py (+62/-11)
tests/vmtests/test_network.py (+224/-0)
To merge this branch: bzr merge lp:~raharper/curtin/iodo_network_rebase_v2
Reviewer Review Type Date Requested Status
Scott Moser Pending
Review via email: mp+269506@code.launchpad.net

Description of the change

vmtest: Add network testing

Update the base vmtests class to handle network tests. If the
class configration includes a network section, parse and use it
to extend the VM launch and run commands.

To post a comment you must log in.
256. By Ryan Harper

Handle when we don't have network config. Fix bug in expected_interfaces method.

257. By Ryan Harper

from trunk

258. By Ryan Harper

Inject versioned network config

Use the same dictionary layout as storage, including a version field and then
a config dict within the outer Network config. Fix example files. Adjust
loading of data and fixup unittests.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'curtin/commands/apply_net.py'
2--- curtin/commands/apply_net.py 1970-01-01 00:00:00 +0000
3+++ curtin/commands/apply_net.py 2015-09-02 14:28:32 +0000
4@@ -0,0 +1,96 @@
5+# Copyright (C) 2015 Canonical Ltd.
6+#
7+# Author: Ryan Harper <ryan.harper@canonical.com>
8+#
9+# Curtin is free software: you can redistribute it and/or modify it under
10+# the terms of the GNU Affero General Public License as published by the
11+# Free Software Foundation, either version 3 of the License, or (at your
12+# option) any later version.
13+#
14+# Curtin is distributed in the hope that it will be useful, but WITHOUT ANY
15+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
17+# more details.
18+#
19+# You should have received a copy of the GNU Affero General Public License
20+# along with Curtin. If not, see <http://www.gnu.org/licenses/>.
21+
22+import os
23+import sys
24+
25+import curtin.net as net
26+import curtin.util as util
27+from . import populate_one_subcmd
28+
29+
30+def apply_net(target, network_state=None, network_config=None):
31+ if network_state is None and network_config is None:
32+ msg = "Must provide at least config or state"
33+ sys.stderr.write(msg + "\n")
34+ raise Exception(msg)
35+
36+ if target is None:
37+ msg = "Must provide target"
38+ sys.stderr.write(msg + "\n")
39+ raise Exception(msg)
40+
41+ if network_state:
42+ ns = net.network_state.from_state_file(network_state)
43+ elif network_config:
44+ ns = net.parse_net_config(network_config)
45+
46+ net.render_network_state(target=target, network_state=ns)
47+
48+
49+def apply_net_main(args):
50+ # curtin apply_net [--net-state=/config/netstate.yml] [--target=/]
51+ # [--net-config=/config/maas_net.yml]
52+ state = util.load_command_environment()
53+
54+ if args.target is not None:
55+ state['target'] = args.target
56+
57+ if args.net_state is not None:
58+ state['network_state'] = args.net_state
59+
60+ if args.net_config is not None:
61+ state['network_config'] = args.net_config
62+
63+ if state['target'] is None:
64+ sys.stderr.write("Unable to find target. "
65+ "Use --target or set TARGET_MOUNT_POINT\n")
66+ sys.exit(2)
67+
68+ if not state['network_config'] and not state['network_state']:
69+ sys.stderr.write("Must provide at least config or state\n")
70+ sys.exit(2)
71+
72+ apply_net(target=state['target'],
73+ network_state=state['network_state'],
74+ network_config=state['network_config'])
75+
76+ sys.exit(0)
77+
78+
79+CMD_ARGUMENTS = (
80+ ((('-s', '--net-state'),
81+ {'help': ('file to read containing network state. '
82+ 'defaults to env["OUTPUT_NETWORK_STATE"]'),
83+ 'metavar': 'NETSTATE', 'action': 'store',
84+ 'default': os.environ.get('OUTPUT_NETWORK_STATE')}),
85+ (('-t', '--target'),
86+ {'help': ('target filesystem root to add swap file to. '
87+ 'default is env["TARGET_MOUNT_POINT"]'),
88+ 'metavar': 'TARGET', 'action': 'store',
89+ 'default': os.environ.get('TARGET_MOUNT_POINT')}),
90+ (('-c', '--net-config'),
91+ {'help': ('file to read containing curtin network config.'
92+ 'defaults to env["OUTPUT_NETWORK_CONFIG"]'),
93+ 'metavar': 'NETCONFIG', 'action': 'store',
94+ 'default': os.environ.get('OUTPUT_NETWORK_CONFIG')})))
95+
96+
97+def POPULATE_SUBCMD(parser):
98+ populate_one_subcmd(parser, CMD_ARGUMENTS, apply_net_main)
99+
100+# vi: ts=4 expandtab syntax=python
101
102=== modified file 'curtin/commands/block_meta.py'
103--- curtin/commands/block_meta.py 2015-08-27 20:27:04 +0000
104+++ curtin/commands/block_meta.py 2015-09-02 14:28:32 +0000
105@@ -21,7 +21,7 @@
106 from curtin.log import LOG
107
108 from . import populate_one_subcmd
109-from curtin.commands.net_meta import compose_udev_equality
110+from curtin.udev import compose_udev_equality
111
112 import glob
113 import os
114
115=== modified file 'curtin/commands/curthooks.py'
116--- curtin/commands/curthooks.py 2015-08-11 16:56:18 +0000
117+++ curtin/commands/curthooks.py 2015-09-02 14:28:32 +0000
118@@ -30,6 +30,7 @@
119 from curtin.log import LOG
120 from curtin import swap
121 from curtin import util
122+from curtin import net
123
124 from . import populate_one_subcmd
125
126@@ -407,6 +408,33 @@
127 'etc/mdadm/mdadm.conf']))
128
129
130+def apply_networking(target, state):
131+ netstate = state.get('network_state')
132+ netconf = state.get('network_config')
133+ interfaces = state.get('interfaces')
134+
135+ def is_valid_src(infile):
136+ with open(infile, 'r') as fp:
137+ content = fp.read()
138+ if len(content.split('\n')) > 1:
139+ return True
140+ return False
141+
142+ ns = None
143+ if is_valid_src(netstate):
144+ LOG.debug("applying network_state")
145+ ns = net.network_state.from_state_file(netstate)
146+ elif is_valid_src(netconf):
147+ LOG.debug("applying network_config")
148+ ns = net.parse_net_config(netconf)
149+
150+ if ns is not None:
151+ net.render_network_state(target=target, network_state=ns)
152+ else:
153+ LOG.debug("copying interfaces")
154+ copy_interfaces(interfaces, target)
155+
156+
157 def copy_interfaces(interfaces, target):
158 if not interfaces:
159 LOG.warn("no interfaces file to copy!")
160@@ -597,7 +625,7 @@
161
162 add_swap(cfg, target, state.get('fstab'))
163
164- copy_interfaces(state.get('interfaces'), target)
165+ apply_networking(target, state)
166 copy_fstab(state.get('fstab'), target)
167
168 detect_and_handle_multipath(cfg, target)
169
170=== modified file 'curtin/commands/install.py'
171--- curtin/commands/install.py 2015-08-27 14:27:20 +0000
172+++ curtin/commands/install.py 2015-09-02 14:28:32 +0000
173@@ -56,6 +56,7 @@
174 'curthooks_commands': {'builtin': ['curtin', 'curthooks']},
175 'late_commands': {'builtin': []},
176 'network_commands': {'builtin': ['curtin', 'net-meta', 'auto']},
177+ 'apply_net_commands': {'builtin': []},
178 'install': {'log_file': INSTALL_LOG},
179 }
180
181@@ -89,6 +90,8 @@
182 for p in (state_d, target_d, scratch_d):
183 os.mkdir(p)
184
185+ netconf_f = os.path.join(state_d, 'network_config')
186+ netstate_f = os.path.join(state_d, 'network_state')
187 interfaces_f = os.path.join(state_d, 'interfaces')
188 config_f = os.path.join(state_d, 'config')
189 fstab_f = os.path.join(state_d, 'fstab')
190@@ -97,7 +100,7 @@
191 json.dump(config, fp)
192
193 # just touch these files to make sure they exist
194- for f in (interfaces_f, config_f, fstab_f):
195+ for f in (interfaces_f, config_f, fstab_f, netconf_f, netstate_f):
196 with open(f, "ab") as fp:
197 pass
198
199@@ -105,6 +108,8 @@
200 self.target = target_d
201 self.top = top_d
202 self.interfaces = interfaces_f
203+ self.netconf = netconf_f
204+ self.netstate = netstate_f
205 self.fstab = fstab_f
206 self.config = config
207 self.config_file = config_f
208@@ -112,6 +117,8 @@
209 def env(self):
210 return ({'WORKING_DIR': self.scratch, 'OUTPUT_FSTAB': self.fstab,
211 'OUTPUT_INTERFACES': self.interfaces,
212+ 'OUTPUT_NETWORK_CONFIG': self.netconf,
213+ 'OUTPUT_NETWORK_STATE': self.netstate,
214 'TARGET_MOUNT_POINT': self.target,
215 'CONFIG': self.config_file})
216
217
218=== modified file 'curtin/commands/main.py'
219--- curtin/commands/main.py 2015-08-11 20:23:35 +0000
220+++ curtin/commands/main.py 2015-09-02 14:28:32 +0000
221@@ -26,9 +26,9 @@
222 from .. import config
223 from ..reporter import (events, update_configuration)
224
225-SUB_COMMAND_MODULES = ['block-meta', 'curthooks', 'extract', 'hook',
226- 'in-target', 'install', 'mkfs', 'net-meta', 'pack',
227- 'swap']
228+SUB_COMMAND_MODULES = ['apply_net', 'block-meta', 'curthooks', 'extract',
229+ 'hook', 'in-target', 'install', 'mkfs', 'net-meta',
230+ 'pack', 'swap']
231
232
233 def add_subcmd(subparser, subcmd):
234
235=== modified file 'curtin/commands/net_meta.py'
236--- curtin/commands/net_meta.py 2015-08-07 15:49:19 +0000
237+++ curtin/commands/net_meta.py 2015-09-02 14:28:32 +0000
238@@ -21,6 +21,7 @@
239
240 from curtin import net
241 import curtin.util as util
242+import curtin.config as config
243
244 from . import populate_one_subcmd
245
246@@ -72,24 +73,6 @@
247
248
249 def interfaces_custom(args):
250- content = '\n'.join(
251- [("# Autogenerated interfaces from net-meta custom mode"),
252- "",
253- "# The loopback network interface",
254- "auto lo",
255- "iface lo inet loopback",
256- "",
257- ])
258-
259- command_handlers = {
260- 'physical': handle_physical,
261- 'vlan': handle_vlan,
262- 'bond': handle_bond,
263- 'bridge': handle_bridge,
264- 'route': handle_route,
265- 'nameserver': handle_nameserver,
266- }
267-
268 state = util.load_command_environment()
269 cfg = util.load_command_config(args, state)
270
271@@ -98,244 +81,7 @@
272 raise Exception("network configuration is required by mode '%s' "
273 "but not provided in the config file" % 'custom')
274
275- for command in network_config:
276- handler = command_handlers.get(command['type'])
277- if not handler:
278- raise ValueError("unknown command type '%s'" % command['type'])
279- content += handler(command, args)
280- content = content.replace('\n\n\n', '\n\n')
281-
282- return content
283-
284-
285-def handle_vlan(command, args):
286- '''
287- auto eth0.222
288- iface eth0.222 inet static
289- address 10.10.10.1
290- netmask 255.255.255.0
291- vlan-raw-device eth0
292- '''
293- content = handle_physical(command, args)[:-1]
294- content += " vlan-raw-device {}".format(command['vlan_link'])
295-
296- return content
297-
298-
299-def handle_bond(command, args):
300- '''
301-#/etc/network/interfaces
302-auto eth0
303-iface eth0 inet manual
304-
305-auto eth1
306-iface eth1 inet manual
307-
308-auto bond0
309-iface bond0 inet static
310- address 192.168.0.10
311- gateway 192.168.0.1
312- netmask 255.255.255.0
313- bond-mode 802.3ad
314- bond-miimon 100
315- bond-downdelay 200
316- bond-updelay 200
317- bond-lacp-rate 4
318- '''
319- # write out bondX iface stanza and options
320- content = handle_physical(command, args)[:-1]
321- params = command.get('params', [])
322- for param, value in params.items():
323- content += " {} {}\n".format(param, value)
324-
325- content += "\n"
326-
327- # now write out slaved iface stanzas
328- for slave in command['bond_interfaces']:
329- content += "auto {}\n".format(slave)
330- content += "iface {} inet manual\n".format(slave)
331- content += " bond-master {}\n\n".format(command['name'])
332-
333- return content
334-
335-
336-def handle_bridge(command, args):
337- '''
338- auto br0
339- iface br0 inet static
340- address 10.10.10.1
341- netmask 255.255.255.0
342- bridge_ports eth0 eth1
343- bridge_stp off
344- bridge_fd 0
345- bridge_maxwait 0
346-
347- '''
348- bridge_params = [
349- "bridge_ports",
350- "bridge_ageing",
351- "bridge_bridgeprio",
352- "bridge_fd",
353- "bridge_gcint",
354- "bridge_hello",
355- "bridge_hw",
356- "bridge_maxage",
357- "bridge_maxwait",
358- "bridge_pathcost",
359- "bridge_portprio",
360- "bridge_stp",
361- "bridge_waitport",
362- ]
363-
364- content = handle_physical(command, args)[:-1]
365- content += " bridge_ports %s\n" % (
366- " ".join(command['bridge_interfaces']))
367- params = command.get('params', [])
368- for param, value in params.items():
369- if param in bridge_params:
370- content += " {} {}\n".format(param, value)
371-
372- return content
373-
374-
375-def cidr2mask(cidr):
376- mask = [0, 0, 0, 0]
377- for i in list(range(0, cidr)):
378- idx = int(i / 8)
379- mask[idx] = mask[idx] + (1 << (7 - i % 8))
380- return ".".join([str(x) for x in mask])
381-
382-
383-def handle_route(command, args):
384- content = "\n"
385- network, cidr = command['destination'].split("/")
386- netmask = cidr2mask(int(cidr))
387- command['network'] = network
388- command['netmask'] = netmask
389- content += "up route add"
390- mapping = {
391- 'network': '-net',
392- 'netmask': 'netmask',
393- 'gateway': 'gw',
394- 'metric': 'metric',
395- }
396- for k in ['network', 'netmask', 'gateway', 'metric']:
397- if k in command:
398- content += " %s %s" % (mapping[k], command[k])
399-
400- content += '\n'
401- return content
402-
403-
404-def handle_nameserver(command, args):
405- content = "\n"
406- if 'address' in command:
407- content += "dns-nameserver {address}\n".format(**command)
408- if 'search' in command:
409- content += "dns-search {search}\n".format(**command)
410-
411- return content
412-
413-
414-def handle_physical(command, args):
415- '''
416- command = {
417- 'type': 'physical',
418- 'mac_address': 'c0:d6:9f:2c:e8:80',
419- 'name': 'eth0',
420- 'subnets': [
421- {'type': 'dhcp4'}
422- ]
423- }
424- '''
425- ctxt = {
426- 'name': command.get('name'),
427- 'inet': 'inet',
428- 'mode': 'manual',
429- 'mtu': command.get('mtu'),
430- 'address': None,
431- 'gateway': None,
432- 'subnets': command.get('subnets'),
433- }
434-
435- content = ""
436- content += "auto {name}\n".format(**ctxt)
437- subnets = command.get('subnets', {})
438- if subnets:
439- for index, subnet in zip(range(0, len(subnets)), subnets):
440- ctxt['index'] = index
441- ctxt['mode'] = subnet['type']
442- if ctxt['mode'].endswith('6'):
443- ctxt['inet'] += '6'
444- elif ctxt['mode'] == 'static' and ":" in subnet['address']:
445- ctxt['inet'] += '6'
446- if ctxt['mode'].startswith('dhcp'):
447- ctxt['mode'] = 'dhcp'
448-
449- if index == 0:
450- content += "iface {name} {inet} {mode}\n".format(**ctxt)
451- else:
452- content += \
453- "iface {name}:{index} {inet} {mode}\n".format(**ctxt)
454-
455- if 'mtu' in ctxt and ctxt['mtu'] and index == 0:
456- content += " mtu {mtu}\n".format(**ctxt)
457- if 'address' in subnet:
458- content += " address {address}\n".format(**subnet)
459- if 'gateway' in subnet:
460- content += " gateway {gateway}\n".format(**subnet)
461- content += "\n"
462- else:
463- content += "iface {name} {inet} {mode}\n\n".format(**ctxt)
464-
465- # for physical interfaces ,write out a persist net udev rule
466- if command['type'] == 'physical' and \
467- 'name' in command and 'mac_address' in command:
468- udev_line = generate_udev_rule(command['name'],
469- command['mac_address'])
470- persist_net = 'etc/udev/rules.d/70-persistent-net.rules'
471- netrules = os.path.sep.join((args.target, persist_net,))
472- util.ensure_dir(os.path.dirname(netrules))
473- with open(netrules, 'a+') as f:
474- f.write(udev_line)
475-
476- return content
477-
478-
479-def compose_udev_equality(key, value):
480- """Return a udev comparison clause, like `ACTION=="add"`."""
481- assert key == key.upper()
482- return '%s=="%s"' % (key, value)
483-
484-
485-def compose_udev_attr_equality(attribute, value):
486- """Return a udev attribute comparison clause, like `ATTR{type}=="1"`."""
487- assert attribute == attribute.lower()
488- return 'ATTR{%s}=="%s"' % (attribute, value)
489-
490-
491-def compose_udev_setting(key, value):
492- """Return a udev assignment clause, like `NAME="eth0"`."""
493- assert key == key.upper()
494- return '%s="%s"' % (key, value)
495-
496-
497-def generate_udev_rule(interface, mac):
498- """Return a udev rule to set the name of network interface with `mac`.
499-
500- The rule ends up as a single line looking something like:
501-
502- SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*",
503- ATTR{address}="ff:ee:dd:cc:bb:aa", NAME="eth0"
504- """
505- rule = ', '.join([
506- compose_udev_equality('SUBSYSTEM', 'net'),
507- compose_udev_equality('ACTION', 'add'),
508- compose_udev_equality('DRIVERS', '?*'),
509- compose_udev_attr_equality('address', mac),
510- compose_udev_setting('NAME', interface),
511- ])
512- return '%s\n' % rule
513+ return config.dump_config({'network': network_config})
514
515
516 def net_meta(args):
517@@ -390,6 +136,11 @@
518 content = interfaces_basic_dhcp(devices)
519 elif args.mode == 'custom':
520 content = interfaces_custom(args)
521+ # if we have a config, write it out to OUTPUT_NETWORK_CONFIG
522+ output_network_config = os.environ.get("OUTPUT_NETWORK_CONFIG", "")
523+ if output_network_config:
524+ with open(output_network_config, "w") as fp:
525+ fp.write(content)
526
527 if args.output == "-":
528 sys.stdout.write(content)
529
530=== modified file 'curtin/config.py'
531--- curtin/config.py 2014-03-24 18:57:19 +0000
532+++ curtin/config.py 2015-09-02 14:28:32 +0000
533@@ -98,3 +98,7 @@
534 return yaml.safe_load(content)
535 else:
536 return load_config_archive(content)
537+
538+
539+def dump_config(config):
540+ return yaml.dump(config, default_flow_style=False)
541
542=== modified file 'curtin/net/__init__.py'
543--- curtin/net/__init__.py 2014-07-21 17:07:39 +0000
544+++ curtin/net/__init__.py 2015-09-02 14:28:32 +0000
545@@ -20,6 +20,10 @@
546 import os
547
548 from curtin.log import LOG
549+from curtin.udev import generate_udev_rule
550+import curtin.util as util
551+import curtin.config as config
552+from . import network_state
553
554 SYS_CLASS_NET = "/sys/class/net/"
555
556@@ -208,4 +212,166 @@
557 os.path.dirname(os.path.abspath(path)))
558 return ifaces
559
560+
561+def parse_net_config_data(net_config):
562+ """Parses the config, returns NetworkState dictionary
563+
564+ :param net_config: curtin network config dict
565+ """
566+ state = None
567+ if 'version' in net_config and 'config' in net_config:
568+ ns = network_state.NetworkState(version=net_config.get('version'),
569+ config=net_config.get('config'))
570+ ns.parse_config()
571+ state = ns.network_state
572+
573+ return state
574+
575+
576+def parse_net_config(path):
577+ """Parses a curtin network configuration file and
578+ return network state"""
579+ ns = None
580+ net_config = config.load_config(path)
581+ if 'network' in net_config:
582+ ns = parse_net_config_data(net_config.get('network'))
583+
584+ return ns
585+
586+
587+def render_persistent_net(network_state):
588+ ''' Given state, emit udev rules to map
589+ mac to ifname
590+ '''
591+ content = ""
592+ interfaces = network_state.get('interfaces')
593+ for iface in interfaces.values():
594+ # for physical interfaces write out a persist net udev rule
595+ if iface['type'] == 'physical' and \
596+ 'name' in iface and 'mac_address' in iface:
597+ content += generate_udev_rule(iface['name'],
598+ iface['mac_address'])
599+
600+ return content
601+
602+
603+# TODO: switch valid_map based on mode inet/inet6
604+def iface_add_subnet(iface, subnet):
605+ content = ""
606+ valid_map = [
607+ 'address',
608+ 'netmask',
609+ 'broadcast',
610+ 'metric',
611+ 'gateway',
612+ 'pointopoint',
613+ 'hwaddress',
614+ 'mtu',
615+ 'scope',
616+ ]
617+ for key, value in subnet.items():
618+ if value and key in valid_map:
619+ if type(value) == list:
620+ value = " ".join(value)
621+ content += " {} {}\n".format(key, value)
622+
623+ return content
624+
625+
626+# TODO: switch to valid_map for attrs
627+def iface_add_attrs(iface):
628+ content = ""
629+ ignore_map = [
630+ 'type',
631+ 'name',
632+ 'inet',
633+ 'mode',
634+ 'index',
635+ 'subnets',
636+ ]
637+ for key, value in iface.items():
638+ if value and key not in ignore_map:
639+ if type(value) == list:
640+ value = " ".join(value)
641+ content += " {} {}\n".format(key, value)
642+
643+ return content
644+
645+
646+def render_route(route):
647+ content = "up route add"
648+ mapping = {
649+ 'network': '-net',
650+ 'netmask': 'netmask',
651+ 'gateway': 'gw',
652+ 'metric': 'metric',
653+ }
654+ for k in ['network', 'netmask', 'gateway', 'metric']:
655+ if k in route:
656+ content += " %s %s" % (mapping[k], route[k])
657+
658+ content += '\n'
659+ return content
660+
661+
662+def render_interfaces(network_state):
663+ ''' Given state, emit etc/network/interfaces content '''
664+
665+ content = ""
666+ interfaces = network_state.get('interfaces')
667+ for iface in interfaces.values():
668+ content += "auto {name}\n".format(**iface)
669+
670+ subnets = iface.get('subnets', {})
671+ if subnets:
672+ for index, subnet in zip(range(0, len(subnets)), subnets):
673+ iface['index'] = index
674+ iface['mode'] = subnet['type']
675+ if iface['mode'].endswith('6'):
676+ iface['inet'] += '6'
677+ elif iface['mode'] == 'static' and ":" in subnet['address']:
678+ iface['inet'] += '6'
679+ if iface['mode'].startswith('dhcp'):
680+ iface['mode'] = 'dhcp'
681+
682+ if index == 0:
683+ content += "iface {name} {inet} {mode}\n".format(**iface)
684+ else:
685+ content += "auto {name}:{index}\n".format(**iface)
686+ content += \
687+ "iface {name}:{index} {inet} {mode}\n".format(**iface)
688+
689+ content += iface_add_subnet(iface, subnet)
690+ content += iface_add_attrs(iface)
691+ content += "\n"
692+ else:
693+ content += "iface {name} {inet} {mode}\n".format(**iface)
694+ content += iface_add_attrs(iface)
695+ content += "\n"
696+
697+ for (addr, dns) in network_state.get('nameservers').items():
698+ content += "{}\n".format(dns)
699+
700+ for route in network_state.get('routes'):
701+ content += render_route(route)
702+
703+ # global replacements until v2 format
704+ content = content.replace('mac_address', 'hwaddress')
705+ return content
706+
707+
708+def render_network_state(target, network_state):
709+ eni = 'etc/network/interfaces'
710+ netrules = 'etc/udev/rules.d/70-persistent-net.rules'
711+
712+ eni = os.path.sep.join((target, eni,))
713+ util.ensure_dir(os.path.dirname(eni))
714+ with open(eni, 'w+') as f:
715+ f.write(render_interfaces(network_state))
716+
717+ netrules = os.path.sep.join((target, netrules,))
718+ util.ensure_dir(os.path.dirname(netrules))
719+ with open(netrules, 'w+') as f:
720+ f.write(render_persistent_net(network_state))
721+
722 # vi: ts=4 expandtab syntax=python
723
724=== added file 'curtin/net/network_state.py'
725--- curtin/net/network_state.py 1970-01-01 00:00:00 +0000
726+++ curtin/net/network_state.py 2015-09-02 14:28:32 +0000
727@@ -0,0 +1,360 @@
728+# Copyright (C) 2013-2014 Canonical Ltd.
729+#
730+# Author: Ryan Harper <ryan.harper@canonical.com>
731+#
732+# Curtin is free software: you can redistribute it and/or modify it under
733+# the terms of the GNU Affero General Public License as published by the
734+# Free Software Foundation, either version 3 of the License, or (at your
735+# option) any later version.
736+#
737+# Curtin is distributed in the hope that it will be useful, but WITHOUT ANY
738+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
739+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
740+# more details.
741+#
742+# You should have received a copy of the GNU Affero General Public License
743+# along with Curtin. If not, see <http://www.gnu.org/licenses/>.
744+
745+from curtin.log import LOG
746+import curtin.config as curtin_config
747+
748+NETWORK_STATE_VERSION = 1
749+NETWORK_STATE_REQUIRED_KEYS = {
750+ 1: ['version', 'config', 'network_state'],
751+}
752+
753+
754+def from_state_file(state_file):
755+ network_state = None
756+ state = curtin_config.load_config(state_file)
757+ network_state = NetworkState()
758+ network_state.load(state)
759+
760+ return network_state
761+
762+
763+class NetworkState:
764+ def __init__(self, version=NETWORK_STATE_VERSION, config=None):
765+ self.version = version
766+ self.config = config
767+ self.network_state = {
768+ 'interfaces': {},
769+ 'routes': [],
770+ 'nameservers': {},
771+ }
772+ self.command_handlers = self.get_command_handlers()
773+
774+ def get_command_handlers(self):
775+ METHOD_PREFIX = 'handle_'
776+ methods = filter(lambda x: callable(getattr(self, x)) and
777+ x.startswith(METHOD_PREFIX), dir(self))
778+ handlers = {}
779+ for m in methods:
780+ key = m.replace(METHOD_PREFIX, '')
781+ handlers[key] = getattr(self, m)
782+
783+ return handlers
784+
785+ def dump(self):
786+ state = {
787+ 'version': self.version,
788+ 'config': self.config,
789+ 'network_state': self.network_state,
790+ }
791+ return curtin_config.dump_config(state)
792+
793+ def load(self, state):
794+ if 'version' not in state:
795+ LOG.error('Invalid state, missing version field')
796+ raise Exception('Invalid state, missing version field')
797+
798+ required_keys = NETWORK_STATE_REQUIRED_KEYS[state['version']]
799+ if not self.valid_command(state, required_keys):
800+ msg = 'Invalid state, missing keys: {}'.format(required_keys)
801+ LOG.error(msg)
802+ raise Exception(msg)
803+
804+ # v1 - direct attr mapping, except version
805+ for key in [k for k in required_keys if k not in ['version']]:
806+ setattr(self, key, state[key])
807+ self.command_handlers = self.get_command_handlers()
808+
809+ def dump_network_state(self):
810+ return curtin_config.dump_config(self.network_state)
811+
812+ def parse_config(self):
813+ # rebuild network state
814+ for command in self.config:
815+ handler = self.command_handlers.get(command['type'])
816+ handler(command)
817+
818+ def valid_command(self, command, required_keys):
819+ if not required_keys:
820+ return False
821+
822+ found_keys = [key for key in command.keys() if key in required_keys]
823+ return len(found_keys) == len(required_keys)
824+
825+ def handle_physical(self, command):
826+ '''
827+ command = {
828+ 'type': 'physical',
829+ 'mac_address': 'c0:d6:9f:2c:e8:80',
830+ 'name': 'eth0',
831+ 'subnets': [
832+ {'type': 'dhcp4'}
833+ ]
834+ }
835+ '''
836+ required_keys = [
837+ 'name',
838+ ]
839+ if not self.valid_command(command, required_keys):
840+ LOG.warn('Skipping Invalid command: {}'.format(command))
841+ LOG.debug(self.dump_network_state())
842+ return
843+
844+ interfaces = self.network_state.get('interfaces')
845+ iface = interfaces.get(command['name'], {})
846+ iface.update({
847+ 'name': command.get('name'),
848+ 'type': command.get('type'),
849+ 'mac_address': command.get('mac_address'),
850+ 'inet': 'inet',
851+ 'mode': 'manual',
852+ 'mtu': command.get('mtu'),
853+ 'address': None,
854+ 'gateway': None,
855+ 'subnets': command.get('subnets'),
856+ })
857+ self.network_state['interfaces'].update({command.get('name'): iface})
858+ self.dump_network_state()
859+
860+ def handle_vlan(self, command):
861+ '''
862+ auto eth0.222
863+ iface eth0.222 inet static
864+ address 10.10.10.1
865+ netmask 255.255.255.0
866+ vlan-raw-device eth0
867+ '''
868+ required_keys = [
869+ 'name',
870+ 'vlan_link',
871+ 'vlan_id',
872+ ]
873+ if not self.valid_command(command, required_keys):
874+ print('Skipping Invalid command: {}'.format(command))
875+ print(self.dump_network_state())
876+ return
877+
878+ interfaces = self.network_state.get('interfaces')
879+ self.handle_physical(command)
880+ iface = interfaces.get(command.get('name'), {})
881+ iface['vlan-raw-device'] = command.get('vlan_link')
882+ iface['vlan_id'] = command.get('vlan_id')
883+ interfaces.update({iface['name']: iface})
884+
885+ def handle_bond(self, command):
886+ '''
887+ #/etc/network/interfaces
888+ auto eth0
889+ iface eth0 inet manual
890+
891+ auto eth1
892+ iface eth1 inet manual
893+
894+ auto bond0
895+ iface bond0 inet static
896+ address 192.168.0.10
897+ gateway 192.168.0.1
898+ netmask 255.255.255.0
899+ bond-mode 802.3ad
900+ bond-miimon 100
901+ bond-downdelay 200
902+ bond-updelay 200
903+ bond-lacp-rate 4
904+ '''
905+ required_keys = [
906+ 'name',
907+ 'bond_interfaces',
908+ 'params',
909+ ]
910+ if not self.valid_command(command, required_keys):
911+ print('Skipping Invalid command: {}'.format(command))
912+ print(self.dump_network_state())
913+ return
914+
915+ self.handle_physical(command)
916+ interfaces = self.network_state.get('interfaces')
917+ iface = interfaces.get(command.get('name'), {})
918+ for param, val in command.get('params').items():
919+ iface.update({param: val})
920+ self.network_state['interfaces'].update({iface['name']: iface})
921+
922+ # handle bond slaves
923+ for ifname in command.get('bond_interfaces'):
924+ if ifname not in interfaces:
925+ cmd = {
926+ 'name': ifname,
927+ 'type': 'bond',
928+ }
929+ # inject placeholder
930+ self.handle_physical(cmd)
931+
932+ interfaces = self.network_state.get('interfaces')
933+ bond_if = interfaces.get(ifname)
934+ bond_if['bond-master'] = command.get('name')
935+ self.network_state['interfaces'].update({ifname: bond_if})
936+
937+ def handle_bridge(self, command):
938+ '''
939+ auto br0
940+ iface br0 inet static
941+ address 10.10.10.1
942+ netmask 255.255.255.0
943+ bridge_ports eth0 eth1
944+ bridge_stp off
945+ bridge_fd 0
946+ bridge_maxwait 0
947+
948+ bridge_params = [
949+ "bridge_ports",
950+ "bridge_ageing",
951+ "bridge_bridgeprio",
952+ "bridge_fd",
953+ "bridge_gcint",
954+ "bridge_hello",
955+ "bridge_hw",
956+ "bridge_maxage",
957+ "bridge_maxwait",
958+ "bridge_pathcost",
959+ "bridge_portprio",
960+ "bridge_stp",
961+ "bridge_waitport",
962+ ]
963+ '''
964+ required_keys = [
965+ 'name',
966+ 'bridge_interfaces',
967+ 'params',
968+ ]
969+ if not self.valid_command(command, required_keys):
970+ print('Skipping Invalid command: {}'.format(command))
971+ print(self.dump_network_state())
972+ return
973+
974+ # find one of the bridge port ifaces to get mac_addr
975+ # handle bridge_slaves
976+ interfaces = self.network_state.get('interfaces')
977+ for ifname in command.get('bridge_interfaces'):
978+ if ifname in interfaces:
979+ continue
980+
981+ cmd = {
982+ 'name': ifname,
983+ }
984+ # inject placeholder
985+ self.handle_physical(cmd)
986+
987+ interfaces = self.network_state.get('interfaces')
988+ self.handle_physical(command)
989+ iface = interfaces.get(command.get('name'), {})
990+ iface['bridge_ports'] = command['bridge_interfaces']
991+ for param, val in command.get('params').items():
992+ iface.update({param: val})
993+
994+ interfaces.update({iface['name']: iface})
995+
996+ def handle_nameserver(self, command):
997+ required_keys = [
998+ 'address',
999+ ]
1000+ if not self.valid_command(command, required_keys):
1001+ print('Skipping Invalid command: {}'.format(command))
1002+ print(self.dump_network_state())
1003+ return
1004+
1005+ nameservers = self.network_state.get('nameservers')
1006+ if 'address' in command:
1007+ nameservers[command['address']] = \
1008+ "dns-nameserver {address}".format(**command)
1009+
1010+ def handle_route(self, command):
1011+ required_keys = [
1012+ 'destination',
1013+ ]
1014+ if not self.valid_command(command, required_keys):
1015+ print('Skipping Invalid command: {}'.format(command))
1016+ print(self.dump_network_state())
1017+ return
1018+
1019+ routes = self.network_state.get('routes')
1020+ network, cidr = command['destination'].split("/")
1021+ netmask = cidr2mask(int(cidr))
1022+ route = {
1023+ 'network': network,
1024+ 'netmask': netmask,
1025+ 'gateway': command.get('gateway'),
1026+ 'metric': command.get('metric'),
1027+ }
1028+ routes.append(route)
1029+
1030+
1031+def cidr2mask(cidr):
1032+ mask = [0, 0, 0, 0]
1033+ for i in list(range(0, cidr)):
1034+ idx = int(i / 8)
1035+ mask[idx] = mask[idx] + (1 << (7 - i % 8))
1036+ return ".".join([str(x) for x in mask])
1037+
1038+
1039+if __name__ == '__main__':
1040+ import sys
1041+ import random
1042+ from curtin import net
1043+
1044+ def load_config(nc):
1045+ version = nc.get('version')
1046+ config = nc.get('config')
1047+ return (version, config)
1048+
1049+ def test_parse(network_config):
1050+ (version, config) = load_config(network_config)
1051+ ns1 = NetworkState(version=version, config=config)
1052+ ns1.parse_config()
1053+ random.shuffle(config)
1054+ ns2 = NetworkState(version=version, config=config)
1055+ ns2.parse_config()
1056+ print("----NS1-----")
1057+ print(ns1.dump_network_state())
1058+ print()
1059+ print("----NS2-----")
1060+ print(ns2.dump_network_state())
1061+ print("NS1 == NS2 ?=> {}".format(
1062+ ns1.network_state == ns2.network_state))
1063+ eni = net.render_interfaces(ns2.network_state)
1064+ print(eni)
1065+ udev_rules = net.render_persistent_net(ns2.network_state)
1066+ print(udev_rules)
1067+
1068+ def test_dump_and_load(network_config):
1069+ print("Loading network_config into NetworkState")
1070+ (version, config) = load_config(network_config)
1071+ ns1 = NetworkState(version=version, config=config)
1072+ ns1.parse_config()
1073+ print("Dumping state to file")
1074+ ns1_dump = ns1.dump()
1075+ ns1_state = "/tmp/ns1.state"
1076+ with open(ns1_state, "w+") as f:
1077+ f.write(ns1_dump)
1078+
1079+ print("Loading state from file")
1080+ ns2 = from_state_file(ns1_state)
1081+ print("NS1 == NS2 ?=> {}".format(
1082+ ns1.network_state == ns2.network_state))
1083+
1084+ y = curtin_config.load_config(sys.argv[1])
1085+ network_config = y.get('network')
1086+ test_parse(network_config)
1087+ test_dump_and_load(network_config)
1088
1089=== added file 'curtin/udev.py'
1090--- curtin/udev.py 1970-01-01 00:00:00 +0000
1091+++ curtin/udev.py 2015-09-02 14:28:32 +0000
1092@@ -0,0 +1,54 @@
1093+# Copyright (C) 2015 Canonical Ltd.
1094+#
1095+# Author: Ryan Harper <ryan.harper@canonical.com>
1096+#
1097+# Curtin is free software: you can redistribute it and/or modify it under
1098+# the terms of the GNU Affero General Public License as published by the
1099+# Free Software Foundation, either version 3 of the License, or (at your
1100+# option) any later version.
1101+#
1102+# Curtin is distributed in the hope that it will be useful, but WITHOUT ANY
1103+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
1104+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
1105+# more details.
1106+#
1107+# You should have received a copy of the GNU Affero General Public License
1108+# along with Curtin. If not, see <http://www.gnu.org/licenses/>.
1109+
1110+
1111+def compose_udev_equality(key, value):
1112+ """Return a udev comparison clause, like `ACTION=="add"`."""
1113+ assert key == key.upper()
1114+ return '%s=="%s"' % (key, value)
1115+
1116+
1117+def compose_udev_attr_equality(attribute, value):
1118+ """Return a udev attribute comparison clause, like `ATTR{type}=="1"`."""
1119+ assert attribute == attribute.lower()
1120+ return 'ATTR{%s}=="%s"' % (attribute, value)
1121+
1122+
1123+def compose_udev_setting(key, value):
1124+ """Return a udev assignment clause, like `NAME="eth0"`."""
1125+ assert key == key.upper()
1126+ return '%s="%s"' % (key, value)
1127+
1128+
1129+def generate_udev_rule(interface, mac):
1130+ """Return a udev rule to set the name of network interface with `mac`.
1131+
1132+ The rule ends up as a single line looking something like:
1133+
1134+ SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*",
1135+ ATTR{address}="ff:ee:dd:cc:bb:aa", NAME="eth0"
1136+ """
1137+ rule = ', '.join([
1138+ compose_udev_equality('SUBSYSTEM', 'net'),
1139+ compose_udev_equality('ACTION', 'add'),
1140+ compose_udev_equality('DRIVERS', '?*'),
1141+ compose_udev_attr_equality('address', mac),
1142+ compose_udev_setting('NAME', interface),
1143+ ])
1144+ return '%s\n' % rule
1145+
1146+# vi: ts=4 expandtab syntax=python
1147
1148=== modified file 'curtin/util.py'
1149--- curtin/util.py 2015-08-11 13:30:17 +0000
1150+++ curtin/util.py 2015-09-02 14:28:32 +0000
1151@@ -101,7 +101,9 @@
1152
1153 mapping = {'scratch': 'WORKING_DIR', 'fstab': 'OUTPUT_FSTAB',
1154 'interfaces': 'OUTPUT_INTERFACES', 'config': 'CONFIG',
1155- 'target': 'TARGET_MOUNT_POINT'}
1156+ 'target': 'TARGET_MOUNT_POINT',
1157+ 'network_state': 'OUTPUT_NETWORK_STATE',
1158+ 'network_config': 'OUTPUT_NETWORK_CONFIG'}
1159
1160 if strict:
1161 missing = [k for k in mapping if k not in env]
1162
1163=== modified file 'examples/network-all.yaml'
1164--- examples/network-all.yaml 2015-08-07 00:16:25 +0000
1165+++ examples/network-all.yaml 2015-09-02 14:28:32 +0000
1166@@ -4,88 +4,90 @@
1167
1168 # YAML example of a network config.
1169 network:
1170- # Physical interfaces.
1171- - type: physical
1172- name: eth0
1173- mac_address: "c0:d6:9f:2c:e8:80"
1174- - type: physical
1175- name: eth1
1176- mac_address: "aa:d6:9f:2c:e8:80"
1177- - type: physical
1178- name: eth2
1179- mac_address: "c0:bb:9f:2c:e8:80"
1180- - type: physical
1181- name: eth3
1182- mac_address: "66:bb:9f:2c:e8:80"
1183- - type: physical
1184- name: eth4
1185- mac_address: "98:bb:9f:2c:e8:80"
1186- # VLAN interface.
1187- - type: vlan
1188- name: eth0.101
1189- vlan_link: eth0
1190- vlan_id: 101
1191- mtu: 1500
1192- subnets:
1193- - type: static
1194- address: 192.168.0.2/24
1195- gateway: 192.168.0.1
1196- dns_nameservers:
1197- - 192.168.0.10
1198- - type: static
1199- address: 192.168.2.10/24
1200- # Bond.
1201- - type: bond
1202- name: bond0
1203- # if 'mac_address' is omitted, the MAC is taken from
1204- # the first slave.
1205- mac_address: "aa:bb:cc:dd:ee:ff"
1206- bond_interfaces:
1207- - eth1
1208- - eth2
1209- params:
1210- bond-mode: active-backup
1211- subnets:
1212- - type: dhcp6
1213- # A Bond VLAN.
1214- - type: vlan
1215- name: bond0.200
1216- vlan_link: bond0
1217- vlan_id: 200
1218- subnets:
1219- - type: dhcp4
1220- # A bridge.
1221- - type: bridge
1222- name: br0
1223- bridge_interfaces:
1224- - eth3
1225- - eth4
1226- ipv4_conf:
1227- rp_filter: 1
1228- proxy_arp: 0
1229- forwarding: 1
1230- ipv6_conf:
1231- autoconf: 1
1232- disable_ipv6: 1
1233- use_tempaddr: 1
1234- forwarding: 1
1235- # basically anything in /proc/sys/net/ipv6/conf/.../
1236- params:
1237- bridge_stp: 'off'
1238- bridge_fd: 0
1239- bridge_maxwait: 0
1240- subnets:
1241- - type: static
1242- address: 192.168.14.2/24
1243- - type: static
1244- address: 2001:1::1/64 # default to /64
1245- # A global nameserver.
1246- - type: nameserver
1247- address: 8.8.8.8
1248- # A global route.
1249- - type: route
1250- destination: 10.0.0.0/8
1251- gateway: 11.0.0.1
1252- metric: 3
1253+ version: 1
1254+ config:
1255+ # Physical interfaces.
1256+ - type: physical
1257+ name: eth0
1258+ mac_address: "c0:d6:9f:2c:e8:80"
1259+ - type: physical
1260+ name: eth1
1261+ mac_address: "aa:d6:9f:2c:e8:80"
1262+ - type: physical
1263+ name: eth2
1264+ mac_address: "c0:bb:9f:2c:e8:80"
1265+ - type: physical
1266+ name: eth3
1267+ mac_address: "66:bb:9f:2c:e8:80"
1268+ - type: physical
1269+ name: eth4
1270+ mac_address: "98:bb:9f:2c:e8:80"
1271+ # VLAN interface.
1272+ - type: vlan
1273+ name: eth0.101
1274+ vlan_link: eth0
1275+ vlan_id: 101
1276+ mtu: 1500
1277+ subnets:
1278+ - type: static
1279+ address: 192.168.0.2/24
1280+ gateway: 192.168.0.1
1281+ dns_nameservers:
1282+ - 192.168.0.10
1283+ - type: static
1284+ address: 192.168.2.10/24
1285+ # Bond.
1286+ - type: bond
1287+ name: bond0
1288+ # if 'mac_address' is omitted, the MAC is taken from
1289+ # the first slave.
1290+ mac_address: "aa:bb:cc:dd:ee:ff"
1291+ bond_interfaces:
1292+ - eth1
1293+ - eth2
1294+ params:
1295+ bond-mode: active-backup
1296+ subnets:
1297+ - type: dhcp6
1298+ # A Bond VLAN.
1299+ - type: vlan
1300+ name: bond0.200
1301+ vlan_link: bond0
1302+ vlan_id: 200
1303+ subnets:
1304+ - type: dhcp4
1305+ # A bridge.
1306+ - type: bridge
1307+ name: br0
1308+ bridge_interfaces:
1309+ - eth3
1310+ - eth4
1311+ ipv4_conf:
1312+ rp_filter: 1
1313+ proxy_arp: 0
1314+ forwarding: 1
1315+ ipv6_conf:
1316+ autoconf: 1
1317+ disable_ipv6: 1
1318+ use_tempaddr: 1
1319+ forwarding: 1
1320+ # basically anything in /proc/sys/net/ipv6/conf/.../
1321+ params:
1322+ bridge_stp: 'off'
1323+ bridge_fd: 0
1324+ bridge_maxwait: 0
1325+ subnets:
1326+ - type: static
1327+ address: 192.168.14.2/24
1328+ - type: static
1329+ address: 2001:1::1/64 # default to /64
1330+ # A global nameserver.
1331+ - type: nameserver
1332+ address: 8.8.8.8
1333+ # A global route.
1334+ - type: route
1335+ destination: 10.0.0.0/8
1336+ gateway: 11.0.0.1
1337+ metric: 3
1338
1339
1340
1341=== modified file 'examples/network-bond.yaml'
1342--- examples/network-bond.yaml 2015-08-07 00:16:25 +0000
1343+++ examples/network-bond.yaml 2015-09-02 14:28:32 +0000
1344@@ -4,43 +4,45 @@
1345
1346 # YAML example of a network config.
1347 network:
1348- # Physical interfaces.
1349- - type: physical
1350- name: eth0
1351- mac_address: "c0:d6:9f:2c:e8:80"
1352- - type: physical
1353- name: eth1
1354- mac_address: "aa:d6:9f:2c:e8:80"
1355- - type: physical
1356- name: eth2
1357- mac_address: "c0:bb:9f:2c:e8:80"
1358- - type: physical
1359- name: eth3
1360- mac_address: "66:bb:9f:2c:e8:80"
1361- - type: physical
1362- name: eth4
1363- mac_address: "98:bb:9f:2c:e8:80"
1364- # Bond.
1365- - type: bond
1366- name: bond0
1367- # if 'mac_address' is omitted, the MAC is taken from
1368- # the first slave.
1369- mac_address: "aa:bb:cc:dd:ee:ff"
1370- bond_interfaces:
1371- - eth1
1372- - eth2
1373- params:
1374- bond-mode: active-backup
1375- subnets:
1376- - type: dhcp6
1377- # A Bond VLAN.
1378- - type: vlan
1379- name: bond0.200
1380- vlan_link: bond0
1381- vlan_id: 200
1382- subnets:
1383- - type: static
1384- address: 192.168.0.2/24
1385- gateway: 192.168.0.1
1386- dns_nameservers:
1387- - 192.168.0.10
1388+ version: 1
1389+ config:
1390+ # Physical interfaces.
1391+ - type: physical
1392+ name: eth0
1393+ mac_address: "c0:d6:9f:2c:e8:80"
1394+ - type: physical
1395+ name: eth1
1396+ mac_address: "aa:d6:9f:2c:e8:80"
1397+ - type: physical
1398+ name: eth2
1399+ mac_address: "c0:bb:9f:2c:e8:80"
1400+ - type: physical
1401+ name: eth3
1402+ mac_address: "66:bb:9f:2c:e8:80"
1403+ - type: physical
1404+ name: eth4
1405+ mac_address: "98:bb:9f:2c:e8:80"
1406+ # Bond.
1407+ - type: bond
1408+ name: bond0
1409+ # if 'mac_address' is omitted, the MAC is taken from
1410+ # the first slave.
1411+ mac_address: "aa:bb:cc:dd:ee:ff"
1412+ bond_interfaces:
1413+ - eth1
1414+ - eth2
1415+ params:
1416+ bond-mode: active-backup
1417+ subnets:
1418+ - type: dhcp6
1419+ # A Bond VLAN.
1420+ - type: vlan
1421+ name: bond0.200
1422+ vlan_link: bond0
1423+ vlan_id: 200
1424+ subnets:
1425+ - type: static
1426+ address: 192.168.0.2/24
1427+ gateway: 192.168.0.1
1428+ dns_nameservers:
1429+ - 192.168.0.10
1430
1431=== modified file 'examples/network-bridge.yaml'
1432--- examples/network-bridge.yaml 2015-08-07 00:16:25 +0000
1433+++ examples/network-bridge.yaml 2015-09-02 14:28:32 +0000
1434@@ -4,25 +4,27 @@
1435
1436 # YAML example of a network config.
1437 network:
1438- # Physical interfaces.
1439- - type: physical
1440- name: eth0
1441- mac_address: "c0:d6:9f:2c:e8:80"
1442- - type: physical
1443- name: eth1
1444- mac_address: "aa:d6:9f:2c:e8:80"
1445- # A bridge.
1446- - type: bridge
1447- name: br0
1448- bridge_interfaces:
1449- - eth0
1450- - eth1
1451- params:
1452- bridge_stp: 'off'
1453- bridge_fd: 0
1454- bridge_maxwait: 0
1455- subnets:
1456- - type: static
1457- address: 192.168.14.2/24
1458- - type: static
1459- address: 2001:1::1/64 # default to /64
1460+ version: 1
1461+ config:
1462+ # Physical interfaces.
1463+ - type: physical
1464+ name: eth0
1465+ mac_address: "c0:d6:9f:2c:e8:80"
1466+ - type: physical
1467+ name: eth1
1468+ mac_address: "aa:d6:9f:2c:e8:80"
1469+ # A bridge.
1470+ - type: bridge
1471+ name: br0
1472+ bridge_interfaces:
1473+ - eth0
1474+ - eth1
1475+ params:
1476+ bridge_stp: 'off'
1477+ bridge_fd: 0
1478+ bridge_maxwait: 0
1479+ subnets:
1480+ - type: static
1481+ address: 192.168.14.2/24
1482+ - type: static
1483+ address: 2001:1::1/64 # default to /64
1484
1485=== modified file 'examples/network-simple.yaml'
1486--- examples/network-simple.yaml 2015-08-07 00:16:25 +0000
1487+++ examples/network-simple.yaml 2015-09-02 14:28:32 +0000
1488@@ -1,25 +1,30 @@
1489 network_commands:
1490 builtin: null
1491- 10_network: curtin net-meta custom
1492+ 10_network:
1493+ - curtin
1494+ - net-meta
1495+ - custom
1496
1497 # YAML example of a simple network config
1498 network:
1499- # Physical interfaces.
1500- - type: physical
1501- name: eth0
1502- mac_address: "c0:d6:9f:2c:e8:80"
1503- subnets:
1504- - type: dhcp4
1505- - type: physical
1506- name: eth1
1507- mtu: 1492
1508- mac_address: "aa:d6:9f:2c:e8:80"
1509- subnets:
1510- - type: static
1511- address: 192.168.14.2/24
1512- gateway: 192.168.14.1
1513- - type: static
1514- address: 192.168.14.4/24
1515- - type: physical
1516- name: eth2
1517- mac_address: "cf:d6:af:48:e8:80"
1518+ version: 1
1519+ config:
1520+ # Physical interfaces.
1521+ - type: physical
1522+ name: eth0
1523+ mac_address: "c0:d6:9f:2c:e8:80"
1524+ subnets:
1525+ - type: dhcp4
1526+ - type: physical
1527+ name: eth1
1528+ mtu: 1492
1529+ mac_address: "aa:d6:9f:2c:e8:80"
1530+ subnets:
1531+ - type: static
1532+ address: 192.168.14.2/24
1533+ gateway: 192.168.14.1
1534+ - type: static
1535+ address: 192.168.14.4/24
1536+ - type: physical
1537+ name: eth2
1538+ mac_address: "cf:d6:af:48:e8:80"
1539
1540=== modified file 'examples/network-vlan.yaml'
1541--- examples/network-vlan.yaml 2015-08-07 00:16:25 +0000
1542+++ examples/network-vlan.yaml 2015-09-02 14:28:32 +0000
1543@@ -4,19 +4,21 @@
1544
1545 # YAML example of a network config.
1546 network:
1547- # Physical interfaces.
1548- - type: physical
1549- name: eth0
1550- mac_address: "c0:d6:9f:2c:e8:80"
1551- # VLAN interface.
1552- - type: vlan
1553- name: eth0.101
1554- vlan_link: eth0
1555- vlan_id: 101
1556- mtu: 1500
1557- subnets:
1558- - type: static
1559- address: 192.168.0.2/24
1560- gateway: 192.168.0.1
1561- dns_nameservers:
1562- - 192.168.0.10
1563+ version: 1
1564+ config:
1565+ # Physical interfaces.
1566+ - type: physical
1567+ name: eth0
1568+ mac_address: "c0:d6:9f:2c:e8:80"
1569+ # VLAN interface.
1570+ - type: vlan
1571+ name: eth0.101
1572+ vlan_link: eth0
1573+ vlan_id: 101
1574+ mtu: 1500
1575+ subnets:
1576+ - type: static
1577+ address: 192.168.0.2/24
1578+ gateway: 192.168.0.1
1579+ dns_nameservers:
1580+ - 192.168.0.10
1581
1582=== added file 'examples/tests/basic_network.yaml'
1583--- examples/tests/basic_network.yaml 1970-01-01 00:00:00 +0000
1584+++ examples/tests/basic_network.yaml 2015-09-02 14:28:32 +0000
1585@@ -0,0 +1,22 @@
1586+network:
1587+ version: 1
1588+ config:
1589+ # Physical interfaces.
1590+ - type: physical
1591+ name: eth0
1592+ mac_address: "52:54:00:12:34:00"
1593+ subnets:
1594+ - type: dhcp4
1595+ - type: physical
1596+ name: eth1
1597+ mtu: 1492
1598+ mac_address: "52:54:00:12:34:02"
1599+ subnets:
1600+ - type: static
1601+ address: 10.0.2.100/24
1602+ gateway: 10.0.2.1
1603+ - type: static
1604+ address: 10.0.2.200/24
1605+ - type: physical
1606+ name: eth2
1607+ mac_address: "52:54:00:12:34:04"
1608
1609=== modified file 'tests/unittests/test_net.py'
1610--- tests/unittests/test_net.py 2014-07-21 17:07:39 +0000
1611+++ tests/unittests/test_net.py 2015-09-02 14:28:32 +0000
1612@@ -2,8 +2,10 @@
1613 import os
1614 import shutil
1615 import tempfile
1616+import yaml
1617
1618 from curtin import net
1619+import curtin.net.network_state as network_state
1620 from textwrap import dedent
1621
1622
1623@@ -241,4 +243,76 @@
1624 expected = net.parse_deb_config(i_path)
1625 self.assertEqual(data, expected)
1626
1627+
1628+class TestNetConfig(TestCase):
1629+ def setUp(self):
1630+ self.target = tempfile.mkdtemp()
1631+ self.config_f = os.path.join(self.target, 'config')
1632+ self.config = '''
1633+# YAML example of a simple network config
1634+network:
1635+ version: 1
1636+ config:
1637+ # Physical interfaces.
1638+ - type: physical
1639+ name: eth0
1640+ mac_address: "c0:d6:9f:2c:e8:80"
1641+ subnets:
1642+ - type: dhcp4
1643+ - type: physical
1644+ name: eth1
1645+ mac_address: "cf:d6:af:48:e8:80"
1646+'''
1647+ with open(self.config_f, 'w') as fp:
1648+ fp.write(self.config)
1649+
1650+ def get_net_config(self):
1651+ cfg = yaml.safe_load(self.config)
1652+ return cfg.get('network')
1653+
1654+ def get_net_state(self):
1655+ net_cfg = self.get_net_config()
1656+ version = net_cfg.get('version')
1657+ config = net_cfg.get('config')
1658+ ns = network_state.NetworkState(version=version, config=config)
1659+ ns.parse_config()
1660+ return ns
1661+
1662+ def tearDown(self):
1663+ shutil.rmtree(self.target)
1664+
1665+ def test_parse_net_config_data(self):
1666+ ns = self.get_net_state()
1667+ net_state_from_cls = ns.network_state
1668+
1669+ net_state_from_fn = net.parse_net_config_data(self.get_net_config())
1670+ self.assertEqual(net_state_from_cls, net_state_from_fn)
1671+
1672+ def test_parse_net_config(self):
1673+ ns = self.get_net_state()
1674+ net_state_from_cls = ns.network_state
1675+
1676+ net_state_from_fn = net.parse_net_config(self.config_f)
1677+ self.assertEqual(net_state_from_cls, net_state_from_fn)
1678+
1679+ def test_render_persistent_net(self):
1680+ ns = self.get_net_state()
1681+ udev_rules = ('SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", ' +
1682+ 'ATTR{address}=="cf:d6:af:48:e8:80", NAME="eth1"\n' +
1683+ 'SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", ' +
1684+ 'ATTR{address}=="c0:d6:9f:2c:e8:80", NAME="eth0"\n')
1685+ persist_net_rules = net.render_persistent_net(ns.network_state)
1686+ self.assertEqual(sorted(udev_rules.split('\n')),
1687+ sorted(persist_net_rules.split('\n')))
1688+
1689+ def test_render_interfaces(self):
1690+ ns = self.get_net_state()
1691+ ifaces = ('auto eth1\n' + 'iface eth1 inet manual\n' +
1692+ ' hwaddress cf:d6:af:48:e8:80\n\n' +
1693+ 'auto eth0\n' + 'iface eth0 inet dhcp\n' +
1694+ ' hwaddress c0:d6:9f:2c:e8:80\n\n')
1695+ net_ifaces = net.render_interfaces(ns.network_state)
1696+ self.assertEqual(sorted(ifaces.split('\n')),
1697+ sorted(net_ifaces.split('\n')))
1698+
1699 # vi: ts=4 expandtab syntax=python
1700
1701=== modified file 'tests/vmtests/__init__.py'
1702--- tests/vmtests/__init__.py 2015-08-27 20:16:43 +0000
1703+++ tests/vmtests/__init__.py 2015-09-02 14:28:32 +0000
1704@@ -5,6 +5,7 @@
1705 import shutil
1706 import subprocess
1707 import tempfile
1708+import curtin.net as curtin_net
1709
1710 IMAGE_DIR = "/srv/images"
1711
1712@@ -22,9 +23,12 @@
1713 print('Query simplestreams for root image: '
1714 'release={release} arch={arch}'.format(release=release,
1715 arch=arch))
1716- out = subprocess.check_output(
1717- ["tools/usquery", "--max=1", repo, "release=%s" % release,
1718- "arch=%s" % arch, "item_name=root-image.gz"])
1719+ cmd = ["tools/usquery", "--max=1", repo, "release=%s" % release,
1720+ "krel=%s" % release, "arch=%s" % arch,
1721+ "item_name=root-image.gz"]
1722+ print(" ".join(cmd))
1723+ out = subprocess.check_output(cmd)
1724+ print(out)
1725 sstream_data = ast.literal_eval(bytes.decode(out))
1726
1727 # Check if we already have the image
1728@@ -127,18 +131,38 @@
1729 if not self.interactive:
1730 cmd.extend(["--silent", "--power=off"])
1731
1732+ # check for network configuration
1733+ self.network_state = curtin_net.parse_net_config(self.conf_file)
1734+ print(self.network_state)
1735+
1736+ # build -n arg list with macaddrs from net_config physical config
1737+ macs = []
1738+ interfaces = {}
1739+ if self.network_state:
1740+ interfaces = self.network_state.get('interfaces')
1741+ for ifname in interfaces:
1742+ print(ifname)
1743+ iface = interfaces.get(ifname)
1744+ hwaddr = iface.get('mac_address')
1745+ if hwaddr:
1746+ macs.append(hwaddr)
1747+ netdevs = []
1748+ if len(macs) > 0:
1749+ for mac in macs:
1750+ netdevs.extend(["--netdev=user,mac={}".format(mac)])
1751+ else:
1752+ netdevs.extend(["--netdev=user"])
1753+
1754 # build disk arguments
1755 extra_disks = []
1756 for (disk_no, disk_sz) in enumerate(self.extra_disks):
1757 dpath = os.path.join(self.td.tmpdir, 'extra_disk_%d.img' % disk_no)
1758 extra_disks.extend(['--disk', '{}:{}'.format(dpath, disk_sz)])
1759
1760- cmd.extend(["--netdev=user", "--disk", self.td.target_disk] +
1761- extra_disks +
1762- [boot_img, "--kernel=%s" % boot_kernel,
1763- "--initrd=%s" % boot_initrd,
1764- "--", "curtin", "install",
1765- "--config=%s" % self.conf_file, "cp:///"])
1766+ cmd.extend(netdevs + ["--disk", self.td.target_disk] + extra_disks +
1767+ [boot_img, "--kernel=%s" % boot_kernel, "--initrd=%s" %
1768+ boot_initrd, "--", "curtin", "install", "--config=%s" %
1769+ self.conf_file, "cp:///"])
1770
1771 # run vm with installer
1772 try:
1773@@ -172,8 +196,8 @@
1774 extra_disks = [x if ":" not in x else x.split(':')[0]
1775 for x in extra_disks]
1776 # create xkvm cmd
1777- cmd = (["tools/xkvm", "--netdev=user", "--disk", self.td.target_disk,
1778- "--disk", self.td.output_disk] + extra_disks +
1779+ cmd = (["tools/xkvm"] + netdevs + ["--disk", self.td.target_disk,
1780+ "--disk", self.td.output_disk] + extra_disks +
1781 ["--", "-drive",
1782 "file=%s,if=virtio,media=cdrom" % self.td.seed_disk,
1783 "-m", "1024"])
1784@@ -208,6 +232,33 @@
1785 if os.path.exists("./serial.log"):
1786 os.remove("./serial.log")
1787
1788+ @classmethod
1789+ def expected_interfaces(self):
1790+ expected = []
1791+ interfaces = {}
1792+ if self.network_state:
1793+ interfaces = self.network_state.get('interfaces')
1794+ # handle interface aliases when subnets have multiple entries
1795+ for iface in interfaces.values():
1796+ subnets = iface.get('subnets', {})
1797+ if subnets:
1798+ for index, subnet in zip(range(0, len(subnets)), subnets):
1799+ if index == 0:
1800+ expected.append(iface)
1801+ else:
1802+ expected.append("{}:{}".format(iface, index))
1803+ else:
1804+ expected.append(iface)
1805+ return expected
1806+
1807+ @classmethod
1808+ def get_network_state(self):
1809+ return self.network_state
1810+
1811+ @classmethod
1812+ def get_expected_etc_network_interfaces(self):
1813+ return curtin_net.render_interfaces(self.network_state)
1814+
1815 # Misc functions that are useful for many tests
1816 def output_files_exist(self, files):
1817 for f in files:
1818
1819=== added file 'tests/vmtests/test_network.py'
1820--- tests/vmtests/test_network.py 1970-01-01 00:00:00 +0000
1821+++ tests/vmtests/test_network.py 2015-09-02 14:28:32 +0000
1822@@ -0,0 +1,224 @@
1823+from . import VMBaseClass
1824+from unittest import TestCase
1825+
1826+import ipaddress
1827+import os
1828+import re
1829+import textwrap
1830+import yaml
1831+
1832+
1833+def iface_extract(input):
1834+ mo = re.search(r'^(?P<interface>\w+|\w+:\d+)\s+' +
1835+ r'Link encap:(?P<link_encap>\S+)\s+' +
1836+ r'(HWaddr\s+(?P<mac_address>\S+))?' +
1837+ r'(\s+inet addr:(?P<address>\S+))?' +
1838+ r'(\s+Bcast:(?P<broadcast>\S+)\s+)?' +
1839+ r'(Mask:(?P<netmask>\S+)\s+)?',
1840+ input, re.MULTILINE)
1841+
1842+ mtu = re.search(r'(\s+MTU:(?P<mtu>\d+)\s+)\s+', input, re.MULTILINE)
1843+ mtu_info = mtu.groupdict('')
1844+ mtu_info['mtu'] = int(mtu_info['mtu'])
1845+
1846+ if mo:
1847+ info = mo.groupdict('')
1848+ info['running'] = False
1849+ info['up'] = False
1850+ info['multicast'] = False
1851+ if 'RUNNING' in input:
1852+ info['running'] = True
1853+ if 'UP' in input:
1854+ info['up'] = True
1855+ if 'MULTICAST' in input:
1856+ info['multicast'] = True
1857+ info.update(mtu_info)
1858+ return info
1859+ return {}
1860+
1861+
1862+def ifconfig_to_dict(ifconfig):
1863+ interfaces = {}
1864+ for iface in [iface_extract(iface) for iface in ifconfig.split('\n\n')
1865+ if iface.strip()]:
1866+ interfaces[iface['interface']] = iface
1867+
1868+ return interfaces
1869+
1870+
1871+class TestNetworkAbs(VMBaseClass):
1872+ __test__ = False
1873+ interactive = False
1874+ conf_file = "examples/tests/basic_network.yaml"
1875+ install_timeout = 600
1876+ boot_timeout = 600
1877+ extra_disks = []
1878+ extra_nics = []
1879+ user_data = textwrap.dedent("""\
1880+ #cloud-config
1881+ password: passw0rd
1882+ chpasswd: { expire: False }
1883+ bootcmd:
1884+ - mkdir -p /media/output
1885+ - mount /dev/vdb /media/output
1886+ runcmd:
1887+ - ifconfig -a > /media/output/ifconfig_a
1888+ - cp -av /etc/network/interfaces /media/output
1889+ - cp -av /etc/udev/rules.d/70-persistent-net.rules /media/output
1890+ - ip -o route show > /media/output/ip_route_show
1891+ - route -n > /media/output/route_n
1892+ power_state:
1893+ mode: poweroff
1894+ """)
1895+
1896+ def test_output_files_exist(self):
1897+ self.output_files_exist(["ifconfig_a",
1898+ "interfaces",
1899+ "70-persistent-net.rules",
1900+ "ip_route_show",
1901+ "route_n"])
1902+
1903+ def test_etc_network_interfaces(self):
1904+ with open(os.path.join(self.td.mnt, "interfaces")) as fp:
1905+ eni = fp.read()
1906+ print('etc/network/interfaces:\n{}'.format(eni))
1907+
1908+ expected_eni = self.get_expected_etc_network_interfaces()
1909+ eni_lines = eni.split('\n')
1910+ for line in expected_eni.split('\n'):
1911+ self.assertTrue(line in eni_lines)
1912+
1913+ def test_ifconfig_output(self):
1914+ '''check ifconfig output with test input'''
1915+ network_state = self.get_network_state()
1916+ print('expected_network_state:\n{}'.format(
1917+ yaml.dump(network_state, default_flow_style=False, indent=4)))
1918+
1919+ with open(os.path.join(self.td.mnt, "ifconfig_a")) as fp:
1920+ ifconfig_a = fp.read()
1921+ print('ifconfig -a:\n{}'.format(ifconfig_a))
1922+
1923+ ifconfig_dict = ifconfig_to_dict(ifconfig_a)
1924+ print('parsed ifcfg dict:\n{}'.format(
1925+ yaml.dump(ifconfig_dict, default_flow_style=False, indent=4)))
1926+
1927+ with open(os.path.join(self.td.mnt, "ip_route_show")) as fp:
1928+ ip_route_show = fp.read()
1929+ print("ip route show:\n{}".format(ip_route_show))
1930+ for line in [line for line in ip_route_show.split('\n')
1931+ if 'src' in line]:
1932+ m = re.search(r'^(?P<network>\S+)\sdev\s' +
1933+ r'(?P<devname>\S+)\s+' +
1934+ r'proto kernel\s+scope link' +
1935+ r'\s+src\s(?P<src_ip>\S+)',
1936+ line)
1937+ route_info = m.groupdict('')
1938+ print(route_info)
1939+
1940+ with open(os.path.join(self.td.mnt, "route_n")) as fp:
1941+ route_n = fp.read()
1942+ print("route -n:\n{}".format(route_n))
1943+
1944+ interfaces = network_state.get('interfaces')
1945+ for iface in interfaces.values():
1946+ subnets = iface.get('subnets', {})
1947+ if subnets:
1948+ for index, subnet in zip(range(0, len(subnets)), subnets):
1949+ iface['index'] = index
1950+ if index == 0:
1951+ ifname = "{name}".format(**iface)
1952+ else:
1953+ ifname = "{name}:{index}".format(**iface)
1954+
1955+ self.check_interface(iface,
1956+ ifconfig_dict.get(ifname),
1957+ route_n)
1958+ else:
1959+ iface['index'] = 0
1960+ self.check_interface(iface,
1961+ ifconfig_dict.get(iface['name']),
1962+ route_n)
1963+
1964+ def check_interface(self, iface, ifconfig, route_n):
1965+ print('testing iface:\n{}\n\nifconfig:\n{}'.format(
1966+ iface, ifconfig))
1967+ subnets = iface.get('subnets', {})
1968+ if subnets and iface['index'] != 0:
1969+ ifname = "{name}:{index}".format(**iface)
1970+ else:
1971+ ifname = "{name}".format(**iface)
1972+
1973+ # initial check, do we have the correct iface ?
1974+ print('ifname={}'.format(ifname))
1975+ print("ifconfig['interface']={}".format(ifconfig['interface']))
1976+ self.assertEqual(ifname, ifconfig['interface'])
1977+
1978+ # check physical interface attributes
1979+ for key in ['mac_address', 'mtu']:
1980+ if key in iface and iface[key]:
1981+ self.assertEqual(iface[key],
1982+ ifconfig[key])
1983+
1984+ def __get_subnet(subnets, subidx):
1985+ for index, subnet in zip(range(0, len(subnets)), subnets):
1986+ if index == subidx:
1987+ break
1988+ return subnet
1989+
1990+ # check subnet related attributes, and specifically only
1991+ # the subnet specified by iface['index']
1992+ subnets = iface.get('subnets', {})
1993+ if subnets:
1994+ subnet = __get_subnet(subnets, iface['index'])
1995+ if 'address' in subnet and subnet['address']:
1996+ if ':' in subnet['address']:
1997+ inet_iface = ipaddress.IPv6Interface(
1998+ subnet['address'])
1999+ else:
2000+ inet_iface = ipaddress.IPv4Interface(
2001+ subnet['address'])
2002+
2003+ # check ip addr
2004+ self.assertEqual(str(inet_iface.ip),
2005+ ifconfig['address'])
2006+
2007+ self.assertEqual(str(inet_iface.netmask),
2008+ ifconfig['netmask'])
2009+
2010+ self.assertEqual(
2011+ str(inet_iface.network.broadcast_address),
2012+ ifconfig['broadcast'])
2013+
2014+ # handle gateway by looking at routing table
2015+ if 'gateway' in subnet and subnet['gateway']:
2016+ gw_ip = subnet['gateway']
2017+ gateways = [line for line in route_n.split('\n')
2018+ if 'UG' in line and gw_ip in line]
2019+ print('matching gateways:\n{}'.format(gateways))
2020+ self.assertEqual(len(gateways), 1)
2021+ [gateways] = gateways
2022+ (dest, gw, genmask, flags, metric, ref, use, iface) = \
2023+ gateways.split()
2024+ print('expected gw:{} found gw:{}'.format(gw_ip, gw))
2025+ self.assertEqual(gw_ip, gw)
2026+
2027+
2028+class TrustyTestBasic(TestNetworkAbs, TestCase):
2029+ __test__ = True
2030+ repo = "maas-daily"
2031+ release = "trusty"
2032+ arch = "amd64"
2033+
2034+
2035+class WilyTestBasic(TestNetworkAbs, TestCase):
2036+ __test__ = True
2037+ repo = "maas-daily"
2038+ release = "wily"
2039+ arch = "amd64"
2040+
2041+
2042+class VividTestBasic(TestNetworkAbs, TestCase):
2043+ __test__ = True
2044+ repo = "maas-daily"
2045+ release = "vivid"
2046+ arch = "amd64"

Subscribers

People subscribed via source and target branches