class MU::Groomer::Ansible
Support for Ansible
as a host configuration management layer.
Constants
Public Class Methods
Hunt down and return a path for Ansible
executables @return [String]
# File modules/mu/groomers/ansible.rb, line 495 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
Are Ansible
executables and key libraries present and accounted for?
# File modules/mu/groomers/ansible.rb, line 66 def self.available?(windows = false) MU::Groomer::Ansible.checkPythonDependencies(windows) end
Make sure what's in our Python requirements.txt is reflected in the Python we're about to run for Ansible
# File modules/mu/groomers/ansible.rb, line 473 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
Nuke everything associated with a deploy. Since we're just some files in the deploy directory, this doesn't have to do anything.
# File modules/mu/groomers/ansible.rb, line 404 def self.cleanup(deploy_id, noop = false) # deploy = MU::MommaCat.new(MU.deploy_id) # inventory = Inventory.new(deploy) end
Delete a Ansible
data bag / Vault @param vault [String]: A repository of secrets to delete
# File modules/mu/groomers/ansible.rb, line 196 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
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
# File modules/mu/groomers/ansible.rb, line 440 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
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]
# File modules/mu/groomers/ansible.rb, line 131 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
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>]
# File modules/mu/groomers/ansible.rb, line 425 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
@param node [MU::Cloud::Server]: The server object on which we'll be operating
# File modules/mu/groomers/ansible.rb, line 39 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
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
# File modules/mu/groomers/ansible.rb, line 413 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
Hunt down and return a path for a Python executable @return [String]
# File modules/mu/groomers/ansible.rb, line 451 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
@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
# File modules/mu/groomers/ansible.rb, line 80 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
Figure out where our main stash of secrets is, and make sure it exists @param user [String]: @return [String]
# File modules/mu/groomers/ansible.rb, line 540 def self.secret_dir(user = MU.mu_user) path = MU.dataDir(user) + "/ansible-secrets" Dir.mkdir(path, 0755) if !Dir.exist?(path) 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]
# File modules/mu/groomers/ansible.rb, line 524 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
Public Instance Methods
Bootstrap our server with Ansible- basically, just make sure this node is listed in our deployment's Ansible
inventory.
# File modules/mu/groomers/ansible.rb, line 308 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
see {MU::Groomer::Ansible.deleteSecret}
# File modules/mu/groomers/ansible.rb, line 220 def deleteSecret(vault: nil, item: nil) self.class.deleteSecret(vault: vault, item: item) end
see {MU::Groomer::Ansible.getSecret}
# File modules/mu/groomers/ansible.rb, line 190 def getSecret(vault: nil, item: nil, field: nil) self.class.getSecret(vault: vault, item: item, field: field, deploy_dir: @server.deploy.deploy_dir) end
Indicate whether our server has been bootstrapped with Ansible
# File modules/mu/groomers/ansible.rb, line 71 def haveBootstrapped? @inventory.haveNode?(@server.mu_name) end
This is a stub; since Ansible
is effectively agentless, this operation doesn't have meaning.
# File modules/mu/groomers/ansible.rb, line 298 def preClean(leave_ours = false) end
This is a stub; since Ansible
is effectively agentless, this operation doesn't have meaning.
# File modules/mu/groomers/ansible.rb, line 303 def reinstall 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
# File modules/mu/groomers/ansible.rb, line 230 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
Synchronize the deployment structure managed by {MU::MommaCat} into some Ansible
variables, so that nodes can access this metadata. @return [Hash]: The data synchronized.
# File modules/mu/groomers/ansible.rb, line 352 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
see {MU::Groomer::Ansible.saveSecret}
# File modules/mu/groomers/ansible.rb, line 121 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
Private Instance Methods
Find all of the Ansible
roles in the various configured Mu repositories and
# File modules/mu/groomers/ansible.rb, line 574 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
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.
# File modules/mu/groomers/ansible.rb, line 557 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
Figure out where our main stash of secrets is, and make sure it exists
# File modules/mu/groomers/ansible.rb, line 550 def secret_dir MU::Groomer::Ansible.secret_dir(@mu_user) end
Upload the certificate to a Chef
Vault for this node
# File modules/mu/groomers/ansible.rb, line 661 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