Skip to content

Commit

Permalink
First whack at windows (target) support for taste-tester
Browse files Browse the repository at this point in the history
I can't imagine we have any desire to have TT itself run on Windows,
however, one has to manage their windows system sometime, and that means
being able to test on those systems.

This adds support for the remote system by generating the right
powershell to send over ssh.

That means there are two requirements to using this:

1. You have ssh enabled on your Windows PC
2. You set the default shell to powershell instead of cmd

Both are easily accomplished with this tiny bit of Chef:

```ruby
powershell_package 'ComputerManagementDsc' do
  action :install
end

dsc_resource 'install ssh-client' do
  resource :windowscapability
  module_name 'ComputerManagementDsc'
  property :name, 'OpenSSH.Client~~~~0.0.1.0'
  property :ensure, 'Present'
end

dsc_resource 'install ssh-server' do
  resource :windowscapability
  module_name 'ComputerManagementDsc'
  property :name, 'OpenSSH.Server~~~~0.0.1.0'
  property :ensure, 'Present'
end

dsc_resource 'start sshd' do
  resource :service
  property :name, 'sshd'
  property :startuptype, 'Automatic'
  property :state, 'Running'
  property :ensure, 'Present'
end
```

This also requires you to be on a version modern enough that symlinks
actually work - sorry, we're not re-inventing how taste-tester works
for old broken OSes. Getting Windws support here is ugly enough as it
is.

You may be wondering "but Windows has bash support now!"... and you'd be
sorta-right. You can enable WSL and Bash in modern Windows, but you end
up in a embedded linux environment. You can access the Windows
filesystem, but it's not a thing most people are going to want to do on
their windows systems. So, powershell it is.

This fully supports tunnels and non-tunnels. As far as I can tell,
everything works except "bundle mode" and "local transport", but I don't
think those are necessary here.

In order to not repeat the *crazy* trans-shell logic, I factored out
some code that was repeated (but badly, with bugs - now we *always*
specify `-o StrictHostKeyChecking=no` and friends, not just sometimes)
between ssh.rb and tunnel.rb into ssh_util.rb. I took this approach
because it was the least change, and since that's not directly related
to this PR, I wanted to minimize that. However, the long-term solution
here is to just roll tunnel.rb into ssh.rb. It already has a `tunnel`
option it ignores in the initializer.

Signed-off-by: Phil Dibowitz <[email protected]>
  • Loading branch information
jaymzh committed Aug 24, 2020
1 parent c123263 commit e795255
Show file tree
Hide file tree
Showing 6 changed files with 405 additions and 121 deletions.
20 changes: 16 additions & 4 deletions bin/taste-tester
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,13 @@ MODES:
'Until when should the host remain in testing.' +
' Anything parsable is ok, such as "5/18 4:35" or "16/9/13".'
) do |time|
options[:testing_until] = Time.parse(time)
rescue StandardError
logger.error("Invalid date: #{time}")
exit 1
# can make this an implicit rescue after we drop ruby 2.4
begin
options[:testing_until] = Time.parse(time)
rescue StandardError
logger.error("Invalid date: #{time}")
exit 1
end
end

