← Back to team overview

cloud-init-dev team mailing list archive

[Merge] lp:~harlowja/cloud-init/system-conf-goodies into lp:cloud-init

 

Joshua Harlow has proposed merging lp:~harlowja/cloud-init/system-conf-goodies into lp:cloud-init.

Requested reviews:
  cloud init development team (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~harlowja/cloud-init/system-conf-goodies/+merge/129071
-- 
https://code.launchpad.net/~harlowja/cloud-init/system-conf-goodies/+merge/129071
Your team cloud init development team is requested to review the proposed merge of lp:~harlowja/cloud-init/system-conf-goodies into lp:cloud-init.
=== modified file 'cloudinit/distros/__init__.py'
--- cloudinit/distros/__init__.py	2012-09-28 21:21:02 +0000
+++ cloudinit/distros/__init__.py	2012-10-10 23:23:21 +0000
@@ -33,6 +33,8 @@
 from cloudinit import ssh_util
 from cloudinit import util
 
+from cloudinit.distros.parsers import hosts
+
 LOG = logging.getLogger(__name__)
 
 
@@ -41,6 +43,8 @@
     __metaclass__ = abc.ABCMeta
     default_user = None
     default_user_groups = None
+    hosts_fn = "/etc/hosts"
+    ci_sudoers_fn = "/etc/sudoers.d/90-cloud-init-users"
 
     def __init__(self, name, cfg, paths):
         self._paths = paths
@@ -65,10 +69,6 @@
         raise NotImplementedError()
 
     @abc.abstractmethod
-    def update_hostname(self, hostname, prev_hostname_fn):
-        raise NotImplementedError()
-
-    @abc.abstractmethod
     def package_command(self, cmd, args=None):
         raise NotImplementedError()
 
@@ -115,43 +115,92 @@
     def _get_localhost_ip(self):
         return "127.0.0.1"
 
+    @abc.abstractmethod
+    def _read_hostname(self, filename, default=None):
+        raise NotImplementedError()
+
+    @abc.abstractmethod
+    def _write_hostname(self, hostname, filename):
+        raise NotImplementedError()
+
+    @abc.abstractmethod
+    def _read_system_hostname(self):
+        raise NotImplementedError()
+
+    def _apply_hostname(self, hostname):
+        LOG.debug("Setting system hostname to %s", hostname)
+        util.subp(['hostname', hostname])
+
+    def update_hostname(self, hostname, prev_hostname_fn):
+        if not hostname:
+            return
+
+        prev_hostname = self._read_hostname(prev_hostname_fn)
+        (sys_fn, sys_hostname) = self._read_system_hostname()
+        update_files = []
+        if not prev_hostname or prev_hostname != hostname:
+            update_files.append(prev_hostname_fn)
+
+        if (not sys_hostname) or (sys_hostname == prev_hostname
+                                  and sys_hostname != hostname):
+            update_files.append(sys_fn)
+
+        update_files = set([f for f in update_files if f])
+        LOG.debug("Attempting to update hostname to %s in %s files",
+                  hostname, len(update_files))
+
+        for fn in update_files:
+            try:
+                self._write_hostname(hostname, fn)
+            except IOError:
+                util.logexc(LOG, "Failed to write hostname %s to %s",
+                            hostname, fn)
+
+        if (sys_hostname and prev_hostname and
+            sys_hostname != prev_hostname):
+            LOG.debug("%s differs from %s, assuming user maintained hostname.",
+                       prev_hostname_fn, sys_fn)
+
+        if sys_fn in update_files:
+            self._apply_hostname(hostname)
+
     def update_etc_hosts(self, hostname, fqdn):
-        # Format defined at
-        # http://unixhelp.ed.ac.uk/CGI/man-cgi?hosts
-        header = "# Added by cloud-init"
-        real_header = "%s on %s" % (header, util.time_rfc2822())
+        header = ''
+        if os.path.exists(self.hosts_fn):
+            eh = hosts.HostsConf(util.load_file(self.hosts_fn))
+        else:
+            eh = hosts.HostsConf('')
+            header = util.make_header(base="added")
         local_ip = self._get_localhost_ip()
-        hosts_line = "%s\t%s %s" % (local_ip, fqdn, hostname)
-        new_etchosts = StringIO()
-        need_write = False
-        need_change = True
-        hosts_ro_fn = self._paths.join(True, "/etc/hosts")
-        for line in util.load_file(hosts_ro_fn).splitlines():
-            if line.strip().startswith(header):
-                continue
-            if not line.strip() or line.strip().startswith("#"):
-                new_etchosts.write("%s\n" % (line))
-                continue
-            split_line = [s.strip() for s in line.split()]
-            if len(split_line) < 2:
-                new_etchosts.write("%s\n" % (line))
-                continue
-            (ip, hosts) = split_line[0], split_line[1:]
-            if ip == local_ip:
-                if sorted([hostname, fqdn]) == sorted(hosts):
-                    need_change = False
-                if need_change:
-                    line = "%s\n%s" % (real_header, hosts_line)
-                    need_change = False
-                    need_write = True
-            new_etchosts.write("%s\n" % (line))
+        prev_info = eh.get_entry(local_ip)
+        need_change = False
+        if not prev_info:
+            eh.add_entry(local_ip, fqdn, hostname)
+            need_change = True
+        else:
+            need_change = True
+            for entry in prev_info:
+                if sorted(entry) == sorted([fqdn, hostname]):
+                    # Exists already, leave it be
+                    need_change = False
+                    break
+            if need_change:
+                # Doesn't exist, change the first
+                # entry to be this entry
+                new_entries = list(prev_info)
+                new_entries[0] = [fqdn, hostname]
+                eh.del_entries(local_ip)
+                for entry in new_entries:
+                    if len(entry) == 1:
+                        eh.add_entry(local_ip, entry[0])
+                    elif len(entry) >= 2:
+                        eh.add_entry(local_ip, *entry)
         if need_change:
-            new_etchosts.write("%s\n%s\n" % (real_header, hosts_line))
-            need_write = True
-        if need_write:
-            contents = new_etchosts.getvalue()
-            util.write_file(self._paths.join(False, "/etc/hosts"),
-                            contents, mode=0644)
+            contents = StringIO()
+            if header:
+                contents.write("%s\n" % (header))
+            contents.write("%s\n" % (eh))
+            util.write_file(self.hosts_fn, contents.getvalue(), mode=0644)
 
     def _bring_up_interface(self, device_name):
         cmd = ['ifup', device_name]
@@ -299,30 +348,31 @@
 
         return True
 
-    def write_sudo_rules(self,
-        user,
-        rules,
-        sudo_file="/etc/sudoers.d/90-cloud-init-users",
-        ):
+    def write_sudo_rules(self, user, rules, sudo_file=None):
+        if not sudo_file:
+            sudo_file = self.ci_sudoers_fn
 
-        content_header = "# user rules for %s" % user
+        content_header = "# User rules for %s" % user
         content = "%s\n%s %s\n\n" % (content_header, user, rules)
 
-        if isinstance(rules, list):
+        if isinstance(rules, (list, tuple, set)):
             content = "%s\n" % content_header
             for rule in rules:
                 content += "%s %s\n" % (user, rule)
             content += "\n"
 
         if not os.path.exists(sudo_file):
-            util.write_file(sudo_file, content, 0440)
-
+            contents = [
+                util.make_header(),
+                content,
+            ]
+            util.write_file(sudo_file, "\n".join(contents), 0440)
         else:
             try:
                 with open(sudo_file, 'a') as f:
                     f.write(content)
             except IOError as e:
-                util.logexc(LOG, "Failed to write %s" % sudo_file, e)
+                util.logexc(LOG, "Failed to write sudoers file %s", sudo_file)
                 raise e
 
     def create_group(self, name, members):

=== modified file 'cloudinit/distros/debian.py'
--- cloudinit/distros/debian.py	2012-09-20 22:55:52 +0000
+++ cloudinit/distros/debian.py	2012-10-10 23:23:21 +0000
@@ -27,12 +27,20 @@
 from cloudinit import log as logging
 from cloudinit import util
 
+from cloudinit.distros.parsers import chop_comment
+
 from cloudinit.settings import PER_INSTANCE
 
 LOG = logging.getLogger(__name__)
 
 
 class Distro(distros.Distro):
+    hostname_conf_fn = "/etc/hostname"
+    locale_conf_fn = "/etc/default/locale"
+    network_conf_fn = "/etc/network/interfaces"
+    tz_conf_fn = "/etc/timezone"
+    tz_local_fn = "/etc/localtime"
+    tz_zone_dir = "/usr/share/zoneinfo"
 
     def __init__(self, name, cfg, paths):
         distros.Distro.__init__(self, name, cfg, paths)
@@ -43,10 +51,15 @@
 
     def apply_locale(self, locale, out_fn=None):
         if not out_fn:
-            out_fn = self._paths.join(False, '/etc/default/locale')
+            out_fn = self.locale_conf_fn
         util.subp(['locale-gen', locale], capture=False)
         util.subp(['update-locale', locale], capture=False)
-        lines = ["# Created by cloud-init", 'LANG="%s"' % (locale), ""]
+        # "" provides trailing newline during join
+        lines = [
+            util.make_header(),
+            'LANG="%s"' % (locale),
+            "",
+        ]
         util.write_file(out_fn, "\n".join(lines))
 
     def install_packages(self, pkglist):
@@ -54,8 +67,7 @@
         self.package_command('install', pkglist)
 
     def _write_network(self, settings):
-        net_fn = self._paths.join(False, "/etc/network/interfaces")
-        util.write_file(net_fn, settings)
+        util.write_file(self.network_conf_fn, settings)
         return ['all']
 
     def _bring_up_interfaces(self, device_names):
@@ -69,54 +81,29 @@
             return distros.Distro._bring_up_interfaces(self, device_names)
 
     def set_hostname(self, hostname):
-        out_fn = self._paths.join(False, "/etc/hostname")
-        self._write_hostname(hostname, out_fn)
-        if out_fn == '/etc/hostname':
-            # Only do this if we are running in non-adjusted root mode
-            LOG.debug("Setting hostname to %s", hostname)
-            util.subp(['hostname', hostname])
+        self._write_hostname(hostname, self.hostname_conf_fn)
+        self._apply_hostname(hostname)
 
     def _write_hostname(self, hostname, out_fn):
         # "" gives trailing newline.
-        util.write_file(out_fn, "%s\n" % str(hostname), 0644)
+        hostname_lines = [
+            str(hostname),
+            "",
+        ]
+        util.write_file(out_fn, "\n".join(hostname_lines), 0644)
 
-    def update_hostname(self, hostname, prev_fn):
-        hostname_prev = self._read_hostname(prev_fn)
-        read_fn = self._paths.join(True, "/etc/hostname")
-        hostname_in_etc = self._read_hostname(read_fn)
-        update_files = []
-        if not hostname_prev or hostname_prev != hostname:
-            update_files.append(prev_fn)
-        if (not hostname_in_etc or
-            (hostname_in_etc == hostname_prev and
-             hostname_in_etc != hostname)):
-            write_fn = self._paths.join(False, "/etc/hostname")
-            update_files.append(write_fn)
-        for fn in update_files:
-            try:
-                self._write_hostname(hostname, fn)
-            except:
-                util.logexc(LOG, "Failed to write hostname %s to %s",
-                            hostname, fn)
-        if (hostname_in_etc and hostname_prev and
-            hostname_in_etc != hostname_prev):
-            LOG.debug(("%s differs from /etc/hostname."
-                        " Assuming user maintained hostname."), prev_fn)
-        if "/etc/hostname" in update_files:
-            # Only do this if we are running in non-adjusted root mode
-            LOG.debug("Setting hostname to %s", hostname)
-            util.subp(['hostname', hostname])
+    def _read_system_hostname(self):
+        return (self.hostname_conf_fn,
+                self._read_hostname(self.hostname_conf_fn))
 
     def _read_hostname(self, filename, default=None):
         contents = util.load_file(filename, quiet=True)
         for line in contents.splitlines():
-            c_pos = line.find("#")
             # Handle inline comments
-            if c_pos != -1:
-                line = line[0:c_pos]
-            line_c = line.strip()
-            if line_c:
-                return line_c
+            (before_comment, _comment) = chop_comment(line, "#")
+            before_comment = before_comment.strip()
+            if len(before_comment):
+                return before_comment
         return default
 
     def _get_localhost_ip(self):
@@ -124,15 +111,18 @@
         return "127.0.1.1"
 
     def set_timezone(self, tz):
-        tz_file = os.path.join("/usr/share/zoneinfo", tz)
+        tz_file = os.path.join(self.tz_zone_dir, tz)
         if not os.path.isfile(tz_file):
             raise RuntimeError(("Invalid timezone %s,"
                                 " no file found at %s") % (tz, tz_file))
         # "" provides trailing newline during join
-        tz_lines = ["# Created by cloud-init", str(tz), ""]
-        tz_fn = self._paths.join(False, "/etc/timezone")
-        util.write_file(tz_fn, "\n".join(tz_lines))
-        util.copy(tz_file, self._paths.join(False, "/etc/localtime"))
+        tz_lines = [
+            util.make_header(),
+            str(tz), 
+            "",
+        ]
+        util.write_file(self.tz_conf_fn, "\n".join(tz_lines))
+        util.copy(tz_file, self.tz_local_fn)
 
     def package_command(self, command, args=None):
         e = os.environ.copy()

=== added directory 'cloudinit/distros/parsers'
=== added file 'cloudinit/distros/parsers/__init__.py'
--- cloudinit/distros/parsers/__init__.py	1970-01-01 00:00:00 +0000
+++ cloudinit/distros/parsers/__init__.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,27 @@
+# vi: ts=4 expandtab
+#
+#    Copyright (C) 2012 Yahoo! Inc.
+#
+#    Author: Joshua Harlow <harlowja@xxxxxxxxxxxxx>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License version 3, as
+#    published by the Free Software Foundation.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+def chop_comment(text, comment_chars):
+    comment_locations = [text.find(c) for c in comment_chars]
+    comment_locations = [c for c in comment_locations if c != -1]
+    if not comment_locations:
+        return (text, '')
+    min_comment = min(comment_locations)
+    before_comment = text[0:min_comment]
+    comment = text[min_comment:]
+    return (before_comment, comment)

=== added file 'cloudinit/distros/parsers/hosts.py'
--- cloudinit/distros/parsers/hosts.py	1970-01-01 00:00:00 +0000
+++ cloudinit/distros/parsers/hosts.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,92 @@
+# vi: ts=4 expandtab
+#
+#    Copyright (C) 2012 Yahoo! Inc.
+#
+#    Author: Joshua Harlow <harlowja@xxxxxxxxxxxxx>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License version 3, as
+#    published by the Free Software Foundation.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from StringIO import StringIO
+
+from cloudinit.distros.parsers import chop_comment
+
+
+# See: man hosts
+# or http://unixhelp.ed.ac.uk/CGI/man-cgi?hosts
+class HostsConf(object):
+    def __init__(self, text):
+        self._text = text
+        self._contents = None
+
+    def parse(self):
+        if self._contents is None:
+            self._contents = self._parse(self._text)
+
+    def get_entry(self, ip):
+        self.parse()
+        options = []
+        for (line_type, components) in self._contents:
+            if line_type == 'option':
+                (pieces, _tail) = components
+                if len(pieces) and pieces[0] == ip:
+                    options.append(pieces[1:])
+        return options
+
+    def del_entries(self, ip):
+        self.parse()
+        n_entries = []
+        for (line_type, components) in self._contents:
+            if line_type != 'option':
+                n_entries.append((line_type, components))
+                continue
+            else:
+                (pieces, _tail) = components
+                if len(pieces) and pieces[0] == ip:
+                    pass
+                elif len(pieces):
+                    n_entries.append((line_type, list(components)))
+        self._contents = n_entries
+
+    def add_entry(self, ip, canonical_hostname, *aliases):
+        self.parse()
+        self._contents.append(('option',
+                              ([ip, canonical_hostname] + list(aliases), '')))
+
+    def _parse(self, contents):
+        entries = []
+        for line in contents.splitlines():
+            if not len(line.strip()):
+                entries.append(('blank', [line]))
+                continue
+            (head, tail) = chop_comment(line.strip(), '#')
+            if not len(head):
+                entries.append(('all_comment', [line]))
+                continue
+            entries.append(('option', [head.split(None), tail]))
+        return entries
+
+    def __str__(self):
+        self.parse()
+        contents = StringIO()
+        for (line_type, components) in self._contents:
+            if line_type == 'blank':
+                contents.write("%s\n")
+            elif line_type == 'all_comment':
+                contents.write("%s\n" % (components[0]))
+            elif line_type == 'option':
+                (pieces, tail) = components
+                pieces = [str(p) for p in pieces]
+                pieces = "\t".join(pieces)
+                contents.write("%s%s\n" % (pieces, tail))
+        return contents.getvalue()
+

=== added file 'cloudinit/distros/parsers/quoting_conf.py'
--- cloudinit/distros/parsers/quoting_conf.py	1970-01-01 00:00:00 +0000
+++ cloudinit/distros/parsers/quoting_conf.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,80 @@
+# vi: ts=4 expandtab
+#
+#    Copyright (C) 2012 Yahoo! Inc.
+#
+#    Author: Joshua Harlow <harlowja@xxxxxxxxxxxxx>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License version 3, as
+#    published by the Free Software Foundation.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# This library is used to parse/write
+# out the various sysconfig files edited
+#
+# It has to be slightly modified though
+# to ensure that all values are quoted
+# since these configs are usually sourced into
+# bash scripts...
+from configobj import ConfigObj
+
+# See: http://tiny.cc/oezbgw
+D_QUOTE_CHARS = {
+    "\"": "\\\"",
+    "(": "\\(",
+    ")": "\\)",
+    "$": '\$',
+    '`': '\`',
+}
+
+# This class helps adjust the configobj
+# writing to ensure that when writing a k/v
+# on a line, that they are properly quoted
+# and have no spaces between the '=' sign.
+# - This is mainly due to the fact that
+# the sysconfig scripts are often sourced
+# directly into bash/shell scripts so ensure
+# that it works for those types of use cases.
+class QuotingConfigObj(ConfigObj):
+    def __init__(self, lines):
+        ConfigObj.__init__(self, lines,
+                           interpolation=False,
+                           write_empty_values=True)
+
+    def _quote_posix(self, text):
+        if not text:
+            return ''
+        for (k, v) in D_QUOTE_CHARS.iteritems():
+            text = text.replace(k, v)
+        return '"%s"' % (text)
+
+    def _quote_special(self, text):
+        if text.lower() in ['yes', 'no', 'true', 'false']:
+            return text
+        else:
+            return self._quote_posix(text)
+
+    def _write_line(self, indent_string, entry, this_entry, comment):
+        # Ensure it is formatted fine for
+        # how these sysconfig scripts are used
+        val = self._decode_element(self._quote(this_entry))
+        # Single quoted strings should
+        # always work.
+        if not val.startswith("'"):
+            # Perform any special quoting
+            val = self._quote_special(val)
+        key = self._decode_element(self._quote(entry, multiline=False))
+        cmnt = self._decode_element(comment)
+        return '%s%s%s%s%s' % (indent_string,
+                               key,
+                               "=",
+                               val,
+                               cmnt)
+

=== added file 'cloudinit/distros/parsers/resolv_conf.py'
--- cloudinit/distros/parsers/resolv_conf.py	1970-01-01 00:00:00 +0000
+++ cloudinit/distros/parsers/resolv_conf.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,171 @@
+# vi: ts=4 expandtab
+#
+#    Copyright (C) 2012 Yahoo! Inc.
+#
+#    Author: Joshua Harlow <harlowja@xxxxxxxxxxxxx>
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License version 3, as
+#    published by the Free Software Foundation.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from StringIO import StringIO
+
+from cloudinit import util
+
+from cloudinit.distros.parsers import chop_comment
+
+
+# See: man resolv.conf
+class ResolvConf(object):
+    def __init__(self, text):
+        self._text = text
+        self._contents = None
+
+    def parse(self):
+        if self._contents is None:
+            self._contents = self._parse(self._text)
+
+    @property
+    def nameservers(self):
+        self.parse()
+        return self._retr_option('nameserver')
+
+    @property
+    def local_domain(self):
+        self.parse()
+        dm = self._retr_option('domain')
+        if dm:
+            return dm[0]
+        return None
+
+    @property
+    def search_domains(self):
+        self.parse()
+        current_sds = self._retr_option('search')
+        flat_sds = []
+        for sdlist in current_sds:
+            for sd in sdlist.split(None):
+                if sd:
+                    flat_sds.append(sd)
+        return flat_sds
+
+    def __str__(self):
+        self.parse()
+        contents = StringIO()
+        for (line_type, components) in self._contents:
+            if line_type == 'blank':
+                contents.write("\n")
+            elif line_type == 'all_comment':
+                contents.write("%s\n" % (components[0]))
+            elif line_type == 'option':
+                (cfg_opt, cfg_value, comment_tail) = components
+                line = "%s %s" % (cfg_opt, cfg_value)
+                if len(comment_tail):
+                    line += comment_tail
+                contents.write("%s\n" % (line))
+        return contents.getvalue()
+
+    def _retr_option(self, opt_name):
+        found = []
+        for (line_type, components) in self._contents:
+            if line_type == 'option':
+                (cfg_opt, cfg_value, _comment_tail) = components
+                if cfg_opt == opt_name:
+                    found.append(cfg_value)
+        return found
+
+    def add_nameserver(self, ns):
+        self.parse()
+        current_ns = self._retr_option('nameserver')
+        new_ns = list(current_ns)
+        new_ns.append(str(ns))
+        new_ns = util.uniq_list(new_ns)
+        if len(new_ns) == len(current_ns):
+            return current_ns
+        if len(current_ns) >= 3:
+            # Hard restriction on only 3 name servers
+            raise ValueError(("Adding %r would go beyond the "
+                              "'3' maximum name servers") % (ns))
+        self._remove_option('nameserver')
+        for n in new_ns:
+            self._contents.append(('option', ['nameserver', n, '']))
+        return new_ns
+
+    def _remove_option(self, opt_name):
+
+        def remove_opt(item):
+            line_type, components = item
+            if line_type != 'option':
+                return False
+            (cfg_opt, _cfg_value, _comment_tail) = components
+            if cfg_opt != opt_name:
+                return False
+            return True
+
+        new_contents = []
+        for c in self._contents:
+            if not remove_opt(c):
+                new_contents.append(c)
+        self._contents = new_contents
+
+    def add_search_domain(self, search_domain):
+        flat_sds = self.search_domains
+        new_sds = list(flat_sds)
+        new_sds.append(str(search_domain))
+        new_sds = util.uniq_list(new_sds)
+        if len(flat_sds) == len(new_sds):
+            return new_sds
+        if len(flat_sds) >= 6:
+            # Hard restriction on only 6 search domains
+            raise ValueError(("Adding %r would go beyond the "
+                              "'6' maximum search domains") % (search_domain))
+        s_list  = " ".join(new_sds)
+        if len(s_list) > 256:
+            # Some hard limit on 256 chars total
+            raise ValueError(("Adding %r would go beyond the "
+                              "256 maximum search list character limit")
+                              % (search_domain))
+        self._remove_option('search')
+        self._contents.append(('option', ['search', s_list, '']))
+        return flat_sds
+
+    @local_domain.setter
+    def local_domain(self, domain):
+        self.parse()
+        self._remove_option('domain')
+        self._contents.append(('option', ['domain', str(domain), '']))
+        return domain
+
+    def _parse(self, contents):
+        entries = []
+        for (i, line) in enumerate(contents.splitlines()):
+            sline = line.strip()
+            if not sline:
+                entries.append(('blank', [line]))
+                continue
+            (head, tail) = chop_comment(line, ';#')
+            if not len(head.strip()):
+                entries.append(('all_comment', [line]))
+                continue
+            if not tail:
+                tail = ''
+            try:
+                (cfg_opt, cfg_values) = head.split(None, 1)
+            except (IndexError, ValueError):
+                raise IOError("Incorrectly formatted resolv.conf line %s"
+                              % (i + 1))
+            if cfg_opt not in ['nameserver', 'domain',
+                               'search', 'sortlist', 'options']:
+                raise IOError("Unexpected resolv.conf option %s" % (cfg_opt))
+            entries.append(("option", [cfg_opt, cfg_values, tail]))
+        return entries
+
+

=== modified file 'cloudinit/distros/rhel.py'
--- cloudinit/distros/rhel.py	2012-09-25 20:44:16 +0000
+++ cloudinit/distros/rhel.py	2012-10-10 23:23:21 +0000
@@ -23,39 +23,17 @@
 import os
 
 from cloudinit import distros
+
+from cloudinit.distros.parsers import (resolv_conf, quoting_conf)
+
 from cloudinit import helpers
 from cloudinit import log as logging
 from cloudinit import util
-from cloudinit import version
 
 from cloudinit.settings import PER_INSTANCE
 
 LOG = logging.getLogger(__name__)
 
-NETWORK_FN_TPL = '/etc/sysconfig/network-scripts/ifcfg-%s'
-
-# See: http://tiny.cc/6r99fw
-# For what alot of these files that are being written
-# are and the format of them
-
-# This library is used to parse/write
-# out the various sysconfig files edited
-#
-# It has to be slightly modified though
-# to ensure that all values are quoted
-# since these configs are usually sourced into
-# bash scripts...
-from configobj import ConfigObj
-
-# See: http://tiny.cc/oezbgw
-D_QUOTE_CHARS = {
-    "\"": "\\\"",
-    "(": "\\(",
-    ")": "\\)",
-    "$": '\$',
-    '`': '\`',
-}
-
 
 def _make_sysconfig_bool(val):
     if val:
@@ -64,12 +42,15 @@
         return 'no'
 
 
-def _make_header():
-    ci_ver = version.version_string()
-    return '# Created by cloud-init v. %s' % (ci_ver)
-
-
 class Distro(distros.Distro):
+    # See: http://tiny.cc/6r99fw
+    clock_conf_fn = "/etc/sysconfig/clock"
+    locale_conf_fn = '/etc/sysconfig/i18n'
+    network_conf_fn = "/etc/sysconfig/network"
+    network_script_tpl = '/etc/sysconfig/network-scripts/ifcfg-%s'
+    resolve_conf_fn = "/etc/resolv.conf"
+    tz_local_fn = "/etc/localtime"
+    tz_zone_dir = "/usr/share/zoneinfo"
 
     def __init__(self, name, cfg, paths):
         distros.Distro.__init__(self, name, cfg, paths)
@@ -81,16 +62,29 @@
     def install_packages(self, pkglist):
         self.package_command('install', pkglist)
 
-    def _write_resolve(self, dns_servers, search_servers):
-        contents = []
+    def _adjust_resolve(self, dns_servers, search_servers):
+        r_conf = resolv_conf.ResolvConf(util.load_file(self.resolve_conf_fn))
+        try:
+            r_conf.parse()
+        except IOError:
+            util.logexc(LOG, 
+                        "Failed at parsing %s reverting to an empty instance",
+                        self.resolve_conf_fn)
+            r_conf = resolv_conf.ResolvConf('')
+            r_conf.parse()
         if dns_servers:
             for s in dns_servers:
-                contents.append("nameserver %s" % (s))
+                try:
+                    r_conf.add_nameserver(s)
+                except ValueError:
+                    util.logexc(LOG, "Failed at adding nameserver %s", s)
         if search_servers:
-            contents.append("search %s" % (" ".join(search_servers)))
-        if contents:
-            contents.insert(0, _make_header())
-            util.write_file("/etc/resolv.conf", "\n".join(contents), 0644)
+            for s in search_servers:
+                try:
+                    r_conf.add_search_domain(s)
+                except ValueError:
+                    util.logexc(LOG, "Failed at adding search domain %s", s)
+        util.write_file(self.resolve_conf_fn, str(r_conf), 0644)
 
     def _write_network(self, settings):
         # TODO(harlowja) fix this... since this is the ubuntu format
@@ -102,7 +96,7 @@
         searchservers = []
         dev_names = entries.keys()
         for (dev, info) in entries.iteritems():
-            net_fn = NETWORK_FN_TPL % (dev)
+            net_fn = self.network_script_tpl % (dev)
             net_cfg = {
                 'DEVICE': dev,
                 'NETMASK': info.get('netmask'),
@@ -119,12 +113,12 @@
             if 'dns-search' in info:
                 searchservers.extend(info['dns-search'])
         if nameservers or searchservers:
-            self._write_resolve(nameservers, searchservers)
+            self._adjust_resolve(nameservers, searchservers)
         if dev_names:
             net_cfg = {
                 'NETWORKING': _make_sysconfig_bool(True),
             }
-            self._update_sysconfig_file("/etc/sysconfig/network", net_cfg)
+            self._update_sysconfig_file(self.network_conf_fn, net_cfg)
         return dev_names
 
     def _update_sysconfig_file(self, fn, adjustments, allow_empty=False):
@@ -143,17 +137,16 @@
         if updated_am:
             lines = contents.write()
             if not exists:
-                lines.insert(0, _make_header())
+                lines.insert(0, util.make_header())
             util.write_file(fn, "\n".join(lines), 0644)
 
     def set_hostname(self, hostname):
-        self._write_hostname(hostname, '/etc/sysconfig/network')
-        LOG.debug("Setting hostname to %s", hostname)
-        util.subp(['hostname', hostname])
+        self._write_hostname(hostname, self.network_conf_fn)
+        self._apply_hostname(hostname)
 
     def apply_locale(self, locale, out_fn=None):
         if not out_fn:
-            out_fn = '/etc/sysconfig/i18n'
+            out_fn = self.locale_conf_fn
         locale_cfg = {
             'LANG': locale,
         }
@@ -165,30 +158,9 @@
         }
         self._update_sysconfig_file(out_fn, host_cfg)
 
-    def update_hostname(self, hostname, prev_file):
-        hostname_prev = self._read_hostname(prev_file)
-        hostname_in_sys = self._read_hostname("/etc/sysconfig/network")
-        update_files = []
-        if not hostname_prev or hostname_prev != hostname:
-            update_files.append(prev_file)
-        if (not hostname_in_sys or
-            (hostname_in_sys == hostname_prev
-             and hostname_in_sys != hostname)):
-            update_files.append("/etc/sysconfig/network")
-        for fn in update_files:
-            try:
-                self._write_hostname(hostname, fn)
-            except:
-                util.logexc(LOG, "Failed to write hostname %s to %s",
-                            hostname, fn)
-        if (hostname_in_sys and hostname_prev and
-            hostname_in_sys != hostname_prev):
-            LOG.debug(("%s differs from /etc/sysconfig/network."
-                        " Assuming user maintained hostname."), prev_file)
-        if "/etc/sysconfig/network" in update_files:
-            # Only do this if we are running in non-adjusted root mode
-            LOG.debug("Setting hostname to %s", hostname)
-            util.subp(['hostname', hostname])
+    def _read_system_hostname(self):
+        return (self.network_conf_fn,
+                self._read_hostname(self.network_conf_fn))
 
     def _read_hostname(self, filename, default=None):
         (_exists, contents) = self._read_conf(filename)
@@ -204,7 +176,8 @@
             exists = True
         else:
             contents = []
-        return (exists, QuotingConfigObj(contents))
+        return (exists,
+                quoting_conf.QuotingConfigObj(contents))
 
     def _bring_up_interfaces(self, device_names):
         if device_names and 'all' in device_names:
@@ -213,17 +186,19 @@
         return distros.Distro._bring_up_interfaces(self, device_names)
 
     def set_timezone(self, tz):
-        tz_file = os.path.join("/usr/share/zoneinfo", tz)
+        # Ensure that this timezone is actually
+        # available on this system, if not give up
+        tz_file = os.path.join(self.tz_zone_dir, str(tz))
         if not os.path.isfile(tz_file):
             raise RuntimeError(("Invalid timezone %s,"
                                 " no file found at %s") % (tz, tz_file))
         # Adjust the sysconfig clock zone setting
         clock_cfg = {
-            'ZONE': tz,
+            'ZONE': str(tz),
         }
-        self._update_sysconfig_file("/etc/sysconfig/clock", clock_cfg)
+        self._update_sysconfig_file(self.clock_conf_fn, clock_cfg)
         # This ensures that the correct tz will be used for the system
-        util.copy(tz_file, "/etc/localtime")
+        util.copy(tz_file, self.tz_local_fn)
 
     def package_command(self, command, args=None):
         cmd = ['yum']
@@ -247,51 +222,6 @@
                          ["makecache"], freq=PER_INSTANCE)
 
 
