#--
# Author:: Daniel DeLeo (<dan@chef.io>)
# Copyright:: Copyright (c) Chef Software Inc.
# License:: Apache License, Version 2.0
#
# 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 "etc" unless defined?(Etc)
require "tmpdir" unless defined?(Dir.mktmpdir)
require "fcntl"
require_relative "shellout/exceptions"
module Mixlib
class ShellOut
READ_WAIT_TIME = 0.01
READ_SIZE = 4096
DEFAULT_READ_TIMEOUT = 600
if RUBY_PLATFORM =~ /mswin|mingw|windows/
require_relative "shellout/windows"
include ShellOut::Windows
else
require_relative "shellout/unix"
include ShellOut::Unix
end
# User the command will run as. Normally set via options passed to new
attr_accessor :user
attr_accessor :domain
attr_accessor :password
# TODO remove
attr_accessor :with_logon
# Whether to simulate logon as the user. Normally set via options passed to new
# Always enabled on windows
attr_accessor :login
# Group the command will run as. Normally set via options passed to new
attr_accessor :group
# Working directory for the subprocess. Normally set via options to new
attr_accessor :cwd
# An Array of acceptable exit codes. #error? (and #error!) use this list
# to determine if the command was successful. Normally set via options to new
attr_accessor :valid_exit_codes
# When live_stdout is set, the stdout of the subprocess will be copied to it
# as the subprocess is running.
attr_accessor :live_stdout
# When live_stderr is set, the stderr of the subprocess will be copied to it
# as the subprocess is running.
attr_accessor :live_stderr
# ShellOut will push data from :input down the stdin of the subprocess.
# Normally set via options passed to new.
# Default: nil
attr_accessor :input
# If a logger is set, ShellOut will log a message before it executes the
# command.
attr_accessor :logger
# The log level at which ShellOut should log.
attr_accessor :log_level
# A string which will be prepended to the log message.
attr_accessor :log_tag
# The command to be executed.
attr_reader :command
# The umask that will be set for the subcommand.
attr_reader :umask
# Environment variables that will be set for the subcommand. Refer to the
# documentation of new to understand how ShellOut interprets this.
attr_accessor :environment
# The maximum time this command is allowed to run. Usually set via options
# to new
attr_writer :timeout
# The amount of time the subcommand took to execute
attr_reader :execution_time
# Data written to stdout by the subprocess
attr_reader :stdout
# Data written to stderr by the subprocess
attr_reader :stderr
# A Process::Status (or ducktype) object collected when the subprocess is
# reaped.
attr_reader :status
attr_reader :stdin_pipe, :stdout_pipe, :stderr_pipe, :process_status_pipe
# Runs windows process with elevated privileges. Required for Powershell commands which need elevated privileges
attr_accessor :elevated
attr_accessor :sensitive
# === Arguments:
# Takes a single command, or a list of command fragments. These are used
# as arguments to Kernel.exec. See the Kernel.exec documentation for more
# explanation of how arguments are evaluated. The last argument can be an
# options Hash.
# === Options:
# If the last argument is a Hash, it is removed from the list of args passed
# to exec and used as an options hash. The following options are available:
# * +user+: the user the command should run as. if an integer is given, it is
# used as a uid. A string is treated as a username and resolved to a uid
# with Etc.getpwnam
# * +group+: the group the command should run as. works similarly to +user+
# * +cwd+: the directory to chdir to before running the command
# * +umask+: a umask to set before running the command. If given as an Integer,
# be sure to use two leading zeros so it's parsed as Octal. A string will
# be treated as an octal integer
# * +returns+: one or more Integer values to use as valid exit codes for the
# subprocess. This only has an effect if you call +error!+ after
# +run_command+.
# * +environment+: a Hash of environment variables to set before the command
# is run.
# * +timeout+: a Numeric value for the number of seconds to wait on the
# child process before raising an Exception. This is calculated as the
# total amount of time that ShellOut waited on the child process without
# receiving any output (i.e., IO.select returned nil). Default is 600
# seconds. Note: the stdlib Timeout library is not used.
# * +input+: A String of data to be passed to the subcommand. This is
# written to the child process' stdin stream before the process is
# launched. The child's stdin stream will be a pipe, so the size of input
# data should not exceed the system's default pipe capacity (4096 bytes
# is a safe value, though on newer Linux systems the capacity is 64k by
# default).
# * +live_stream+: An IO or Logger-like object (must respond to the append
# operator +<<+) that will receive data as ShellOut reads it from the
# child process. Generally this is used to copy data from the child to
# the parent's stdout so that users may observe the progress of
# long-running commands.
# * +login+: Whether to simulate a login (set secondary groups, primary group, environment
# variables etc) as done by the OS in an actual login
# === Examples:
# Invoke find(1) to search for .rb files:
# find = Mixlib::ShellOut.new("find . -name '*.rb'")
# find.run_command
# # If all went well, the results are on +stdout+
# puts find.stdout
# # find(1) prints diagnostic info to STDERR:
# puts "error messages" + find.stderr
# # Raise an exception if it didn't exit with 0
# find.error!
# Run a command as the +www+ user with no extra ENV settings from +/tmp+
# cmd = Mixlib::ShellOut.new("apachectl", "start", :user => 'www', :env => nil, :cwd => '/tmp')
# cmd.run_command # etc.
def initialize(*command_args)
@stdout, @stderr, @process_status = "", "", ""
@live_stdout = @live_stderr = nil
@input = nil
@log_level = :debug
@log_tag = nil
@environment = {}
@cwd = nil
@valid_exit_codes = [0]
@terminate_reason = nil
@timeout = nil
@elevated = false
@sensitive = false
if command_args.last.is_a?(Hash)
parse_options(command_args.pop)
end
@command = command_args.size == 1 ? command_args.first : command_args
end
# Returns the stream that both is being used by both live_stdout and live_stderr, or nil
def live_stream
live_stdout == live_stderr ? live_stdout : nil
end
# A shortcut for setting both live_stdout and live_stderr, so that both the
# stdout and stderr from the subprocess will be copied to the same stream as
# the subprocess is running.
def live_stream=(stream)
@live_stdout = @live_stderr = stream
end
# Set the umask that the subprocess will have. If given as a string, it
# will be converted to an integer by String#oct.
def umask=(new_umask)
@umask = (new_umask.respond_to?(:oct) ? new_umask.oct : new_umask.to_i) & 007777
end
# The uid that the subprocess will switch to. If the user attribute was
# given as a username, it is converted to a uid by Etc.getpwnam
# TODO migrate to shellout/unix.rb
def uid
return nil unless user
user.is_a?(Integer) ? user : Etc.getpwnam(user.to_s).uid
end
# The gid that the subprocess will switch to. If the group attribute is
# given as a group name, it is converted to a gid by Etc.getgrnam
# TODO migrate to shellout/unix.rb
def gid
return group.is_a?(Integer) ? group : Etc.getgrnam(group.to_s).gid if group
return Etc.getpwuid(uid).gid if using_login?
nil
end
def timeout
@timeout || DEFAULT_READ_TIMEOUT
end
# Creates a String showing the output of the command, including a banner
# showing the exact command executed. Used by +invalid!+ to show command
# results when the command exited with an unexpected status.
def format_for_exception
return "Command execution failed. STDOUT/STDERR suppressed for sensitive resource" if sensitive
msg = ""
msg << "#{@terminate_reason}\n" if @terminate_reason
msg << "---- Begin output of #{command} ----\n"
msg << "STDOUT: #{stdout.strip}\n"
msg << "STDERR: #{stderr.strip}\n"
msg << "---- End output of #{command} ----\n"
msg << "Ran #{command} returned #{status.exitstatus}" if status
msg
end
# The exit status of the subprocess. Will be nil if the command is still
# running or died without setting an exit status (e.g., terminated by
# `kill -9`).
def exitstatus
@status&.exitstatus
end
# Run the command, writing the command's standard out and standard error
# to +stdout+ and +stderr+, and saving its exit status object to +status+
# === Returns
# returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be
# populated with results of the command
# === Raises
# * Errno::EACCES when you are not privileged to execute the command
# * Errno::ENOENT when the command is not available on the system (or not
# in the current $PATH)
# * CommandTimeout when the command does not complete
# within +timeout+ seconds (default: 600s)
def run_command
if logger
log_message = (log_tag.nil? ? "" : "#{@log_tag} ") << "sh(#{@command})"
logger.send(log_level, log_message)
end
super
end
# Checks the +exitstatus+ against the set of +valid_exit_codes+.
# === Returns
# +true+ if +exitstatus+ is not in the list of +valid_exit_codes+, false
# otherwise.
def error?
!Array(valid_exit_codes).include?(exitstatus)
end
# If #error? is true, calls +invalid!+, which raises an Exception.
# === Returns
# nil::: always returns nil when it does not raise
# === Raises
# ::ShellCommandFailed::: via +invalid!+
def error!
invalid!("Expected process to exit with #{valid_exit_codes.inspect}, but received '#{exitstatus}'") if error?
end
# Raises a ShellCommandFailed exception, appending the
# command's stdout, stderr, and exitstatus to the exception message.
# === Arguments
# +msg+: A String to use as the basis of the exception message. The
# default explanation is very generic, providing a more informative message
# is highly encouraged.
# === Raises
# ShellCommandFailed always
def invalid!(msg = nil)
msg ||= "Command produced unexpected results"
raise ShellCommandFailed, msg + "\n" + format_for_exception
end
def inspect
"<#{self.class.name}##{object_id}: command: '#{@command}' process_status: #{@status.inspect} " +
"stdout: '#{stdout.strip}' stderr: '#{stderr.strip}' child_pid: #{@child_pid.inspect} " +
"environment: #{@environment.inspect} timeout: #{timeout} user: #{@user} group: #{@group} working_dir: #{@cwd} >"
end
private
def parse_options(opts)
opts.each do |option, setting|
case option.to_s
when "cwd"
self.cwd = setting
when "domain"
self.domain = setting
when "password"
self.password = setting
when "user"
self.user = setting
self.with_logon = setting
when "group"
self.group = setting
when "umask"
self.umask = setting
when "timeout"
self.timeout = setting
when "returns"
self.valid_exit_codes = Array(setting)
when "live_stream"
self.live_stdout = self.live_stderr = setting
when "live_stdout"
self.live_stdout = setting
when "live_stderr"
self.live_stderr = setting
when "input"
self.input = setting
when "logger"
self.logger = setting
when "log_level"
self.log_level = setting
when "log_tag"
self.log_tag = setting
when "environment", "env"
if setting
self.environment = Hash[setting.map { |(k, v)| [k.to_s, v] }]
else
self.environment = {}
end
when "login"
self.login = setting
when "elevated"
self.elevated = setting
when "sensitive"
self.sensitive = setting
else
raise InvalidCommandOption, "option '#{option.inspect}' is not a valid option for #{self.class.name}"
end
end
validate_options(opts)
end
def validate_options(opts)
if login && !user
raise InvalidCommandOption, "cannot set login without specifying a user"
end
super
end
end
end