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:
parent
270ba0992a
commit
3e69673501
|
@ -16,7 +16,6 @@
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import select
|
import select
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
@ -44,6 +43,8 @@ from subiquitycore.ui.views.network import ApplyingConfigWidget
|
||||||
from subiquitycore.ui.dummy import DummyView
|
from subiquitycore.ui.dummy import DummyView
|
||||||
from subiquitycore.controller import BaseController
|
from subiquitycore.controller import BaseController
|
||||||
from subiquitycore.utils import run_command
|
from subiquitycore.utils import run_command
|
||||||
|
from subiquitycore.file_util import write_file
|
||||||
|
from subiquitycore import netplan
|
||||||
|
|
||||||
log = logging.getLogger("subiquitycore.controller.network")
|
log = logging.getLogger("subiquitycore.controller.network")
|
||||||
|
|
||||||
|
@ -245,22 +246,16 @@ class NetworkController(BaseController, TaskWatcher):
|
||||||
log.debug("network config: \n%s",
|
log.debug("network config: \n%s",
|
||||||
yaml.dump(sanitize_config(config), default_flow_style=False))
|
yaml.dump(sanitize_config(config), default_flow_style=False))
|
||||||
|
|
||||||
netplan_path = self.netplan_path
|
for p in netplan.configs_in_root(self.root, masked=True):
|
||||||
while True:
|
if p == self.netplan_path:
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
else:
|
os.rename(p, p + ".dist-" + self.opts.project)
|
||||||
break
|
|
||||||
w = os.fdopen(fd, 'w')
|
write_file(self.netplan_path, '\n'.join((
|
||||||
with w:
|
("# This is the network config written by '%s'" %
|
||||||
w.write("# This is the network config written by "
|
self.opts.project),
|
||||||
"'%s'\n" % (self.opts.project))
|
yaml.dump(config))), omode="w")
|
||||||
w.write(yaml.dump(config))
|
|
||||||
os.rename(tmppath, netplan_path)
|
|
||||||
self.model.parse_netplan_configs(self.root)
|
self.model.parse_netplan_configs(self.root)
|
||||||
if self.opts.dry_run:
|
if self.opts.dry_run:
|
||||||
tasks = [
|
tasks = [
|
||||||
|
|
|
@ -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
|
|
@ -14,15 +14,11 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import fnmatch
|
|
||||||
import glob
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from socket import AF_INET, AF_INET6
|
from socket import AF_INET, AF_INET6
|
||||||
|
|
||||||
import yaml
|
from subiquitycore import netplan
|
||||||
from yaml.reader import ReaderError
|
|
||||||
|
|
||||||
|
|
||||||
NETDEV_IGNORED_IFACE_NAMES = ['lo']
|
NETDEV_IGNORED_IFACE_NAMES = ['lo']
|
||||||
|
@ -30,76 +26,6 @@ NETDEV_IGNORED_IFACE_TYPES = ['bridge', 'tun', 'tap', 'dummy', 'sit']
|
||||||
log = logging.getLogger('subiquitycore.models.network')
|
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):
|
def ip_version(ip):
|
||||||
return ipaddress.ip_interface(ip).version
|
return ipaddress.ip_interface(ip).version
|
||||||
|
|
||||||
|
@ -393,21 +319,9 @@ class NetworkModel(object):
|
||||||
self.network_routes = {}
|
self.network_routes = {}
|
||||||
|
|
||||||
def parse_netplan_configs(self, netplan_root):
|
def parse_netplan_configs(self, netplan_root):
|
||||||
self.config = NetplanConfig()
|
config = netplan.Config()
|
||||||
configs_by_basename = {}
|
config.load_from_root(netplan_root)
|
||||||
paths = (
|
self.config = config
|
||||||
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())
|
|
||||||
|
|
||||||
def get_menu(self):
|
def get_menu(self):
|
||||||
return self.additional_options
|
return self.additional_options
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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))
|
Loading…
Reference in New Issue