-# This class helps adjust the configobj
-# writing to ensure that when writing a k/v
-# on a line, that they are properly quoted
-# and have no spaces between the '=' sign.
-# - This is mainly due to the fact that
-# the sysconfig scripts are often sourced
-# directly into bash/shell scripts so ensure
-# that it works for those types of use cases.
-class QuotingConfigObj(ConfigObj):
-    def __init__(self, lines):
-        ConfigObj.__init__(self, lines,
-                           interpolation=False,
-                           write_empty_values=True)
-
-    def _quote_posix(self, text):
-        if not text:
-            return ''
-        for (k, v) in D_QUOTE_CHARS.iteritems():
-            text = text.replace(k, v)
-        return '"%s"' % (text)
-
-    def _quote_special(self, text):
-        if text.lower() in ['yes', 'no', 'true', 'false']:
-            return text
-        else:
-            return self._quote_posix(text)
-
-    def _write_line(self, indent_string, entry, this_entry, comment):
-        # Ensure it is formatted fine for
-        # how these sysconfig scripts are used
-        val = self._decode_element(self._quote(this_entry))
-        # Single quoted strings should
-        # always work.
-        if not val.startswith("'"):
-            # Perform any special quoting
-            val = self._quote_special(val)
-        key = self._decode_element(self._quote(entry, multiline=False))
-        cmnt = self._decode_element(comment)
-        return '%s%s%s%s%s' % (indent_string,
-                               key,
-                               "=",
-                               val,
-                               cmnt)
-
-
 # This is a util function to translate a ubuntu /etc/network/interfaces 'blob'
 # to a rhel equiv. that can then be written to /etc/sysconfig/network-scripts/
 # TODO(harlowja) remove when we have python-netcf active...

