Fix writing of netplan configs

When user configures network with subiquity, it's rendered
netplan should be wholly definitive.  So, we remove the other files
that may have config.  This fixes a bug where running in an instance
when running on a system where cloud-init had rendered a 'match' with
'macaddress'.

When writing netplan we keep 'macaddress' match in place but drop
others.  The others may just wildcard from the installer environment,
but macaddress are likely by cloud-init or otherwise intentionally
written.

Also add an atomic write in subiquitycore/file_util and move the
netplan code into subiquitycore/netplan.py, and add some unit test
helpers from cloud-init.
This commit is contained in:
Scott Moser 2018-05-24 17:06:09 -04:00
parent 270ba0992a
commit 3e69673501
6 changed files with 253 additions and 106 deletions

View File

@ -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 = [

View File

@ -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

View File

@ -14,15 +14,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

122
subiquitycore/netplan.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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))