lib/taste_tester/host.rb (318 lines of code) (raw):

# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2 # Copyright 2013-present Facebook # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # 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. require 'fileutils' require 'base64' require 'open3' require 'colorize' require 'taste_tester/ssh' require 'taste_tester/noop' require 'taste_tester/locallink' require 'taste_tester/tunnel' require 'taste_tester/exceptions' module TasteTester # Manage state of the remote node class Host include TasteTester::Logging TASTE_TESTER_CONFIG = 'client-taste-tester.rb'.freeze USER_PREAMBLE = '# TasteTester by '.freeze attr_reader :name def initialize(name, server) @name = name @user = ENV['USER'] @server = server if TasteTester::Config.use_ssh_tunnels @tunnel = TasteTester::Tunnel.new(@name, @server) end end def runchef logger.warn("Running '#{TasteTester::Config.chef_client_command}' " + "on #{@name}") transport = get_transport transport << TasteTester::Config.chef_client_command io = IO.new(1) status, = transport.run(io) logger.warn("Finished #{TasteTester::Config.chef_client_command}" + " on #{@name} with status #{status}") if status.zero? msg = "#{TasteTester::Config.chef_client_command} was successful" + ' - please log to the host and confirm all the intended' + ' changes were made' logger.error msg.upcase end end def get_transport case TasteTester::Config.transport when 'locallink' TasteTester::LocalLink.new when 'noop' TasteTester::NoOp.new else TasteTester::SSH.new(@name) end end def test logger.warn("Taste-testing on #{@name}") if TasteTester::Config.use_ssh_tunnels # Nuke any existing tunnels that may be there TasteTester::Tunnel.kill(@name) # Then setup the tunnel @tunnel.run end serialized_config = Base64.encode64(config).delete("\n") # Then setup the testing transport = get_transport # see if someone else is taste-testing transport << we_testing if TasteTester::Config.windows_target add_windows_test_cmds(transport, serialized_config) else add_sane_os_test_cmds(transport, serialized_config) end # look again to see if someone else is taste-testing. This is where # we work out if we won or lost a race with another user. transport << we_testing status, output = transport.run case status when 0 # no problem, keep going. nil when 42 fail TasteTester::Exceptions::AlreadyTestingError, output.chomp else transport.error! end # Then run any other stuff they wanted cmds = TasteTester::Hooks.test_remote_cmds( TasteTester::Config.dryrun, @name, ) if cmds&.any? transport = get_transport cmds.each { |c| transport << c } transport.run! end end def untest logger.warn("Removing #{@name} from taste-tester") transport = get_transport if TasteTester::Config.use_ssh_tunnels TasteTester::Tunnel.kill(@name) end if TasteTester::Config.windows_target add_windows_untest_cmds(transport) else add_sane_os_untest_cmds(transport) end transport.run! end def we_testing config_file = "#{TasteTester::Config.chef_config_path}/" + TasteTester::Config.chef_config # Look for signature of TasteTester # 1. Look for USER_PREAMBLE line prefix # 2. See if user is us, or someone else # 3. if someone else is testing: emit username, exit with code 42 which # short circuits the test verb # This is written as a squiggly heredoc so the indentation of the awk is # preserved. Later we remove the newlines to make it a bit easier to read. if TasteTester::Config.windows_target shellcode = <<~ENDOFSHELLCODE Get-Content #{config_file} | ForEach-Object { if (\$_ -match "#{USER_PREAMBLE}" ) { $user = \$_.Split()[-1] if (\$user -ne "#{@user}") { echo \$user exit 42 } } } ENDOFSHELLCODE else shellcode = <<~ENDOFSHELLCODE awk "\\$0 ~ /^#{USER_PREAMBLE}/{ if (\\$NF != \\"#{@user}\\"){ print \\$NF; exit 42 } }" #{config_file} ENDOFSHELLCODE shellcode.chomp! end shellcode end def keeptesting logger.warn("Renewing taste-tester on #{@name} until" + " #{TasteTester::Config.testing_end_time.strftime('%y%m%d%H%M.%S')}") if TasteTester::Config.use_ssh_tunnels TasteTester::Tunnel.kill(@name) @tunnel = TasteTester::Tunnel.new(@name, @server) @tunnel.run else transport = get_transport transport << touchcmd transport.run! end end private # Sources must be 'registered' with the Eventlog, so check if we have # registered and register if necessary def create_eventlog_if_needed_cmd get_src = 'Get-EventLog -LogName Application -source taste-tester 2>$null' mk_src = 'New-EventLog -source "taste-tester" -LogName Application' "if (-Not (#{get_src})) { #{mk_src} }" end # Remote testing commands for most OSes... def add_sane_os_test_cmds(transport, serialized_config) transport << 'logger -t taste-tester Moving server into taste-tester' + " for #{@user}" transport << touchcmd # shell redirection is also racy, so make a temporary file first transport << "tmpconf=$(mktemp #{TasteTester::Config.chef_config_path}/" + "#{TASTE_TESTER_CONFIG}.TMPXXXXXX)" transport << "/bin/echo -n \"#{serialized_config}\" | base64 --decode" + ' > "${tmpconf}"' # then rename it to replace any existing file transport << 'mv -f "${tmpconf}" ' + "#{TasteTester::Config.chef_config_path}/#{TASTE_TESTER_CONFIG}" transport << "( ln -vsf #{TasteTester::Config.chef_config_path}" + "/#{TASTE_TESTER_CONFIG} #{TasteTester::Config.chef_config_path}/" + "#{TasteTester::Config.chef_config}; true )" end # Remote testing commands for Windows def add_windows_test_cmds(transport, serialized_config) # This is the closest equivalent to 'bash -x' - but if we put it on # by default the way we do with linux it badly breaks our output. So only # set it if we're in debug # # This isn't the most optimal place for this. It should be in ssh_util # and we should jam this into the beggining of the cmds list we get, # but this is early enough and good enough for now and we can think about # that when we refactor tunnel.sh, ssh.sh and ssh_util.sh into one sane # class. if logger.level == Logger::DEBUG transport << 'Set-PSDebug -trace 1' end ttconfig = "#{TasteTester::Config.chef_config_path}/#{TASTE_TESTER_CONFIG}" realconfig = "#{TasteTester::Config.chef_config_path}/" + TasteTester::Config.chef_config [ create_eventlog_if_needed_cmd, 'Write-EventLog -LogName "Application" -Source "taste-tester" ' + '-EventID 1 -EntryType Information ' + "-Message \"Moving server into taste-tester for #{@user}\"", touchcmd, "$b64 = \"#{serialized_config}\"", "$ttconfig = \"#{ttconfig}\"", "$realconfig = \"#{realconfig}\"", '$tmp64 = (New-TemporaryFile).name', '$tmp = (New-TemporaryFile).name', '$b64 | Out-File -Encoding ASCII $tmp64 -Force', # Remove our tmp file before we write to it or certutil crashes... "#{win_rm_f} $tmp", 'certutil -decode $tmp64 $tmp', 'mv $tmp $ttconfig -Force', 'New-Item -ItemType SymbolicLink -Value $ttconfig $realconfig -Force', ].each do |cmd| transport << cmd end end def touchcmd if TasteTester::Config.windows_target # There's no good touch equivalent in Windows. You can force # creation of a new file, but that'll nuke it's contents, which if we're # 'keeptesting'ing, then we'll loose the contents (PID and such). # We can set the timestamp with Get-Item.creationtime, but it must exist # if we're not gonna crash. So do both. [ "$ts = \"#{TasteTester::Config.timestamp_file}\"", 'if (-Not (Test-Path $ts)) { New-Item -ItemType file $ts }', '(Get-Item "$ts").LastWriteTime=("' + "#{TasteTester::Config.testing_end_time}\")", ].join(';') else touch = Base64.encode64( "if [ 'Darwin' = $(uname) ]; then touch -t \"$(date -r " + "#{TasteTester::Config.testing_end_time.to_i} +'%Y%m%d%H%M.%S')\" " + "#{TasteTester::Config.timestamp_file}; else touch --date \"$(date " + "-d @#{TasteTester::Config.testing_end_time.to_i} +'%Y-%m-%d %T')\"" + " #{TasteTester::Config.timestamp_file}; fi", ).delete("\n") "/bin/echo -n '#{touch}' | base64 --decode | bash" end end # Remote untesting commands for Windows def add_windows_untest_cmds(transport) config_prod = TasteTester::Config.chef_config.split('.').join('-prod.') tt_config = "#{TasteTester::Config.chef_config_path}/#{TASTE_TESTER_CONFIG}" pem_file = "#{TasteTester::Config.chef_config_path}/client-prod.pem" pem_link = "#{TasteTester::Config.chef_config_path}/client.pem" [ 'New-Item -ItemType SymbolicLink -Force -Value ' + "#{TasteTester::Config.chef_config_path}/#{config_prod} " + "#{TasteTester::Config.chef_config_path}/" + TasteTester::Config.chef_config, 'New-Item -ItemType SymbolicLink -Force -Value ' + "#{pem_file} #{pem_link}", "#{win_rm_f} #{tt_config}", "#{win_rm_f} #{TasteTester::Config.timestamp_file}", create_eventlog_if_needed_cmd, 'Write-EventLog -LogName "Application" -Source "taste-tester" ' + '-EventID 4 -EntryType Information -Message "Returning server ' + 'to production"', ].each do |cmd| transport << cmd end end # Remote untesting commands for most OSes... def add_sane_os_untest_cmds(transport) config_prod = TasteTester::Config.chef_config.split('.').join('-prod.') [ "ln -vsf #{TasteTester::Config.chef_config_path}/#{config_prod} " + "#{TasteTester::Config.chef_config_path}/" + TasteTester::Config.chef_config, "ln -vsf #{TasteTester::Config.chef_config_path}/client-prod.pem " + "#{TasteTester::Config.chef_config_path}/client.pem", "rm -vf #{TasteTester::Config.chef_config_path}/#{TASTE_TESTER_CONFIG}", "rm -vf #{TasteTester::Config.timestamp_file}", 'logger -t taste-tester Returning server to production', ].each do |cmd| transport << cmd end end def win_rm_f 'Remove-Item -Force -ErrorAction SilentlyContinue' end def config scheme = TasteTester::Config.use_ssl ? 'https' : 'http' if TasteTester::Config.use_ssh_tunnels url = "#{scheme}://localhost:#{@tunnel.port}" else url = +"#{scheme}://#{@server.host}" url << ":#{TasteTester::State.port}" if TasteTester::State.port end ttconfig = <<~ENDOFSCRIPT #{USER_PREAMBLE}#{@user} # Prevent people from screwing up their permissions if Process.euid != 0 puts 'Please run chef as root!' Process.exit! end log_level :info log_location STDOUT ssl_verify_mode :verify_none ohai.plugin_path << File.join('#{TasteTester::Config.chef_config_path}', 'ohai_plugins') ENDOFSCRIPT if TasteTester::Config.bundle ttconfig += <<~ENDOFSCRIPT taste_tester_dest = File.join(Dir.tmpdir, 'taste-tester') puts 'INFO: Downloading bundle from #{url}...' FileUtils.rmtree(taste_tester_dest) FileUtils.mkpath(taste_tester_dest) FileUtils.touch(File.join(taste_tester_dest, 'chefignore')) uri = URI.parse('#{url}/file_store/tt.tgz') Net::HTTP.start( uri.host, uri.port, :use_ssl => #{TasteTester::Config.use_ssl}, # we expect self signed certificates :verify_mode => OpenSSL::SSL::VERIFY_NONE, ) do |http| http.request_get(uri) do |response| # the use of stringIO means we are buffering the entire file in # memory. This isn't very efficient, but it should work for # most practical cases. stream = Zlib::GzipReader.new(StringIO.new(response.body)) Gem::Package::TarReader.new(stream).each do |e| dest = File.join(taste_tester_dest, e.full_name) FileUtils.mkpath(File.dirname(dest)) if e.symlink? File.symlink(e.header.linkname, dest) else File.open(dest, 'wb+') do |f| # https://github.com/rubygems/rubygems/pull/2303 # IO.copy_stream(e, f) # workaround: f.write(e.read) end end end end end puts 'INFO: Download complete' solo true local_mode true ENDOFSCRIPT else ttconfig += <<~ENDOFSCRIPT chef_server_url '#{url}' ENDOFSCRIPT end extra = TasteTester::Hooks.test_remote_client_rb_extra_code(@name) if extra ttconfig += <<~ENDOFSCRIPT # Begin user-hook specified code #{extra} # End user-hook secified code ENDOFSCRIPT end ttconfig += <<~ENDOFSCRIPT puts 'INFO: Running on #{@name} in taste-tester by #{@user}' ENDOFSCRIPT if TasteTester::Config.bundle # This is last in the configuration file because it needs to override # any values in test_remote_client_rb_extra_code ttconfig += <<~ENDOFSCRIPT chef_repo_path taste_tester_dest ENDOFSCRIPT end ttconfig end end end