# -*- encoding: utf-8 -*- # # Author:: Fletcher Nichol () # # Copyright (C) 2012, Fletcher Nichol # # 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 'base64' require 'benchmark' require 'celluloid' require 'delegate' require 'digest' require 'erb' require 'fileutils' require 'json' require 'logger' require 'mixlib/shellout' require 'net/https' require 'net/scp' require 'net/ssh' require 'pathname' require 'thread' require 'socket' require 'stringio' require 'vendor/hash_recursive_merge' require 'yaml' require 'jamie/version' module Jamie class << self attr_accessor :logger attr_accessor :crashes attr_accessor :mutex # Returns the root path of the Jamie gem source code. # # @return [Pathname] root path of gem def source_root @source_root ||= Pathname.new(File.expand_path('../../', __FILE__)) end def crashes? ! crashes.empty? end def default_logger Logger.new(:stdout => STDOUT, :level => env_log) end def default_file_logger logfile = File.expand_path(File.join(".jamie", "logs", "jamie.log")) Logger.new(:stdout => STDOUT, :logdev => logfile, :level => env_log) end private def env_log level = ENV['JAMIE_LOG'] && ENV['JAMIE_LOG'].downcase.to_sym level = Util.to_logger_level(level) unless level.nil? end end module Error ; end # Base exception class from which all Jamie exceptions derive. This class # nests an exception when this class is re-raised from a rescue block. class StandardError < ::StandardError include Error attr_reader :original def initialize(msg, original = $!) super(msg) @original = original end end # Base exception class for all exceptions that are caused by user input # errors. class UserError < StandardError ; end # Base exception class for all exceptions that are caused by incorrect use # of an API. class ClientError < StandardError ; end # Base exception class for exceptions that are caused by external library # failures which may be temporary. class TransientFailure < StandardError ; end # Exception class for any exceptions raised when performing an instance # action. class ActionFailed < TransientFailure ; end # Base configuration class for Jamie. This class exposes configuration such # as the location of the Jamie YAML file, instances, log_levels, etc. # # @author Fletcher Nichol class Config attr_writer :yaml_file attr_writer :platforms attr_writer :suites attr_writer :log_level attr_writer :supervised attr_writer :test_base_path # Default path to the Jamie YAML file DEFAULT_YAML_FILE = File.join(Dir.pwd, '.jamie.yml').freeze # Default driver plugin to use DEFAULT_DRIVER_PLUGIN = "dummy".freeze # Default base path which may contain `data_bags/` directories DEFAULT_TEST_BASE_PATH = File.join(Dir.pwd, 'test/integration').freeze # Creates a new configuration. # # @param yaml_file [String] optional path to Jamie YAML file def initialize(yaml_file = nil) @yaml_file = yaml_file end # @return [Array] all defined platforms which will be used in # convergence integration def platforms @platforms ||= Collection.new( Array(yaml[:platforms]).map { |hash| new_platform(hash) }) end # @return [Array] all defined suites which will be used in # convergence integration def suites @suites ||= Collection.new( Array(yaml[:suites]).map { |hash| new_suite(hash) }) end # @return [Array] all instances, resulting from all platform and # suite combinations def instances instances_array(load_instances) end # @return [String] path to the Jamie YAML file def yaml_file @yaml_file ||= DEFAULT_YAML_FILE end # @return [Symbol] log level verbosity def log_level @log_level ||= begin ENV['JAMIE_LOG'] && ENV['JAMIE_LOG'].downcase.to_sym || Jamie::DEFAULT_LOG_LEVEL end end def supervised @supervised.nil? ? @supervised = true : @supervised end # @return [String] base path that may contain a common `data_bags/` # directory or an instance's `data_bags/` directory def test_base_path @test_base_path ||= DEFAULT_TEST_BASE_PATH end # Delegate class which adds the ability to find single and multiple # objects by their #name in an Array. Hey, it's better than monkey-patching # Array, right? # # @author Fletcher Nichol class Collection < SimpleDelegator # Returns a single object by its name, or nil if none are found. # # @param name [String] name of object # @return [Object] first match by name, or nil if none are found def get(name) __getobj__.find { |i| i.name == name } end # Returns a Collection of all objects whose #name is matched by the # regular expression. # # @param regexp [Regexp] a regular expression pattern # @return [Jamie::Config::Collection] a new collection of # matched objects def get_all(regexp) Jamie::Config::Collection.new( __getobj__.find_all { |i| i.name =~ regexp } ) end # Returns an Array of names from the collection as strings. # # @return [Array] array of name strings def as_names __getobj__.map { |i| i.name } end end private def load_instances return @instance_count if @instance_count && @instance_count > 0 results = [] suites.product(platforms).each_with_index do |arr, index| results << new_instance(arr[0], arr[1], index) end @instance_count = results.size end def instances_array(instance_count) results = [] instance_count.times do |index| results << Celluloid::Actor["instance_#{index}".to_sym] end Collection.new(results) end def new_suite(hash) path_hash = { :data_bags_path => calculate_path("data_bags", hash[:name]), :roles_path => calculate_path("roles", hash[:name]), } Suite.new(hash.rmerge(path_hash)) end def new_platform(hash) Platform.new(hash) end def new_driver(hash) hash[:driver_config] ||= Hash.new hash[:driver_config][:jamie_root] = jamie_root Driver.for_plugin(hash[:driver_plugin], hash[:driver_config]) end def new_instance(suite, platform, index) platform_hash = platform_driver_hash(platform.name) driver = new_driver(merge_driver_hash(platform_hash)) actor_name = "instance_#{index}".to_sym opts = { :suite => suite, :platform => platform, :driver => driver, :jr => Jr.new(suite.name), :logger => new_instance_logger(index) } new_instance_supervised_or_not(actor_name, opts) end def new_instance_supervised_or_not(actor_name, opts) if supervised supervisor = Instance.supervise_as(actor_name, opts) actor = supervisor.actors.first Jamie.logger.debug("Supervising #{actor.to_str} with #{supervisor}") actor else Celluloid::Actor[actor_name] = Instance.new(opts) end end def log_root File.expand_path(File.join(jamie_root, ".jamie", "logs")) end def platform_driver_hash(platform_name) h = yaml[:platforms].find { |p| p[:name] == platform_name } || Hash.new h.select { |key, value| [:driver_plugin, :driver_config].include?(key) } end def new_instance_logger(index) level = Util.to_logger_level(self.log_level) color = Color::COLORS[index % Color::COLORS.size].to_sym lambda do |name| logfile = File.join(log_root, "#{name}.log") Logger.new(:stdout => STDOUT, :color => color, :logdev => logfile, :level => level, :progname => name) end end def yaml @yaml ||= Util.symbolized_hash( YAML.load(yaml_contents).rmerge(local_yaml)) end def yaml_contents ERB.new(IO.read(File.expand_path(yaml_file))).result end def local_yaml_file std = File.expand_path(yaml_file) std.sub(/(#{File.extname(std)})$/, '.local\1') end def local_yaml @local_yaml ||= begin if File.exists?(local_yaml_file) YAML.load(ERB.new(IO.read(local_yaml_file)).result) else Hash.new end end end def jamie_root File.dirname(yaml_file) end def merge_driver_hash(driver_hash) default_driver_hash.rmerge(common_driver_hash.rmerge(driver_hash)) end def calculate_path(path, suite_name) suite_path = File.join(test_base_path, suite_name, path) common_path = File.join(test_base_path, path) top_level_path = File.join(Dir.pwd, path) if File.directory?(suite_path) suite_path elsif File.directory?(common_path) common_path elsif File.directory?(top_level_path) top_level_path else nil end end def default_driver_hash { :driver_plugin => DEFAULT_DRIVER_PLUGIN, :driver_config => {} } end def common_driver_hash yaml.select do |key, value| [:driver_plugin, :driver_config].include?(key) end end end # Default log level verbosity DEFAULT_LOG_LEVEL = :info module Color ANSI = { :reset => 0, :black => 30, :red => 31, :green => 32, :yellow => 33, :blue => 34, :magenta => 35, :cyan => 36, :white => 37, :bright_black => 30, :bright_red => 31, :bright_green => 32, :bright_yellow => 33, :bright_blue => 34, :bright_magenta => 35, :bright_cyan => 36, :bright_white => 37 }.freeze COLORS = %w( cyan yellow green magenta red blue bright_cyan bright_yellow bright_green bright_magenta bright_red bright_blue ).freeze def self.escape(name) return "" if name.nil? return "" unless ansi = ANSI[name] "\e[#{ansi}m" end def self.colorize(str, name) "#{escape(name)}#{str}#{escape(:reset)}" end end # Logging implementation for Jamie. By default the console/stdout output will # be displayed differently than the file log output. Therefor, this class # wraps multiple loggers that conform to the stdlib `Logger` class behavior. # # @author Fletcher Nichol class Logger include ::Logger::Severity attr_reader :logdev def initialize(options = {}) color = options[:color] || :bright_white @loggers = [] @loggers << @logdev = logdev_logger(options[:logdev]) if options[:logdev] @loggers << stdout_logger(options[:stdout], color) if options[:stdout] @loggers << stdout_logger(STDOUT, color) if @loggers.empty? self.progname = options[:progname] || "Jamie" self.level = options[:level] || default_log_level end %w{ level progname datetime_format debug? info? error? warn? fatal? }.each do |meth| define_method(meth) do |*args| @loggers.first.public_send(meth, *args) end end %w{ level= progname= datetime_format= add << banner debug info error warn fatal unknown close }.map(&:to_sym).each do |meth| define_method(meth) do |*args| result = nil @loggers.each { |l| result = l.public_send(meth, *args) } result end end private def default_log_level Util.to_logger_level(Jamie::DEFAULT_LOG_LEVEL) end def stdout_logger(stdout, color) logger = StdoutLogger.new(stdout) logger.formatter = proc do |severity, datetime, progname, msg| Color.colorize("#{msg}\n", color) end logger end def logdev_logger(filepath_or_logdev) LogdevLogger.new(resolve_logdev(filepath_or_logdev)) end def resolve_logdev(filepath_or_logdev) if filepath_or_logdev.is_a? String FileUtils.mkdir_p(File.dirname(filepath_or_logdev)) file = File.open(File.expand_path(filepath_or_logdev), "ab") file.sync = true file else filepath_or_logdev end end # Internal class which adds a #banner method call that displays the # message with a callout arrow. class LogdevLogger < ::Logger alias_method :super_info, :info def <<(msg) msg =~ /\n/ ? msg.split("\n").each { |l| format_line(l) } : super end def banner(msg = nil, &block) super_info("-----> #{msg}", &block) end private def format_line(line) case line when %r{^-----> } then banner(line.gsub(%r{^[ >-]{6} }, '')) when %r{^>>>>>> } then error(line.gsub(%r{^[ >-]{6} }, '')) when %r{^ } then info(line.gsub(%r{^[ >-]{6} }, '')) else info(line) end end end # Internal class which reformats logging methods for display as console # output. class StdoutLogger < LogdevLogger def debug(msg = nil, &block) super("D #{msg}", &block) end def info(msg = nil, &block) super(" #{msg}", &block) end def warn(msg = nil, &block) super("$$$$$$ #{msg}", &block) end def error(msg = nil, &block) super(">>>>>> #{msg}", &block) end def fatal(msg = nil, &block) super("!!!!!! #{msg}", &block) end end end module Logging %w{banner debug info warn error fatal}.map(&:to_sym).each do |meth| define_method(meth) do |*args| logger.public_send(meth, *args) end end end # A Chef run_list and attribute hash that will be used in a convergence # integration. # # @author Fletcher Nichol class Suite # @return [String] logical name of this suite attr_reader :name # @return [Array] Array of Chef run_list items attr_reader :run_list # @return [Hash] Hash of Chef node attributes attr_reader :attributes # @return [String] local path to the suite's data bags, or nil if one does # not exist attr_reader :data_bags_path # @return [String] local path to the suite's roles, or nil if one does # not exist attr_reader :roles_path # Constructs a new suite. # # @param [Hash] options configuration for a new suite # @option options [String] :name logical name of this suit (**Required**) # @option options [String] :run_list Array of Chef run_list items # (**Required**) # @option options [Hash] :attributes Hash of Chef node attributes # @option options [String] :data_bags_path path to data bags # @option options [String] :roles_path path to roles def initialize(options = {}) validate_options(options) @name = options[:name] @run_list = options[:run_list] @attributes = options[:attributes] || Hash.new @data_bags_path = options[:data_bags_path] @roles_path = options[:roles_path] end private def validate_options(opts) [:name, :run_list].each do |k| raise ClientError, "Suite#new requires option :#{k}" if opts[k].nil? end end end # A target operating system environment in which convergence integration # will take place. This may represent a specific operating system, version, # and machine architecture. # # @author Fletcher Nichol class Platform # @return [String] logical name of this platform attr_reader :name # @return [Array] Array of Chef run_list items attr_reader :run_list # @return [Hash] Hash of Chef node attributes attr_reader :attributes # Constructs a new platform. # # @param [Hash] options configuration for a new platform # @option options [String] :name logical name of this platform # (**Required**) # @option options [Array] :run_list Array of Chef run_list # items # @option options [Hash] :attributes Hash of Chef node attributes def initialize(options = {}) validate_options(options) @name = options[:name] @run_list = Array(options[:run_list]) @attributes = options[:attributes] || Hash.new end private def validate_options(opts) [:name].each do |k| raise ClientError, "Platform#new requires option :#{k}" if opts[k].nil? end end end # An instance of a suite running on a platform. A created instance may be a # local virtual machine, cloud instance, container, or even a bare metal # server, which is determined by the platform's driver. # # @author Fletcher Nichol class Instance include Celluloid include Logging class << self attr_accessor :mutexes end # @return [Suite] the test suite configuration attr_reader :suite # @return [Platform] the target platform configuration attr_reader :platform # @return [Driver::Base] driver object which will manage this instance's # lifecycle actions attr_reader :driver # @return [Jr] jr command string generator attr_reader :jr # @return [Logger] the logger for this instance attr_reader :logger # Creates a new instance, given a suite and a platform. # # @param [Hash] options configuration for a new suite # @option options [Suite] :suite the suite # @option options [Platform] :platform the platform # @option options [Driver::Base] :driver the driver # @option options [Jr] :jr the jr command string generator # @option options [Logger] :logger the instance logger def initialize(options = {}) options = { :logger => Jamie.logger }.merge(options) validate_options(options) logger = options[:logger] @suite = options[:suite] @platform = options[:platform] @driver = options[:driver] @jr = options[:jr] @logger = logger.is_a?(Proc) ? logger.call(name) : logger @driver.instance = self setup_driver_mutex end # @return [String] name of this instance def name "#{suite.name}-#{platform.name}".gsub(/_/, '-').gsub(/\./, '') end def to_str "<#{name}>" end # Returns a combined run_list starting with the platform's run_list # followed by the suite's run_list. # # @return [Array] combined run_list from suite and platform def run_list Array(platform.run_list) + Array(suite.run_list) end # Returns a merged hash of Chef node attributes with values from the # suite overriding values from the platform. # # @return [Hash] merged hash of Chef node attributes def attributes platform.attributes.rmerge(suite.attributes) end def dna attributes.rmerge({ :run_list => run_list }) end # Creates this instance. # # @see Driver::Base#create # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def create transition_to(:create) end # Converges this running instance. # # @see Driver::Base#converge # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def converge transition_to(:converge) end # Sets up this converged instance for suite tests. # # @see Driver::Base#setup # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def setup transition_to(:setup) end # Verifies this set up instance by executing suite tests. # # @see Driver::Base#verify # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def verify transition_to(:verify) end # Destroys this instance. # # @see Driver::Base#destroy # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def destroy transition_to(:destroy) end # Tests this instance by creating, converging and verifying. If this # instance is running, it will be pre-emptively destroyed to ensure a # clean slate. The instance will be left post-verify in a running state. # # @param destroy_mode [Symbol] strategy used to cleanup after instance # has finished verifying (default: `:passing`) # @return [self] this instance, used to chain actions # # @todo rescue Driver::ActionFailed and return some kind of null object # to gracfully stop action chaining def test(destroy_mode = :passing) elapsed = Benchmark.measure do banner "Cleaning up any prior instances of #{to_str}" destroy banner "Testing #{to_str}" verify destroy if destroy_mode == :passing end info "Finished testing #{to_str} #{Util.duration(elapsed.real)}." Actor.current ensure destroy if destroy_mode == :always end # Logs in to this instance by invoking a system command, provided by the # instance's driver. This could be an SSH command, telnet, or serial # console session. # # **Note** This method calls exec and will not return. # # @see Driver::Base#login_command def login command, *args = driver.login_command(load_state) debug("Login command: #{command} #{args.join(' ')}") Kernel.exec(command, *args) end def last_action load_state[:last_action] end private def validate_options(opts) [:suite, :platform, :driver, :jr, :logger].each do |k| raise ClientError, "Instance#new requires option :#{k}" if opts[k].nil? end end def setup_driver_mutex if driver.class.serial_actions Jamie.mutex.synchronize do self.class.mutexes ||= Hash.new self.class.mutexes[driver.class] = Mutex.new end end end def transition_to(desired) result = nil FSM.actions(last_action, desired).each do |transition| result = send("#{transition}_action") end result end def create_action perform_action(:create, "Creating") end def converge_action perform_action(:converge, "Converging") end def setup_action perform_action(:setup, "Setting up") end def verify_action perform_action(:verify, "Verifying") end def destroy_action perform_action(:destroy, "Destroying") { destroy_state } end def perform_action(verb, output_verb) banner "#{output_verb} #{to_str}" elapsed = action(verb) { |state| driver.public_send(verb, state) } info("Finished #{output_verb.downcase} #{to_str}" + " #{Util.duration(elapsed.real)}.") yield if block_given? Actor.current end def action(what, &block) state = load_state elapsed = Benchmark.measure do synchronize_or_call(what, state, &block) end state[:last_action] = what elapsed rescue ActionFailed raise rescue Exception => e raise ActionFailed, "Failed to complete ##{what} action: [#{e.message}]" ensure dump_state(state) end def synchronize_or_call(what, state, &block) if Array(driver.class.serial_actions).include?(what) debug("#{to_str} is synchronizing on #{driver.class}##{what}") self.class.mutexes[driver.class].synchronize do debug("#{to_str} is messaging #{driver.class}##{what}") block.call(state) end else block.call(state) end end def load_state if File.exists?(statefile) Util.symbolized_hash(YAML.load_file(statefile)) else Hash.new end end def dump_state(state) dir = File.dirname(statefile) FileUtils.mkdir_p(dir) if !File.directory?(dir) File.open(statefile, "wb") { |f| f.write(YAML.dump(state)) } end def destroy_state FileUtils.rm(statefile) if File.exists?(statefile) end def statefile File.expand_path(File.join( driver[:jamie_root], ".jamie", "#{name}.yml" )) end def banner(*args) Jamie.logger.logdev && Jamie.logger.logdev.banner(*args) super end # The simplest finite state machine pseudo-implementation needed to manage # an Instance. # # @author Fletcher Nichol class FSM # Returns an Array of all transitions to bring an Instance from its last # reported transistioned state into the desired transitioned state. # # @param last [String,Symbol,nil] the last known transitioned state of # the Instance, defaulting to `nil` (for unknown or no history) # @param desired [String,Symbol] the desired transitioned state for the # Instance # @return [Array] an Array of transition actions to perform def self.actions(last = nil, desired) last_index = index(last) desired_index = index(desired) if last_index == desired_index || last_index > desired_index Array(TRANSITIONS[desired_index]) else TRANSITIONS.slice(last_index + 1, desired_index - last_index) end end private TRANSITIONS = [:destroy, :create, :converge, :setup, :verify] def self.index(transition) if transition.nil? 0 else TRANSITIONS.find_index { |t| t == transition.to_sym } end end end end # Command string generator to interface with Jamie Runner (jr). The # commands that are generated are safe to pass to an SSH command or as an # unix command argument (escaped in single quotes). # # @author Fletcher Nichol class Jr # Constructs a new jr command generator, given a suite name. # # @param [String] suite_name name of suite on which to operate # (**Required**) # @param [Hash] opts optional configuration # @option opts [TrueClass, FalseClass] :use_sudo whether or not to invoke # sudo before commands requiring root access (default: `true`) def initialize(suite_name, opts = { :use_sudo => true }) validate_options(suite_name) @suite_name = suite_name @use_sudo = opts[:use_sudo] end # Returns a command string which installs the Jamie Runner (jr), installs # all required jr plugins for the suite. # # If no work needs to be performed, for example if there are no tests for # the given suite, then `nil` will be returned. # # @return [String] a command string to setup the test suite, or nil if no # work needs to be performed def setup_cmd @setup_cmd ||= if local_suite_files.empty? nil else <<-INSTALL_CMD.gsub(/^ {10}/, '') #{sudo}#{ruby_bin} -e "$(cat <<"EOF" #{install_script} EOF )" #{sudo}#{jr_bin} install #{plugins.join(' ')} INSTALL_CMD end end # Returns a command string which transfers all suite test files to the # instance. # # If no work needs to be performed, for example if there are no tests for # the given suite, then `nil` will be returned. # # @return [String] a command string to transfer all suite test files, or # nil if no work needs to be performed. def sync_cmd @sync_cmd ||= if local_suite_files.empty? nil else <<-INSTALL_CMD.gsub(/^ {10}/, '') #{sudo}#{jr_bin} cleanup-suites #{local_suite_files.map { |f| stream_file(f, remote_file(f)) }.join} INSTALL_CMD end end # Returns a command string which runs all jr suite tests for the suite. # # If no work needs to be performed, for example if there are no tests for # the given suite, then `nil` will be returned. # # @return [String] a command string to run the test suites, or nil if no # work needs to be performed def run_cmd @run_cmd ||= local_suite_files.empty? ? nil : "#{sudo}#{jr_bin} test" end private INSTALL_URL = "https://raw.github.com/jamie-ci/jr/go".freeze DEFAULT_RUBY_BINPATH = "/opt/chef/embedded/bin".freeze DEFAULT_JR_ROOT = "/opt/jr".freeze DEFAULT_TEST_ROOT = File.join(Dir.pwd, "test/integration").freeze def validate_options(suite_name) raise ClientError, "Jr#new requires a suite_name" if suite_name.nil? end def install_script @install_script ||= begin uri = URI.parse(INSTALL_URL) http = Net::HTTP.new(uri.host, 443) http.use_ssl = true response = http.request(Net::HTTP::Get.new(uri.path)) response.body end end def plugins Dir.glob(File.join(test_root, @suite_name, "*")).select { |d| File.directory?(d) && File.basename(d) != "data_bags" }.map { |d| File.basename(d) }.sort.uniq end def local_suite_files Dir.glob(File.join(test_root, @suite_name, "*/**/*")).reject do |f| f["data_bags"] || File.directory?(f) end end def remote_file(file) local_prefix = File.join(test_root, @suite_name) "$(#{jr_bin} suitepath)/".concat(file.sub(%r{^#{local_prefix}/}, '')) end def stream_file(local_path, remote_path) local_file = IO.read(local_path) md5 = Digest::MD5.hexdigest(local_file) perms = sprintf("%o", File.stat(local_path).mode)[3, 3] jr_stream_file = "#{jr_bin} stream-file #{remote_path} #{md5} #{perms}" <<-STREAMFILE.gsub(/^ {8}/, '') echo "Uploading #{remote_path} (mode=#{perms})" cat <<"__EOFSTREAM__" | #{sudo}#{jr_stream_file} #{Base64.encode64(local_file)} __EOFSTREAM__ STREAMFILE end def sudo @use_sudo ? "sudo " : "" end def ruby_bin File.join(DEFAULT_RUBY_BINPATH, "ruby") end def jr_bin File.join(DEFAULT_JR_ROOT, "bin/jr") end def test_root DEFAULT_TEST_ROOT end end # Stateless utility methods used in different contexts. Essentially a mini # PassiveSupport library. module Util def self.to_camel_case(str) str.split('_').map { |w| w.capitalize }.join end def self.to_snake_case(str) str.split('::'). last. gsub(/([A-Z+])([A-Z][a-z])/, '\1_\2'). gsub(/([a-z\d])([A-Z])/, '\1_\2'). downcase end def self.to_logger_level(symbol) return nil unless [:debug, :info, :warn, :error, :fatal].include?(symbol) Logger.const_get(symbol.to_s.upcase) end def self.from_logger_level(const) case const when Logger::DEBUG then :debug when Logger::INFO then :info when Logger::WARN then :warn when Logger::ERROR then :error else :fatal end end def self.symbolized_hash(obj) if obj.is_a?(Hash) obj.inject({}) { |h, (k, v)| h[k.to_sym] = symbolized_hash(v) ; h } elsif obj.is_a?(Array) obj.inject([]) { |a, v| a << symbolized_hash(v) ; a } else obj end end def self.duration(total) minutes = (total / 60).to_i seconds = (total - (minutes * 60)) "(%dm%.2fs)" % [minutes, seconds] end end # Mixin that wraps a command shell out invocation, providing a #run_command # method. # # @author Fletcher Nichol module ShellOut # Wrapped exception for any interally raised shell out commands. class ShellCommandFailed < TransientFailure ; end # Executes a command in a subshell on the local running system. # # @param cmd [String] command to be executed locally # @param use_sudo [TrueClass, FalseClass] whether or not to use sudo # @param log_subject [String] used in the output or log header for clarity # and context # @raise [ShellCommandFailed] if the command fails # @raise [Error] for all other unexpected exceptions def run_command(cmd, use_sudo = false, log_subject = "local") cmd = "sudo #{cmd}" if use_sudo subject = "[#{log_subject} command]" info("#{subject} BEGIN (#{display_cmd(cmd)})") sh = Mixlib::ShellOut.new(cmd, :live_stream => logger, :timeout => 60000) sh.run_command info("#{subject} END #{Util.duration(sh.execution_time)}") sh.error! rescue Mixlib::ShellOut::ShellCommandFailed => ex raise ShellCommandFailed, ex.message rescue Exception => error error.extend(Jamie::Error) raise end private def display_cmd(cmd) first_line, newline, rest = cmd.partition("\n") last_char = cmd[cmd.size - 1] newline == "\n" ? "#{first_line}\\n...#{last_char}" : cmd end end module Driver # Returns an instance of a driver given a plugin type string. # # @param plugin [String] a driver plugin type, which will be constantized # @return [Driver::Base] a driver instance # @raise [ClientError] if a driver instance could not be created def self.for_plugin(plugin, config) require "jamie/driver/#{plugin}" str_const = Util.to_camel_case(plugin) klass = self.const_get(str_const) klass.new(config) rescue UserError raise rescue LoadError raise ClientError, "Could not require '#{plugin}' plugin from load path" rescue raise ClientError, "Failed to create a driver for '#{plugin}' plugin" end # Base class for a driver. A driver is responsible for carrying out the # lifecycle activities of an instance, such as creating, converging, and # destroying an instance. # # @author Fletcher Nichol class Base include ShellOut include Logging attr_writer :instance class << self attr_reader :serial_actions end def initialize(config = {}) @config = config self.class.defaults.each do |attr, value| @config[attr] = value unless @config[attr] end Array(self.class.validations).each do |tuple| tuple.last.call(tuple.first, config[tuple.first]) end end # Provides hash-like access to configuration keys. # # @param attr [Object] configuration key # @return [Object] value at configuration key def [](attr) config[attr] end # Creates an instance. # # @param state [Hash] mutable instance and driver state # @raise [ActionFailed] if the action could not be completed def create(state) ; end # Converges a running instance. # # @param state [Hash] mutable instance and driver state # @raise [ActionFailed] if the action could not be completed def converge(state) ; end # Sets up an instance. # # @param state [Hash] mutable instance and driver state # @raise [ActionFailed] if the action could not be completed def setup(state) ; end # Verifies a converged instance. # # @param state [Hash] mutable instance and driver state # @raise [ActionFailed] if the action could not be completed def verify(state) ; end # Destroys an instance. # # @param state [Hash] mutable instance and driver state # @raise [ActionFailed] if the action could not be completed def destroy(state) ; end # Returns the shell command array that will log into an instance. # # @param state [Hash] mutable instance and driver state # @return [Array] an array of command line tokens to be used in a # fork/exec # @raise [ActionFailed] if the action could not be completed def login_command(state) raise ActionFailed, "Remote login is not supported in this driver." end protected attr_reader :config, :instance ACTION_METHODS = %w{create converge setup verify destroy}. map(&:to_sym).freeze def logger instance.logger end def puts(msg) info(msg) end def print(msg) info(msg) end def run_command(cmd, use_sudo = nil, log_subject = nil) use_sudo = config[:use_sudo] if use_sudo.nil? log_subject = Util.to_snake_case(self.class.to_s) super(cmd, use_sudo, log_subject) end def self.defaults @defaults ||= Hash.new end def self.default_config(attr, value) defaults[attr] = value end def self.validations @validations end def self.required_config(attr, &block) @validations = [] if @validations.nil? if ! block_given? klass = self block = lambda do |attr, value| if value.nil? || value.to_s.empty? raise UserError, "#{klass}#config[:#{attr}] cannot be blank" end end end @validations << [attr, block] end def self.no_parallel_for(*methods) Array(methods).each do |meth| if ! ACTION_METHODS.include?(meth) raise ClientError, "##{meth} is not a valid no_parallel_for method" end end @serial_actions ||= [] @serial_actions += methods end end # Base class for a driver that uses SSH to communication with an instance. # A subclass must implement the following methods: # * #create(state) # * #destroy(state) # # @author Fletcher Nichol class SSHBase < Base def create(state) raise ClientError, "#{self.class}#create must be implemented" end def converge(state) ssh_args = build_ssh_args(state) install_omnibus(ssh_args) if config[:require_chef_omnibus] prepare_chef_home(ssh_args) upload_chef_data(ssh_args) run_chef_solo(ssh_args) end def setup(state) ssh_args = build_ssh_args(state) if instance.jr.setup_cmd ssh(ssh_args, instance.jr.setup_cmd) end end def verify(state) ssh_args = build_ssh_args(state) if instance.jr.run_cmd ssh(ssh_args, instance.jr.sync_cmd) ssh(ssh_args, instance.jr.run_cmd) end end def destroy(state) raise ClientError, "#{self.class}#destroy must be implemented" end def login_command(state) args = %W{ -o UserKnownHostsFile=/dev/null } args += %W{ -o StrictHostKeyChecking=no } args += %W{ -i #{config[:ssh_key]}} if config[:ssh_key] args += %W{ #{config[:username]}@#{state[:hostname]}} ["ssh", *args] end protected def build_ssh_args(state) opts = Hash.new opts[:user_known_hosts_file] = "/dev/null" opts[:paranoid] = false opts[:password] = config[:password] if config[:password] opts[:keys] = Array(config[:ssh_key]) if config[:ssh_key] [state[:hostname], config[:username], opts] end def chef_home "/tmp/jamie-chef-solo".freeze end def install_omnibus(ssh_args) flag = config[:require_chef_omnibus] version = flag.is_a?(String) ? "-s -- -v #{flag}" : "" ssh(ssh_args, <<-INSTALL.gsub(/^ {10}/, '')) if [ ! -d "/opt/chef" ] ; then curl -sSL https://www.opscode.com/chef/install.sh \ | sudo bash #{version} fi INSTALL end def prepare_chef_home(ssh_args) ssh(ssh_args, "sudo rm -rf #{chef_home} && mkdir -p #{chef_home}/cache") end def upload_chef_data(ssh_args) Jamie::ChefDataUploader.new( instance, ssh_args, config[:jamie_root], chef_home ).upload end def run_chef_solo(ssh_args) ssh(ssh_args, <<-RUN_SOLO) sudo chef-solo -c #{chef_home}/solo.rb -j #{chef_home}/dna.json \ --log_level #{Util.from_logger_level(logger.level)} RUN_SOLO end def ssh(ssh_args, cmd) debug("[SSH] #{ssh_args[1]}@#{ssh_args[0]} (#{cmd})") Net::SSH.start(*ssh_args) do |ssh| exit_code = ssh_exec_with_exit!(ssh, cmd) if exit_code != 0 shorter_cmd = cmd.squeeze(" ").strip raise ActionFailed, "SSH exited (#{exit_code}) for command: [#{shorter_cmd}]" end end rescue Net::SSH::Exception => ex raise ActionFailed, ex.message end def ssh_exec_with_exit!(ssh, cmd) exit_code = nil ssh.open_channel do |channel| channel.request_pty channel.exec(cmd) do |ch, success| channel.on_data do |ch, data| logger << data end channel.on_extended_data do |ch, type, data| logger << data end channel.on_request("exit-status") do |ch, data| exit_code = data.read_long end end end ssh.loop exit_code end def wait_for_sshd(hostname) logger << "." until test_ssh(hostname) end def test_ssh(hostname) socket = TCPSocket.new(hostname, config[:port]) IO.select([socket], nil, nil, 5) rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, IOError sleep 2 false rescue Errno::EPERM, Errno::ETIMEDOUT false ensure socket && socket.close end end end # Uploads Chef asset files such as dna.json, data bags, and cookbooks to an # instance over SSH. # # @author Fletcher Nichol class ChefDataUploader include ShellOut include Logging def initialize(instance, ssh_args, jamie_root, chef_home) @instance = instance @ssh_args = ssh_args @jamie_root = jamie_root @chef_home = chef_home end def upload Net::SCP.start(*ssh_args) do |scp| upload_json scp upload_solo_rb scp upload_cookbooks scp upload_data_bags scp if instance.suite.data_bags_path upload_roles scp if instance.suite.roles_path end end private attr_reader :instance, :ssh_args, :jamie_root, :chef_home def logger instance.logger end def upload_json(scp) json_file = StringIO.new(instance.dna.to_json) scp.upload!(json_file, "#{chef_home}/dna.json") end def upload_solo_rb(scp) solo_rb_file = StringIO.new(solo_rb_contents) scp.upload!(solo_rb_file, "#{chef_home}/solo.rb") end def upload_cookbooks(scp) cookbooks_dir = local_cookbooks upload_path(scp, cookbooks_dir, "cookbooks") ensure FileUtils.rmtree(cookbooks_dir) end def upload_data_bags(scp) upload_path(scp, instance.suite.data_bags_path) end def upload_roles(scp) upload_path(scp, instance.suite.roles_path) end def upload_path(scp, path, dir = File.basename(path)) dest = "#{chef_home}/#{dir}" scp.upload!(path, dest, :recursive => true) do |ch, name, sent, total| if sent == total info("Uploaded #{name.sub(%r{^#{path}/}, '')} (#{total} bytes)") end end end def solo_rb_contents solo = [] solo << %{node_name "#{instance.name}"} solo << %{file_cache_path "#{chef_home}/cache"} solo << %{cookbook_path "#{chef_home}/cookbooks"} solo << %{role_path "#{chef_home}/roles"} if instance.suite.data_bags_path solo << %{data_bag_path "#{chef_home}/data_bags"} end solo.join("\n") end def local_cookbooks tmpdir = Dir.mktmpdir("#{instance.name}-cookbooks") prepare_tmpdir(tmpdir) tmpdir end def prepare_tmpdir(tmpdir) if File.exists?(File.join(jamie_root, "Berksfile")) run_resolver("Berkshelf", "berks", tmpdir) elsif File.exists?(File.join(jamie_root, "Cheffile")) run_resolver("Librarian", "librarian-chef", tmpdir) elsif File.directory?(File.join(jamie_root, "cookbooks")) cp_cookbooks(tmpdir) else FileUtils.rmtree(tmpdir) fatal("Berksfile, Cheffile or cookbooks/ must exist in #{jamie_root}") raise UserError, "Cookbooks could not be found" end end def run_resolver(name, bin, tmpdir) begin run_command "if ! command -v #{bin} >/dev/null; then exit 1; fi" rescue Jamie::ShellOut::ShellCommandFailed fatal("#{name} must be installed, add it to your Gemfile.") raise UserError, "#{bin} command not found" end Jamie.mutex.synchronize { run_command "#{bin} install --path #{tmpdir}" } end def cp_cookbooks(tmpdir) FileUtils.cp_r(File.join(jamie_root, "cookbooks", "."), tmpdir) cp_this_cookbook(tmpdir) if File.exists?(File.expand_path('metadata.rb')) end def cp_this_cookbook(tmpdir) metadata_rb = File.join(jamie_root, "metadata.rb") cb_name = MetadataChopper.extract(metadata_rb).first cb_path = File.join(tmpdir, cb_name) glob = Dir.glob("#{jamie_root}/{metadata.rb,README.*," + "attributes,files,libraries,providers,recipes,resources,templates}") FileUtils.mkdir_p(cb_path) FileUtils.cp_r(glob, cb_path) end end # A rather insane and questionable class to quickly consume a metadata.rb # file and return the cookbook name and version attributes. # # @see https://twitter.com/fnichol/status/281650077901144064 # @see https://gist.github.com/4343327 class MetadataChopper < Hash # Return an Array containing the cookbook name and version attributes, # or nil values if they could not be parsed. # # @param metadata_file [String] path to a metadata.rb file # @return [Array] array containing the cookbook name and version # attributes or nil values if they could not be determined def self.extract(metadata_file) mc = new(File.expand_path(metadata_file)) [mc[:name], mc[:version]] end # Creates a new instances and loads in the contents of the metdata.rb # file. If you value your life, you may want to avoid reading the # implementation. # # @param metadata_file [String] path to a metadata.rb file def initialize(metadata_file) eval(IO.read(metadata_file), nil, metadata_file) end def method_missing(meth, *args, &block) self[meth] = args.first end end end # Initialize the base logger and use that for Celluloid's logger Jamie.logger = Jamie.default_logger Celluloid.logger = Jamie.logger # Setup a collection of instance crash exceptions for error reporting Jamie.crashes = [] Celluloid.exception_handler do |exception| Jamie.logger.debug("An instance crashed because of #{exception.inspect}") Jamie.mutex.synchronize { Jamie.crashes << exception } end Jamie.mutex = Mutex.new