=== modified file 'cloudinit/util.py'
--- cloudinit/util.py	2012-09-28 21:21:02 +0000
+++ cloudinit/util.py	2012-10-10 23:23:21 +0000
@@ -52,6 +52,7 @@
 from cloudinit import log as logging
 from cloudinit import safeyaml
 from cloudinit import url_helper as uhelp
+from cloudinit import version
 
 from cloudinit.settings import (CFG_BUILTIN)
 
@@ -272,11 +273,7 @@
             # Kickout the empty ones
             a_list = [a for a in a_list if len(a)]
         combined_list.extend(a_list)
-    uniq_list = []
-    for i in combined_list:
-        if i not in uniq_list:
-            uniq_list.append(i)
-    return uniq_list
+    return uniq_list(combined_list)
 
 
 def clean_filename(fn):
@@ -983,6 +980,16 @@
     return entries
 
 
+def uniq_list(in_list):
+    out_list = []
+    for i in in_list:
+        if i in out_list:
+            continue
+        else:
+            out_list.append(i)
+    return out_list
+
+
 def load_file(fname, read_cb=None, quiet=False):
     LOG.debug("Reading from %s (quiet=%s)", fname, quiet)
     ofh = StringIO()
@@ -1419,6 +1426,14 @@
     return (out, err)
 
 
