cloudamatic/mu

View on GitHub
modules/mu/master/chef.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# Copyright:: Copyright (c) 2014 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
  class Master

    # Routines for managing Chef users and orgs on the Mu Master.
    class Chef

      @chef_api = nil
      # Create and return a connection to the Chef REST API. If we've already opened
      # one, return that.
      # @return [Chef::ServerAPI]
      def self.chefAPI
        @chef_api ||= ::Chef::ServerAPI.new("https://#{$MU_CFG["public_address"]}:7443", client_name: "pivotal", signing_key_filename: "/etc/opscode/pivotal.pem")
        @chef_api
      end

      # @param user [String]: The user whose data we'll be fetching from the Chef API.
      # @return [<Hash>]
      def self.getUser(user)
        begin
          Timeout::timeout(45) {
            response = chefAPI.get("users/#{user}")
            return response
          }
        rescue Timeout::Error
          MU.log "Timed out fetching Chef user #{user}, retrying", MU::WARN
          retry
        end rescue Net::HTTPServerException
        return nil
      end

      # Remove an organization from the Chef server.
      # @param org [String]
      # @return [Boolean]
      def self.deleteOrg(org)
        begin
          Timeout::timeout(45) {
            chefAPI.delete("organizations/#{org}")
          }
          MU.log "Removed Chef organization #{org}", MU::NOTICE
          return true
        rescue Timeout::Error
          MU.log "Timed out removing Chef organization #{org}, retrying", MU::WARN
          retry
        rescue Net::HTTPServerException => e
          if !e.message.match(/^404 /)
            MU.log "Couldn't remove Chef organization #{org}: #{e.message}", MU::WARN
          else
            MU.log "#{org} does not exist in Chef, cannot remove.", MU::DEBUG
            return false
          end
          return false
        end
      end

      # Remove a user account from the Chef server.
      # @param user [String]
      # @return [Boolean]
      def self.deleteUser(user)
        cur_users = MU::Master.listUsers
        chef_user = nil
        if cur_users.has_key?(user) and cur_users[user].has_key?("chef_user")
          chef_user = cur_users[user]["chef_user"]
        else
          chef_user = user
        end

        deleteOrg(chef_user)

        begin
          Timeout::timeout(45) {
            chefAPI.delete("users/#{chef_user}")
          }
          MU.log "Removed Chef user #{chef_user}", MU::NOTICE
          return true
        rescue Timeout::Error
          MU.log "Timed out removing Chef user #{chef_user}, retrying", MU::WARN
          retry
        rescue Net::HTTPServerException => e
          if !e.message.match(/^404 /)
            MU.log "Couldn't remove Chef user #{chef_user}: #{e.message}", MU::WARN
          else
            MU.log "#{chef_user} does not exist in Chef, cannot remove.", MU::DEBUG
            return false
          end
          return false
        end
      end

      # @param user [String]: The regular, system name of the user
      # @param chef_user [String]: The user's Chef username, which may differ
      def self.createUserClientCfg(user, chef_user)
        chefdir = Etc.getpwnam(user).dir+"/.chef"
        FileUtils.mkdir_p chefdir
        File.open(chefdir+"/client.rb.tmp.#{Process.pid}", File::CREAT|File::RDWR, 0640) { |f|
          f.puts "log_level        :info"
          f.puts "log_location     STDOUT"
          f.puts "chef_server_url  'https://#{$MU_CFG["public_address"]}/organizations/#{chef_user}'"
          f.puts "validation_client_name '#{chef_user}-validator'"
        }
        if !File.exist?("#{chefdir}/client.rb") or
            File.read("#{chefdir}/client.rb") != File.read("#{chefdir}/client.rb.tmp.#{Process.pid}")
          File.rename(chefdir+"/client.rb.tmp.#{Process.pid}", chefdir+"/client.rb")
          FileUtils.chown_R(user, user+".mu-user", Etc.getpwnam(user).dir+"/.chef")
          MU.log "Generated #{chefdir}/client.rb"
        else
          File.unlink("#{chefdir}/client.rb.tmp.#{Process.pid}")
        end
      end

      # @param user [String]: The regular, system name of the user
      # @param chef_user [String]: The user's Chef username, which may differ
      def self.createUserKnifeCfg(user, chef_user)
        chefdir = Etc.getpwnam(user).dir+"/.chef"
        FileUtils.mkdir_p chefdir
        File.open(chefdir+"/knife.rb.tmp.#{Process.pid}", File::CREAT|File::RDWR, 0640) { |f|
          f.puts "log_level                :info"
          f.puts "log_location             STDOUT"
          f.puts "node_name                '#{chef_user}'"
          f.puts "client_key               '#{chefdir}/#{chef_user}.user.key'"
          f.puts "validation_client_name   '#{chef_user}-validator'"
          f.puts "validation_key           '#{chefdir}/#{chef_user}.org.key'"
          f.puts "chef_server_url 'https://#{$MU_CFG["public_address"]}:7443/organizations/#{chef_user}'"
          f.puts "chef_server_root 'https://#{$MU_CFG["public_address"]}:7443/organizations/#{chef_user}'"
          f.puts "syntax_check_cache_path  '#{chefdir}/syntax_check_cache'"
          f.puts "cookbook_path [ '#{chefdir}/cookbooks', '#{chefdir}/site_cookbooks' ]"
          f.puts "knife[:vault_mode] = 'client'"
          f.puts "knife[:vault_admins] = ['#{chef_user}']"
          # f.puts "verify_api_cert    false"
          # f.puts "ssl_verify_mode    :verify_none"
        }
        if !File.exist?("#{chefdir}/knife.rb") or
            File.read("#{chefdir}/knife.rb") != File.read("#{chefdir}/knife.rb.tmp.#{Process.pid}")
          File.rename(chefdir+"/knife.rb.tmp.#{Process.pid}", chefdir+"/knife.rb")
          FileUtils.chown_R(user, user+".mu-user", Etc.getpwnam(user).dir+"/.chef")
          MU.log "Generated #{chefdir}/knife.rb"
        else
          File.unlink("#{chefdir}/knife.rb.tmp.#{Process.pid}")
        end
      end

      # Save a Chef key into both Mu's user metadata cache and the user's ~/.chef.
      # @param user [String]: The (system) name of the user.
      # @param keyname [String]: The name of the key, e.g. myuser.user.key or myuser.org.key
      # @param key [String]: The Chef private key to save
      def self.saveKey(user, keyname, key)
        FileUtils.mkdir_p $MU_CFG['datadir']+"/users/#{user}"
        FileUtils.mkdir_p Etc.getpwnam(user).dir+"/.chef"
        [$MU_CFG['datadir']+"/users/#{user}/#{keyname}", Etc.getpwnam(user).dir+"/.chef/#{keyname}"].each { |keyfile|
          if File.exist?(keyfile)
            File.rename(keyfile, keyfile+"."+Time.now.to_i.to_s)
          end
          File.open(keyfile, File::CREAT|File::RDWR, 0640) { |f|
            f.puts key
          }
          MU.log "Wrote Chef key #{keyname} to #{keyfile}", MU::DEBUG
        }
        FileUtils.chown_R(user, user+".mu-user", Etc.getpwnam(user).dir+"/.chef")
      end

      # Fetch the Chef server's metadata about an organization. Return nil if not found.
      # @param org [String]: The name of the organization
      # @return [Hash]
      def self.getOrg(org)
        begin
          Timeout::timeout(45) {
            response = chefAPI.get("organizations/#{org}")
            return response
          }
        rescue Timeout::Error
          MU.log "Timed out fetching Chef organization #{org}, retrying", MU::WARN
          retry
        end rescue Net::HTTPServerException
        return nil
      end

      # Fetch the Chef server's metadata about an organization. Return nil if not found.
      # @param org [String]: The name of the organization
      # @param fullname [String]: A more descriptive name for the organization.
      # @param add_users [Array<String>]: Users to add to the org.
      # @param remove_users [Array<String>]: Users to remove from the org.
      # @return [Boolean]
      def self.manageOrg(org, fullname: nil, add_users: [], remove_users: [])
        existing_org = getOrg(org)
        orgkey = nil
        add_users << "mu" if !add_users.include?("mu") and org != "mu"

        # This organization does not yet exist, create it
        if !existing_org
          begin
            org_data = {
              :name => org.dup,
              :full_name => fullname
            }
            Timeout::timeout(45) {
              response = chefAPI.post("organizations", org_data)
              MU.log "Created Chef organization #{org}", details: response
              orgkey = response["private_key"]

              add_users.each { |user|
                if getUser(user) == nil
                  MU.log "Requested addition of Chef user #{user} to organization #{org}, but no such user exists", MU::WARN
                  next
                end
                response = chefAPI.post("organizations/#{org}/association_requests", {:user => user})
                association_id = response["uri"].split("/").last
                response = chefAPI.put("users/#{user}/association_requests/#{association_id}", { :response => 'accept' })
                next if user == "mu"
                MU.log "Added user #{user} to Chef organization #{org}", details: response
              }
            }
            return orgkey
          rescue Net::HTTPServerException => e
            MU.log "Error setting up Chef organization #{org}: #{e.message}", MU::ERR, details: org_data
            return false
          rescue Timeout::Error
            MU.log "Timed out setting up Chef organization #{org}, retrying", MU::WARN
            retry
          end
        else
          begin
            Timeout::timeout(45) {
              add_users.each { |user|
                if getUser(user) == nil
                  MU.log "Requested addition of Chef user #{user} to organization #{org}, but no such user exists", MU::WARN
                  next
                end
                begin
                  response = chefAPI.post("organizations/#{org}/association_requests", {:user => user})
                rescue Net::HTTPServerException => e
                  if e.message == '409 "Conflict"'
                    next
                  else
                    raise e
                  end
                end
                association_id = response["uri"].split("/").last
                response = chefAPI.put("users/#{user}/association_requests/#{association_id}", { :response => 'accept' })
                next if user == "mu"
                MU.log "Added user #{user} to Chef organization #{org}", details: response
              }
              remove_users.each { |user|
                begin
                  chefAPI.delete("organizations/#{org}/users/#{user}")
                  MU.log "Removed Chef user #{user} from organization #{org}", MU::NOTICE
                rescue Net::HTTPServerException => e
                end
              }
            }
          rescue Timeout::Error
            MU.log "Timed out modifying Chef organization #{org}, retrying", MU::WARN
            retry
          end
        end
        return orgkey
      end

      # Call when creating or modifying a user. While Chef technically does
      # communicate with LDAP, it's only for the web UI, which we don't even use.
      # Keys still need to be managed, and sometimes the username can't even match
      # the LDAP one due to Chef's weird restrictions.
      def self.manageUser(chef_user, name: nil, email: nil, orgs: [], remove_orgs: [], admin: false, ldap_user: nil, pass: nil)
        orgs = [] if orgs.nil?
        remove_orgs = [] if remove_orgs.nil?

        # In this shining future, there are no situations where we will *not* have
        # an LDAP user to link to.
        ldap_user = chef_user.dup if ldap_user.nil?
        if chef_user.gsub!(/\./, "")
          MU.log "Stripped . from username to create Chef user #{chef_user}.\nSee: https://github.com/chef/chef-server/issues/557", MU::NOTICE
          orgs.delete(ldap_user)
        end

        if admin
          orgs << "mu"
        else
          remove_orgs << "mu"
        end

        if remove_orgs.include?(chef_user)
          raise MU::MuError, "Can't remove Chef user #{chef_user} from the #{chef_user} org"
        end
        if (orgs & remove_orgs).size > 0
          raise MU::MuError, "Cannot both add and remove from the same Chef org"
        end

        MU::Master.setLocalDataPerms(ldap_user)

        first = last = nil
        if !name.nil?
          last = name.split(/\s+/).pop
          first = name.split(/\s+/).shift
        end
        mangled_email = email.dup

        ext = getUser(chef_user)

        if !ext
          if name.nil? or email.nil?
            MU.log "Error creating Chef user #{chef_user}: Must supply real name and email address", MU::ERR
            return false
          end

          # We don't ever really need this password, so generate a random one if none
          # was supplied.
          if pass.nil?
            pass = (0...8).map { ('a'..'z').to_a[rand(26)] }.join
          end
          user_data = {
            :username => chef_user.dup,
            :first_name => first,
            :last_name => last,
            :display_name => name.dup,
            :email => email.dup,
            :create_key => true,
            :recovery_authentication_enabled => false,
            :external_authentication_uid => ldap_user.dup,
            :password => pass.dup
          }
          begin
            Timeout::timeout(45) {
              response = chefAPI.post("users", user_data)
              MU.log "Created Chef user #{chef_user}", details: response
              saveKey(ldap_user, "#{chef_user}.user.key", response["chef_key"]["private_key"])
              key = manageOrg(chef_user, fullname: "#{name}'s Chef Organization", add_users: [chef_user])
              if key
                saveKey(ldap_user, "#{chef_user}.org.key", key)
              end
              createUserKnifeCfg(ldap_user, chef_user)
              createUserClientCfg(ldap_user, chef_user)
            }
          rescue Timeout::Error
            MU.log "Timed out creating Chef user #{chef_user}, retrying", MU::WARN
            retry
          rescue Net::HTTPServerException => e
            # Work around Chef's baffling inability to use the same email address for
            # more than one user.
            # https://github.com/chef/chef-server/issues/59
            if e.message.match(/409/) and !user_data[:email].match(/\+/)
              user_data[:email].sub!(/@/, "+"+(0...8).map { ('a'..'z').to_a[rand(26)] }.join+"@")
              retry
            end
            MU.log "Bad response when creating Chef user #{chef_user}: #{e.message}", MU::ERR, details: user_data
            return false
          end
        # This user exists, so modify it
        else
          retries = 0
          begin
            user_data = {
              :username => chef_user,
              :recovery_authentication_enabled => false,
              :external_authentication_uid => ldap_user
            }
            ext.each_pair { |key, val| user_data[key.to_sym] = val }
            user_data[:display_name] = name.dup if !name.nil?
            user_data[:first_name] = first if !first.nil?
            user_data[:last_name] = last if !last.nil?
            user_data[:password] = pass.dup if !pass.nil?
            if !email.nil?
              if !user_data[:email].nil?
                mailbox, host = mangled_email.split(/@/)
                if !user_data[:email].match(/^#{Regexp.escape(mailbox)}\+.+?@#{Regexp.escape(host)}$/)
                  user_data[:email] = mangled_email
                end
              else
                user_data[:email] = mangled_email
              end
            end
            Timeout::timeout(45) {
              response = chefAPI.put("users/#{chef_user}", user_data)
              user_data[:password] = "********"
              MU.log "Chef user #{chef_user} already exists, updating", details: user_data
              if response.has_key?("chef_key") and response["chef_key"].has_key?("private_key")
                saveKey(ldap_user, "#{chef_user}.user.key", response["chef_key"]["private_key"])
              end
            }
            createUserKnifeCfg(ldap_user, chef_user)
            createUserClientCfg(ldap_user, chef_user)
            %{/bin/su "#{ldap_user}" -c "cd && /opt/chef/bin/knife ssl fetch"}
          rescue Timeout::Error
            MU.log "Timed out modifying Chef user #{chef_user}, retrying", MU::WARN
            retry
          rescue Net::HTTPServerException => e
            # Work around Chef's baffling inability to use the same email address for
            # more than one user.
            # https://github.com/chef/chef-server/issues/59
            if e.message.match(/409/) and !user_data[:email].match(/\+/)
              if retries > 3
                raise MU::MuError, "Got #{e.message} modifying Chef user #{chef_user} (#{user_data})"
              end
              sleep 5
              retries = retries + 1
              mangled_email.sub!(/@/, "+"+(0...8).map { ('a'..'z').to_a[rand(26)] }.join+"@")
              retry
            end
            MU.log "Failed to update user #{chef_user}: #{e.message}", MU::ERR, details: user_data
            raise e
          end
        end

        if ldap_user != chef_user
          File.open($MU_CFG['datadir']+"/users/#{ldap_user}/chef_user", File::CREAT|File::RDWR, 0640) { |f|
            f.puts chef_user
          }
        end
        orgs.each { |org|
          key = manageOrg(org, add_users: [chef_user])
          if key
            saveKey(ldap_user, "#{org}.org.key", key)
          end
        }
        remove_orgs.each { |org|
          manageOrg(org, remove_users: [chef_user])
        }

        # Meddling in the user's home directory
        # Make sure they'll trust the Chef server's SSL certificate

        MU::Master.setLocalDataPerms(ldap_user)
        true
      end

      # Mangle Chef's server config to speak to LDAP. Technically this only
      # impacts logins for their web UI, which we currently don't use.
      def self.configureChefForLDAP
        if $MU_CFG.has_key?("ldap")
          bind_creds = MU::Groomer::Chef.getSecret(vault: $MU_CFG["ldap"]["bind_creds"]["vault"], item: $MU_CFG["ldap"]["bind_creds"]["item"])
          vars = {
            "server_url" => $MU_CFG["public_address"],
            "ldap" => true,
            "base_dn" => $MU_CFG["ldap"]["base_dn"],
            "group_dn" => $MU_CFG["ldap"]["admin_group_dn"],
            "dc" => $MU_CFG["ldap"]["dcs"].first,
            "bind_dn" => bind_creds[$MU_CFG["ldap"]["bind_creds"]["username_field"]],
            "bind_pw" => bind_creds[$MU_CFG["ldap"]["bind_creds"]["password_field"]],
          }
          chef_cfgfile = "/etc/opscode/chef-server.rb"
          chef_tmpfile = "#{chef_cfgfile}.tmp.#{Process.pid}"
          File.open(chef_tmpfile, File::CREAT|File::RDWR, 0644) { |f|
            f.puts Erubis::Eruby.new(File.read("#{$MU_CFG['libdir']}/install/chef-server.rb.erb")).result(vars)
          }
          new = File.read(chef_tmpfile)
          current = File.read(chef_cfgfile)
          if new != current
            MU.log "Updating #{chef_cfgfile}", MU::NOTICE
            File.rename(chef_tmpfile, chef_cfgfile)
            system("/opt/opscode/bin/chef-server-ctl reconfigure")
          else
            File.unlink(chef_tmpfile)
          end
        end
      end
    end
  end
end