369 lines
12 KiB
Ruby

# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
require "pathname"
require "tempfile"
require "vagrant/util/downloader"
require "vagrant/util/line_buffer"
require "vagrant/util/retryable"
module VagrantPlugins
module Shell
class Provisioner < Vagrant.plugin("2", :provisioner)
include Vagrant::Util::Retryable
DEFAULT_WINDOWS_SHELL_EXT = ".ps1".freeze
CMD_WINDOWS_SHELL_EXT = ".bat".freeze
def provision
args = ""
if config.args.is_a?(String)
args = " #{config.args.to_s}"
elsif config.args.is_a?(Array)
args = config.args.map { |a| quote_and_escape(a) }
args = " #{args.join(" ")}"
end
# In cases where the connection is just being reset
# bail out before attempting to do any actual provisioning
return if !config.path && !config.inline
case @machine.config.vm.communicator
when :winrm
provision_winrm(args)
when :winssh
provision_winssh(args)
else
provision_ssh(args)
end
ensure
if config.reboot
@machine.guest.capability(:reboot)
else
@machine.communicate.reset! if config.reset
end
end
def upload_path
if !defined?(@_upload_path)
case @machine.config.vm.guest
when :windows
@_upload_path = Vagrant::Util::Platform.unix_windows_path(config.upload_path.to_s)
else
@_upload_path = config.upload_path.to_s
end
if @_upload_path.empty?
case @machine.config.vm.guest
when :windows
@_upload_path = "C:/tmp/vagrant-shell"
else
@_upload_path = "/tmp/vagrant-shell"
end
end
end
@_upload_path
end
protected
def build_outputs
outputs = {
stdout: Vagrant::Util::LineBuffer.new { |line| handle_comm(:stdout, line) },
stderr: Vagrant::Util::LineBuffer.new { |line| handle_comm(:stderr, line) },
}
block = proc { |type, data|
outputs[type] << data if outputs[type]
}
return outputs, block
end
# This handles outputting the communication line back to the UI
def handle_comm(type, data)
if [:stderr, :stdout].include?(type)
# Output the line with the proper color based on the stream.
color = type == :stdout ? :green : :red
options = {}
options[:color] = color if !config.keep_color
@machine.ui.detail(data.chomp, **options)
end
end
# This is the provision method called if SSH is what is running
# on the remote end, which assumes a POSIX-style host.
def provision_ssh(args)
env = config.env.map { |k,v| "#{k}=#{quote_and_escape(v.to_s)}" }
env = env.join(" ")
command = "chmod +x '#{upload_path}'"
command << " &&"
command << " #{env}" if !env.empty?
command << " #{upload_path}#{args}"
with_script_file do |path|
# Upload the script to the machine
@machine.communicate.tap do |comm|
# Reset upload path permissions for the current ssh user
info = nil
retryable(on: Vagrant::Errors::SSHNotReady, tries: 3, sleep: 2) do
info = @machine.ssh_info
raise Vagrant::Errors::SSHNotReady if info.nil?
end
comm.upload(path.to_s, upload_path)
user = info[:username]
comm.sudo("chown -R #{user} #{upload_path}",
error_check: false)
if config.name
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "script: #{config.name}"))
elsif config.path
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: path.to_s))
else
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "inline script"))
end
# Execute it with sudo
outputs, handler = build_outputs
begin
comm.execute(
command,
sudo: config.privileged,
error_key: :ssh_bad_exit_status_muted,
&handler
)
ensure
outputs.values.map(&:close)
end
end
end
end
# This is the provision method called if Windows OpenSSH is what is running
# on the remote end, which assumes a non-POSIX-style host.
def provision_winssh(args)
with_script_file do |path|
# Upload the script to the machine
@machine.communicate.tap do |comm|
env = config.env.map{|k,v| comm.generate_environment_export(k, v)}.join(';')
remote_ext = get_windows_ext(path)
remote_path = add_extension(upload_path, remote_ext)
if remote_ext == ".bat"
command = "#{env}\n cmd.exe /c \"#{remote_path}\" #{args}"
else
# Copy powershell_args from configuration
shell_args = config.powershell_args
# For PowerShell scripts bypass the execution policy unless already specified
shell_args += " -ExecutionPolicy Bypass" if config.powershell_args !~ /[-\/]ExecutionPolicy/i
# CLIXML output is kinda useless, especially on non-windows hosts
shell_args += " -OutputFormat Text" if config.powershell_args !~ /[-\/]OutputFormat/i
command = "#{env}\npowershell #{shell_args} -file \"#{remote_path}\"#{args}"
end
# Reset upload path permissions for the current ssh user
info = nil
retryable(on: Vagrant::Errors::SSHNotReady, tries: 3, sleep: 2) do
info = @machine.ssh_info
raise Vagrant::Errors::SSHNotReady if info.nil?
end
comm.upload(path.to_s, remote_path)
if config.name
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "script: #{config.name}"))
elsif config.path
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: path.to_s))
else
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "inline script"))
end
# Execute it with sudo
begin
outputs, handler = build_outputs
comm.execute(
command,
shell: :powershell,
error_key: :ssh_bad_exit_status_muted,
&handler
)
ensure
outputs.values.map(&:close)
end
end
end
end
# This provisions using WinRM, which assumes a PowerShell
# console on the other side.
def provision_winrm(args)
if @machine.guest.capability?(:wait_for_reboot)
@machine.guest.capability(:wait_for_reboot)
end
with_script_file do |path|
@machine.communicate.tap do |comm|
# Make sure that the upload path has an extension, since
# having an extension is critical for Windows execution
winrm_upload_path = add_extension(upload_path, get_windows_ext(path))
# Upload it
comm.upload(path.to_s, winrm_upload_path)
# Build the environment
env = config.env.map { |k,v| "$env:#{k} = #{quote_and_escape(v.to_s)}" }
env = env.join("; ")
# Calculate the path that we'll be executing
exec_path = winrm_upload_path
exec_path.gsub!('/', '\\')
exec_path = "c:#{exec_path}" if exec_path.start_with?("\\")
# Copy powershell_args from configuration
shell_args = config.powershell_args
# For PowerShell scripts bypass the execution policy unless already specified
shell_args += " -ExecutionPolicy Bypass" if config.powershell_args !~ /[-\/]ExecutionPolicy/i
# CLIXML output is kinda useless, especially on non-windows hosts
shell_args += " -OutputFormat Text" if config.powershell_args !~ /[-\/]OutputFormat/i
command = "\"#{exec_path}\"#{args}"
if File.extname(exec_path).downcase == ".ps1"
command = "powershell #{shell_args.to_s} -file #{command}"
else
command = "cmd /q /c #{command}"
end
# Append the environment
if !env.empty?
command = "#{env}; #{command}"
end
if config.name
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "script: #{config.name}"))
elsif config.path
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.runningas",
local: config.path.to_s, remote: exec_path))
else
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "inline PowerShell script"))
end
# Execute it with sudo
begin
outputs, handler = build_outputs
comm.sudo(command,
elevated: config.privileged,
interactive: config.powershell_elevated_interactive,
&handler
)
ensure
outputs.values.map(&:close)
end
end
end
end
# Quote and escape strings for shell execution, thanks to Capistrano.
def quote_and_escape(text, quote = '"')
"#{quote}#{text.gsub(/#{quote}/) { |m| "#{m}\\#{m}#{m}" }}#{quote}"
end
def add_extension(path, ext)
return path if !File.extname(path.to_s).empty?
path + ext
end
def get_windows_ext(path)
remote_ext = File.extname(upload_path.to_s)
if remote_ext.empty?
remote_ext = File.extname(path.to_s)
if remote_ext.empty?
remote_ext = @machine.config.winssh.shell == "cmd" ? CMD_WINDOWS_SHELL_EXT : DEFAULT_WINDOWS_SHELL_EXT
end
end
remote_ext
end
# This method yields the path to a script to upload and execute
# on the remote server. This method will properly clean up the
# script file if needed.
def with_script_file
ext = nil
script = nil
if config.remote?
download_path = @machine.env.tmp_path.join(
"#{@machine.id}-remote-script")
download_path.delete if download_path.file?
begin
Vagrant::Util::Downloader.new(
config.path,
download_path,
md5: config.md5,
sha1: config.sha1,
sha256: config.sha256,
sha384: config.sha384,
sha512: config.sha512
).download!
ext = File.extname(config.path)
script = download_path.read
ensure
download_path.delete if download_path.file?
end
elsif config.path
# Just yield the path to that file...
root_path = @machine.env.root_path
ext = File.extname(config.path)
script = Pathname.new(config.path).expand_path(root_path).read
else
script = config.inline
end
# Replace Windows line endings with Unix ones unless binary file
# or we're running on Windows.
if !config.binary && @machine.config.vm.guest != :windows
begin
script = script.gsub(/\r\n?$/, "\n")
rescue ArgumentError
script = script.force_encoding("ASCII-8BIT").gsub(/\r\n?$/, "\n")
end
end
# Otherwise we have an inline script, we need to Tempfile it,
# and handle it specially...
file = Tempfile.new(['vagrant-shell', ext])
# Unless you set binmode, on a Windows host the shell script will
# have CRLF line endings instead of LF line endings, causing havoc
# when the guest executes it. This fixes [GH-1181].
file.binmode
begin
file.write(script)
file.fsync
file.close
yield file.path
ensure
file.close
file.unlink
end
end
end
end
end