3
# Copyright (C) 2012 Canonical Ltd.
4
# Copyright (C) 2012, 2013 Hewlett-Packard Development Company, L.P.
5
# Copyright (C) 2012 Yahoo! Inc.
7
# Author: Scott Moser <scott.moser@canonical.com>
8
# Author: Juerg Haefliger <juerg.haefliger@hp.com>
9
# Author: Joshua Harlow <harlowja@yahoo-inc.com>
10
# Author: Ben Howard <ben.howard@canonical.com>
12
# This program is free software: you can redistribute it and/or modify
13
# it under the terms of the GNU General Public License version 3, as
14
# published by the Free Software Foundation.
16
# This program is distributed in the hope that it will be useful,
17
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
# GNU General Public License for more details.
21
# You should have received a copy of the GNU General Public License
22
# along with this program. If not, see <http://www.gnu.org/licenses/>.
24
from StringIO import StringIO
31
from cloudinit import importer
32
from cloudinit import log as logging
33
from cloudinit import ssh_util
34
from cloudinit import type_utils
35
from cloudinit import util
37
from cloudinit.distros.parsers import hosts
40
'debian': ['debian', 'ubuntu'],
41
'redhat': ['fedora', 'rhel'],
43
'freebsd': ['freebsd'],
48
LOG = logging.getLogger(__name__)
52
__metaclass__ = abc.ABCMeta
54
hosts_fn = "/etc/hosts"
55
ci_sudoers_fn = "/etc/sudoers.d/90-cloud-init-users"
56
hostname_conf_fn = "/etc/hostname"
57
tz_zone_dir = "/usr/share/zoneinfo"
58
init_cmd = ['service'] # systemctl, service etc
60
def __init__(self, name, cfg, paths):
66
def install_packages(self, pkglist):
67
raise NotImplementedError()
70
def _write_network(self, settings):
71
# In the future use the http://fedorahosted.org/netcf/
72
# to write this blob out in a distro format
73
raise NotImplementedError()
75
def _find_tz_file(self, tz):
76
tz_file = os.path.join(self.tz_zone_dir, str(tz))
77
if not os.path.isfile(tz_file):
78
raise IOError(("Invalid timezone %s,"
79
" no file found at %s") % (tz, tz_file))
82
def get_option(self, opt_name, default=None):
83
return self._cfg.get(opt_name, default)
85
def set_hostname(self, hostname, fqdn=None):
86
writeable_hostname = self._select_hostname(hostname, fqdn)
87
self._write_hostname(writeable_hostname, self.hostname_conf_fn)
88
self._apply_hostname(hostname)
91
def package_command(self, cmd, args=None, pkgs=None):
92
raise NotImplementedError()
95
def update_package_sources(self):
96
raise NotImplementedError()
98
def get_primary_arch(self):
100
if arch in ("i386", "i486", "i586", "i686"):
104
def _get_arch_package_mirror_info(self, arch=None):
105
mirror_info = self.get_option("package_mirrors", [])
107
arch = self.get_primary_arch()
108
return _get_arch_package_mirror_info(mirror_info, arch)
110
def get_package_mirror_info(self, arch=None,
111
availability_zone=None):
112
# This resolves the package_mirrors config option
113
# down to a single dict of {mirror_name: mirror_url}
114
arch_info = self._get_arch_package_mirror_info(arch)
115
return _get_package_mirror_info(availability_zone=availability_zone,
116
mirror_info=arch_info)
118
def apply_network(self, settings, bring_up=True):
120
dev_names = self._write_network(settings)
121
# Now try to bring them up
123
return self._bring_up_interfaces(dev_names)
127
def apply_locale(self, locale, out_fn=None):
128
raise NotImplementedError()
131
def set_timezone(self, tz):
132
raise NotImplementedError()
134
def _get_localhost_ip(self):
138
def _read_hostname(self, filename, default=None):
139
raise NotImplementedError()
142
def _write_hostname(self, hostname, filename):
143
raise NotImplementedError()
146
def _read_system_hostname(self):
147
raise NotImplementedError()
149
def _apply_hostname(self, hostname):
150
# This really only sets the hostname
151
# temporarily (until reboot so it should
152
# not be depended on). Use the write
153
# hostname functions for 'permanent' adjustments.
154
LOG.debug("Non-persistently setting the system hostname to %s",
157
util.subp(['hostname', hostname])
158
except util.ProcessExecutionError:
159
util.logexc(LOG, "Failed to non-persistently adjust the system "
160
"hostname to %s", hostname)
163
def _select_hostname(self, hostname, fqdn):
164
raise NotImplementedError()
167
def expand_osfamily(family_list):
169
for family in family_list:
170
if not family in OSFAMILIES:
171
raise ValueError("No distibutions found for osfamily %s"
173
distros.extend(OSFAMILIES[family])
176
def update_hostname(self, hostname, fqdn, prev_hostname_fn):
177
applying_hostname = hostname
179
# Determine what the actual written hostname should be
180
hostname = self._select_hostname(hostname, fqdn)
182
# If the previous hostname file exists lets see if we
183
# can get a hostname from it
184
if prev_hostname_fn and os.path.exists(prev_hostname_fn):
185
prev_hostname = self._read_hostname(prev_hostname_fn)
189
# Lets get where we should write the system hostname
190
# and what the system hostname is
191
(sys_fn, sys_hostname) = self._read_system_hostname()
194
# If there is no previous hostname or it differs
195
# from what we want, lets update it or create the
196
# file in the first place
197
if not prev_hostname or prev_hostname != hostname:
198
update_files.append(prev_hostname_fn)
200
# If the system hostname is different than the previous
201
# one or the desired one lets update it as well
202
if (not sys_hostname) or (sys_hostname == prev_hostname
203
and sys_hostname != hostname):
204
update_files.append(sys_fn)
206
# Remove duplicates (incase the previous config filename)
207
# is the same as the system config filename, don't bother
209
update_files = set([f for f in update_files if f])
210
LOG.debug("Attempting to update hostname to %s in %s files",
211
hostname, len(update_files))
213
for fn in update_files:
215
self._write_hostname(hostname, fn)
217
util.logexc(LOG, "Failed to write hostname %s to %s", hostname,
220
if (sys_hostname and prev_hostname and
221
sys_hostname != prev_hostname):
222
LOG.debug("%s differs from %s, assuming user maintained hostname.",
223
prev_hostname_fn, sys_fn)
225
# If the system hostname file name was provided set the
226
# non-fqdn as the transient hostname.
227
if sys_fn in update_files:
228
self._apply_hostname(applying_hostname)
230
def update_etc_hosts(self, hostname, fqdn):
232
if os.path.exists(self.hosts_fn):
233
eh = hosts.HostsConf(util.load_file(self.hosts_fn))
235
eh = hosts.HostsConf('')
236
header = util.make_header(base="added")
237
local_ip = self._get_localhost_ip()
238
prev_info = eh.get_entry(local_ip)
241
eh.add_entry(local_ip, fqdn, hostname)
245
for entry in prev_info:
249
entry_fqdn = entry[0]
251
entry_aliases = entry[1:]
252
if entry_fqdn is not None and entry_fqdn == fqdn:
253
if hostname in entry_aliases:
254
# Exists already, leave it be
257
# Doesn't exist, add that entry in...
258
new_entries = list(prev_info)
259
new_entries.append([fqdn, hostname])
260
eh.del_entries(local_ip)
261
for entry in new_entries:
263
eh.add_entry(local_ip, entry[0])
264
elif len(entry) >= 2:
265
eh.add_entry(local_ip, *entry)
267
contents = StringIO()
269
contents.write("%s\n" % (header))
270
contents.write("%s\n" % (eh))
271
util.write_file(self.hosts_fn, contents.getvalue(), mode=0644)
273
def _bring_up_interface(self, device_name):
274
cmd = ['ifup', device_name]
275
LOG.debug("Attempting to run bring up interface %s using command %s",
278
(_out, err) = util.subp(cmd)
280
LOG.warn("Running %s resulted in stderr output: %s", cmd, err)
282
except util.ProcessExecutionError:
283
util.logexc(LOG, "Running interface command %s failed", cmd)
286
def _bring_up_interfaces(self, device_names):
288
for d in device_names:
289
if not self._bring_up_interface(d):
295
def get_default_user(self):
296
return self.get_option('default_user')
298
def add_user(self, name, **kwargs):
300
Add a user to the system using standard GNU tools
302
if util.is_user(name):
303
LOG.info("User %s already exists, skipping." % name)
306
adduser_cmd = ['useradd', name]
307
log_adduser_cmd = ['useradd', name]
309
# Since we are creating users, we want to carefully validate the
310
# inputs. If something goes wrong, we can end up with a system
311
# that nobody can login to.
313
"gecos": '--comment',
315
"primary_group": '--gid',
316
"groups": '--groups',
317
"passwd": '--password',
319
"expiredate": '--expiredate',
320
"inactive": '--inactive',
321
"selinux_user": '--selinux-user',
325
"no_user_group": '--no-user-group',
326
"system": '--system',
327
"no_log_init": '--no-log-init',
330
redact_opts = ['passwd']
332
# Check the values and create the command
333
for key, val in kwargs.iteritems():
335
if key in adduser_opts and val and isinstance(val, str):
336
adduser_cmd.extend([adduser_opts[key], val])
338
# Redact certain fields from the logs
339
if key in redact_opts:
340
log_adduser_cmd.extend([adduser_opts[key], 'REDACTED'])
342
log_adduser_cmd.extend([adduser_opts[key], val])
344
elif key in adduser_flags and val:
345
adduser_cmd.append(adduser_flags[key])
346
log_adduser_cmd.append(adduser_flags[key])
348
# Don't create the home directory if directed so or if the user is a
350
if 'no_create_home' in kwargs or 'system' in kwargs:
351
adduser_cmd.append('-M')
352
log_adduser_cmd.append('-M')
354
adduser_cmd.append('-m')
355
log_adduser_cmd.append('-m')
358
LOG.debug("Adding user %s", name)
360
util.subp(adduser_cmd, logstring=log_adduser_cmd)
361
except Exception as e:
362
util.logexc(LOG, "Failed to create user %s", name)
365
def create_user(self, name, **kwargs):
367
Creates users for the system using the GNU passwd tools. This
368
will work on an GNU system. This should be overriden on
369
distros where useradd is not desirable or not available.
373
self.add_user(name, **kwargs)
375
# Set password if plain-text password provided and non-empty
376
if 'plain_text_passwd' in kwargs and kwargs['plain_text_passwd']:
377
self.set_passwd(name, kwargs['plain_text_passwd'])
379
# Default locking down the account. 'lock_passwd' defaults to True.
380
# lock account unless lock_password is False.
381
if kwargs.get('lock_passwd', True):
382
self.lock_passwd(name)
384
# Configure sudo access
386
self.write_sudo_rules(name, kwargs['sudo'])
389
if 'ssh_authorized_keys' in kwargs:
390
keys = set(kwargs['ssh_authorized_keys']) or []
391
ssh_util.setup_user_keys(keys, name, options=None)
395
def lock_passwd(self, name):
397
Lock the password of a user, i.e., disable password logins
400
# Need to use the short option name '-l' instead of '--lock'
401
# (which would be more descriptive) since SLES 11 doesn't know
403
util.subp(['passwd', '-l', name])
404
except Exception as e:
405
util.logexc(LOG, 'Failed to disable password for user %s', name)
408
def set_passwd(self, user, passwd, hashed=False):
409
pass_string = '%s:%s' % (user, passwd)
413
# Need to use the short option name '-e' instead of '--encrypted'
414
# (which would be more descriptive) since SLES 11 doesn't know
419
util.subp(cmd, pass_string, logstring="chpasswd for %s" % user)
420
except Exception as e:
421
util.logexc(LOG, "Failed to set password for %s", user)
426
def ensure_sudo_dir(self, path, sudo_base='/etc/sudoers'):
427
# Ensure the dir is included and that
428
# it actually exists as a directory
429
sudoers_contents = ''
431
if os.path.exists(sudo_base):
432
sudoers_contents = util.load_file(sudo_base)
434
found_include = False
435
for line in sudoers_contents.splitlines():
437
include_match = re.search(r"^#includedir\s+(.*)$", line)
438
if not include_match:
440
included_dir = include_match.group(1).strip()
443
included_dir = os.path.abspath(included_dir)
444
if included_dir == path:
447
if not found_include:
450
lines = [('# See sudoers(5) for more information'
451
' on "#include" directives:'), '',
452
util.make_header(base="added"),
453
"#includedir %s" % (path), '']
454
sudoers_contents = "\n".join(lines)
455
util.write_file(sudo_base, sudoers_contents, 0440)
457
lines = ['', util.make_header(base="added"),
458
"#includedir %s" % (path), '']
459
sudoers_contents = "\n".join(lines)
460
util.append_file(sudo_base, sudoers_contents)
461
LOG.debug("Added '#includedir %s' to %s" % (path, sudo_base))
463
util.logexc(LOG, "Failed to write %s", sudo_base)
465
util.ensure_dir(path, 0750)
467
def write_sudo_rules(self, user, rules, sudo_file=None):
469
sudo_file = self.ci_sudoers_fn
473
"# User rules for %s" % user,
475
if isinstance(rules, (list, tuple)):
477
lines.append("%s %s" % (user, rule))
478
elif isinstance(rules, (basestring, str)):
479
lines.append("%s %s" % (user, rules))
481
msg = "Can not create sudoers rule addition with type %r"
482
raise TypeError(msg % (type_utils.obj_name(rules)))
483
content = "\n".join(lines)
484
content += "\n" # trailing newline
486
self.ensure_sudo_dir(os.path.dirname(sudo_file))
487
if not os.path.exists(sudo_file):
493
util.write_file(sudo_file, "\n".join(contents), 0440)
495
util.logexc(LOG, "Failed to write sudoers file %s", sudo_file)
499
util.append_file(sudo_file, content)
501
util.logexc(LOG, "Failed to append sudoers file %s", sudo_file)
504
def create_group(self, name, members):
505
group_add_cmd = ['groupadd', name]
507
# Check if group exists, and then add it doesn't
508
if util.is_group(name):
509
LOG.warn("Skipping creation of existing group '%s'" % name)
512
util.subp(group_add_cmd)
513
LOG.info("Created new group %s" % name)
515
util.logexc("Failed to create group %s", name)
517
# Add members to the group, if so defined
519
for member in members:
520
if not util.is_user(member):
521
LOG.warn("Unable to add group member '%s' to group '%s'"
522
"; user does not exist.", member, name)
525
util.subp(['usermod', '-a', '-G', name, member])
526
LOG.info("Added user '%s' to group '%s'" % (member, name))
529
def _get_package_mirror_info(mirror_info, availability_zone=None,
530
mirror_filter=util.search_for_mirror):
531
# given a arch specific 'mirror_info' entry (from package_mirrors)
532
# search through the 'search' entries, and fallback appropriately
533
# return a dict with only {name: mirror} entries.
537
# ec2 availability zones are named cc-direction-[0-9][a-d] (us-east-1b)
538
# the region is us-east-1. so region = az[0:-1]
539
directions_re = '|'.join([
540
'central', 'east', 'north', 'northeast', 'northwest',
541
'south', 'southeast', 'southwest', 'west'])
542
ec2_az_re = ("^[a-z][a-z]-(%s)-[1-9][0-9]*[a-z]$" % directions_re)
545
if availability_zone:
546
subst['availability_zone'] = availability_zone
548
if availability_zone and re.match(ec2_az_re, availability_zone):
549
subst['ec2_region'] = "%s" % availability_zone[0:-1]
552
for (name, mirror) in mirror_info.get('failsafe', {}).iteritems():
553
results[name] = mirror
555
for (name, searchlist) in mirror_info.get('search', {}).iteritems():
557
for tmpl in searchlist:
559
mirrors.append(tmpl % subst)
563
found = mirror_filter(mirrors)
565
results[name] = found
567
LOG.debug("filtered distro mirror info: %s" % results)
572
def _get_arch_package_mirror_info(package_mirrors, arch):
573
# pull out the specific arch from a 'package_mirrors' config option
575
for item in package_mirrors:
576
arches = item.get("arches")
579
if "default" in arches:
584
# Normalizes a input group configuration
585
# which can be a comma seperated list of
586
# group names, or a list of group names
587
# or a python dictionary of group names
588
# to a list of members of that group.
590
# The output is a dictionary of group
591
# names => members of that group which
592
# is the standard form used in the rest
594
def _normalize_groups(grp_cfg):
595
if isinstance(grp_cfg, (str, basestring)):
596
grp_cfg = grp_cfg.strip().split(",")
597
if isinstance(grp_cfg, (list)):
600
if isinstance(i, (dict)):
601
for k, v in i.items():
602
if k not in c_grp_cfg:
603
if isinstance(v, (list)):
604
c_grp_cfg[k] = list(v)
605
elif isinstance(v, (basestring, str)):
608
raise TypeError("Bad group member type %s" %
609
type_utils.obj_name(v))
611
if isinstance(v, (list)):
612
c_grp_cfg[k].extend(v)
613
elif isinstance(v, (basestring, str)):
614
c_grp_cfg[k].append(v)
616
raise TypeError("Bad group member type %s" %
617
type_utils.obj_name(v))
618
elif isinstance(i, (str, basestring)):
619
if i not in c_grp_cfg:
622
raise TypeError("Unknown group name type %s" %
623
type_utils.obj_name(i))
626
if isinstance(grp_cfg, (dict)):
627
for (grp_name, grp_members) in grp_cfg.items():
628
groups[grp_name] = util.uniq_merge_sorted(grp_members)
630
raise TypeError(("Group config must be list, dict "
631
" or string types only and not %s") %
632
type_utils.obj_name(grp_cfg))
636
# Normalizes a input group configuration
637
# which can be a comma seperated list of
638
# user names, or a list of string user names
639
# or a list of dictionaries with components
640
# that define the user config + 'name' (if
641
# a 'name' field does not exist then the
642
# default user is assumed to 'own' that
645
# The output is a dictionary of user
646
# names => user config which is the standard
647
# form used in the rest of cloud-init. Note
648
# the default user will have a special config
649
# entry 'default' which will be marked as true
650
# all other users will be marked as false.
651
def _normalize_users(u_cfg, def_user_cfg=None):
652
if isinstance(u_cfg, (dict)):
654
for (k, v) in u_cfg.items():
655
if isinstance(v, (bool, int, basestring, str, float)):
657
ad_ucfg.append(str(k))
658
elif isinstance(v, (dict)):
662
raise TypeError(("Unmappable user value type %s"
663
" for key %s") % (type_utils.obj_name(v), k))
665
elif isinstance(u_cfg, (str, basestring)):
666
u_cfg = util.uniq_merge_sorted(u_cfg)
669
for user_config in u_cfg:
670
if isinstance(user_config, (str, basestring, list)):
671
for u in util.uniq_merge(user_config):
672
if u and u not in users:
674
elif isinstance(user_config, (dict)):
675
if 'name' in user_config:
676
n = user_config.pop('name')
677
prev_config = users.get(n) or {}
678
users[n] = util.mergemanydict([prev_config,
681
# Assume the default user then
682
prev_config = users.get('default') or {}
683
users['default'] = util.mergemanydict([prev_config,
686
raise TypeError(("User config must be dictionary/list "
687
" or string types only and not %s") %
688
type_utils.obj_name(user_config))
690
# Ensure user options are in the right python friendly format
693
for (uname, uconfig) in users.items():
695
for (k, v) in uconfig.items():
696
k = k.replace('-', '_').strip()
699
c_users[uname] = c_uconfig
702
# Fixup the default user into the real
703
# default user name and replace it...
705
if users and 'default' in users:
706
def_config = users.pop('default')
708
# Pickup what the default 'real name' is
709
# and any groups that are provided by the
711
def_user_cfg = def_user_cfg.copy()
712
def_user = def_user_cfg.pop('name')
713
def_groups = def_user_cfg.pop('groups', [])
714
# Pickup any config + groups for that user name
715
# that we may have previously extracted
716
parsed_config = users.pop(def_user, {})
717
parsed_groups = parsed_config.get('groups', [])
718
# Now merge our extracted groups with
719
# anything the default config provided
720
users_groups = util.uniq_merge_sorted(parsed_groups, def_groups)
721
parsed_config['groups'] = ",".join(users_groups)
722
# The real config for the default user is the
723
# combination of the default user config provided
724
# by the distro, the default user config provided
725
# by the above merging for the user 'default' and
726
# then the parsed config from the user's 'real name'
727
# which does not have to be 'default' (but could be)
728
users[def_user] = util.mergemanydict([def_user_cfg,
732
# Ensure that only the default user that we
733
# found (if any) is actually marked as being
736
for (uname, uconfig) in users.items():
737
if def_user and uname == def_user:
738
uconfig['default'] = True
740
uconfig['default'] = False
745
# Normalizes a set of user/users and group
746
# dictionary configuration into a useable
747
# format that the rest of cloud-init can
748
# understand using the default user
749
# provided by the input distrobution (if any)
750
# to allow for mapping of the 'default' user.
752
# Output is a dictionary of group names -> [member] (list)
753
# and a dictionary of user names -> user configuration (dict)
755
# If 'user' exists it will override
756
# the 'users'[0] entry (if a list) otherwise it will
757
# just become an entry in the returned dictionary (no override)
758
def normalize_users_groups(cfg, distro):
765
groups = _normalize_groups(cfg['groups'])
767
# Handle the previous style of doing this where the first user
768
# overrides the concept of the default user if provided in the user: XYZ
771
if 'user' in cfg and cfg['user']:
772
old_user = cfg['user']
773
# Translate it into the format that is more useful
775
if isinstance(old_user, (basestring, str)):
779
if not isinstance(old_user, (dict)):
780
LOG.warn(("Format for 'user' key must be a string or "
781
"dictionary and not %s"), type_utils.obj_name(old_user))
784
# If no old user format, then assume the distro
785
# provides what the 'default' user maps to, but notice
786
# that if this is provided, we won't automatically inject
787
# a 'default' user into the users list, while if a old user
788
# format is provided we will.
789
distro_user_config = {}
791
distro_user_config = distro.get_default_user()
792
except NotImplementedError:
793
LOG.warn(("Distro has not implemented default user "
794
"access. No distribution provided default user"
795
" will be normalized."))
797
# Merge the old user (which may just be an empty dict when not
798
# present with the distro provided default user configuration so
799
# that the old user style picks up all the distribution specific
800
# attributes (if any)
801
default_user_config = util.mergemanydict([old_user, distro_user_config])
803
base_users = cfg.get('users', [])
804
if not isinstance(base_users, (list, dict, str, basestring)):
805
LOG.warn(("Format for 'users' key must be a comma separated string"
806
" or a dictionary or a list and not %s"),
807
type_utils.obj_name(base_users))
811
# Ensure that when user: is provided that this user
812
# always gets added (as the default user)
813
if isinstance(base_users, (list)):
814
# Just add it on at the end...
815
base_users.append({'name': 'default'})
816
elif isinstance(base_users, (dict)):
817
base_users['default'] = dict(base_users).get('default', True)
818
elif isinstance(base_users, (str, basestring)):
819
# Just append it on to be re-parsed later
820
base_users += ",default"
822
users = _normalize_users(base_users, default_user_config)
823
return (users, groups)
826
# Given a user dictionary config it will
827
# extract the default user name and user config
828
# from that list and return that tuple or
829
# return (None, None) if no default user is
830
# found in the given input
831
def extract_default(users, default_name=None, default_config=None):
835
def safe_find(entry):
837
if not config or 'default' not in config:
840
return config['default']
842
tmp_users = users.items()
843
tmp_users = dict(itertools.ifilter(safe_find, tmp_users))
845
return (default_name, default_config)
847
name = tmp_users.keys()[0]
848
config = tmp_users[name]
849
config.pop('default', None)
850
return (name, config)
854
locs = importer.find_module(name,
858
raise ImportError("No distribution found for distro %s"
860
mod = importer.import_module(locs[0])
861
cls = getattr(mod, 'Distro')