+def make_header(comment_char="#", base='created'):
+    ci_ver = version.version_string()
+    header = str(comment_char)
+    header += " %s by cloud-init v. %s" % (base.title(), ci_ver)
+    header += " on %s" % time_rfc2822()
+    return header
+
+
 def abs_join(*paths):
     return os.path.abspath(os.path.join(*paths))
 

=== added file 'tests/unittests/test_distros/test_hosts.py'
--- tests/unittests/test_distros/test_hosts.py	1970-01-01 00:00:00 +0000
+++ tests/unittests/test_distros/test_hosts.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,41 @@
+from mocker import MockerTestCase
+
+from cloudinit.distros.parsers import hosts
+
+
+BASE_ETC = '''
+# Example
+127.0.0.1	localhost
+192.168.1.10	foo.mydomain.org  foo
+192.168.1.10 	bar.mydomain.org  bar
+146.82.138.7	master.debian.org      master
+209.237.226.90	www.opensource.org
+'''
+BASE_ETC = BASE_ETC.strip()
+
+
+class TestHostsHelper(MockerTestCase):
+    def test_parse(self):
+        eh = hosts.HostsConf(BASE_ETC)
+        self.assertEquals(eh.get_entry('127.0.0.1'), [['localhost']])
+        self.assertEquals(eh.get_entry('192.168.1.10'),
+                          [['foo.mydomain.org', 'foo'],
+                           ['bar.mydomain.org', 'bar']])
+        eh = str(eh)
+        self.assertTrue(eh.startswith('# Example'))
+
+    def test_add(self):
+        eh = hosts.HostsConf(BASE_ETC)
+        eh.add_entry('127.0.0.0', 'blah')
+        self.assertEquals(eh.get_entry('127.0.0.0'), [['blah']])
+        eh.add_entry('127.0.0.3', 'blah', 'blah2', 'blah3')
+        self.assertEquals(eh.get_entry('127.0.0.3'),
+                          [['blah', 'blah2', 'blah3']])
+
+    def test_del(self):
+        eh = hosts.HostsConf(BASE_ETC)
+        eh.add_entry('127.0.0.0', 'blah')
+        self.assertEquals(eh.get_entry('127.0.0.0'), [['blah']])
+
+        eh.del_entries('127.0.0.0')
+        self.assertEquals(eh.get_entry('127.0.0.0'), [])

