# vi: ts=4 expandtab # """ snappy modules allows configuration of snappy. Example config: #cloud-config snappy: system_snappy: auto ssh_enabled: auto packages: [etcd, pkg2.smoser] config: pkgname: key2: value2 pkg2: key1: value1 packages_dir: '/writable/user-data/cloud-init/snaps' - ssh_enabled: This controls the system's ssh service. The default value is 'auto'. True: enable ssh service False: disable ssh service auto: enable ssh service if either ssh keys have been provided or user has requested password authentication (ssh_pwauth). - snap installation and config The above would install 'etcd', and then install 'pkg2.smoser' with a '' argument where 'config-file' has 'config-blob' inside it. If 'pkgname' is installed already, then 'snappy config pkgname ' will be called where 'file' has 'pkgname-config-blob' as its content. Entries in 'config' can be namespaced or non-namespaced for a package. In either case, the config provided to snappy command is non-namespaced. The package name is provided as it appears. If 'packages_dir' has files in it that end in '.snap', then they are installed. Given 3 files: /foo.snap /foo.config /bar.snap cloud-init will invoke: snappy install /foo.snap /foo.config snappy install /bar.snap Note, that if provided a 'config' entry for 'ubuntu-core', then cloud-init will invoke: snappy config ubuntu-core Allowing you to configure ubuntu-core in this way. """ from cloudinit import log as logging from cloudinit import util from cloudinit.settings import PER_INSTANCE import glob import tempfile import os LOG = logging.getLogger(__name__) frequency = PER_INSTANCE SNAPPY_CMD = "snappy" NAMESPACE_DELIM = '.' BUILTIN_CFG = { 'packages': [], 'packages_dir': '/writable/user-data/cloud-init/snaps', 'ssh_enabled': "auto", 'system_snappy': "auto", 'config': {}, } def parse_filename(fname): fname = os.path.basename(fname) fname_noext = fname.rpartition(".")[0] name = fname_noext.partition("_")[0] shortname = name.partition(".")[0] return(name, shortname, fname_noext) def get_fs_package_ops(fspath): if not fspath: return [] ops = [] for snapfile in sorted(glob.glob(os.path.sep.join([fspath, '*.snap']))): (name, shortname, fname_noext) = parse_filename(snapfile) cfg = None for cand in (fname_noext, name, shortname): fpcand = os.path.sep.join([fspath, cand]) + ".config" if os.path.isfile(fpcand): cfg = fpcand break ops.append(makeop('install', name, config=None, path=snapfile, cfgfile=cfg)) return ops def makeop(op, name, config=None, path=None, cfgfile=None): return({'op': op, 'name': name, 'config': config, 'path': path, 'cfgfile': cfgfile}) def get_package_config(configs, name): # load the package's config from the configs dict. # prefer full-name entry (config-example.canonical) # over short name entry (config-example) if name in configs: return configs[name] return configs.get(name.partition(NAMESPACE_DELIM)[0]) def get_package_ops(packages, configs, installed=None, fspath=None): # get the install an config operations that should be done if installed is None: installed = read_installed_packages() short_installed = [p.partition(NAMESPACE_DELIM)[0] for p in installed] if not packages: packages = [] if not configs: configs = {} ops = [] ops += get_fs_package_ops(fspath) for name in packages: ops.append(makeop('install', name, get_package_config(configs, name))) to_install = [f['name'] for f in ops] short_to_install = [f['name'].partition(NAMESPACE_DELIM)[0] for f in ops] for name in configs: if name in to_install: continue shortname = name.partition(NAMESPACE_DELIM)[0] if shortname in short_to_install: continue if name in installed or shortname in short_installed: ops.append(makeop('config', name, config=get_package_config(configs, name))) # prefer config entries to filepath entries for op in ops: if op['op'] != 'install' or not op['cfgfile']: continue name = op['name'] fromcfg = get_package_config(configs, op['name']) if fromcfg: LOG.debug("preferring configs[%(name)s] over '%(cfgfile)s'", op) op['cfgfile'] = None op['config'] = fromcfg return ops def render_snap_op(op, name, path=None, cfgfile=None, config=None): if op not in ('install', 'config'): raise ValueError("cannot render op '%s'" % op) shortname = name.partition(NAMESPACE_DELIM)[0] try: cfg_tmpf = None if config is not None: # input to 'snappy config packagename' must have nested data. odd. # config: # packagename: # config # Note, however, we do not touch config files on disk. nested_cfg = {'config': {shortname: config}} (fd, cfg_tmpf) = tempfile.mkstemp() os.write(fd, util.yaml_dumps(nested_cfg).encode()) os.close(fd) cfgfile = cfg_tmpf cmd = [SNAPPY_CMD, op] if op == 'install': if path: cmd.append("--allow-unauthenticated") cmd.append(path) else: cmd.append(name) if cfgfile: cmd.append(cfgfile) elif op == 'config': cmd += [name, cfgfile] util.subp(cmd) finally: if cfg_tmpf: os.unlink(cfg_tmpf) def read_installed_packages(): ret = [] for (name, date, version, dev) in read_pkg_data(): if dev: ret.append(NAMESPACE_DELIM.join([name, dev])) else: ret.append(name) return ret def read_pkg_data(): out, err = util.subp([SNAPPY_CMD, "list"]) pkg_data = [] for line in out.splitlines()[1:]: toks = line.split(sep=None, maxsplit=3) if len(toks) == 3: (name, date, version) = toks dev = None else: (name, date, version, dev) = toks pkg_data.append((name, date, version, dev,)) return pkg_data def disable_enable_ssh(enabled): LOG.debug("setting enablement of ssh to: %s", enabled) # do something here that would enable or disable not_to_be_run = "/etc/ssh/sshd_not_to_be_run" if enabled: util.del_file(not_to_be_run) # this is an indempotent operation util.subp(["systemctl", "start", "ssh"]) else: # this is an indempotent operation util.subp(["systemctl", "stop", "ssh"]) util.write_file(not_to_be_run, "cloud-init\n") def system_is_snappy(): # channel.ini is configparser loadable. # snappy will move to using /etc/system-image/config.d/*.ini # this is certainly not a perfect test, but good enough for now. content = util.load_file("/etc/system-image/channel.ini", quiet=True) if 'ubuntu-core' in content.lower(): return True if os.path.isdir("/etc/system-image/config.d/"): return True return False def set_snappy_command(): global SNAPPY_CMD if util.which("snappy-go"): SNAPPY_CMD = "snappy-go" else: SNAPPY_CMD = "snappy" LOG.debug("snappy command is '%s'", SNAPPY_CMD) def handle(name, cfg, cloud, log, args): cfgin = cfg.get('snappy') if not cfgin: cfgin = {} mycfg = util.mergemanydict([cfgin, BUILTIN_CFG]) sys_snappy = str(mycfg.get("system_snappy", "auto")) if util.is_false(sys_snappy): LOG.debug("%s: System is not snappy. disabling", name) return if sys_snappy.lower() == "auto" and not(system_is_snappy()): LOG.debug("%s: 'auto' mode, and system not snappy", name) return set_snappy_command() pkg_ops = get_package_ops(packages=mycfg['packages'], configs=mycfg['config'], fspath=mycfg['packages_dir']) fails = [] for pkg_op in pkg_ops: try: render_snap_op(**pkg_op) except Exception as e: fails.append((pkg_op, e,)) LOG.warn("'%s' failed for '%s': %s", pkg_op['op'], pkg_op['name'], e) # Default to disabling SSH ssh_enabled = mycfg.get('ssh_enabled', "auto") # If the user has not explicitly enabled or disabled SSH, then enable it # when password SSH authentication is requested or there are SSH keys if ssh_enabled == "auto": user_ssh_keys = cloud.get_public_ssh_keys() or None password_auth_enabled = cfg.get('ssh_pwauth', False) if user_ssh_keys: LOG.debug("Enabling SSH, ssh keys found in datasource") ssh_enabled = True elif cfg.get('ssh_authorized_keys'): LOG.debug("Enabling SSH, ssh keys found in config") elif password_auth_enabled: LOG.debug("Enabling SSH, password authentication requested") ssh_enabled = True elif ssh_enabled not in (True, False): LOG.warn("Unknown value '%s' in ssh_enabled", ssh_enabled) disable_enable_ssh(ssh_enabled) if fails: raise Exception("failed to install/configure snaps")