Merge lp:~raharper/curtin/trunk.passthrough-netconfig into lp:~curtin-dev/curtin/trunk

Proposed by Ryan Harper
Status: Rejected
Rejected by: Scott Moser
Proposed branch: lp:~raharper/curtin/trunk.passthrough-netconfig
Merge into: lp:~curtin-dev/curtin/trunk
Diff against target: 1549 lines (+1116/-93)
14 files modified
curtin/block/__init__.py (+67/-0)
curtin/commands/apply_net.py (+32/-8)
curtin/commands/curthooks.py (+47/-53)
curtin/net/__init__.py (+111/-0)
curtin/util.py (+3/-0)
examples/network-ipv6-bond-vlan.yaml (+2/-2)
examples/tests/network_v2_passthrough.yaml (+8/-0)
tests/unittests/test_commands_apply_net.py (+351/-0)
tests/unittests/test_curthooks.py (+184/-0)
tests/unittests/test_net.py (+137/-1)
tests/vmtests/test_network.py (+147/-24)
tests/vmtests/test_network_alias.py (+6/-0)
tests/vmtests/test_network_bridging.py (+15/-5)
tests/vmtests/test_network_ipv6_enisource.py (+6/-0)
To merge this branch: bzr merge lp:~raharper/curtin/trunk.passthrough-netconfig
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
curtin developers Pending
Review via email: mp+324801@code.launchpad.net

Description of the change

Enable curtin to pass network configuration through to capable targets

Curtin can detect if the cloud-init package in a target is capable of
accepting network configuration, and if so, write out the network-config
into the target instead of rendering it during installation.

To post a comment you must log in.
Revision history for this message
Ryan Harper (raharper) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

well, first few comments
i didn't get very far. sorry.

Revision history for this message
Ryan Harper (raharper) wrote :
Download full text (4.2 KiB)

On Wed, May 31, 2017 at 4:11 PM, Scott Moser <email address hidden> wrote:

> well, first few comments
> i didn't get very far. sorry.
>
>
> Diff comments:
>
> > === modified file 'curtin/__init__.py'
> > --- curtin/__init__.py 2017-03-21 16:41:33 +0000
> > +++ curtin/__init__.py 2017-05-31 00:24:53 +0000
> > @@ -37,6 +37,8 @@
> > 'SUBCOMMAND_SYSTEM_UPGRADE',
> > # supports new format of apt configuration
> > 'APT_CONFIG_V1',
> > + # supports passing network config to target
> > + 'NETWORK_PASSTHROUGH',
>
> Why would someone need to know this ?
> i think it is related to our plan on implementing images that have builtin
> hooks but dont want to use them.
> that is worth an explanation in the commit message.
>

It's worth comment and *documentation*; will add. I think we might also
want to allow caller to
control whether we render eni or passthrough; ie, MaaS may want to *force*
an eni rendering
even if passthrough is possible.

