diff --git a/subiquitycore/controllers/network.py b/subiquitycore/controllers/network.py index ed5d71bd..fca812d5 100644 --- a/subiquitycore/controllers/network.py +++ b/subiquitycore/controllers/network.py @@ -16,7 +16,6 @@ from functools import partial import logging import os -import random import select import socket @@ -44,6 +43,8 @@ from subiquitycore.ui.views.network import ApplyingConfigWidget from subiquitycore.ui.dummy import DummyView from subiquitycore.controller import BaseController from subiquitycore.utils import run_command +from subiquitycore.file_util import write_file +from subiquitycore import netplan log = logging.getLogger("subiquitycore.controller.network") @@ -245,22 +246,16 @@ class NetworkController(BaseController, TaskWatcher): log.debug("network config: \n%s", yaml.dump(sanitize_config(config), default_flow_style=False)) - netplan_path = self.netplan_path - while True: - try: - tmppath = '%s.%s' % (netplan_path, random.randrange(0, 1000)) - fd = os.open(tmppath, - os.O_WRONLY | os.O_EXCL | os.O_CREAT, 0o0600) - except FileExistsError: + for p in netplan.configs_in_root(self.root, masked=True): + if p == self.netplan_path: continue - else: - break - w = os.fdopen(fd, 'w') - with w: - w.write("# This is the network config written by " - "'%s'\n" % (self.opts.project)) - w.write(yaml.dump(config)) - os.rename(tmppath, netplan_path) + os.rename(p, p + ".dist-" + self.opts.project) + + write_file(self.netplan_path, '\n'.join(( + ("# This is the network config written by '%s'" % + self.opts.project), + yaml.dump(config))), omode="w") + self.model.parse_netplan_configs(self.root) if self.opts.dry_run: tasks = [ diff --git a/subiquitycore/file_util.py b/subiquitycore/file_util.py new file mode 100644 index 00000000..268032eb --- /dev/null +++ b/subiquitycore/file_util.py @@ -0,0 +1,32 @@ +import os +import stat +import tempfile + +_DEF_PERMS = 0o644 + + +def write_file(filename, content, mode=None, omode="wb", copy_mode=False): + """Atomically write filename. + open filename in mode 'omode', write content, chmod to 'mode'. + """ + if mode is None: + mode = _DEF_PERMS + if copy_mode: + try: + file_stat = os.stat(filename) + mode = stat.S_IMODE(file_stat.st_mode) + except OSError: + pass + + tf = None + try: + tf = tempfile.NamedTemporaryFile(dir=os.path.dirname(filename), + delete=False, mode=omode) + tf.write(content) + tf.close() + os.chmod(tf.name, mode) + os.rename(tf.name, filename) + except OSError as e: + if tf is not None: + os.unlink(tf.name) + raise e diff --git a/subiquitycore/models/network.py b/subiquitycore/models/network.py index d5736386..9199a6de 100644 --- a/subiquitycore/models/network.py +++ b/subiquitycore/models/network.py @@ -14,15 +14,11 @@ # along with this program. If not, see . import copy -import fnmatch -import glob import ipaddress import logging -import os from socket import AF_INET, AF_INET6 -import yaml -from yaml.reader import ReaderError +from subiquitycore import netplan NETDEV_IGNORED_IFACE_NAMES = ['lo'] @@ -30,76 +26,6 @@ NETDEV_IGNORED_IFACE_TYPES = ['bridge', 'tun', 'tap', 'dummy', 'sit'] log = logging.getLogger('subiquitycore.models.network') -class _NetplanDevice: - def __init__(self, name, config): - match = config.get('match') - if match is None: - self.match_name = name - self.match_mac = None - self.match_driver = None - else: - self.match_name = match.get('name') - self.match_mac = match.get('macaddress') - self.match_driver = match.get('driver') - self.config = config - - def matches_link(self, link): - if self.match_name is not None: - matches_name = fnmatch.fnmatch(link.name, self.match_name) - else: - matches_name = True - if self.match_mac is not None: - matches_mac = self.match_mac == link.hwaddr - else: - matches_mac = True - if self.match_driver is not None: - matches_driver = self.match_driver == link.driver - else: - matches_driver = True - return matches_name and matches_mac and matches_driver - - -class NetplanConfig: - """A NetplanConfig represents the network config for a system. - - Call parse_netplan_config() with each piece of yaml config, and then - call config_for_device to get the config that matches a particular - network devices, if any. - """ - - def __init__(self): - self.devices = [] - - def parse_netplan_config(self, config): - try: - config = yaml.safe_load(config) - except ReaderError as e: - log.info("could not parse config: %s", e) - return - network = config.get('network') - if network is None: - log.info("no 'network' key in config") - return - version = network.get("version") - if version != 2: - log.info("network has no/unexpected version %s", version) - return - for ethernet, eth_config in network.get('ethernets', {}).items(): - self.devices.append(_NetplanDevice(ethernet, eth_config)) - for wifi, wifi_config in network.get('wifis', {}).items(): - self.devices.append(_NetplanDevice(wifi, wifi_config)) - - def config_for_device(self, link): - for dev in self.devices: - if dev.matches_link(link): - config = copy.deepcopy(dev.config) - if 'match' in config: - del config['match'] - return config - else: - return {} - - def ip_version(ip): return ipaddress.ip_interface(ip).version @@ -393,21 +319,9 @@ class NetworkModel(object): self.network_routes = {} def parse_netplan_configs(self, netplan_root): - self.config = NetplanConfig() - configs_by_basename = {} - paths = ( - glob.glob(os.path.join(netplan_root, 'lib/netplan', "*.yaml")) + - glob.glob(os.path.join(netplan_root, 'etc/netplan', "*.yaml")) + - glob.glob(os.path.join(netplan_root, 'run/netplan', "*.yaml"))) - for path in paths: - configs_by_basename[os.path.basename(path)] = path - for _, path in sorted(configs_by_basename.items()): - try: - fp = open(path) - except OSError: - log.exception("opening %s failed", path) - with fp: - self.config.parse_netplan_config(fp.read()) + config = netplan.Config() + config.load_from_root(netplan_root) + self.config = config def get_menu(self): return self.additional_options diff --git a/subiquitycore/netplan.py b/subiquitycore/netplan.py new file mode 100644 index 00000000..9d6c5945 --- /dev/null +++ b/subiquitycore/netplan.py @@ -0,0 +1,122 @@ +import copy +import glob +import fnmatch +import os +import logging +import yaml + +log = logging.getLogger("subiquitycore.netplan") + + +class Config: + """A NetplanConfig represents the network config for a system. + + Call parse_netplan_config() with each piece of yaml config, and then + call config_for_device to get the config that matches a particular + network devices, if any. + """ + + def __init__(self): + self.devices = [] + + def parse_netplan_config(self, config): + try: + config = yaml.safe_load(config) + except yaml.ReaderError as e: + log.info("could not parse config: %s", e) + return + network = config.get('network') + if network is None: + log.info("no 'network' key in config") + return + version = network.get("version") + if version != 2: + log.info("network has no/unexpected version %s", version) + return + for ethernet, eth_config in network.get('ethernets', {}).items(): + self.devices.append(_Device(ethernet, eth_config)) + for wifi, wifi_config in network.get('wifis', {}).items(): + self.devices.append(_Device(wifi, wifi_config)) + + def config_for_device(self, link): + allowed_matches = ('macaddress',) + match_key = 'match' + for dev in self.devices: + if dev.matches_link(link): + config = copy.deepcopy(dev.config) + if match_key in config: + match = {k: v for k, v in config[match_key].items() + if k in allowed_matches} + if match: + config[match_key] = match + else: + del config[match_key] + return config + return {} + + def load_from_root(self, root): + for path in configs_in_root(root): + try: + fp = open(path) + except OSError: + log.exception("opening %s failed", path) + with fp: + self.parse_netplan_config(fp.read()) + + +class _Device: + def __init__(self, name, config): + match = config.get('match') + if match is None: + self.match_name = name + self.match_mac = None + self.match_driver = None + else: + self.match_name = match.get('name') + self.match_mac = match.get('macaddress') + self.match_driver = match.get('driver') + self.config = config + log.info("config for %s = %s" % (name, self.config)) + + def matches_link(self, link): + if self.match_name is not None: + matches_name = fnmatch.fnmatch(link.name, self.match_name) + else: + matches_name = True + if self.match_mac is not None: + matches_mac = self.match_mac == link.hwaddr + else: + matches_mac = True + if self.match_driver is not None: + matches_driver = self.match_driver == link.driver + else: + matches_driver = True + return matches_name and matches_mac and matches_driver + + +def configs_in_root(root, masked=False): + """Return a list of all netplan configs under root. + + The list is ordered in increasing precedence. + @param masked: if True, include config paths that are masked + by the same basename in a different directory.""" + if not os.path.isabs(root): + root = os.path.abspath(root) + wildcard = "*.yaml" + dirs = {"lib": "0", "etc": "1", "run": "2"} + rootlen = len(root) + + paths = [] + for d in dirs: + paths.extend(glob.glob(os.path.join(root, d, "netplan", wildcard))) + + def mykey(path): + """returned key is basename + string-precidence based on dir.""" + bname = os.path.basename(path) + bdir = path[rootlen + 1] + bdir = bdir[:bdir.find(os.path.sep)] + return "%s/%s" % (bname, bdir) + + if not masked: + paths = {os.path.basename(p): p for p in paths}.values() + return sorted(paths, key=mykey) diff --git a/subiquitycore/tests/__init__.py b/subiquitycore/tests/__init__.py new file mode 100644 index 00000000..da500c3f --- /dev/null +++ b/subiquitycore/tests/__init__.py @@ -0,0 +1,44 @@ +import functools +import os +import shutil +import tempfile + +from unittest import TestCase + + +class SubiTestCase(TestCase): + def tmp_dir(self, dir=None, cleanup=True): + # return a full path to a temporary directory that will be cleaned up. + if dir is None: + tmpd = tempfile.mkdtemp( + prefix="subiquity-%s." % self.__class__.__name__) + else: + tmpd = tempfile.mkdtemp(dir=dir) + self.addCleanup(functools.partial(shutil.rmtree, tmpd)) + return tmpd + + def tmp_path(self, path, dir=None): + # return an absolute path to 'path' under dir. + # if dir is None, one will be created with tmp_dir() + # the file is not created or modified. + if dir is None: + dir = self.tmp_dir() + return os.path.normpath(os.path.abspath(os.path.join(dir, path))) + + +def populate_dir(path, files): + if not os.path.exists(path): + os.makedirs(path) + ret = [] + for (name, content) in files.items(): + p = os.path.sep.join([path, name]) + if not os.path.isdir(os.path.dirname(p)): + os.makedirs(os.path.dirname(p)) + with open(p, "wb") as fp: + if isinstance(content, bytes): + fp.write(content) + else: + fp.write(content.encode('utf-8')) + fp.close() + ret.append(p) + return ret diff --git a/subiquitycore/tests/test_netplan.py b/subiquitycore/tests/test_netplan.py new file mode 100644 index 00000000..614db140 --- /dev/null +++ b/subiquitycore/tests/test_netplan.py @@ -0,0 +1,40 @@ +import os + +from subiquitycore.tests import SubiTestCase, populate_dir +from subiquitycore.netplan import configs_in_root + + +class TestConfigsInRoot(SubiTestCase): + def test_masked_true(self): + """configs_in_root masked=False should not return masked files.""" + my_dir = self.tmp_dir() + unmasked = ['run/netplan/00base.yaml', + 'lib/netplan/01system.yaml', 'etc/netplan/99end.yaml'] + masked = ["etc/netplan/00base.yaml"] + populate_dir(my_dir, {f: "key: here\n" for f in unmasked + masked}) + self.assertEqual( + [os.path.join(my_dir, p) for p in unmasked], + configs_in_root(my_dir)) + + def test_masked_false(self): + """configs_in_root mask=True should return all configs.""" + my_dir = self.tmp_dir() + yamls = [ + 'etc/netplan/00base.yaml', + 'run/netplan/00base.yaml', + 'lib/netplan/01system.yaml', + 'etc/netplan/99end.yaml'] + populate_dir(my_dir, {f: "someyaml: here\n" for f in yamls}) + self.assertEqual( + [os.path.join(my_dir, p) for p in yamls], + configs_in_root(my_dir, masked=True)) + + def test_only_includes_yaml(self): + """configs_in_root should only return *.yaml files.""" + my_dir = self.tmp_dir() + yamls = ['etc/netplan/00base.yaml', 'etc/netplan/99end.yaml'] + nonyamls = ['etc/netplan/ignored.yaml.dist', 'run/netplan/my.cfg'] + populate_dir(my_dir, {f: "someyaml: here\n" for f in yamls + nonyamls}) + self.assertEqual( + [os.path.join(my_dir, p) for p in yamls], + configs_in_root(my_dir))