=== modified file 'tests/unittests/test_distros/test_netconfig.py'
--- tests/unittests/test_distros/test_netconfig.py	2012-09-25 20:59:02 +0000
+++ tests/unittests/test_distros/test_netconfig.py	2012-10-10 23:23:21 +0000
@@ -83,7 +83,7 @@
         self.assertEquals(write_buf.mode, 0644)
 
     def assertCfgEquals(self, blob1, blob2):
-        cfg_tester = distros.rhel.QuotingConfigObj
+        cfg_tester = distros.parsers.quoting_conf.QuotingConfigObj
         b1 = dict(cfg_tester(blob1.strip().splitlines()))
         b2 = dict(cfg_tester(blob2.strip().splitlines()))
         self.assertEquals(b1, b2)

=== added file 'tests/unittests/test_distros/test_resolv.py'
--- tests/unittests/test_distros/test_resolv.py	1970-01-01 00:00:00 +0000
+++ tests/unittests/test_distros/test_resolv.py	2012-10-10 23:23:21 +0000
@@ -0,0 +1,63 @@
+from mocker import MockerTestCase
+
+from cloudinit.distros.parsers import resolv_conf
+
+import re
+
+
+BASE_RESOLVE = '''
+; generated by /sbin/dhclient-script
+search blah.yahoo.com yahoo.com
+nameserver 10.15.44.14
+nameserver 10.15.30.92
+'''
+BASE_RESOLVE = BASE_RESOLVE.strip()
+
+
+class TestResolvHelper(MockerTestCase):
+    def test_parse_same(self):
+        rp = resolv_conf.ResolvConf(BASE_RESOLVE)
+        rp_r = str(rp).strip()
+        self.assertEquals(BASE_RESOLVE, rp_r)
+
+    def test_local_domain(self):
+        rp = resolv_conf.ResolvConf(BASE_RESOLVE)
+        self.assertEquals(None, rp.local_domain)
+
+        rp.local_domain = "bob"
+        self.assertEquals('bob', rp.local_domain)
+        self.assertIn('domain bob', str(rp))
+
+    def test_nameservers(self):
+        rp = resolv_conf.ResolvConf(BASE_RESOLVE)
+        self.assertIn('10.15.44.14', rp.nameservers)
+        self.assertIn('10.15.30.92', rp.nameservers)
+        rp.add_nameserver('10.2')
+        self.assertIn('10.2', rp.nameservers)
+        self.assertIn('nameserver 10.2', str(rp))
+        self.assertNotIn('10.3', rp.nameservers)
+        self.assertEquals(len(rp.nameservers), 3)
+        rp.add_nameserver('10.2')
+        with self.assertRaises(ValueError):
+            rp.add_nameserver('10.3')
+        self.assertNotIn('10.3', rp.nameservers)
+
+    def test_search_domains(self):
+        rp = resolv_conf.ResolvConf(BASE_RESOLVE)
+        self.assertIn('yahoo.com', rp.search_domains)
+        self.assertIn('blah.yahoo.com', rp.search_domains)
+        rp.add_search_domain('bbb.y.com')
+        self.assertIn('bbb.y.com', rp.search_domains)
+        self.assertTrue(re.search(r'search(.*)bbb.y.com(.*)', str(rp)))
+        self.assertIn('bbb.y.com', rp.search_domains)
+        rp.add_search_domain('bbb.y.com')
+        self.assertEquals(len(rp.search_domains), 3)
+        rp.add_search_domain('bbb2.y.com')
+        self.assertEquals(len(rp.search_domains), 4)
+        rp.add_search_domain('bbb3.y.com')
+        self.assertEquals(len(rp.search_domains), 5)
+        rp.add_search_domain('bbb4.y.com')
+        self.assertEquals(len(rp.search_domains), 6)
+        with self.assertRaises(ValueError):
+            rp.add_search_domain('bbb5.y.com')
+        self.assertEquals(len(rp.search_domains), 6)


Follow ups