vaguerent/lib/vagrant/util/powershell.rb
2018-06-12 16:47:47 -07:00

260 lines
8.6 KiB
Ruby

require "tmpdir"
require_relative "subprocess"
require_relative "which"
module Vagrant
module Util
# Executes PowerShell scripts.
#
# This is primarily a convenience wrapper around Subprocess that
# properly sets powershell flags for you.
class PowerShell
# NOTE: Version checks are only on Major
MINIMUM_REQUIRED_VERSION = 3
# Number of seconds to wait while attempting to get powershell version
DEFAULT_VERSION_DETECTION_TIMEOUT = 30
LOGGER = Log4r::Logger.new("vagrant::util::powershell")
# @return [String|nil] a powershell executable, depending on environment
def self.executable
if !defined?(@_powershell_executable)
@_powershell_executable = "powershell"
if Which.which(@_powershell_executable).nil?
# Try to use WSL interoperability if PowerShell is not symlinked to
# the container.
if Platform.wsl?
@_powershell_executable += ".exe"
if Which.which(@_powershell_executable).nil?
@_powershell_executable = nil
end
else
@_powershell_executable = nil
end
end
end
@_powershell_executable
end
# @return [Boolean] powershell executable available on PATH
def self.available?
!executable.nil?
end
# Execute a powershell script.
#
# @param [String] path Path to the PowerShell script to execute.
# @param [Array<String>] args Command arguments
# @param [Hash] opts Options passed to execute
# @option opts [Hash] :env Custom environment variables
# @return [Subprocess::Result]
def self.execute(path, *args, **opts, &block)
validate_install!
if opts.delete(:sudo) || opts.delete(:runas)
powerup_command(path, args, opts)
else
if mpath = opts.delete(:module_path)
m_env = opts.fetch(:env, {})
m_env["PSModulePath"] = "$env:PSModulePath+';#{mpath}'"
opts[:env] = m_env
end
if env = opts.delete(:env)
env = env.map{|k,v| "$env:#{k}=#{v}"}.join(";") + "; "
end
command = [
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"#{env}&('#{path}')",
args
].flatten
# Append on the options hash since Subprocess doesn't use
# Ruby 2.0 style options yet.
command << opts
Subprocess.execute(*command, &block)
end
end
# Execute a powershell command.
#
# @param [String] command PowerShell command to execute.
# @param [Hash] opts Extra options
# @option opts [Hash] :env Custom environment variables
# @return [nil, String] Returns nil if exit code is non-zero.
# Returns stdout string if exit code is zero.
def self.execute_cmd(command, **opts)
validate_install!
if mpath = opts.delete(:module_path)
m_env = opts.fetch(:env, {})
m_env["PSModulePath"] = "$env:PSModulePath+';#{mpath}'"
opts[:env] = m_env
end
if env = opts.delete(:env)
env = env.map{|k,v| "$env:#{k}=#{v}"}.join(";") + "; "
end
c = [
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command",
"#{env}#{command}"
].flatten.compact
r = Subprocess.execute(*c)
return nil if r.exit_code != 0
return r.stdout.chomp
end
# Execute a powershell command and return a result
#
# @param [String] command PowerShell command to execute.
# @param [Hash] opts A collection of options for subprocess::execute
# @option opts [Hash] :env Custom environment variables
# @param [Block] block Ruby block
def self.execute_inline(*command, **opts, &block)
validate_install!
if mpath = opts.delete(:module_path)
m_env = opts.fetch(:env, {})
m_env["PSModulePath"] = "$env:PSModulePath+';#{mpath}'"
opts[:env] = m_env
end
if env = opts.delete(:env)
env = env.map{|k,v| "$env:#{k}=#{v}"}.join(";") + "; "
end
c = [
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command",
"#{env}#{command}"
].flatten.compact
c << opts
Subprocess.execute(*c, &block)
end
# Returns the version of PowerShell that is installed.
#
# @return [String]
def self.version
if !defined?(@_powershell_version)
command = [
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command",
"Write-Output $PSVersionTable.PSVersion.Major"
].flatten
version = nil
timeout = ENV["VAGRANT_POWERSHELL_VERSION_DETECTION_TIMEOUT"].to_i
if timeout < 1
timeout = DEFAULT_VERSION_DETECTION_TIMEOUT
end
begin
r = Subprocess.execute(*command,
notify: [:stdout, :stderr],
timeout: timeout,
) {|io_name,data| version = data}
rescue Vagrant::Util::Subprocess::TimeoutExceeded
LOGGER.debug("Timeout exceeded while attempting to determine version of Powershell.")
end
@_powershell_version = version
end
@_powershell_version
end
# Validates that powershell is installed, available, and
# at or above minimum required version
#
# @return [Boolean]
# @raises []
def self.validate_install!
if !defined?(@_powershell_validation)
raise Errors::PowerShellNotFound if !available?
if version.to_i < MINIMUM_REQUIRED_VERSION
raise Errors::PowerShellInvalidVersion,
minimum_version: MINIMUM_REQUIRED_VERSION,
installed_version: version ? version : "N/A"
end
@_powershell_validation = true
end
@_powershell_validation
end
# Powerup the given command to perform privileged operations.
#
# @param [String] path
# @param [Array<String>] args
# @return [Array<String>]
def self.powerup_command(path, args, opts)
Dir.mktmpdir("vagrant") do |dpath|
all_args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", path] + args
arg_list = "@('" + all_args.join("', '") + "')"
stdout = File.join(dpath, "stdout.txt")
stderr = File.join(dpath, "stderr.txt")
exitcode = File.join(dpath, "exitcode.txt")
script = "$sp = Start-Process -FilePath powershell -ArgumentList #{arg_list} " \
"-PassThru -Wait -RedirectStandardOutput '#{stdout}' -RedirectStandardError '#{stderr}' -WindowStyle Hidden; " \
"if($sp){ Set-Content -Path '#{exitcode}' -Value $sp.ExitCode;exit $sp.ExitCode; }else{ exit 1 }"
# escape quotes so we can nest our script within a start-process
script.gsub!("'", "''")
cmd = [
"powershell",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command", "$p = Start-Process -FilePath powershell -ArgumentList " \
"@('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '#{script}') " \
"-PassThru -Wait -WindowStyle Hidden -Verb RunAs; if($p){ exit $p.ExitCode; }else{ exit 1 }"
]
result = Subprocess.execute(*cmd.push(opts))
if File.exist?(stdout)
r_stdout = File.read(stdout)
else
r_stdout = result.stdout
end
if File.exist?(stderr)
r_stderr = File.read(stderr)
else
r_stderr = result.stderr
end
code = 1
if File.exist?(exitcode)
code_txt = File.read(exitcode).strip
if code_txt.match(/^\d+$/)
code = code_txt.to_i
end
end
Subprocess::Result.new(code, r_stdout, r_stderr)
end
end
# @private
# Reset the cached values for platform. This is not considered a public
# API and should only be used for testing.
def self.reset!
instance_variables.each(&method(:remove_instance_variable))
end
end
end
end