>
> > # has version module
> > 'HAS_VERSION_MODULE',
> > ]
> >
> > === modified file 'curtin/block/__init__.py'
> > --- curtin/block/__init__.py 2017-05-19 18:52:21 +0000
> > +++ curtin/block/__init__.py 2017-05-31 00:24:53 +0000
> > @@ -978,4 +978,71 @@
> > else:
> > raise ValueError("wipe mode %s not supported" % mode)
> >
> > +
> > +def storage_config_required_packages(storage_config, mapping):
> > + """Read storage configuration dictionary and determine
> > + which packages are required for the supplied configuration
> > + to function. Return a list of packaged to install.
> > + """
> > +
> > + if not storage_config or not isinstance(storage_config, dict):
> > + raise ValueError('Invalid storage configuration. '
> > + 'Must be a dict:\n %s' % storage_config)
> > +
> > + if not mapping or not isinstance(mapping, dict):
> > + raise ValueError('Invalid storage mapping. Must be a dict')
> > +
> > + if 'storage' in storage_config:
> > + storage_config = storage_config.get('storage')
> > +
> > + needed_packages = []
> > +
> > + # get reqs by device operation type
> > + dev_configs = set(operation['type']
> > + for operation in storage_config['config'])
> > +
> > + for dev_type in dev_configs:
> > + if dev_type in mapping:
> > + needed_packages.extend(mapping[dev_type])
> > +
> > + # for any format operations, check the fstype and
> > + # determine if we need any mkfs tools as well.
> > + format_configs = set([operation['fstype']
> > + for operation in storage_config['config']
> > + if operation['type'] == 'format'])
> > + for format_type in format_configs:
> > + if format_type in mapping:
> > + needed_packages.extend(mapping[format_type])
> > +
> > + return needed_packages
> > +
> > +
> > +def detect_required_packages_mapping():
> > + """Return a dictionary providing a versioned configuration which
> maps
> > + storage configuration elements to the packages which are required
> > + for functionality.
> > +
> > + The mapping key is either...

Read more...

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

Summary of review:
 - your commit message doesn't mention that you've modified the block/fs depends (i dont think)
 - more unit tests would be good.
 - I don't love the 'passthrough'. if we dont have an actual need for it i think i'd drop it for now.

but over all, seems sane.

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for the review, will fix ack'ed items.

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

Moved this to 'Work in progress'. Move it back to 'Needs Review' when you "fix ack'ed items.".

Thanks

Revision history for this message
Ryan Harper (raharper) wrote :

Updated branch with fixes for items marked ACK. Custom config deps discussion still needed.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

inline comments, also:
 * Chad has started requesting Human readable """This unit tests does..."""
   in each unit test, and I generally agree.

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

shoot. hit submit before i was done... continuing.

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

a lot of prints in test cases.

Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for the review, will update with ACK'ed fixes. I think the items that remain for discussion:

1) parsing shebang for which python interpreter to use for cloud-init in-target
2) side-effect = (Exception) which it appears there was some confusion with the assert_not_called (which I agree need to be replaced with call_count checks).

Revision history for this message
Ryan Harper (raharper) wrote :

Fixed all of the ACK's here, filed a bug in cloud-init for UpperCase MAC issue, and applied these fixes to centos-passthrough branch, mp here (https://code.launchpad.net/~raharper/curtin/centos-passthrough/+merge/327536)

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

Thanks for the follow-up. I'll fix up (1) and (2) as discussed below.

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

marking this as rejected.
the changes here all went into
 https://code.launchpad.net/~raharper/curtin/centos-passthrough/+merge/327536
which got merged (see comments there).

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'curtin/block/__init__.py'
--- curtin/block/__init__.py 2017-05-19 18:52:21 +0000
+++ curtin/block/__init__.py 2017-06-19 16:35:41 +0000
@@ -978,4 +978,71 @@
978 else:978 else:
979 raise ValueError("wipe mode %s not supported" % mode)979 raise ValueError("wipe mode %s not supported" % mode)
980980
981
982def storage_config_required_packages(storage_config, mapping):
983 """Read storage configuration dictionary and determine
984 which packages are required for the supplied configuration
985 to function. Return a list of packaged to install.
986 """
987
988 if not storage_config or not isinstance(storage_config, dict):
989 raise ValueError('Invalid storage configuration. '
990 'Must be a dict:\n %s' % storage_config)
991
992 if not mapping or not isinstance(mapping, dict):
993 raise ValueError('Invalid storage mapping. Must be a dict')
994
995 if 'storage' in storage_config:
996 storage_config = storage_config.get('storage')
997
998 needed_packages = []
999
1000 # get reqs by device operation type
1001 dev_configs = set(operation['type']
1002 for operation in storage_config['config'])
1003
1004 for dev_type in dev_configs:
1005 if dev_type in mapping:
1006 needed_packages.extend(mapping[dev_type])
1007
1008 # for any format operations, check the fstype and
1009 # determine if we need any mkfs tools as well.
1010 format_configs = set([operation['fstype']
1011 for operation in storage_config['config']
1012 if operation['type'] == 'format'])
1013 for format_type in format_configs:
1014 if format_type in mapping:
1015 needed_packages.extend(mapping[format_type])
1016
1017 return needed_packages
1018
1019
1020def detect_required_packages_mapping():
1021 """Return a dictionary providing a versioned configuration which maps
1022 storage configuration elements to the packages which are required
1023 for functionality.
1024
1025 The mapping key is either a config type value, or an fstype value.
1026
1027 """
1028 version = 1
1029 mapping = {
1030 version: {
1031 'handler': storage_config_required_packages,
1032 'mapping': {
1033 'bcache': ['bcache-tools'],
1034 'btrfs': ['btrfs-tools'],
1035 'ext2': ['e2fsprogs'],
1036 'ext3': ['e2fsprogs'],
1037 'ext4': ['e2fsprogs'],
1038 'lvm_partition': ['lvm2'],
1039 'lvm_volgroup': ['lvm2'],
1040 'raid': ['mdadm'],
1041 'xfs': ['xfsprogs']
1042 },
1043 },
1044 }
1045 return mapping
1046
1047
981# vi: ts=4 expandtab syntax=python1048# vi: ts=4 expandtab syntax=python
9821049
=== modified file 'curtin/commands/apply_net.py'
--- curtin/commands/apply_net.py 2017-02-08 05:56:12 +0000
+++ curtin/commands/apply_net.py 2017-06-19 16:35:41 +0000
@@ -21,6 +21,7 @@
21from .. import log21from .. import log
22import curtin.net as net22import curtin.net as net
23import curtin.util as util23import curtin.util as util
24from curtin import config
24from . import populate_one_subcmd25from . import populate_one_subcmd
2526
2627
@@ -89,15 +90,36 @@
89 sys.stderr.write(msg + "\n")90 sys.stderr.write(msg + "\n")
90 raise Exception(msg)91 raise Exception(msg)
9192
93 passthrough = False
92 if network_state:94 if network_state:
95 # NB: we cannot support passthrough until curtin can convert from
96 # network_state to network-config yaml
93 ns = net.network_state.from_state_file(network_state)97 ns = net.network_state.from_state_file(network_state)
98 raise ValueError('Not Supported; curtin lacks a network_state to '
99 'network_config converter.')
94 elif network_config:100 elif network_config:
95 ns = net.parse_net_config(network_config)101 netcfg = config.load_config(network_config)
96102
97 net.render_network_state(target=target, network_state=ns)103 # curtin will pass-through the netconfig into the target
104 # for rendering at runtime, unless:
105 # 1) target OS does not support (cloud-init too old)
106 LOG.info('Checking cloud-init in target [%s] for network '
107 'configuration passthrough support.', target)
108 passthrough = net.netconfig_passthrough_available(target)
109 LOG.debug('passthrough available via in-target: %s', passthrough)
110
111 if passthrough:
112 LOG.info('Passing network configuration through to target: %s',
113 target)
114 net.render_netconfig_passthrough(target, netconfig=netcfg)
115 else:
116 ns = net.parse_net_config_data(netcfg.get('network', {}))
117
118 if not passthrough:
119 LOG.info('Rendering network configuration in target')
120 net.render_network_state(target=target, network_state=ns)
98121
99 _maybe_remove_legacy_eth0(target)122 _maybe_remove_legacy_eth0(target)
100 LOG.info('Attempting to remove ipv6 privacy extensions')
101 _disable_ipv6_privacy_extensions(target)123 _disable_ipv6_privacy_extensions(target)
102 _patch_ifupdown_ipv6_mtu_hook(target)124 _patch_ifupdown_ipv6_mtu_hook(target)
103125
@@ -130,6 +152,7 @@
130 by default; this races with the cloud-image desire to disable them.152 by default; this races with the cloud-image desire to disable them.
131 Resolve this by allowing the cloud-image setting to win. """153 Resolve this by allowing the cloud-image setting to win. """
132154
155 LOG.debug('Attempting to remove ipv6 privacy extensions')
133 cfg = util.target_path(target, path=path)156 cfg = util.target_path(target, path=path)
134 if not os.path.exists(cfg):157 if not os.path.exists(cfg):
135 LOG.warn('Failed to find ipv6 privacy conf file %s', cfg)158 LOG.warn('Failed to find ipv6 privacy conf file %s', cfg)
@@ -143,7 +166,7 @@
143 lines = [f.strip() for f in contents.splitlines()166 lines = [f.strip() for f in contents.splitlines()
144 if not f.startswith("#")]167 if not f.startswith("#")]
145 if lines == known_contents:168 if lines == known_contents:
146 LOG.info('deleting file: %s', cfg)169 LOG.info('Found expected contents, deleting file: %s', cfg)
147 util.del_file(cfg)170 util.del_file(cfg)
148 msg = "removed %s with known contents" % cfg171 msg = "removed %s with known contents" % cfg
149 curtin_contents = '\n'.join(172 curtin_contents = '\n'.join(
@@ -153,9 +176,10 @@
153 "# net.ipv6.conf.default.use_tempaddr = 2"])176 "# net.ipv6.conf.default.use_tempaddr = 2"])
154 util.write_file(cfg, curtin_contents)177 util.write_file(cfg, curtin_contents)
155 else:178 else:
156 LOG.info('skipping, content didnt match')179 LOG.debug('skipping removal of %s, expected content not found',
157 LOG.debug("found content:\n%s", lines)180 cfg)
158 LOG.debug("expected contents:\n%s", known_contents)181 LOG.debug("Found content in file %s:\n%s", cfg, lines)
182 LOG.debug("Expected contents in file %s:\n%s", cfg, known_contents)
159 msg = (bmsg + " '%s' exists with user configured content." % cfg)183 msg = (bmsg + " '%s' exists with user configured content." % cfg)
160 except Exception as e:184 except Exception as e:
161 msg = bmsg + " %s exists, but could not be read. %s" % (cfg, e)185 msg = bmsg + " %s exists, but could not be read. %s" % (cfg, e)
162186
=== modified file 'curtin/commands/curthooks.py'
--- curtin/commands/curthooks.py 2017-05-12 00:49:43 +0000
+++ curtin/commands/curthooks.py 2017-06-19 16:35:41 +0000
@@ -25,6 +25,7 @@
2525
26from curtin import config26from curtin import config
27from curtin import block27from curtin import block
28from curtin import net
28from curtin import futil29from curtin import futil
29from curtin.log import LOG30from curtin.log import LOG
30from curtin import swap31from curtin import swap
@@ -648,6 +649,38 @@
648 update_initramfs(target, all_kernels=True)649 update_initramfs(target, all_kernels=True)
649650
650651
652def detect_required_packages(cfg):
653 """
654 detect packages that will be required in-target by custom config items
655 """
656
657 mapping = {
658 'storage': block.detect_required_packages_mapping(),
659 'network': net.detect_required_packages_mapping(),
660 }
661
662 needed_packages = []
663 for cfg_type, cfg_map in mapping.items():
664
665 # skip missing or invalid config items, configs may
666 # only have network or storage, not always both
667 if not isinstance(cfg.get(cfg_type), dict):
668 continue
669
670 cfg_version = cfg[cfg_type].get('version')
671 if not isinstance(cfg_version, int) or cfg_version not in cfg_map:
672 msg = ('Supplied configuration version "%s", for config type'
673 '"%s" is not present in the known mapping.' % (cfg_version,
674 cfg_type))
675 raise ValueError(msg)
676
677 mapped_config = cfg_map[cfg_version]
678 found_reqs = mapped_config['handler'](cfg, mapped_config['mapping'])
679 needed_packages.extend(found_reqs)
680
681 return needed_packages
682
683
651def install_missing_packages(cfg, target):684def install_missing_packages(cfg, target):
652 ''' describe which operation types will require specific packages685 ''' describe which operation types will require specific packages
653686
@@ -655,46 +688,10 @@
655 'pkg1': ['op_name_1', 'op_name_2', ...]688 'pkg1': ['op_name_1', 'op_name_2', ...]
656 }689 }
657 '''690 '''
658 custom_configs = {691
659 'storage': {
660 'lvm2': ['lvm_volgroup', 'lvm_partition'],
661 'mdadm': ['raid'],
662 'bcache-tools': ['bcache']},
663 'network': {
664 'vlan': ['vlan'],
665 'ifenslave': ['bond'],
666 'bridge-utils': ['bridge']},
667 }
668
669 format_configs = {
670 'xfsprogs': ['xfs'],
671 'e2fsprogs': ['ext2', 'ext3', 'ext4'],
672 'btrfs-tools': ['btrfs'],
673 }
674
675 needed_packages = []
676 installed_packages = util.get_installed_packages(target)692 installed_packages = util.get_installed_packages(target)
677 for cust_cfg, pkg_reqs in custom_configs.items():693 needed_packages = [pkg for pkg in detect_required_packages(cfg)
678 if cust_cfg not in cfg:694 if pkg not in installed_packages]
679 continue
680
681 all_types = set(
682 operation['type']
683 for operation in cfg[cust_cfg]['config']
684 )
685 for pkg, types in pkg_reqs.items():
686 if set(types).intersection(all_types) and \
687 pkg not in installed_packages:
688 needed_packages.append(pkg)
689
690 format_types = set(
691 [operation['fstype']
692 for operation in cfg[cust_cfg]['config']
693 if operation['type'] == 'format'])
694 for pkg, fstypes in format_configs.items():
695 if set(fstypes).intersection(format_types) and \
696 pkg not in installed_packages:
697 needed_packages.append(pkg)
698695
699 arch_packages = {696 arch_packages = {
700 's390x': [('s390-tools', 'zipl')],697 's390x': [('s390-tools', 'zipl')],
@@ -852,7 +849,11 @@
852 disable_overlayroot(cfg, target)849 disable_overlayroot(cfg, target)
853850
854 # packages may be needed prior to installing kernel851 # packages may be needed prior to installing kernel
855 install_missing_packages(cfg, target)852 with events.ReportEventStack(
853 name=stack_prefix + '/installing-missing-packages',
854 reporting_enabled=True, level="INFO",
855 description="installing missing packages"):
856 install_missing_packages(cfg, target)
856857
857 # If a /etc/iscsi/nodes/... file was created by block_meta then it858 # If a /etc/iscsi/nodes/... file was created by block_meta then it
858 # needs to be copied onto the target system859 # needs to be copied onto the target system
@@ -880,10 +881,15 @@
880 setup_zipl(cfg, target)881 setup_zipl(cfg, target)
881 install_kernel(cfg, target)882 install_kernel(cfg, target)
882 run_zipl(cfg, target)883 run_zipl(cfg, target)
883
884 restore_dist_interfaces(cfg, target)884 restore_dist_interfaces(cfg, target)
885885
886 with events.ReportEventStack(886 with events.ReportEventStack(
887 name=stack_prefix + '/system-upgrade',
888 reporting_enabled=True, level="INFO",
889 description="updating packages on target system"):
890 system_upgrade(cfg, target)
891
892 with events.ReportEventStack(
887 name=stack_prefix + '/setting-up-swap',893 name=stack_prefix + '/setting-up-swap',
888 reporting_enabled=True, level="INFO",894 reporting_enabled=True, level="INFO",
889 description="setting up swap"):895 description="setting up swap"):
@@ -907,18 +913,6 @@
907 description="configuring multipath"):913 description="configuring multipath"):
908 detect_and_handle_multipath(cfg, target)914 detect_and_handle_multipath(cfg, target)
909915
910 with events.ReportEventStack(
911 name=stack_prefix + '/installing-missing-packages',
912 reporting_enabled=True, level="INFO",
913 description="installing missing packages"):
914 install_missing_packages(cfg, target)
915
916 with events.ReportEventStack(
917 name=stack_prefix + '/system-upgrade',
918 reporting_enabled=True, level="INFO",
919 description="updating packages on target system"):
920 system_upgrade(cfg, target)
921
922 # If a crypttab file was created by block_meta than it needs to be copied916 # If a crypttab file was created by block_meta than it needs to be copied
923 # onto the target system, and update_initramfs() needs to be run, so that917 # onto the target system, and update_initramfs() needs to be run, so that
924 # the cryptsetup hooks are properly configured on the installed system and918 # the cryptsetup hooks are properly configured on the installed system and
925919
=== modified file 'curtin/net/__init__.py'
--- curtin/net/__init__.py 2017-02-06 20:58:37 +0000
+++ curtin/net/__init__.py 2017-06-19 16:35:41 +0000
@@ -520,7 +520,63 @@
520 return content520 return content
521521
522522
523def netconfig_passthrough_available(target, feature='NETWORK_CONFIG_V2'):
524 """
525 Determine if curtin can pass v2 network config to in target cloud-init
526 """
527 LOG.debug('Checking in-target cloud-init features')
528 cmd = ("from cloudinit import version;"
529 "print('{}' in getattr(version, 'FEATURES', []))"
530 .format(feature))
531 with util.ChrootableTarget(target) as in_chroot:
532
533 def run_cmd(cmd):
534 (out, _) = in_chroot.subp(cmd, capture=True)
535 return out.strip()
536
537 cloudinit = util.which('cloud-init', target=target)
538 if not cloudinit:
539 LOG.warning('Target does not have cloud-init installed')
540 return False
541
542 # here we read shebang from cloud-init and extract python path as we
543 # cannot use the presence of the python package to determine which
544 # python cloud-init will use. E.g trusty has python3 support but
545 # cloud-init uses python2.7
546 python = util.load_file(util.target_path(target, path=cloudinit))
547 python = python.splitlines()[0].split("#!")[-1].strip()
548 try:
549 feature_available = run_cmd([python, '-c', cmd])
550 except util.ProcessExecutionError:
551 LOG.exception("An error occurred while probing cloudinit features")
552 return False
553
554 available = config.value_as_boolean(feature_available)
555 LOG.debug('%s available? %s', feature, available)
556 return available
557
558
559def render_netconfig_passthrough(target, netconfig=None):
560 """
561 Extract original network config and pass it
562 through to cloud-init in target
563 """
564 LOG.debug("rendering passthrough netconfig")
565 cc = 'etc/cloud/cloud.cfg.d/curtin-networking.cfg'
566 if not isinstance(netconfig, dict):
567 raise ValueError('Network config must be a dictionary')
568
569 if 'network' not in netconfig:
570 raise ValueError("Network config must contain the key 'network'")
571
572 content = config.dump_config(netconfig)
573 cc_passthrough = os.path.sep.join((target, cc,))
574 LOG.info('Writing ' + cc_passthrough)
575 util.write_file(cc_passthrough, content=content)
576
577
523def render_network_state(target, network_state):578def render_network_state(target, network_state):
579 LOG.debug("rendering eni from netconfig")
524 eni = 'etc/network/interfaces'580 eni = 'etc/network/interfaces'
525 netrules = 'etc/udev/rules.d/70-persistent-net.rules'581 netrules = 'etc/udev/rules.d/70-persistent-net.rules'
526 cc = 'etc/cloud/cloud.cfg.d/curtin-disable-cloudinit-networking.cfg'582 cc = 'etc/cloud/cloud.cfg.d/curtin-disable-cloudinit-networking.cfg'
@@ -542,4 +598,59 @@
542 """Returns the string value of an interface's MAC Address"""598 """Returns the string value of an interface's MAC Address"""
543 return read_sys_net(ifname, "address", enoent=False)599 return read_sys_net(ifname, "address", enoent=False)
544600
601
602def network_config_required_packages(network_config, mapping=None):
603
604 if not network_config or not isinstance(network_config, dict):
605 raise ValueError('Invalid network configuration. Must be a dict')
606
607 if not mapping or not isinstance(mapping, dict):
608 raise ValueError('Invalid network mapping. Must be a dict')
609
610 # allow top-level 'network' key
611 if 'network' in network_config:
612 network_config = network_config.get('network')
613
614 # v1 has 'config' key and uses type: devtype elements
615 if 'config' in network_config:
616 dev_configs = set(device['type']
617 for device in network_config['config'])
618 else:
619 # v2 has no config key
620 dev_configs = set(cfgtype for (cfgtype, cfg) in
621 network_config.items() if cfgtype not in ['version'])
622
623 needed_packages = []
624 for dev_type in dev_configs:
625 if dev_type in mapping:
626 needed_packages.extend(mapping[dev_type])
627
628 return needed_packages
629
630
631def detect_required_packages_mapping():
632 """Return a dictionary providing a versioned configuration which maps
633 network configuration elements to the packages which are required
634 for functionality.
635 """
636 mapping = {
637 1: {
638 'handler': network_config_required_packages,
639 'mapping': {
640 'bond': ['ifenslave'],
641 'bridge': ['bridge-utils'],
642 'vlan': ['vlan']},
643 },
644 2: {
645 'handler': network_config_required_packages,
646 'mapping': {
647 'bonds': ['ifenslave'],
648 'bridges': ['bridge-utils'],
649 'vlans': ['vlan']}
650 },
651 }
652
653 return mapping
654
655
545# vi: ts=4 expandtab syntax=python656# vi: ts=4 expandtab syntax=python
546657
=== modified file 'curtin/util.py'
--- curtin/util.py 2017-06-05 02:55:31 +0000
+++ curtin/util.py 2017-06-19 16:35:41 +0000
@@ -1347,6 +1347,9 @@
1347 if not path:1347 if not path:
1348 return target1348 return target
13491349
1350 if not isinstance(path, string_types):
1351 raise ValueError("Unexpected input for path: %s" % path)
1352
1350 # os.path.join("/etc", "/foo") returns "/foo". Chomp all leading /.1353 # os.path.join("/etc", "/foo") returns "/foo". Chomp all leading /.
1351 while len(path) and path[0] == "/":1354 while len(path) and path[0] == "/":
1352 path = path[1:]1355 path = path[1:]
13531356
=== modified file 'examples/network-ipv6-bond-vlan.yaml'
--- examples/network-ipv6-bond-vlan.yaml 2016-08-12 17:24:13 +0000
+++ examples/network-ipv6-bond-vlan.yaml 2017-06-19 16:35:41 +0000
@@ -3,10 +3,10 @@
3 config:3 config:
4 - name: interface04 - name: interface0
5 type: physical5 type: physical
6 mac_address: BC:76:4E:06:96:B36 mac_address: bc:76:4e:06:96:b3
7 - name: interface17 - name: interface1
8 type: physical8 type: physical
9 mac_address: BC:76:4E:04:88:419 mac_address: bc:76:4e:04:88:41
10 - type: bond10 - type: bond
11 bond_interfaces:11 bond_interfaces:
12 - interface012 - interface0
1313
=== added file 'examples/tests/network_v2_passthrough.yaml'
--- examples/tests/network_v2_passthrough.yaml 1970-01-01 00:00:00 +0000
+++ examples/tests/network_v2_passthrough.yaml 2017-06-19 16:35:41 +0000
@@ -0,0 +1,8 @@
1showtrace: true
2network:
3 version: 2
4 ethernets:
5 interface0:
6 match:
7 mac_address: "52:54:00:12:34:00"
8 dhcp4: true
09
=== added file 'tests/unittests/test_commands_apply_net.py'
--- tests/unittests/test_commands_apply_net.py 1970-01-01 00:00:00 +0000
+++ tests/unittests/test_commands_apply_net.py 2017-06-19 16:35:41 +0000
@@ -0,0 +1,351 @@
1from unittest import TestCase
2from mock import patch, call
3import copy
4import os
5
6from curtin.commands import apply_net
7from curtin import util
8
9
10class ApplyNetTestBase(TestCase):
11 def setUp(self):
12 super(ApplyNetTestBase, self).setUp()
13
14 def add_patch(self, target, attr):
15 """Patches specified target object and sets it as attr on test
16 instance also schedules cleanup"""
17 m = patch(target, autospec=True)
18 p = m.start()
19 self.addCleanup(m.stop)
20 setattr(self, attr, p)
21
22
23class TestApplyNet(ApplyNetTestBase):
24 def setUp(self):
25 super(TestApplyNet, self).setUp()
26
27 basepath = 'curtin.commands.apply_net.'
28 self.add_patch(basepath + '_maybe_remove_legacy_eth0', 'mock_legacy')
29 self.add_patch(basepath + '_disable_ipv6_privacy_extensions',
30 'mock_ipv6_priv')
31 self.add_patch(basepath + '_patch_ifupdown_ipv6_mtu_hook',
32 'mock_ipv6_mtu')
33 self.add_patch('curtin.net.netconfig_passthrough_available',
34 'mock_netpass_avail')
35 self.add_patch('curtin.net.render_netconfig_passthrough',
36 'mock_netpass_render')
37 self.add_patch('curtin.net.parse_net_config_data',
38 'mock_net_parsedata')
39 self.add_patch('curtin.net.render_network_state',
40 'mock_net_renderstate')
41 self.add_patch('curtin.net.network_state.from_state_file',
42 'mock_ns_from_file')
43 self.add_patch('curtin.config.load_config', 'mock_load_config')
44
45 self.target = "my_target"
46 self.network_config = {
47 'network': {
48 'version': 1,
49 'config': {},
50 }
51 }
52 self.ns = {
53 'interfaces': {},
54 'routes': [],
55 'dns': {
56 'nameservers': [],
57 'search': [],
58 }
59 }
60
61 def test_apply_net_notarget(self):
62 self.assertRaises(Exception,
63 apply_net.apply_net, None, "", "")
64
65 def test_apply_net_nostate_or_config(self):
66 self.assertRaises(Exception,
67 apply_net.apply_net, "")
68
69 def test_apply_net_target_and_state(self):
70 self.mock_ns_from_file.return_value = self.ns
71
72 self.assertRaises(ValueError,
73 apply_net.apply_net, self.target,
74 network_state=self.ns, network_config=None)
75
76 def test_apply_net_target_and_config(self):
77 self.mock_load_config.return_value = self.network_config
78 self.mock_netpass_avail.return_value = False
79 self.mock_net_parsedata.return_value = self.ns
80
81 apply_net.apply_net(self.target, network_state=None,
82 network_config=self.network_config)
83
84 self.mock_netpass_avail.assert_called_with(self.target)
85
86 self.mock_net_renderstate.assert_called_with(target=self.target,
87 network_state=self.ns)
88 self.mock_legacy.assert_called_with(self.target)
89 self.mock_ipv6_priv.assert_called_with(self.target)
90 self.mock_ipv6_mtu.assert_called_with(self.target)
91
92 def test_apply_net_target_and_config_passthrough(self):
93 self.mock_load_config.return_value = self.network_config
94 self.mock_netpass_avail.return_value = True
95
96 netcfg = "network_config.yaml"
97 apply_net.apply_net(self.target, network_state=None,
98 network_config=netcfg)
99
100 self.assertFalse(self.mock_ns_from_file.called)
101 self.mock_load_config.assert_called_with(netcfg)
102 self.mock_netpass_avail.assert_called_with(self.target)
103 nc = self.network_config
104 self.mock_netpass_render.assert_called_with(self.target, netconfig=nc)
105
106 self.assertFalse(self.mock_net_renderstate.called)
107 self.mock_legacy.assert_called_with(self.target)
108 self.mock_ipv6_priv.assert_called_with(self.target)
109 self.mock_ipv6_mtu.assert_called_with(self.target)
110
111 def test_apply_net_target_and_config_passthrough_nonet(self):
112 nc = {'storage': {}}
113 self.mock_load_config.return_value = nc
114 self.mock_netpass_avail.return_value = True
115
116 netcfg = "network_config.yaml"
117
118 apply_net.apply_net(self.target, network_state=None,
119 network_config=netcfg)
120
121 self.assertFalse(self.mock_ns_from_file.called)
122 self.mock_load_config.assert_called_with(netcfg)
123 self.mock_netpass_avail.assert_called_with(self.target)
124 self.mock_netpass_render.assert_called_with(self.target, netconfig=nc)
125
126 self.assertFalse(self.mock_net_renderstate.called)
127 self.mock_legacy.assert_called_with(self.target)
128 self.mock_ipv6_priv.assert_called_with(self.target)
129 self.mock_ipv6_mtu.assert_called_with(self.target)
130
131 def test_apply_net_target_and_config_passthrough_v2_not_available(self):
132 nc = copy.deepcopy(self.network_config)
133 nc['network']['version'] = 2
134 self.mock_load_config.return_value = nc
135 self.mock_netpass_avail.return_value = False
136 self.mock_net_parsedata.return_value = self.ns
137
138 netcfg = "network_config.yaml"
139
140 apply_net.apply_net(self.target, network_state=None,
141 network_config=netcfg)
142
143 self.assertFalse(self.mock_ns_from_file.called)
144 self.mock_load_config.assert_called_with(netcfg)
145 self.mock_netpass_avail.assert_called_with(self.target)
146 self.assertFalse(self.mock_netpass_render.called)
147 self.mock_net_parsedata.assert_called_with(nc['network'])
148
149 self.mock_net_renderstate.assert_called_with(
150 target=self.target, network_state=self.ns)
151 self.mock_legacy.assert_called_with(self.target)
152 self.mock_ipv6_priv.assert_called_with(self.target)
153 self.mock_ipv6_mtu.assert_called_with(self.target)
154
155
156class TestApplyNetPatchIfupdown(ApplyNetTestBase):
157
158 @patch('curtin.util.write_file')
159 def test_apply_ipv6_mtu_hook(self, mock_write):
160 target = 'mytarget'
161 prehookfn = 'if-pre-up.d/mtuipv6'
162 posthookfn = 'if-up.d/mtuipv6'
163 mode = 0o755
164
165 apply_net._patch_ifupdown_ipv6_mtu_hook(target,
166 prehookfn=prehookfn,
167 posthookfn=posthookfn)
168
169 precfg = util.target_path(target, path=prehookfn)
170 postcfg = util.target_path(target, path=posthookfn)
171 precontents = apply_net.IFUPDOWN_IPV6_MTU_PRE_HOOK
172 postcontents = apply_net.IFUPDOWN_IPV6_MTU_POST_HOOK
173
174 hook_calls = [
175 call(precfg, precontents, mode=mode),
176 call(postcfg, postcontents, mode=mode),
177 ]
178 mock_write.assert_has_calls(hook_calls)
179
180 @patch('curtin.util.write_file')
181 def test_apply_ipv6_mtu_hook_write_fail(self, mock_write):
182 target = 'mytarget'
183 prehookfn = 'if-pre-up.d/mtuipv6'
184 posthookfn = 'if-up.d/mtuipv6'
185 mock_write.side_effect = (Exception)
186
187 self.assertRaises(Exception,
188 apply_net._patch_ifupdown_ipv6_mtu_hook,
189 target,
190 prehookfn=prehookfn,
191 posthookfn=posthookfn)
192
193 @patch('curtin.util.write_file')
194 def test_apply_ipv6_mtu_hook_invalid_target(self, mock_write):
195 """ Test that an invalid target will fail to build a
196 proper path for util.write_file
197 """
198 target = {}
199 prehookfn = 'if-pre-up.d/mtuipv6'
200 posthookfn = 'if-up.d/mtuipv6'
201 mock_write.side_effect = (Exception)
202
203 self.assertRaises(ValueError,
204 apply_net._patch_ifupdown_ipv6_mtu_hook,
205 target,
206 prehookfn=prehookfn,
207 posthookfn=posthookfn)
208
209 @patch('curtin.util.write_file')
210 def test_apply_ipv6_mtu_hook_invalid_prepost_fn(self, mock_write):
211 """ Test that invalid prepost filenames will fail to build a
212 proper path for util.write_file
213 """
214 target = "mytarget"
215 prehookfn = {'a': 1}
216 posthookfn = {'b': 2}
217 mock_write.side_effect = (Exception)
218
219 self.assertRaises(ValueError,
220 apply_net._patch_ifupdown_ipv6_mtu_hook,
221 target,
222 prehookfn=prehookfn,
223 posthookfn=posthookfn)
224
225
226class TestApplyNetPatchIpv6Priv(ApplyNetTestBase):
227
228 @patch('curtin.util.del_file')
229 @patch('curtin.util.load_file')
230 @patch('os.path')
231 @patch('curtin.util.write_file')
232 def test_disable_ipv6_priv_extentions(self, mock_write, mock_ospath,
233 mock_load, mock_del):
234 target = 'mytarget'
235 path = 'etc/sysctl.d/10-ipv6-privacy.conf'
236 ipv6_priv_contents = (
237 'net.ipv6.conf.all.use_tempaddr = 2\n'
238 'net.ipv6.conf.default.use_tempaddr = 2')
239 expected_ipv6_priv_contents = '\n'.join(
240 ["# IPv6 Privacy Extensions (RFC 4941)",
241 "# Disabled by curtin",
242 "# net.ipv6.conf.all.use_tempaddr = 2",
243 "# net.ipv6.conf.default.use_tempaddr = 2"])
244 mock_ospath.exists.return_value = True
245 mock_load.side_effect = [ipv6_priv_contents]
246
247 apply_net._disable_ipv6_privacy_extensions(target)
248
249 cfg = util.target_path(target, path=path)
250 mock_write.assert_called_with(cfg, expected_ipv6_priv_contents)
251
252 @patch('curtin.util.load_file')
253 @patch('os.path')
254 def test_disable_ipv6_priv_extentions_decoderror(self, mock_ospath,
255 mock_load):
256 target = 'mytarget'
257 mock_ospath.exists.return_value = True
258
259 # simulate loading of binary data
260 mock_load.side_effect = (Exception)
261
262 self.assertRaises(Exception,
263 apply_net._disable_ipv6_privacy_extensions,
264 target)
265
266 @patch('curtin.util.load_file')
267 @patch('os.path')
268 def test_disable_ipv6_priv_extentions_notfound(self, mock_ospath,
269 mock_load):
270 target = 'mytarget'
271 path = 'foo.conf'
272 mock_ospath.exists.return_value = False
273
274 apply_net._disable_ipv6_privacy_extensions(target, path=path)
275
276 # source file not found
277 cfg = util.target_path(target, path)
278 mock_ospath.exists.assert_called_with(cfg)
279 mock_load.assert_not_called()
280
281
282class TestApplyNetRemoveLegacyEth0(ApplyNetTestBase):
283
284 @patch('curtin.util.del_file')
285 @patch('curtin.util.load_file')
286 @patch('os.path')
287 def test_remove_legacy_eth0(self, mock_ospath, mock_load, mock_del):
288 target = 'mytarget'
289 path = 'eth0.cfg'
290 cfg = util.target_path(target, path)
291 legacy_eth0_contents = (
292 'auto eth0\n'
293 'iface eth0 inet dhcp')
294
295 mock_ospath.exists.return_value = True
296 mock_load.side_effect = [legacy_eth0_contents]
297
298 apply_net._maybe_remove_legacy_eth0(target, path)
299
300 mock_del.assert_called_with(cfg)
301
302 @patch('curtin.util.del_file')
303 @patch('curtin.util.load_file')
304 @patch('os.path')
305 def test_remove_legacy_eth0_nomatch(self, mock_ospath, mock_load,
306 mock_del):
307 target = 'mytarget'
308 path = 'eth0.cfg'
309 legacy_eth0_contents = "nomatch"
310 mock_ospath.join.side_effect = os.path.join
311 mock_ospath.exists.return_value = True
312 mock_load.side_effect = [legacy_eth0_contents]
313
314 self.assertRaises(Exception,
315 apply_net._maybe_remove_legacy_eth0,
316 target, path)
317
318 mock_del.assert_not_called()
319
320 @patch('curtin.util.del_file')
321 @patch('curtin.util.load_file')
322 @patch('os.path')
323 def test_remove_legacy_eth0_badload(self, mock_ospath, mock_load,
324 mock_del):
325 target = 'mytarget'
326 path = 'eth0.cfg'
327 mock_ospath.exists.return_value = True
328 mock_load.side_effect = (Exception)
329
330 self.assertRaises(Exception,
331 apply_net._maybe_remove_legacy_eth0,
332 target, path)
333
334 mock_del.assert_not_called()
335
336 @patch('curtin.util.del_file')
337 @patch('curtin.util.load_file')
338 @patch('os.path')
339 def test_remove_legacy_eth0_notfound(self, mock_ospath, mock_load,
340 mock_del):
341 target = 'mytarget'
342 path = 'eth0.conf'
343 mock_ospath.exists.return_value = False
344
345 apply_net._maybe_remove_legacy_eth0(target, path)
346
347 # source file not found
348 cfg = util.target_path(target, path)
349 mock_ospath.exists.assert_called_with(cfg)
350 mock_load.assert_not_called()
351 mock_del.assert_not_called()
0352
=== modified file 'tests/unittests/test_curthooks.py'
--- tests/unittests/test_curthooks.py 2017-05-12 14:35:53 +0000
+++ tests/unittests/test_curthooks.py 2017-06-19 16:35:41 +0000
@@ -577,4 +577,188 @@
577 with self.assertRaises(ValueError):577 with self.assertRaises(ValueError):
578 curthooks.handle_cloudconfig([], target="foobar")578 curthooks.handle_cloudconfig([], target="foobar")
579579
580
581class TestDetectRequiredPackages(TestCase):
582 test_config = {
583 'storage': {
584 1: {
585 'bcache': {
586 'type': 'bcache', 'name': 'bcache0', 'id': 'cache0',
587 'backing_device': 'sda3', 'cache_device': 'sdb'},
588 'lvm_partition': {
589 'id': 'lvol1', 'name': 'lv1', 'volgroup': 'vg1',
590 'type': 'lvm_partition'},
591 'lvm_volgroup': {
592 'id': 'vol1', 'name': 'vg1', 'devices': ['sda', 'sdb'],
593 'type': 'lvm_volgroup'},
594 'raid': {
595 'id': 'mddevice', 'name': 'md0', 'type': 'raid',
596 'raidlevel': 5, 'devices': ['sda1', 'sdb1', 'sdc1']},
597 'ext2': {
598 'id': 'format0', 'fstype': 'ext2', 'type': 'format'},
599 'ext3': {
600 'id': 'format1', 'fstype': 'ext3', 'type': 'format'},
601 'ext4': {
602 'id': 'format2', 'fstype': 'ext4', 'type': 'format'},
603 'btrfs': {
604 'id': 'format3', 'fstype': 'btrfs', 'type': 'format'},
605 'xfs': {
606 'id': 'format4', 'fstype': 'xfs', 'type': 'format'}}
607 },
608 'network': {
609 1: {
610 'bond': {
611 'name': 'bond0', 'type': 'bond',
612 'bond_interfaces': ['interface0', 'interface1'],
613 'params': {'bond-mode': 'active-backup'},
614 'subnets': [
615 {'type': 'static', 'address': '10.23.23.2/24'},
616 {'type': 'static', 'address': '10.23.24.2/24'}]},
617 'vlan': {
618 'id': 'interface1.2667', 'mtu': 1500, 'name':
619 'interface1.2667', 'type': 'vlan', 'vlan_id': 2667,
620 'vlan_link': 'interface1',
621 'subnets': [{'address': '10.245.184.2/24',
622 'dns_nameservers': [], 'type': 'static'}]},
623 'bridge': {
624 'name': 'br0', 'bridge_interfaces': ['eth0', 'eth1'],
625 'type': 'bridge', 'params': {
626 'bridge_stp': 'off', 'bridge_fd': 0,
627 'bridge_maxwait': 0},
628 'subnets': [
629 {'type': 'static', 'address': '192.168.14.2/24'},
630 {'type': 'static', 'address': '2001:1::1/64'}]}},
631 2: {
632 'vlan': {
633 'vlans': {
634 'en-intra': {'id': 1, 'link': 'eno1', 'dhcp4': 'yes'},
635 'en-vpn': {'id': 2, 'link': 'eno1'}}},
636 'bridge': {
637 'bridges': {
638 'br0': {
639 'interfaces': ['wlp1s0', 'switchports'],
640 'dhcp4': True}}}}
641 },
642 }
643
644 def _fmt_config(self, config_items):
645 res = {}
646 for item, item_confs in config_items.items():
647 version = item_confs['version']
648 res[item] = {'version': version}
649 if version == 1:
650 res[item]['config'] = [self.test_config[item][version][i]
651 for i in item_confs['items']]
652 elif version == 2 and item == 'network':
653 for cfg_item in item_confs['items']:
654 res[item].update(self.test_config[item][version][cfg_item])
655 else:
656 raise NotImplementedError
657 return res
658
659 def _test_req_mappings(self, req_mappings):
660 for (config_items, expected_reqs) in req_mappings:
661 cfg = self._fmt_config(config_items)
662 actual_reqs = curthooks.detect_required_packages(cfg)
663 self.assertEqual(set(actual_reqs), set(expected_reqs),
664 'failed for config: {}'.format(config_items))
665
666 def test_storage_v1_detect(self):
667 self._test_req_mappings((
668 ({'storage': {
669 'version': 1,
670 'items': ('lvm_partition', 'lvm_volgroup', 'btrfs', 'xfs')}},
671 ('lvm2', 'xfsprogs', 'btrfs-tools')),
672 ({'storage': {
673 'version': 1,
674 'items': ('raid', 'bcache', 'ext3', 'xfs')}},
675 ('mdadm', 'bcache-tools', 'e2fsprogs', 'xfsprogs')),
676 ({'storage': {
677 'version': 1,
678 'items': ('raid', 'lvm_volgroup', 'lvm_partition', 'ext3',
679 'ext4', 'btrfs')}},
680 ('lvm2', 'mdadm', 'e2fsprogs', 'btrfs-tools')),
681 ({'storage': {
682 'version': 1,
683 'items': ('bcache', 'lvm_volgroup', 'lvm_partition', 'ext2')}},
684 ('bcache-tools', 'lvm2', 'e2fsprogs')),
685 ))
686
687 def test_network_v1_detect(self):
688 self._test_req_mappings((
689 ({'network': {
690 'version': 1,
691 'items': ('bridge',)}},
692 ('bridge-utils',)),
693 ({'network': {
694 'version': 1,
695 'items': ('vlan', 'bond')}},
696 ('vlan', 'ifenslave')),
697 ({'network': {
698 'version': 1,
699 'items': ('bond', 'bridge')}},
700 ('ifenslave', 'bridge-utils')),
701 ({'network': {
702 'version': 1,
703 'items': ('vlan', 'bridge', 'bond')}},
704 ('ifenslave', 'bridge-utils', 'vlan')),
705 ))
706
707 def test_mixed_v1_detect(self):
708 self._test_req_mappings((
709 ({'storage': {
710 'version': 1,
711 'items': ('raid', 'bcache', 'ext4')},
712 'network': {
713 'version': 1,
714 'items': ('vlan',)}},
715 ('mdadm', 'bcache-tools', 'e2fsprogs', 'vlan')),
716 ({'storage': {
717 'version': 1,
718 'items': ('lvm_partition', 'lvm_volgroup', 'xfs')},
719 'network': {
720 'version': 1,
721 'items': ('bridge', 'bond')}},
722 ('lvm2', 'xfsprogs', 'bridge-utils', 'ifenslave')),
723 ({'storage': {
724 'version': 1,
725 'items': ('ext3', 'ext4', 'btrfs')},
726 'network': {
727 'version': 1,
728 'items': ('bond', 'vlan')}},
729 ('e2fsprogs', 'btrfs-tools', 'vlan', 'ifenslave')),
730 ))
731
732 def test_network_v2_detect(self):
733 self._test_req_mappings((
734 ({'network': {
735 'version': 2,
736 'items': ('bridge',)}},
737 ('bridge-utils',)),
738 ({'network': {
739 'version': 2,
740 'items': ('vlan',)}},
741 ('vlan',)),
742 ({'network': {
743 'version': 2,
744 'items': ('vlan', 'bridge')}},
745 ('vlan', 'bridge-utils')),
746 ))
747
748 def test_mixed_storage_v1_network_v2_detect(self):
749 self._test_req_mappings((
750 ({'network': {
751 'version': 2,
752 'items': ('bridge', 'vlan')},
753 'storage': {
754 'version': 1,
755 'items': ('raid', 'bcache', 'ext4')}},
756 ('vlan', 'bridge-utils', 'mdadm', 'bcache-tools', 'e2fsprogs')),
757 ))
758
759 def test_invalid_version_in_config(self):
760 with self.assertRaises(ValueError):
761 curthooks.detect_required_packages({'network': {'version': 3}})
762
763
580# vi: ts=4 expandtab syntax=python764# vi: ts=4 expandtab syntax=python
581765
=== modified file 'tests/unittests/test_net.py'
--- tests/unittests/test_net.py 2017-02-09 16:58:23 +0000
+++ tests/unittests/test_net.py 2017-06-19 16:35:41 +0000
@@ -1,10 +1,11 @@
1from unittest import TestCase1from unittest import TestCase
2import mock
2import os3import os
3import shutil4import shutil
4import tempfile5import tempfile
5import yaml6import yaml
67
7from curtin import net8from curtin import config, net, util
8import curtin.net.network_state as network_state9import curtin.net.network_state as network_state
9from textwrap import dedent10from textwrap import dedent
1011
@@ -654,6 +655,141 @@
654 self.assertEqual(sorted(ifaces.split('\n')),655 self.assertEqual(sorted(ifaces.split('\n')),
655 sorted(net_ifaces.split('\n')))656 sorted(net_ifaces.split('\n')))
656657
658 @mock.patch('curtin.util.load_file')
659 @mock.patch('curtin.util.subp')
660 @mock.patch('curtin.util.which')
661 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
662 def test_netconfig_passthrough_available(self, mock_which, mock_subp,
663 mock_load_file):
664 cloud_init = '/usr/bin/cloud-init'
665 python = '/usr/bin/python3'
666 mock_which.return_value = cloud_init
667 mock_load_file.return_value = "#! %s" % python
668 mock_subp.return_value = ('True', '')
669 feature = 'NETWORK_CONFIG_V2'
670 expected_cmd = (
671 "from cloudinit import version;"
672 "print('%s' in getattr(version, 'FEATURES', []))" % feature)
673
674 available = net.netconfig_passthrough_available(self.target)
675
676 self.assertEqual(True, available,
677 "netconfig passthrough was NOT available")
678 mock_which.assert_called_with('cloud-init', target=self.target)
679 mock_load_file.assert_called_with(self.target + cloud_init)
680 mock_subp.assert_called_with([python, '-c', expected_cmd],
681 capture=True, target=self.target)
682
683 @mock.patch('curtin.util.load_file')
684 @mock.patch('curtin.util.subp')
685 @mock.patch('curtin.util.which')
686 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
687 def test_netconfig_passthrough_available_no_py3(self, mock_which,
688 mock_subp, mock_load_file):
689 cloud_init = '/usr/bin/cloud-init'
690 python = '/usr/bin/python'
691 mock_which.return_value = cloud_init
692 mock_load_file.return_value = "#! %s" % python
693 mock_subp.return_value = ('True', '')
694 feature = 'NETWORK_CONFIG_V2'
695 expected_cmd = (
696 "from cloudinit import version;"
697 "print('%s' in getattr(version, 'FEATURES', []))" % feature)
698
699 available = net.netconfig_passthrough_available(self.target)
700
701 self.assertEqual(True, available,
702 "netconfig passthrough was available")
703 mock_which.assert_called_with('cloud-init', target=self.target)
704 mock_load_file.assert_called_with(self.target + cloud_init)
705 mock_subp.assert_called_with([python, '-c', expected_cmd],
706 capture=True, target=self.target)
707
708 @mock.patch('curtin.net.LOG')
709 @mock.patch('curtin.util.subp')
710 @mock.patch('curtin.util.which')
711 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
712 def test_netconfig_passthrough_available_no_cloudinit(self, mock_which,
713 mock_subp, mock_log):
714 mock_which.return_value = None
715
716 available = net.netconfig_passthrough_available(self.target)
717
718 self.assertEqual(False, available,
719 "netconfig passthrough was available")
720 self.assertTrue(mock_log.warning.called)
721 self.assertFalse(mock_subp.called)
722
723 @mock.patch('curtin.util.load_file')
724 @mock.patch('curtin.util.subp')
725 @mock.patch('curtin.util.which')
726 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
727 def test_netconfig_passthrough_available_feature_not_found(self,
728 mock_which,
729 mock_subp,
730 mock_load_file):
731 cloud_init = '/usr/bin/cloud-init'
732 python = '/usr/bin/python3'
733 mock_which.return_value = cloud_init
734 mock_load_file.return_value = "#! %s" % python
735 mock_subp.return_value = ('False', '')
736 feature = 'NETWORK_CONFIG_V2'
737 expected_cmd = (
738 "from cloudinit import version;"
739 "print('%s' in getattr(version, 'FEATURES', []))" % feature)
740
741 available = net.netconfig_passthrough_available(self.target)
742
743 self.assertEqual(False, available,
744 "netconfig passthrough was available")
745 mock_which.assert_called_with('cloud-init', target=self.target)
746 mock_load_file.assert_called_with(self.target + cloud_init)
747 mock_subp.assert_called_with([python, '-c', expected_cmd],
748 capture=True, target=self.target)
749
750 @mock.patch('curtin.util.load_file')
751 @mock.patch('curtin.net.LOG')
752 @mock.patch('curtin.util.subp')
753 @mock.patch('curtin.util.which')
754 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
755 def test_netconfig_passthrough_available_exc(self, mock_which, mock_subp,
756 mock_log, mock_load_file):
757 cloud_init = '/usr/bin/cloud-init'
758 python = '/usr/bin/python3'
759 mock_which.return_value = cloud_init
760 mock_load_file.return_value = "#! %s" % python
761 mock_subp.side_effect = util.ProcessExecutionError
762 feature = 'NETWORK_CONFIG_V2'
763 expected_cmd = (
764 "from cloudinit import version;"
765 "print('%s' in getattr(version, 'FEATURES', []))" % feature)
766
767 available = net.netconfig_passthrough_available(self.target)
768
769 self.assertEqual(False, available,
770 "netconfig passthrough was available")
771 mock_which.assert_called_with('cloud-init', target=self.target)
772 mock_subp.assert_called_with([python, '-c', expected_cmd],
773 capture=True, target=self.target)
774 self.assertTrue(mock_log.exception.called)
775
776 @mock.patch('curtin.util.write_file')
777 def test_render_netconfig_passthrough(self, mock_writefile):
778 netcfg = yaml.safe_load(self.config)
779 pt_config = 'etc/cloud/cloud.cfg.d/curtin-networking.cfg'
780 target_config = os.path.sep.join((self.target, pt_config),)
781
782 net.render_netconfig_passthrough(self.target, netconfig=netcfg)
783
784 content = config.dump_config(netcfg)
785 mock_writefile.assert_called_with(target_config, content=content)
786
787 def test_render_netconfig_passthrough_nonetcfg(self):
788 netcfg = None
789 self.assertRaises(ValueError,
790 net.render_netconfig_passthrough,
791 self.target, netconfig=netcfg)
792
657 def test_routes_rendered(self):793 def test_routes_rendered(self):
658 # as reported in bug 1649652794 # as reported in bug 1649652
659 conf = [795 conf = [
660796
=== modified file 'tests/vmtests/test_network.py'
--- tests/vmtests/test_network.py 2017-05-19 21:40:48 +0000
+++ tests/vmtests/test_network.py 2017-06-19 16:35:41 +0000
@@ -1,7 +1,12 @@
1from . import VMBaseClass, logger, helpers1from . import VMBaseClass, logger, helpers
2from .releases import base_vm_classes as relbase2from .releases import base_vm_classes as relbase
33
4from unittest import SkipTest
5from curtin import config
6
7import glob
4import ipaddress8import ipaddress
9import os
5import re10import re
6import textwrap11import textwrap
7import yaml12import yaml
@@ -14,26 +19,41 @@
14 collect_scripts = [textwrap.dedent("""19 collect_scripts = [textwrap.dedent("""
15 cd OUTPUT_COLLECT_D20 cd OUTPUT_COLLECT_D
16 echo "waiting for ipv6 to settle" && sleep 521 echo "waiting for ipv6 to settle" && sleep 5
17 ifconfig -a > ifconfig_a22 route -n | tee first_route_n
18 ip link show > ip_link_show23 ifconfig -a | tee ifconfig_a
19 ip a > ip_a24 ip link show | tee ip_link_show
25 ip a | tee ip_a
20 find /etc/network/interfaces.d > find_interfacesd26 find /etc/network/interfaces.d > find_interfacesd
21 cp -av /etc/network/interfaces .27 cp -av /etc/network/interfaces .
22 cp -av /etc/network/interfaces.d .28 cp -av /etc/network/interfaces.d .
23 cp /etc/resolv.conf .29 cp /etc/resolv.conf .
24 cp -av /etc/udev/rules.d/70-persistent-net.rules .30 cp -av /etc/udev/rules.d/70-persistent-net.rules . ||:
25 ip -o route show > ip_route_show31 ip -o route show | tee ip_route_show
26 ip -6 -o route show > ip_6_route_show32 ip -6 -o route show | tee ip_6_route_show
27 route -n > route_n33 route -n |tee route_n
28 route -6 -n > route_6_n34 route -6 -n |tee route_6_n
29 cp -av /run/network ./run_network35 cp -av /run/network ./run_network
30 cp -av /var/log/upstart ./upstart ||:36 cp -av /var/log/upstart ./upstart ||:
31 sleep 10 && ip a > ip_a37 cp -av /etc/cloud ./etc_cloud
38 cp -av /var/log/cloud*.log ./
39 dpkg-query -W -f '${Version}' cloud-init |tee dpkg_cloud-init_version
40 dpkg-query -W -f '${Version}' nplan |tee dpkg_nplan_version
41 dpkg-query -W -f '${Version}' systemd |tee dpkg_systemd_version
42 V=/usr/lib/python*/dist-packages/cloudinit/version.py;
43 grep -c NETWORK_CONFIG_V2 $V > cloudinit_passthrough_available
44 mkdir -p etc_netplan
45 cp -av /etc/netplan/* ./etc_netplan/ ||:
46 networkctl |tee networkctl
47 mkdir -p run_systemd_network
48 cp -a /run/systemd/network/* ./run_systemd_network/ ||:
49 cp -a /run/systemd/netif ./run_systemd_netif ||:
50 cp -a /run/systemd/resolve ./run_systemd_resolve ||:
51 cp -a /etc/systemd ./etc_systemd ||:
52 journalctl --no-pager -b -x > journalctl_out
32 """)]53 """)]
3354
34 def test_output_files_exist(self):55 def test_output_files_exist(self):
35 self.output_files_exist([56 self.output_files_exist([
36 "70-persistent-net.rules",
37 "find_interfacesd",57 "find_interfacesd",
38 "ifconfig_a",58 "ifconfig_a",
39 "interfaces",59 "interfaces",
@@ -44,17 +64,109 @@
44 "route_n",64 "route_n",
45 ])65 ])
4666
47 def test_etc_network_interfaces(self):67 def read_eni(self):
68 eni = ""
69 eni_cfg = ""
70
48 eni = self.load_collect_file("interfaces")71 eni = self.load_collect_file("interfaces")
49 logger.debug('etc/network/interfaces:\n{}'.format(eni))72 logger.debug('etc/network/interfaces:\n{}'.format(eni))
5073
74 # we don't use collect_path as we're building a glob
75 eni_dir = os.path.join(self.td.collect, "interfaces.d", "*.cfg")
76 for cfg in glob.glob(eni_dir):
77 with open(cfg) as fp:
78 eni_cfg += fp.read()
79
80 return (eni, eni_cfg)
81
82 def _network_renderer(self):
83 """ Determine if target uses eni/ifupdown or netplan/networkd """
84
85 etc_netplan = self.collect_path('etc_netplan')
86 networkd = self.collect_path('run_systemd_network')
87
88 if len(os.listdir(etc_netplan)) > 0 and len(os.listdir(networkd)) > 0:
89 print('Network Renderer: systemd-networkd')
90 return 'systemd-networkd'
91
92 print('Network Renderer: ifupdown')
93 return 'ifupdown'
94
95 def test_etc_network_interfaces(self):
96 if self._network_renderer() != "ifupdown":
97 reason = ("{}: using net-passthrough; "
98 "deferring to cloud-init".format(self.__class__))
99 raise SkipTest(reason)
100
101 eni, eni_cfg = self.read_eni()
51 expected_eni = self.get_expected_etc_network_interfaces()102 expected_eni = self.get_expected_etc_network_interfaces()
52 eni_lines = eni.split('\n')103
53 for line in expected_eni.split('\n'):104 eni_lines = eni.split('\n') + eni_cfg.split('\n')
54 self.assertTrue(line in eni_lines, msg="missing line: %s" % line)105 print("\n".join(eni_lines))
106 for line in [l for l in expected_eni.split('\n') if len(l) > 0]:
107 if line.startswith("#"):
108 continue
109 if "hwaddress ether" in line:
110 continue
111 print('expected line:\n%s' % line)
112 self.assertTrue(line in eni_lines, "not in eni: %s" % line)
113
114 def test_cloudinit_network_passthrough(self):
115 cc_passthrough = "cloud.cfg.d/curtin-networking.cfg"
116
117 avail_str = self.load_collect_file('cloudinit_passthrough_available')
118 available = int(avail_str) == 1
119 print('avail_str=%s available=%s' % (avail_str, available))
120
121 if not available:
122 raise SkipTest('not available on %s' % self.__class__)
123
124 print('passthrough was available')
125 pt_file = os.path.join(self.td.collect, 'etc_cloud',
126 cc_passthrough)
127 print('checking if passthrough file written: %s' % pt_file)
128 self.assertTrue(os.path.exists(pt_file))
129
130 # compare
131 original = {'network':
132 config.load_config(self.conf_file).get('network')}
133 intarget = config.load_config(pt_file)
134 self.assertEqual(original, intarget)
135
136 def test_cloudinit_network_disabled(self):
137 cc_disabled = 'cloud.cfg.d/curtin-disable-cloudinit-networking.cfg'
138
139 avail_str = self.load_collect_file('cloudinit_passthrough_available')
140 available = int(avail_str) == 1
141 print('avail_str=%s available=%s' % (avail_str, available))
142
143 if available:
144 raise SkipTest('passthrough available on %s' % self.__class__)
145
146 print('passthrough not available')
147 cc_disable_file = os.path.join(self.td.collect, 'etc_cloud',
148 cc_disabled)
149 print('checking if network:disable file written: %s' %
150 cc_disable_file)
151 self.assertTrue(os.path.exists(cc_disable_file))
152
153 # compare
154 original = {'network': {'config': 'disabled'}}
155 intarget = config.load_config(cc_disable_file)
156
157 print('checking cloud-init network-cfg content')
158 self.assertEqual(original, intarget)
55159
56 def test_etc_resolvconf(self):160 def test_etc_resolvconf(self):
57 resolvconf = self.load_collect_file("resolv.conf")161 render2resolvconf = {
162 'ifupdown': "resolv.conf",
163 'systemd-networkd': "run_systemd_resolve/resolv.conf"
164 }
165 resolvconfpath = render2resolvconf.get(self._network_renderer(), None)
166 self.assertTrue(resolvconfpath is not None)
167 logger.debug('Selected path to resolvconf: %s', resolvconfpath)
168
169 resolvconf = self.load_collect_file(resolvconfpath)
58 logger.debug('etc/resolv.conf:\n{}'.format(resolvconf))170 logger.debug('etc/resolv.conf:\n{}'.format(resolvconf))
59171
60 resolv_lines = resolvconf.split('\n')172 resolv_lines = resolvconf.split('\n')
@@ -111,12 +223,21 @@
111 def test_static_routes(self):223 def test_static_routes(self):
112 '''check routing table'''224 '''check routing table'''
113 network_state = self.get_network_state()225 network_state = self.get_network_state()
226
227 # if we're using passthrough then we can't load state
228 cc_passthrough = "cloud.cfg.d/curtin-networking.cfg"
229 pt_file = os.path.join(self.td.collect, 'etc_cloud', cc_passthrough)
230 print('checking if passthrough file written: %s' % pt_file)
231 if not network_state and os.path.exists(pt_file):
232 raise SkipTest('passthrough enabled, skipping %s' % self.__class__)
233
114 ip_route_show = self.load_collect_file("ip_route_show")234 ip_route_show = self.load_collect_file("ip_route_show")
115 route_n = self.load_collect_file("route_n")235 route_n = self.load_collect_file("route_n")
116236
117 print("ip route show:\n%s" % ip_route_show)237 print("ip route show:\n%s" % ip_route_show)
118 print("route -n:\n%s" % route_n)238 print("route -n:\n%s" % route_n)
119 routes = network_state.get('routes')239 routes = network_state.get('routes', [])
240 print("found routes: [%s]" % routes)
120 for route in routes:241 for route in routes:
121 print('Checking static route: %s' % route)242 print('Checking static route: %s' % route)
122 destnet = (243 destnet = (
@@ -144,6 +265,12 @@
144 print('parsed ip_a dict:\n{}'.format(265 print('parsed ip_a dict:\n{}'.format(
145 yaml.dump(ip_dict, default_flow_style=False, indent=4)))266 yaml.dump(ip_dict, default_flow_style=False, indent=4)))
146267
268 route_n = self.load_collect_file("route_n")
269 logger.debug("route -n:\n{}".format(route_n))
270
271 route_6_n = self.load_collect_file("route_6_n")
272 logger.debug("route -6 -n:\n{}".format(route_6_n))
273
147 ip_route_show = self.load_collect_file("ip_route_show")274 ip_route_show = self.load_collect_file("ip_route_show")
148 logger.debug("ip route show:\n{}".format(ip_route_show))275 logger.debug("ip route show:\n{}".format(ip_route_show))
149 for line in [line for line in ip_route_show.split('\n')276 for line in [line for line in ip_route_show.split('\n')
@@ -156,12 +283,6 @@
156 route_info = m.groupdict('')283 route_info = m.groupdict('')
157 logger.debug(route_info)284 logger.debug(route_info)
158285
159 route_n = self.load_collect_file("route_n")
160 logger.debug("route -n:\n{}".format(route_n))
161
162 route_6_n = self.load_collect_file("route_6_n")
163 logger.debug("route -6 -n:\n{}".format(route_6_n))
164
165 routes = {286 routes = {
166 '4': route_n,287 '4': route_n,
167 '6': route_6_n,288 '6': route_6_n,
@@ -170,9 +291,10 @@
170 for iface in interfaces.values():291 for iface in interfaces.values():
171 print("\nnetwork_state iface: %s" % (292 print("\nnetwork_state iface: %s" % (
172 yaml.dump(iface, default_flow_style=False, indent=4)))293 yaml.dump(iface, default_flow_style=False, indent=4)))
294 ipcfg = ip_dict.get(iface['name'], {})
173 self.check_interface(iface['name'],295 self.check_interface(iface['name'],
174 iface,296 iface,
175 ip_dict.get(iface['name']),297 ipcfg,
176 routes)298 routes)
177299
178 def check_interface(self, ifname, iface, ipcfg, routes):300 def check_interface(self, ifname, iface, ipcfg, routes):
@@ -182,8 +304,9 @@
182 # FIXME: remove check?304 # FIXME: remove check?
183 # initial check, do we have the correct iface ?305 # initial check, do we have the correct iface ?
184 print('ifname={}'.format(ifname))306 print('ifname={}'.format(ifname))
307 self.assertTrue(type(ipcfg) == dict, "%s is not dict" % (ipcfg))
308 print("ipcfg['interface']={}".format(ipcfg['interface']))
185 self.assertEqual(ifname, ipcfg['interface'])309 self.assertEqual(ifname, ipcfg['interface'])
186 print("ipcfg['interface']={}".format(ipcfg['interface']))
187310
188 # check physical interface attributes (skip bond members, macs change)311 # check physical interface attributes (skip bond members, macs change)
189 if iface['type'] in ['physical'] and 'bond-master' not in iface:312 if iface['type'] in ['physical'] and 'bond-master' not in iface:
190313
=== modified file 'tests/vmtests/test_network_alias.py'
--- tests/vmtests/test_network_alias.py 2017-04-26 16:14:04 +0000
+++ tests/vmtests/test_network_alias.py 2017-06-19 16:35:41 +0000
@@ -1,5 +1,6 @@
1from .releases import base_vm_classes as relbase1from .releases import base_vm_classes as relbase
2from .test_network import TestNetworkBaseTestsAbs2from .test_network import TestNetworkBaseTestsAbs
3from unittest import SkipTest
34
45
5class TestNetworkAliasAbs(TestNetworkBaseTestsAbs):6class TestNetworkAliasAbs(TestNetworkBaseTestsAbs):
@@ -7,6 +8,11 @@
7 """8 """
8 conf_file = "examples/tests/network_alias.yaml"9 conf_file = "examples/tests/network_alias.yaml"
910
11 def test_etc_network_interfaces(self):
12 reason = ("%s: cloud-init and curtin eni rendering"
13 " differ" % (self.__class__))
14 raise SkipTest(reason)
15
1016
11class PreciseHWETTestNetworkAlias(relbase.precise_hwe_t, TestNetworkAliasAbs):17class PreciseHWETTestNetworkAlias(relbase.precise_hwe_t, TestNetworkAliasAbs):
12 # FIXME: off due to hang at test: Starting execute cloud user/final scripts18 # FIXME: off due to hang at test: Starting execute cloud user/final scripts
1319
=== modified file 'tests/vmtests/test_network_bridging.py'
--- tests/vmtests/test_network_bridging.py 2017-05-19 21:40:48 +0000
+++ tests/vmtests/test_network_bridging.py 2017-06-19 16:35:41 +0000
@@ -111,7 +111,6 @@
111 self.assertEqual('install ok installed', status)111 self.assertEqual('install ok installed', status)
112112
113 def test_bridge_params(self):113 def test_bridge_params(self):
114 """ Test if configure bridge params match values on the device """
115114
116 def _load_sysfs_bridge_data():115 def _load_sysfs_bridge_data():
117 sysfs_br0 = sysfs_to_dict(self.collect_path("sysfs_br0"))116 sysfs_br0 = sysfs_to_dict(self.collect_path("sysfs_br0"))
@@ -138,6 +137,7 @@
138 p not in bridge_params_uncheckable)]137 p not in bridge_params_uncheckable)]
139138
140 def _check_bridge_param(sysfs_vals, p, br):139 def _check_bridge_param(sysfs_vals, p, br):
140 print('Checking bridge %s param %s' % (br, p))
141 value = br.get(param)141 value = br.get(param)
142 if param in ['bridge_stp']:142 if param in ['bridge_stp']:
143 if value in ['off', '0']:143 if value in ['off', '0']:
@@ -146,25 +146,35 @@
146 value = 1146 value = 1
147 else:147 else:
148 print('bridge_stp not in known val list')148 print('bridge_stp not in known val list')
149 elif param in ['bridge_portprio']:
150 if self._network_renderer() == "systemd-networkd":
151 reason = ("%s: skip until lp#1668347"
152 " is fixed" % self.__class__)
153 logger.warn('Skipping: %s', reason)
154 print(reason)
155 return
149156
150 print('key=%s value=%s' % (param, value))157 print('key=%s value=%s' % (param, value))
151 if type(value) == list:158 if type(value) == list:
152 for subval in value:159 for subval in value:
153 (port, pval) = subval.split(" ")160 (port, pval) = subval.split(" ")
154 print('key=%s port=%s pval=%s' % (param, port, pval))161 print('param=%s port=%s pval=%s' % (param, port, pval))
155 sys_file_val = _get_sysfs_value(sysfs_vals, br0['name'],162 sys_file_val = _get_sysfs_value(sysfs_vals, br0['name'],
156 param, port)163 param, port)
157164
158 self.assertEqual(int(pval), int(sys_file_val))165 msg = "Source cfg: %s=%s on port %s" % (param, value, port)
166 self.assertEqual(int(pval), int(sys_file_val), msg)
159 else:167 else:
160 sys_file_val = _get_sysfs_value(sysfs_vals, br0['name'],168 sys_file_val = _get_sysfs_value(sysfs_vals, br0['name'],
161 param, port=None)169 param, port=None)
162 self.assertEqual(int(value), int(sys_file_val))170 self.assertEqual(int(value), int(sys_file_val),
171 "Source cfg: %s=%s" % (param, value))
163172
164 sysfs_vals = _load_sysfs_bridge_data()173 sysfs_vals = _load_sysfs_bridge_data()
165 print(sysfs_vals)174 # print(sysfs_vals)
166 br0 = _get_bridge_config()175 br0 = _get_bridge_config()
167 for param in _get_bridge_params(br0):176 for param in _get_bridge_params(br0):
177 print('Checking param %s' % param)
168 _check_bridge_param(sysfs_vals, param, br0)178 _check_bridge_param(sysfs_vals, param, br0)
169179
170180
171181
=== modified file 'tests/vmtests/test_network_ipv6_enisource.py'
--- tests/vmtests/test_network_ipv6_enisource.py 2017-04-26 16:14:04 +0000
+++ tests/vmtests/test_network_ipv6_enisource.py 2017-06-19 16:35:41 +0000
@@ -1,10 +1,16 @@
1from .releases import base_vm_classes as relbase1from .releases import base_vm_classes as relbase
2from .test_network_enisource import TestNetworkENISource2from .test_network_enisource import TestNetworkENISource
33
4import unittest
5
46
5class TestNetworkIPV6ENISource(TestNetworkENISource):7class TestNetworkIPV6ENISource(TestNetworkENISource):
6 conf_file = "examples/tests/network_source_ipv6.yaml"8 conf_file = "examples/tests/network_source_ipv6.yaml"
79
10 @unittest.skip("FIXME: cloud-init.net needs update")
11 def test_etc_network_interfaces(self):
12 pass
13
814
9class PreciseTestNetworkIPV6ENISource(relbase.precise,15class PreciseTestNetworkIPV6ENISource(relbase.precise,
10 TestNetworkIPV6ENISource):16 TestNetworkIPV6ENISource):

Subscribers

People subscribed via source and target branches