opts.on(
Expand Down Expand Up @@ -365,6 +368,15 @@ MODES:
options[:json] = true
end

opts.on(
'-w', '--windows-target',
'The target is a Windows machine. You will likely want to override ' +
'`test_timestamp` and `chef_config_path`, but *not* `config_file`. ' +
'Requires the target be running PowerShell >= 5.1 as the default shell.'
) do
options[:windows_target] = true
end

opts.separator ''
opts.separator 'Control local hook behavior with these options:'

Expand Down
1 change: 1 addition & 0 deletions lib/taste_tester/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ module Config
no_repo false
json false
jumps nil
windows_target false

# Start/End refs for calculating changes in the repo.
# - start_ref should be the "master" commit of the repository
Expand Down
210 changes: 167 additions & 43 deletions lib/taste_tester/host.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,11 @@ def test
# see if someone else is taste-testing
transport << we_testing

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 )"
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.
Expand Down Expand Up @@ -142,18 +133,10 @@ def untest
if TasteTester::Config.use_ssh_tunnels
TasteTester::Tunnel.kill(@name)
end
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
if TasteTester::Config.windows_target
add_windows_untest_cmds(transport)
else
add_sane_os_untest_cmds(transport)
end
transport.run!
end
Expand All @@ -168,15 +151,31 @@ def we_testing
# 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.
shellcode = <<~ENDOFSHELLCODE
awk "\\$0 ~ /^#{USER_PREAMBLE}/{
if (\\$NF != \\"#{@user}\\"){
print \\$NF;
exit 42
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
}
}
}
}" #{config_file}
ENDOFSHELLCODE
shellcode.delete("\n")
ENDOFSHELLCODE
shellcode.chomp
else
shellcode = <<~ENDOFSHELLCODE
awk "\\$0 ~ /^#{USER_PREAMBLE}/{
if (\\$NF != \\"#{@user}\\"){
print \\$NF;
exit 42
}
}" #{config_file}
ENDOFSHELLCODE
shellcode.delete("\n")
end
shellcode
end

def keeptesting
Expand All @@ -195,15 +194,140 @@ def keeptesting

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...
'if (Test-Path $tmp) { rm $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
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"
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.')
[
'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 ' +
"#{TasteTester::Config.chef_config_path}/client-prod.pem " +
"#{TasteTester::Config.chef_config_path}/client.pem",
'rm -Force ' +
"#{TasteTester::Config.chef_config_path}/#{TASTE_TESTER_CONFIG}",
"rm -Force #{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 config
Expand Down Expand Up @@ -295,7 +419,7 @@ def config
chef_repo_path taste_tester_dest
ENDOFSCRIPT
end
return ttconfig
ttconfig
end
end
end
36 changes: 3 additions & 33 deletions lib/taste_tester/ssh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
# limitations under the License.

require 'taste_tester/exceptions'
require 'taste_tester/ssh_util'

module TasteTester
# Thin ssh wrapper
class SSH
include TasteTester::Logging
include BetweenMeals::Util
include TasteTester::SSH::Util

def initialize(host, tunnel = false)
@host = host
Expand All @@ -45,45 +47,13 @@ def run!(stream = nil)
error!
end

def error!
error = <<~ERRORMESSAGE
SSH returned error while connecting to #{TasteTester::Config.user}@#{@host}
The host might be broken or your SSH access is not working properly
Try doing
#{ssh_base_cmd} -v #{TasteTester::Config.user}@#{@host}
to see if ssh connection is good.
If ssh works, add '-v' key to taste-tester to see the list of commands it's
trying to execute, and try to run them manually on destination host
ERRORMESSAGE
logger.error(error)
fail TasteTester::Exceptions::SshError
end

private

def ssh_base_cmd
jumps = TasteTester::Config.jumps ? "-J #{TasteTester::Config.jumps}" : ''
"#{TasteTester::Config.ssh_command} #{jumps}"
end

def cmd
@cmds.each do |cmd|
logger.info("Will run: '#{cmd}' on #{@host}")
end
cmds = @cmds.join(' && ')
cmd = "#{ssh_base_cmd} -T -o BatchMode=yes " +
"-o ConnectTimeout=#{TasteTester::Config.ssh_connect_timeout} " +
"#{TasteTester::Config.user}@#{@host} "
cc = Base64.encode64(cmds).delete("\n")
cmd += "\"echo '#{cc}' | base64 --decode"
if TasteTester::Config.user != 'root'
cmd += ' | sudo bash -x"'
else
cmd += ' | bash -x"'
end
cmd
build_ssh_cmd(ssh_base_cmd, @cmds)
end
end
end
Loading

0 comments on commit e795255

Please sign in to comment.