cloudamatic/mu

View on GitHub
modules/mu/groomers/ansible.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved
#
# Licensed under the BSD-3 license (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the root of the project or at
#
#    http://egt-labs.com/mu/LICENSE.html
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


module MU
  # Plugins under this namespace serve as interfaces to host configuration
  # management tools, like Ansible or Puppet.
  class Groomer
    # Support for Ansible as a host configuration management layer.
    class Ansible

      # Failure to load or create a deploy
      class NoAnsibleExecError < MuError;
      end

      # One or more Python dependencies missing
      class AnsibleLibrariesError < MuError;
      end

      # Location in which we'll find our Ansible executables. This only applies
      # to full-grown Mu masters; minimalist gem installs will have to make do
      # with whatever Ansible executables they can find in $PATH.
      BINDIR = "/usr/local/python-current/bin"
      @@pwfile_semaphore = Mutex.new


      # @param node [MU::Cloud::Server]: The server object on which we'll be operating
      def initialize(node)
        @config = node.config
        @server = node
        @inventory = Inventory.new(node.deploy)
        @mu_user = node.deploy.mu_user
        @ansible_path = node.deploy.deploy_dir+"/ansible"
        @ansible_execs = MU::Groomer::Ansible.ansibleExecDir

        if !MU::Groomer::Ansible.checkPythonDependencies(@server.windows?)
          raise AnsibleLibrariesError, "One or more python dependencies not available"
        end

        if !@ansible_execs or @ansible_execs.empty?
          raise NoAnsibleExecError, "No Ansible executables found in visible paths"
        end

        [@ansible_path, @ansible_path+"/roles", @ansible_path+"/vars", @ansible_path+"/group_vars", @ansible_path+"/vaults"].each { |dir|
          if !Dir.exist?(dir)
            MU.log "Creating #{dir}", MU::DEBUG
            Dir.mkdir(dir, 0755)
          end
        }
        MU::Groomer::Ansible.vaultPasswordFile(pwfile: "#{@ansible_path}/.vault_pw")
        installRoles
      end

      # Are Ansible executables and key libraries present and accounted for?
      def self.available?(windows = false)
        MU::Groomer::Ansible.checkPythonDependencies(windows)
      end

      # Indicate whether our server has been bootstrapped with Ansible
      def haveBootstrapped?
        @inventory.haveNode?(@server.mu_name)
      end

      # @param vault [String]: A repository of secrets to create/save into.
      # @param item [String]: The item within the repository to create/save.
      # @param data [Hash]: Data to save
      # @param permissions [Boolean]: If true, save the secret under the current active deploy (if any), rather than in the global location for this user
      # @param deploy_dir [String]: If permissions is +true+, save the secret here
      def self.saveSecret(vault: nil, item: nil, data: nil, permissions: false, deploy_dir: nil)

        if vault.nil? or vault.empty? or item.nil? or item.empty?
          raise MuError, "Must call saveSecret with vault and item names"
        end
        if vault.match(/\//) or item.match(/\//) #XXX this should just check for all valid dirname/filename chars
          raise MuError, "Ansible vault/item names cannot include forward slashes"
        end
        pwfile = vaultPasswordFile

        dir = if permissions
          if deploy_dir
            deploy_dir+"/ansible/vaults/"+vault
          elsif MU.mommacat
            MU.mommacat.deploy_dir+"/ansible/vaults/"+vault
          else
            raise "MU::Ansible::Groomer.saveSecret had permissions set to true, but I couldn't find an active deploy directory to save into"
          end
        else
          secret_dir+"/"+vault
        end
        path = dir+"/"+item

        if !Dir.exist?(dir)
          FileUtils.mkdir_p(dir, mode: 0700)
        end

        if File.exist?(path)
          MU.log "Overwriting existing vault #{vault} item #{item}"
        end

        File.open(path, File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
          f.write data.to_yaml
        }

        cmd = %Q{#{ansibleExecDir}/ansible-vault encrypt #{path} --vault-password-file #{pwfile}}
        MU.log cmd
        raise MuError, "Failed Ansible command: #{cmd}" if !system(cmd)
      end

      # see {MU::Groomer::Ansible.saveSecret}
      def saveSecret(vault: @server.mu_name, item: nil, data: nil, permissions: true)
        self.class.saveSecret(vault: vault, item: item, data: data, permissions: permissions, deploy_dir: @server.deploy.deploy_dir)
      end

      # Retrieve sensitive data, which hopefully we're storing and retrieving
      # in a secure fashion.
      # @param vault [String]: A repository of secrets to search
      # @param item [String]: The item within the repository to retrieve
      # @param field [String]: OPTIONAL - A specific field within the item to return.
      # @return [Hash]
      def self.getSecret(vault: nil, item: nil, field: nil, deploy_dir: nil)
        if vault.nil? or vault.empty?
          raise MuError, "Must call getSecret with at least a vault name"
        end
        pwfile = vaultPasswordFile

        dir = nil
        try = [secret_dir+"/"+vault]
        try << deploy_dir+"/ansible/vaults/"+vault if deploy_dir
        try << MU.mommacat.deploy_dir+"/ansible/vaults/"+vault if MU.mommacat.deploy_dir
        try.each { |maybe_dir|
          if Dir.exist?(maybe_dir) and (item.nil? or File.exist?(maybe_dir+"/"+item))
            dir = maybe_dir
            break
          end
        }
        if dir.nil?
          raise MuNoSuchSecret, "No such vault #{vault}"
        end

        data = nil
        if item
          itempath = dir+"/"+item
          if !File.exist?(itempath)
            raise MuNoSuchSecret, "No such item #{item} in vault #{vault}"
          end
          cmd = %Q{#{ansibleExecDir}/ansible-vault view #{itempath} --vault-password-file #{pwfile}}
          MU.log cmd
          a = `#{cmd}`
          # If we happen to have stored recognizeable JSON or YAML, return it
          # as parsed, which is a behavior we're used to from Chef vault.
          # Otherwise, return a String.
          begin
            data = JSON.parse(a)
          rescue JSON::ParserError
            begin
              data = YAML.load(a)
            rescue Psych::SyntaxError => e
              data = a
            end
          end
          [vault, item, field].each { |tier|
            if data and data.is_a?(Hash) and tier and data[tier]
              data = data[tier]
            end
          }
        else
          data = []
          Dir.foreach(dir) { |entry|
            next if entry == "." or entry == ".."
            next if File.directory?(dir+"/"+entry)
            data << entry
          }
        end

        data
      end

      # see {MU::Groomer::Ansible.getSecret}
      def getSecret(vault: nil, item: nil, field: nil)
        self.class.getSecret(vault: vault, item: item, field: field, deploy_dir: @server.deploy.deploy_dir)
      end

      # Delete a Ansible data bag / Vault
      # @param vault [String]: A repository of secrets to delete
      def self.deleteSecret(vault: nil, item: nil)
        if vault.nil? or vault.empty?
          raise MuError, "Must call deleteSecret with at least a vault name"
        end
        dir = secret_dir+"/"+vault
        if !Dir.exist?(dir)
          raise MuNoSuchSecret, "No such vault #{vault}"
        end

        if item
          itempath = dir+"/"+item
          if !File.exist?(itempath)
            raise MuNoSuchSecret, "No such item #{item} in vault #{vault}"
          end
          MU.log "Deleting Ansible vault #{vault} item #{item}", MU::NOTICE
          File.unlink(itempath)
        else
          MU.log "Deleting Ansible vault #{vault}", MU::NOTICE
          FileUtils.rm_rf(dir)
        end

      end

      # see {MU::Groomer::Ansible.deleteSecret}
      def deleteSecret(vault: nil, item: nil)
        self.class.deleteSecret(vault: vault, item: item)
      end

      # Invoke the Ansible client on the node at the other end of a provided SSH
      # session.
      # @param purpose [String]: A string describing the purpose of this client run.
      # @param max_retries [Integer]: The maximum number of attempts at a successful run to make before giving up.
      # @param output [Boolean]: Display Ansible's regular (non-error) output to the console
      # @param override_runlist [String]: Use the specified run list instead of the node's configured list
      def run(purpose: "Ansible run", update_runlist: true, max_retries: 10, output: true, override_runlist: nil, reboot_first_fail: false, timeout: 1800)
        bootstrap
        pwfile = MU::Groomer::Ansible.vaultPasswordFile
        stashHostSSLCertSecret

        ssh_user = @server.config['ssh_user'] || "root"

        if update_runlist
          bootstrap
        end

        tmpfile = nil
        playbook = if override_runlist and !override_runlist.empty?
          play = {
            "hosts" => @server.config['name']
          }
          if !@server.windows? and @server.config['ssh_user'] != "root"
            play["become"] = "yes"
          end
          play["roles"] = override_runlist if @server.config['run_list'] and !@server.config['run_list'].empty?
          play["vars"] = @server.config['ansible_vars'] if @server.config['ansible_vars']

          tmpfile = Tempfile.new("#{@server.config['name']}-override-runlist.yml")
          tmpfile.puts [play].to_yaml
          tmpfile.close
          tmpfile.path
        else
          "#{@server.config['name']}.yml"
        end

        cmd = %Q{cd #{@ansible_path} && echo "#{purpose}" && #{@ansible_execs}/ansible-playbook -i hosts #{playbook} --limit=#{@server.windows? ? @server.canonicalIP : @server.mu_name} --vault-password-file #{pwfile} --timeout=30 --vault-password-file #{@ansible_path}/.vault_pw -u #{ssh_user}}

        retries = 0
        begin
          MU.log cmd
          Timeout::timeout(timeout) {
            if output
              system("#{cmd}")
            else
              %x{#{cmd} 2>&1}
            end

            if $?.exitstatus != 0
              raise MU::Groomer::RunError, "Failed Ansible command: #{cmd}"
            end
          }
        rescue Timeout::Error, MU::Groomer::RunError => e
          if retries < max_retries
            if reboot_first_fail and e.class.name == "MU::Groomer::RunError"
              @server.reboot
              reboot_first_fail = false
            end
            sleep 30
            retries += 1
            MU.log "Failed Ansible run, will retry (#{retries.to_s}/#{max_retries.to_s})", MU::NOTICE, details: cmd

            retry
          else
            tmpfile.unlink if tmpfile
            raise MuError, "Failed Ansible command: #{cmd}"
          end
        end

        tmpfile.unlink if tmpfile
      end

      # This is a stub; since Ansible is effectively agentless, this operation
      # doesn't have meaning.
      def preClean(leave_ours = false)
      end

      # This is a stub; since Ansible is effectively agentless, this operation
      # doesn't have meaning.
      def reinstall
      end

      # Bootstrap our server with Ansible- basically, just make sure this node
      # is listed in our deployment's Ansible inventory.
      def bootstrap
        @inventory.add(@server.config['name'], @server.windows? ? @server.canonicalIP : @server.mu_name)
        play = {
          "hosts" => @server.config['name']
        }

        if !@server.windows? and @server.config['ssh_user'] != "root"
          play["become"] = "yes"
        end

        if @server.config['run_list'] and !@server.config['run_list'].empty?
          play["roles"] = @server.config['run_list']
        end

        if @server.config['ansible_vars']
          play["vars"] = @server.config['ansible_vars']
        end

        if @server.windows?
          play["vars"] ||= {}
          play["vars"]["ansible_connection"] = "winrm"
          play["vars"]["ansible_winrm_scheme"] = "https"
          play["vars"]["ansible_winrm_transport"] = "ntlm"
          play["vars"]["ansible_winrm_server_cert_validation"] = "ignore" # XXX this sucks; use Mu_CA.pem if we can get it to work
#          play["vars"]["ansible_winrm_ca_trust_path"] = "#{MU.mySSLDir}/Mu_CA.pem"
          play["vars"]["ansible_user"] = @server.config['windows_admin_username']
          win_pw = @server.getWindowsAdminPassword

          pwfile = MU::Groomer::Ansible.vaultPasswordFile
          cmd = %Q{#{MU::Groomer::Ansible.ansibleExecDir}/ansible-vault}
          output = %x{#{cmd} encrypt_string '#{win_pw.gsub(/'/, "\\\\'")}' --vault-password-file #{pwfile}}

          play["vars"]["ansible_password"] = output
        end

        File.open(@ansible_path+"/"+@server.config['name']+".yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
          f.flock(File::LOCK_EX)
          f.puts [play].to_yaml.sub(/ansible_password: \|-?[\n\s]+/, 'ansible_password: ') # Ansible doesn't like this (legal) YAML
          f.flock(File::LOCK_UN)
        }
      end

      # Synchronize the deployment structure managed by {MU::MommaCat} into some Ansible variables, so that nodes can access this metadata.
      # @return [Hash]: The data synchronized.
      def saveDeployData
        @server.describe

        allvars = {
          "mu_deployment" => MU::Config.stripConfig(@server.deploy.deployment),
          "mu_service_name" => @config["name"],
          "mu_canonical_ip" => @server.canonicalIP,
          "mu_admin_email" => $MU_CFG['mu_admin_email'],
          "mu_environment" => MU.environment.downcase
        }
        allvars['mu_deployment']['ssh_public_key'] = @server.deploy.ssh_public_key

        if @server.config['cloud'] == "AWS"
          allvars["ec2"] = MU.structToHash(@server.cloud_desc, stringify_keys: true)
        end

        if @server.windows?
          allvars['windows_admin_username'] = @config['windows_admin_username']
        end

        if !@server.cloud.nil?
          allvars["cloudprovider"] = @server.cloud
        end

        File.open(@ansible_path+"/vars/main.yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
          f.flock(File::LOCK_EX)
          f.puts allvars.to_yaml
          f.flock(File::LOCK_UN)
        }

        groupvars = allvars.dup
        if @server.deploy.original_config.has_key?('parameters')
          groupvars["mu_parameters"] = @server.deploy.original_config['parameters']
        end
        if !@config['application_attributes'].nil?
          groupvars["application_attributes"] = @config['application_attributes']
        end
        if !@config['groomer_variables'].nil?
          groupvars["mu"] = @config['groomer_variables']
        end

        File.open(@ansible_path+"/group_vars/"+@server.config['name']+".yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
          f.flock(File::LOCK_EX)
          f.puts groupvars.to_yaml
          f.flock(File::LOCK_UN)
        }

        allvars['deployment']
      end

      # Nuke everything associated with a deploy. Since we're just some files
      # in the deploy directory, this doesn't have to do anything.
      def self.cleanup(deploy_id, noop = false)
#        deploy = MU::MommaCat.new(MU.deploy_id)
#        inventory = Inventory.new(deploy)
      end

      # Expunge Ansible resources associated with a node.
      # @param node [String]: The Mu name of the node in question.
      # @param _vaults_to_clean [Array<Hash>]: Dummy argument, part of this method's interface but not used by the Ansible layer
      # @param noop [Boolean]: Skip actual deletion, just state what we'd do
      def self.purge(node, _vaults_to_clean = [], noop = false)
        deploy = MU::MommaCat.new(MU.deploy_id)
        inventory = Inventory.new(deploy)
#        ansible_path = deploy.deploy_dir+"/ansible"
        if !noop
          inventory.remove(node)
        end
      end

      # List the Ansible vaults, if any, owned by the specified Mu user
      # @param user [String]: The user whose vaults we will list
      # @return [Array<String>]
      def self.listSecrets(user = MU.mu_user)
        path = secret_dir(user)
        found = []
        Dir.foreach(path) { |entry|
          next if entry == "." or entry == ".."
          next if !File.directory?(path+"/"+entry)
          found << entry
        }
        found
      end

      # Encrypt a string using +ansible-vault encrypt_string+ and print the
      # the results to +STDOUT+.
      # @param name [String]: The variable name to use for the string's YAML key
      # @param string [String]: The string to encrypt
      def self.encryptString(name, string)
        pwfile = vaultPasswordFile
        cmd = %Q{#{ansibleExecDir}/ansible-vault}
        if !system(cmd, "encrypt_string", string, "--name", name, "--vault-password-file", pwfile)
          raise MuError, "Failed Ansible command: #{cmd} encrypt_string <redacted> --name #{name} --vault-password-file"
        end
        output
      end

      # Hunt down and return a path for a Python executable
      # @return [String]
      def self.pythonExecDir
        path = nil

        if File.exist?(BINDIR+"/python")
          path = BINDIR
        else
          paths = [ansibleExecDir]
          paths.concat(ENV['PATH'].split(/:/))
          paths << "/usr/bin" # not always in path, esp in pared-down Docker images
          paths.reject! { |p| p.nil? }
          paths.uniq.each { |bindir|
            if File.exist?(bindir+"/python")
              path = bindir
              break
            end
          }
        end
        path
      end

      # Make sure what's in our Python requirements.txt is reflected in the
      # Python we're about to run for Ansible
      def self.checkPythonDependencies(windows = false)
        return nil if !ansibleExecDir

        execline = File.readlines(ansibleExecDir+"/ansible-playbook").first.chomp.sub(/^#!/, '')
        if !execline
          MU.log "Unable to extract a Python executable from #{ansibleExecDir}/ansible-playbook", MU::ERR
          return false
        end

        require 'tempfile'
        f = Tempfile.new("pythoncheck")
        f.puts "import ansible"
        f.puts "import winrm" if windows
        f.close

        system(%Q{#{execline} #{f.path}})
        f.unlink
        $?.exitstatus == 0 ? true : false
      end

      # Hunt down and return a path for Ansible executables
      # @return [String]
      def self.ansibleExecDir
        path = nil
        if File.exist?(BINDIR+"/ansible-playbook")
          path = BINDIR
        else
          paths = ENV['PATH'].split(/:/)
          paths << "/usr/bin"
          paths.uniq.each { |bindir|
            if File.exist?(bindir+"/ansible-playbook")
              path = bindir
              if !File.exist?(bindir+"/ansible-vault")
                MU.log "Found ansible-playbook executable in #{bindir}, but no ansible-vault. Vault functionality will not work!", MU::WARN
              end
              if !File.exist?(bindir+"/ansible-galaxy")
                MU.log "Found ansible-playbook executable in #{bindir}, but no ansible-galaxy. Automatic community role fetch will not work!", MU::WARN
              end
              break
            end
          }
        end
        path
      end

      # Get path to the +.vault_pw+ file for the appropriate user. If it
      # doesn't exist, generate it. 
      #
      # @param for_user [String]:
      # @param pwfile [String]
      # @return [String]
      def self.vaultPasswordFile(for_user = nil, pwfile: nil)
        pwfile ||= secret_dir(for_user)+"/.vault_pw"
        @@pwfile_semaphore.synchronize {
          if !File.exist?(pwfile)
            MU.log "Generating Ansible vault password file at #{pwfile}", MU::DEBUG
            File.open(pwfile, File::CREAT|File::RDWR|File::TRUNC, 0400) { |f|
              f.write Password.random(12..14)
            }
          end
        }
        pwfile
      end

      # Figure out where our main stash of secrets is, and make sure it exists
      # @param user [String]:
      # @return [String]
      def self.secret_dir(user = MU.mu_user)
        path = MU.dataDir(user) + "/ansible-secrets"
        Dir.mkdir(path, 0755) if !Dir.exist?(path)

        path
      end

      private

      # Figure out where our main stash of secrets is, and make sure it exists
      def secret_dir
        MU::Groomer::Ansible.secret_dir(@mu_user)
      end

      # Make an effort to distinguish an Ansible role from other sorts of
      # artifacts, since 'roles' is an awfully generic name for a directory.
      # Short of a full, slow syntax check, this is the best we're liable to do.
      def isAnsibleRole?(path)
        begin
        Dir.foreach(path) { |entry|
          if File.directory?(path+"/"+entry) and
             ["tasks", "vars"].include?(entry)
            return true # https://knowyourmeme.com/memes/close-enough
          elsif ["metadata.rb", "recipes"].include?(entry)
            return false
          end
        }
        rescue Errno::ENOTDIR
        end
        false
      end

      # Find all of the Ansible roles in the various configured Mu repositories
      # and 
      def installRoles
        roledir = @ansible_path+"/roles"

        canon_links = {}

        repodirs = []

        # Make sure we search the global ansible_dir, if any is set
        if $MU_CFG and $MU_CFG['ansible_dir'] and !$MU_CFG['ansible_dir'].empty?
          if !Dir.exist?($MU_CFG['ansible_dir'])
            MU.log "Config lists an Ansible directory at #{$MU_CFG['ansible_dir']}, but I see no such directory", MU::WARN
          else
            repodirs << $MU_CFG['ansible_dir']
          end
        end

        # Hook up any Ansible roles listed in our platform repos
        if $MU_CFG and $MU_CFG['repos']
          $MU_CFG['repos'].each { |repo|
            repo.match(/\/([^\/]+?)(\.git)?$/)
            shortname = Regexp.last_match(1)
            repodirs << MU.dataDir + "/" + shortname
          }
        end

        repodirs.each { |repodir|
          ["roles", "ansible/roles"].each { |subdir|
            next if !Dir.exist?(repodir+"/"+subdir)
            Dir.foreach(repodir+"/"+subdir) { |role|
              next if [".", ".."].include?(role)
              realpath = repodir+"/"+subdir+"/"+role
              link = roledir+"/"+role
              
              if isAnsibleRole?(realpath)
                if !File.exist?(link)
                  File.symlink(realpath, link)
                  canon_links[role] = realpath
                elsif File.symlink?(link)
                  cur_target = File.readlink(link)
                  if cur_target == realpath
                    canon_links[role] = realpath
                  elsif !canon_links[role]
                    File.unlink(link)
                    File.symlink(realpath, link)
                    canon_links[role] = realpath
                  end
                end
              end
            }
          }
        }

        # Now layer on everything bundled in the main Mu repo
        Dir.foreach(MU.myRoot+"/ansible/roles") { |role|
          next if [".", ".."].include?(role)
          next if File.exist?(roledir+"/"+role)
          File.symlink(MU.myRoot+"/ansible/roles/"+role, roledir+"/"+role)
        }

        if @server.config['run_list']
          @server.config['run_list'].each { |role|
            found = false
            if !File.exist?(roledir+"/"+role)
              if role.match(/[^\.]\.[^\.]/) and @server.config['groomer_autofetch']
                system(%Q{#{@ansible_execs}/ansible-galaxy}, "--roles-path", roledir, "install", role)
                found = true
# XXX check return value
              else
                canon_links.keys.each { |longrole|
                  if longrole.match(/\.#{Regexp.quote(role)}$/)
                    File.symlink(roledir+"/"+longrole, roledir+"/"+role)
                    found = true
                    break
                  end
                }
              end
            else
              found = true
            end
            if !found
              raise MuError, "Unable to locate Ansible role #{role}"
            end
          }
        end
      end

      # Upload the certificate to a Chef Vault for this node
      def stashHostSSLCertSecret
        cert, key = @server.deploy.nodeSSLCerts(@server)
        certdata = {
          "data" => {
            "node.crt" => cert.to_pem.chomp!.gsub(/\n/, "\\n"),
            "node.key" => key.to_pem.chomp!.gsub(/\n/, "\\n")
          }
        }
        saveSecret(item: "ssl_cert", data: certdata, permissions: true)

        saveSecret(item: "secrets", data: @config['secrets'], permissions: true) if !@config['secrets'].nil?
        certdata
      end

      # Simple interface for an Ansible inventory file.
      class Inventory

        # @param deploy [MU::MommaCat]
        def initialize(deploy)
          @deploy = deploy
          @ansible_path = @deploy.deploy_dir+"/ansible"
          if !Dir.exist?(@ansible_path)
            Dir.mkdir(@ansible_path, 0755)
          end

          @lockfile = File.open(@ansible_path+"/.hosts.lock", File::CREAT|File::RDWR, 0600)
        end

        # See if we have a particular node in our inventory.
        def haveNode?(name)
          lock
          read
          @inv.values.each { |nodes|
            if nodes.include?(name)
              unlock
              return true
            end
          }
          unlock
          false
        end

        # Add a node to our Ansible inventory
        # @param group [String]: The host group to which the node belongs
        # @param name [String]: The hostname or IP of the node
        def add(group, name)
          if group.nil? or group.empty? or name.nil? or name.empty?
            raise MuError, "Ansible::Inventory.add requires both a host group string and a name"
          end
          lock
          read
          @inv[group] ||= []
          @inv[group] << name
          @inv[group].uniq!
          save!
          unlock
        end

        # Remove a node from our Ansible inventory
        # @param name [String]: The hostname or IP of the node
        def remove(name)
          lock
          read
          @inv.each_pair { |_group, nodes|
            nodes.delete(name)
          }
          save!
          unlock
        end

        private

        def lock
          @lockfile.flock(File::LOCK_EX)
        end

        def unlock
          @lockfile.flock(File::LOCK_UN)
        end

        def save!
          @inv ||= {}

          File.open(@ansible_path+"/hosts", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f|
            @inv.each_pair { |group, hosts|
              next if hosts.size == 0 # don't write empty groups
              f.puts "["+group+"]"
              f.puts hosts.join("\n")
            }
          }
        end

        def read
          @inv = {}
          if File.exist?(@ansible_path+"/hosts")
            section = nil
            File.readlines(@ansible_path+"/hosts").each { |l|
              l.chomp!
              l.sub!(/#.*/, "")
              next if l.empty?
              if l.match(/\[(.+?)\]/)
                section = Regexp.last_match[1]
                @inv[section] ||= []
              else
                @inv[section] << l
              end
            }
          end

          @inv
        end

      end

    end # class Ansible
  end # class Groomer
end # Module Mu