512 lines
16 KiB
Python
512 lines
16 KiB
Python
# Copyright (C) 2012 Canonical Ltd.
|
|
# Copyright (C) 2012 Yahoo! Inc.
|
|
# Copyright (C) 2012-2013 CERIT Scientific Cloud
|
|
# Copyright (C) 2012-2013 OpenNebula.org
|
|
# Copyright (C) 2014 Consejo Superior de Investigaciones Cientificas
|
|
#
|
|
# Author: Scott Moser <scott.moser@canonical.com>
|
|
# Author: Joshua Harlow <harlowja@yahoo-inc.com>
|
|
# Author: Vlastimil Holer <xholer@mail.muni.cz>
|
|
# Author: Javier Fontan <jfontan@opennebula.org>
|
|
# Author: Enol Fernandez <enolfc@ifca.unican.es>
|
|
#
|
|
# This file is part of cloud-init. See LICENSE file for license information.
|
|
|
|
import collections
|
|
import functools
|
|
import logging
|
|
import os
|
|
import pwd
|
|
import re
|
|
import shlex
|
|
import textwrap
|
|
|
|
from cloudinit import atomic_helper, net, sources, subp, util
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
DEFAULT_IID = "iid-dsopennebula"
|
|
DEFAULT_PARSEUSER = "nobody"
|
|
CONTEXT_DISK_FILES = ["context.sh"]
|
|
EXCLUDED_VARS = (
|
|
"EPOCHREALTIME",
|
|
"EPOCHSECONDS",
|
|
"RANDOM",
|
|
"LINENO",
|
|
"SECONDS",
|
|
"_",
|
|
"SRANDOM",
|
|
"__v",
|
|
)
|
|
|
|
|
|
class DataSourceOpenNebula(sources.DataSource):
|
|
|
|
dsname = "OpenNebula"
|
|
|
|
def __init__(self, sys_cfg, distro, paths):
|
|
sources.DataSource.__init__(self, sys_cfg, distro, paths)
|
|
self.seed = None
|
|
self.seed_dir = os.path.join(paths.seed_dir, "opennebula")
|
|
self.network = None
|
|
|
|
def __str__(self):
|
|
root = sources.DataSource.__str__(self)
|
|
return "%s [seed=%s][dsmode=%s]" % (root, self.seed, self.dsmode)
|
|
|
|
def _get_data(self):
|
|
defaults = {"instance-id": DEFAULT_IID}
|
|
results = None
|
|
seed = None
|
|
|
|
# decide parseuser for context.sh shell reader
|
|
parseuser = DEFAULT_PARSEUSER
|
|
if "parseuser" in self.ds_cfg:
|
|
parseuser = self.ds_cfg.get("parseuser")
|
|
|
|
candidates = [self.seed_dir]
|
|
candidates.extend(find_candidate_devs())
|
|
for cdev in candidates:
|
|
try:
|
|
if os.path.isdir(self.seed_dir):
|
|
results = read_context_disk_dir(
|
|
cdev, self.distro, asuser=parseuser
|
|
)
|
|
elif cdev.startswith("/dev"):
|
|
# util.mount_cb only handles passing a single argument
|
|
# through to the wrapped function, so we have to partially
|
|
# apply the function to pass in `distro`. See LP: #1884979
|
|
partially_applied_func = functools.partial(
|
|
read_context_disk_dir,
|
|
asuser=parseuser,
|
|
distro=self.distro,
|
|
)
|
|
results = util.mount_cb(cdev, partially_applied_func)
|
|
except NonContextDiskDir:
|
|
continue
|
|
except BrokenContextDiskDir as exc:
|
|
raise exc
|
|
except util.MountFailedError:
|
|
LOG.warning("%s was not mountable", cdev)
|
|
|
|
if results:
|
|
seed = cdev
|
|
LOG.debug("found datasource in %s", cdev)
|
|
break
|
|
|
|
if not seed:
|
|
return False
|
|
|
|
# merge fetched metadata with datasource defaults
|
|
md = results["metadata"]
|
|
md = util.mergemanydict([md, defaults])
|
|
|
|
# check for valid user specified dsmode
|
|
self.dsmode = self._determine_dsmode(
|
|
[results.get("DSMODE"), self.ds_cfg.get("dsmode")]
|
|
)
|
|
|
|
if self.dsmode == sources.DSMODE_DISABLED:
|
|
return False
|
|
|
|
self.seed = seed
|
|
self.network = results.get("network-interfaces")
|
|
self.metadata = md
|
|
self.userdata_raw = results.get("userdata")
|
|
return True
|
|
|
|
def _get_subplatform(self):
|
|
"""Return the subplatform metadata source details."""
|
|
if self.seed_dir in self.seed:
|
|
subplatform_type = "seed-dir"
|
|
else:
|
|
subplatform_type = "config-disk"
|
|
return "%s (%s)" % (subplatform_type, self.seed)
|
|
|
|
@property
|
|
def network_config(self):
|
|
if self.network is not None:
|
|
return self.network
|
|
else:
|
|
return None
|
|
|
|
def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
|
|
if resolve_ip is None:
|
|
if self.dsmode == sources.DSMODE_NETWORK:
|
|
resolve_ip = True
|
|
else:
|
|
resolve_ip = False
|
|
return sources.DataSource.get_hostname(self, fqdn, resolve_ip)
|
|
|
|
|
|
class NonContextDiskDir(Exception):
|
|
pass
|
|
|
|
|
|
class BrokenContextDiskDir(Exception):
|
|
pass
|
|
|
|
|
|
class OpenNebulaNetwork:
|
|
def __init__(self, context, distro, system_nics_by_mac=None):
|
|
self.context = context
|
|
if system_nics_by_mac is None:
|
|
system_nics_by_mac = get_physical_nics_by_mac(distro)
|
|
self.ifaces = collections.OrderedDict(
|
|
[
|
|
k
|
|
for k in sorted(
|
|
system_nics_by_mac.items(),
|
|
key=lambda k: net.natural_sort_key(k[1]),
|
|
)
|
|
]
|
|
)
|
|
|
|
# OpenNebula 4.14+ provide macaddr for ETHX in variable ETH_MAC.
|
|
# context_devname provides {mac.lower():ETHX, mac2.lower():ETHX}
|
|
self.context_devname = {}
|
|
for k, v in context.items():
|
|
m = re.match(r"^(.+)_MAC$", k)
|
|
if m:
|
|
self.context_devname[v.lower()] = m.group(1)
|
|
|
|
def mac2ip(self, mac):
|
|
return ".".join([str(int(c, 16)) for c in mac.split(":")[2:]])
|
|
|
|
def get_nameservers(self, dev):
|
|
nameservers = {}
|
|
dns = self.get_field(dev, "dns", "").split()
|
|
dns.extend(self.context.get("DNS", "").split())
|
|
if dns:
|
|
nameservers["addresses"] = dns
|
|
search_domain = self.get_field(dev, "search_domain", "").split()
|
|
if search_domain:
|
|
nameservers["search"] = search_domain
|
|
return nameservers
|
|
|
|
def get_mtu(self, dev):
|
|
return self.get_field(dev, "mtu")
|
|
|
|
def get_ip(self, dev, mac):
|
|
return self.get_field(dev, "ip", self.mac2ip(mac))
|
|
|
|
def get_ip6(self, dev):
|
|
addresses6 = []
|
|
ip6 = self.get_field(dev, "ip6")
|
|
if ip6:
|
|
addresses6.append(ip6)
|
|
ip6_ula = self.get_field(dev, "ip6_ula")
|
|
if ip6_ula:
|
|
addresses6.append(ip6_ula)
|
|
return addresses6
|
|
|
|
def get_ip6_prefix(self, dev):
|
|
return self.get_field(dev, "ip6_prefix_length", "64")
|
|
|
|
def get_gateway(self, dev):
|
|
return self.get_field(dev, "gateway")
|
|
|
|
def get_gateway6(self, dev):
|
|
# OpenNebula 6.1.80 introduced new context parameter ETHx_IP6_GATEWAY
|
|
# to replace old ETHx_GATEWAY6. Old ETHx_GATEWAY6 will be removed in
|
|
# OpenNebula 6.4.0 (https://github.com/OpenNebula/one/issues/5536).
|
|
return self.get_field(
|
|
dev, "ip6_gateway", self.get_field(dev, "gateway6")
|
|
)
|
|
|
|
def get_mask(self, dev):
|
|
return self.get_field(dev, "mask", "255.255.255.0")
|
|
|
|
def get_field(self, dev, name, default=None):
|
|
"""return the field name in context for device dev.
|
|
|
|
context stores <dev>_<NAME> (example: eth0_DOMAIN).
|
|
an empty string for value will return default."""
|
|
val = self.context.get(
|
|
"_".join(
|
|
(
|
|
dev,
|
|
name,
|
|
)
|
|
).upper()
|
|
)
|
|
# allow empty string to return the default.
|
|
return default if val in (None, "") else val
|
|
|
|
def gen_conf(self):
|
|
netconf = {}
|
|
netconf["version"] = 2
|
|
netconf["ethernets"] = {}
|
|
|
|
ethernets = {}
|
|
for mac, dev in self.ifaces.items():
|
|
mac = mac.lower()
|
|
|
|
# c_dev stores name in context 'ETHX' for this device.
|
|
# dev stores the current system name.
|
|
c_dev = self.context_devname.get(mac, dev)
|
|
|
|
devconf = {}
|
|
|
|
# Set MAC address
|
|
devconf["match"] = {"macaddress": mac}
|
|
|
|
# Set IPv4 address
|
|
devconf["addresses"] = []
|
|
mask = self.get_mask(c_dev)
|
|
prefix = str(net.ipv4_mask_to_net_prefix(mask))
|
|
devconf["addresses"].append(self.get_ip(c_dev, mac) + "/" + prefix)
|
|
|
|
# Set IPv6 Global and ULA address
|
|
addresses6 = self.get_ip6(c_dev)
|
|
if addresses6:
|
|
prefix6 = self.get_ip6_prefix(c_dev)
|
|
devconf["addresses"].extend(
|
|
[i + "/" + prefix6 for i in addresses6]
|
|
)
|
|
|
|
# Set IPv4 default gateway
|
|
gateway = self.get_gateway(c_dev)
|
|
if gateway:
|
|
devconf["gateway4"] = gateway
|
|
|
|
# Set IPv6 default gateway
|
|
gateway6 = self.get_gateway6(c_dev)
|
|
if gateway6:
|
|
devconf["gateway6"] = gateway6
|
|
|
|
# Set DNS servers and search domains
|
|
nameservers = self.get_nameservers(c_dev)
|
|
if nameservers:
|
|
devconf["nameservers"] = nameservers
|
|
|
|
# Set MTU size
|
|
mtu = self.get_mtu(c_dev)
|
|
if mtu:
|
|
devconf["mtu"] = mtu
|
|
|
|
ethernets[dev] = devconf
|
|
|
|
netconf["ethernets"] = ethernets
|
|
return netconf
|
|
|
|
|
|
def find_candidate_devs():
|
|
"""
|
|
Return a list of devices that may contain the context disk.
|
|
"""
|
|
combined = []
|
|
for f in ("LABEL=CONTEXT", "LABEL=CDROM", "TYPE=iso9660"):
|
|
devs = util.find_devs_with(f)
|
|
devs.sort()
|
|
for d in devs:
|
|
if d not in combined:
|
|
combined.append(d)
|
|
|
|
return combined
|
|
|
|
|
|
def switch_user_cmd(user):
|
|
return ["sudo", "-u", user]
|
|
|
|
|
|
def varprinter():
|
|
"""print the shell environment variables within delimiters to be parsed"""
|
|
return textwrap.dedent(
|
|
"""
|
|
printf "%s\\0" _start_
|
|
[ $0 != 'sh' ] && set -o posix
|
|
set
|
|
[ $0 != 'sh' ] && set +o posix
|
|
printf "%s\\0" _start_
|
|
"""
|
|
)
|
|
|
|
|
|
def parse_shell_config(content, asuser=None):
|
|
"""run content and return environment variables which changed
|
|
|
|
WARNING: the special variable _start_ is used to delimit content
|
|
|
|
a context.sh that defines this variable might break in unexpected
|
|
ways
|
|
|
|
compatible with posix shells such as dash and ash and any shell
|
|
which supports `set -o posix`
|
|
"""
|
|
if b"_start_\x00" in content.encode():
|
|
LOG.warning(
|
|
"User defined _start_ variable in context.sh, this may break"
|
|
"cloud-init in unexpected ways."
|
|
)
|
|
|
|
# the rendered 'bcmd' does:
|
|
#
|
|
# setup: declare variables we use (so they show up in 'all')
|
|
# varprinter(allvars): print all variables known at beginning
|
|
# content: execute the provided content
|
|
# varprinter(keylist): print all variables known after content
|
|
#
|
|
# output is then a newline terminated array of:
|
|
# [0] unwanted content before first _start_
|
|
# [1] key=value (for each preset variable)
|
|
# [2] unwanted content between second and third _start_
|
|
# [3] key=value (for each post set variable)
|
|
bcmd = (
|
|
varprinter()
|
|
+ "{\n%s\n\n:\n} > /dev/null\n" % content
|
|
+ varprinter()
|
|
+ "\n"
|
|
)
|
|
|
|
cmd = []
|
|
if asuser is not None:
|
|
cmd = switch_user_cmd(asuser)
|
|
cmd.extend(["sh", "-e"])
|
|
|
|
output = subp.subp(cmd, data=bcmd).stdout
|
|
|
|
# exclude vars that change on their own or that we used
|
|
ret = {}
|
|
|
|
# Add to ret only things were changed and not in excluded.
|
|
# skip all content before initial _start_\x00 pair
|
|
sections = output.split("_start_\x00")[1:]
|
|
|
|
# store env variables prior to content run
|
|
# skip all content before second _start\x00 pair
|
|
# store env variables prior to content run
|
|
before, after = sections[0], sections[2]
|
|
|
|
pre_env = dict(
|
|
variable.split("=", maxsplit=1) for variable in shlex.split(before)
|
|
)
|
|
post_env = dict(
|
|
variable.split("=", maxsplit=1) for variable in shlex.split(after)
|
|
)
|
|
for key in set(pre_env.keys()).union(set(post_env.keys())):
|
|
if key in EXCLUDED_VARS:
|
|
continue
|
|
value = post_env.get(key)
|
|
if value is not None and value != pre_env.get(key):
|
|
ret[key] = value
|
|
|
|
return ret
|
|
|
|
|
|
def read_context_disk_dir(source_dir, distro, asuser=None):
|
|
"""
|
|
read_context_disk_dir(source_dir):
|
|
read source_dir and return a tuple with metadata dict and user-data
|
|
string populated. If not a valid dir, raise a NonContextDiskDir
|
|
"""
|
|
found = {}
|
|
for af in CONTEXT_DISK_FILES:
|
|
fn = os.path.join(source_dir, af)
|
|
if os.path.isfile(fn):
|
|
found[af] = fn
|
|
|
|
if not found:
|
|
raise NonContextDiskDir("%s: %s" % (source_dir, "no files found"))
|
|
|
|
context = {}
|
|
results = {"userdata": None, "metadata": {}}
|
|
|
|
if "context.sh" in found:
|
|
if asuser is not None:
|
|
try:
|
|
pwd.getpwnam(asuser)
|
|
except KeyError as e:
|
|
raise BrokenContextDiskDir(
|
|
"configured user '{user}' does not exist".format(
|
|
user=asuser
|
|
)
|
|
) from e
|
|
try:
|
|
path = os.path.join(source_dir, "context.sh")
|
|
content = util.load_text_file(path)
|
|
context = parse_shell_config(content, asuser=asuser)
|
|
except subp.ProcessExecutionError as e:
|
|
raise BrokenContextDiskDir(
|
|
"Error processing context.sh: %s" % (e)
|
|
) from e
|
|
except IOError as e:
|
|
raise NonContextDiskDir(
|
|
"Error reading context.sh: %s" % (e)
|
|
) from e
|
|
else:
|
|
raise NonContextDiskDir("Missing context.sh")
|
|
|
|
if not context:
|
|
return results
|
|
|
|
results["metadata"] = context
|
|
|
|
# process single or multiple SSH keys
|
|
ssh_key_var = None
|
|
if "SSH_KEY" in context:
|
|
ssh_key_var = "SSH_KEY"
|
|
elif "SSH_PUBLIC_KEY" in context:
|
|
ssh_key_var = "SSH_PUBLIC_KEY"
|
|
|
|
if ssh_key_var:
|
|
lines = context.get(ssh_key_var).splitlines()
|
|
results["metadata"]["public-keys"] = [
|
|
line for line in lines if len(line) and not line.startswith("#")
|
|
]
|
|
|
|
# custom hostname -- try hostname or leave cloud-init
|
|
# itself create hostname from IP address later
|
|
for k in ("SET_HOSTNAME", "HOSTNAME", "PUBLIC_IP", "IP_PUBLIC", "ETH0_IP"):
|
|
if k in context:
|
|
results["metadata"]["local-hostname"] = context[k]
|
|
break
|
|
|
|
# raw user data
|
|
if "USER_DATA" in context:
|
|
results["userdata"] = context["USER_DATA"]
|
|
elif "USERDATA" in context:
|
|
results["userdata"] = context["USERDATA"]
|
|
|
|
# b64decode user data if necessary (default)
|
|
if "userdata" in results:
|
|
encoding = context.get(
|
|
"USERDATA_ENCODING", context.get("USER_DATA_ENCODING")
|
|
)
|
|
if encoding == "base64":
|
|
try:
|
|
results["userdata"] = atomic_helper.b64d(results["userdata"])
|
|
except TypeError:
|
|
LOG.warning("Failed base64 decoding of userdata")
|
|
|
|
# generate Network Configuration v2
|
|
# only if there are any required context variables
|
|
# http://docs.opennebula.org/5.4/operation/references/template.html#context-section
|
|
ipaddr_keys = [k for k in context if re.match(r"^ETH\d+_IP.*$", k)]
|
|
if ipaddr_keys:
|
|
onet = OpenNebulaNetwork(context, distro)
|
|
results["network-interfaces"] = onet.gen_conf()
|
|
|
|
return results
|
|
|
|
|
|
def get_physical_nics_by_mac(distro):
|
|
devs = net.get_interfaces_by_mac()
|
|
return dict(
|
|
[(m, n) for m, n in devs.items() if distro.networking.is_physical(n)]
|
|
)
|
|
|
|
|
|
# Legacy: Must be present in case we load an old pkl object
|
|
DataSourceOpenNebulaNet = DataSourceOpenNebula
|
|
|
|
# Used to match classes to dependencies
|
|
datasources = [
|
|
(DataSourceOpenNebula, (sources.DEP_FILESYSTEM,)),
|
|
]
|
|
|
|
|
|
# Return a list of data sources that match this set of dependencies
|
|
def get_datasource_list(depends):
|
|
return sources.list_from_depends(depends, datasources)
|