lib/jamie.rb in jamie-0.1.0.alpha8 vs lib/jamie.rb in jamie-0.1.0.alpha9

- old
+ new

@@ -1,11 +1,18 @@ # -*- encoding: utf-8 -*- require 'base64' require 'delegate' require 'digest' +require 'fileutils' +require 'json' +require 'mixlib/shellout' require 'net/https' +require 'net/scp' +require 'net/ssh' +require 'socket' +require 'stringio' require 'yaml' require 'vendor/hash_recursive_merge' require 'jamie/version' @@ -49,11 +56,11 @@ # @return [Array<Suite>] all defined suites which will be used in # convergence integration def suites @suites ||= Collection.new( - Array(yaml["suites"]).map { |hash| Suite.new(hash) }) + Array(yaml["suites"]).map { |hash| new_suite(hash) }) end # @return [Array<Instance>] all instances, resulting from all platform and # suite combinations def instances @@ -111,12 +118,18 @@ end end private + def new_suite(hash) + data_bags_path = calculate_data_bags_path(hash['name']) + Suite.new(hash.rmerge({ 'data_bags_path' => data_bags_path })) + end + def new_platform(hash) mpc = merge_platform_config(hash) + mpc['driver_config']['jamie_root'] = File.dirname(yaml_file) mpc['driver'] = new_driver(mpc['driver_plugin'], mpc['driver_config']) Platform.new(mpc) end def new_driver(plugin, config) @@ -144,10 +157,23 @@ def merge_platform_config(platform_config) default_driver_config.rmerge(common_driver_config.rmerge(platform_config)) end + def calculate_data_bags_path(suite_name) + suite_data_bags_path = File.join(test_base_path, suite_name, "data_bags") + common_data_bags_path = File.join(test_base_path, "data_bags") + + if File.directory?(suite_data_bags_path) + suite_data_bags_path + elsif File.directory?(common_data_bags_path) + common_data_bags_path + else + nil + end + end + def default_driver_config { 'driver_plugin' => DEFAULT_DRIVER_PLUGIN } end def common_driver_config @@ -164,25 +190,31 @@ # @return [Array] Array of Chef run_list items attr_reader :run_list # @return [Hash] Hash of Chef node attributes - attr_reader :json + 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 + # 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] :json Hash of Chef node attributes + # @option options [Hash] :attributes Hash of Chef node attributes + # @option options [String] :data_bags_path path to data bags def initialize(options = {}) validate_options(options) @name = options['name'] @run_list = options['run_list'] - @json = options['json'] || Hash.new + @attributes = options['attributes'] || Hash.new + @data_bags_path = options['data_bags_path'] end private def validate_options(opts) @@ -206,29 +238,29 @@ # @return [Array] Array of Chef run_list items attr_reader :run_list # @return [Hash] Hash of Chef node attributes - attr_reader :json + 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 [Driver::Base] :driver subclass of Driver::Base which # will manage this platform's lifecycle actions (**Required**) # @option options [Array<String>] :run_list Array of Chef run_list # items - # @option options [Hash] :json Hash of Chef node attributes + # @option options [Hash] :attributes Hash of Chef node attributes def initialize(options = {}) validate_options(options) @name = options['name'] @driver = options['driver'] @run_list = Array(options['run_list']) - @json = options['json'] || Hash.new + @attributes = options['attributes'] || Hash.new end private def validate_options(opts) @@ -277,14 +309,18 @@ # 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 json - platform.json.rmerge(suite.json) + 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 # @@ -550,50 +586,339 @@ # Provides hash-like access to configuration keys. # # @param attr [Object] configuration key # @return [Object] value at configuration key def [](attr) - @config[attr] + config[attr] end # Creates an instance. # # @param instance [Instance] an instance # @raise [ActionFailed] if the action could not be completed - def create(instance) ; end + def create(instance) + action(:create, instance) + end # Converges a running instance. # # @param instance [Instance] an instance # @raise [ActionFailed] if the action could not be completed - def converge(instance) ; end + def converge(instance) + action(:converge, instance) + end # Sets up an instance. # # @param instance [Instance] an instance # @raise [ActionFailed] if the action could not be completed - def setup(instance) ; end + def setup(instance) + action(:setup, instance) + end # Verifies a converged instance. # # @param instance [Instance] an instance # @raise [ActionFailed] if the action could not be completed - def verify(instance) ; end + def verify(instance) + action(:verify, instance) + end # Destroys an instance. # # @param instance [Instance] an instance # @raise [ActionFailed] if the action could not be completed - def destroy(instance) ; end + def destroy(instance) + action(:destroy, instance) + destroy_state(instance) + end - private + protected + attr_reader :config + + def action(what, instance) + state = load_state(instance) + public_send("perform_#{what}", instance, state) + state['last_action'] = what.to_s + ensure + dump_state(instance, state) + end + + def load_state(instance) + statefile = state_filepath(instance) + + if File.exists?(statefile) + YAML.load_file(statefile) + else + { 'name' => instance.name } + end + end + + def dump_state(instance, state) + statefile = state_filepath(instance) + 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(instance) + statefile = state_filepath(instance) + FileUtils.rm(statefile) if File.exists?(statefile) + end + + def state_filepath(instance) + File.expand_path(File.join( + config['jamie_root'], ".jamie", "#{instance.name}.yml" + )) + end + def self.defaults @defaults ||= Hash.new end def self.default_config(attr, value) defaults[attr] = value end + end + + # Base class for a driver that uses SSH to communication with an instance. + # A subclass must implement the following methods: + # * #perform_create(instance, state) + # * #perform_destroy(instance, state) + class SSHBase < Base + + def perform_converge(instance, state) + ssh_args = generate_ssh_args(state) + + install_omnibus(ssh_args) if config['require_chef_omnibus'] + prepare_chef_home(ssh_args) + upload_chef_data(ssh_args, instance) + run_chef_solo(ssh_args) + end + + def perform_setup(instance, state) + ssh_args = generate_ssh_args(state) + + if instance.jr.setup_cmd + ssh(ssh_args, instance.jr.setup_cmd) + else + super + end + end + + def perform_verify(instance, state) + ssh_args = generate_ssh_args(state) + + if instance.jr.run_cmd + ssh(ssh_args, instance.jr.sync_cmd) + ssh(ssh_args, instance.jr.run_cmd) + else + super + end + end + + protected + + def generate_ssh_args(state) + [ state['hostname'], + config['username'], + { :password => config['password'] } + ] + end + + def chef_home + "/tmp/jamie-chef-solo".freeze + end + + def install_omnibus(ssh_args) + ssh(ssh_args, <<-INSTALL) + if [ ! -d "/opt/chef" ] ; then + curl -L https://www.opscode.com/chef/install.sh | sudo bash + fi + INSTALL + end + + def prepare_chef_home(ssh_args) + ssh(ssh_args, "sudo rm -rf #{chef_home} && mkdir -p #{chef_home}") + end + + def upload_chef_data(ssh_args, instance) + 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 + RUN_SOLO + end + + def ssh(ssh_args, 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.exec(cmd) do |ch, success| + + channel.on_data do |ch, data| + $stdout.print data + end + + channel.on_extended_data do |ch, type, data| + $stderr.print 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) + print "." 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. + class ChefDataUploader + + 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 + end + end + + private + + attr_reader :instance, :ssh_args, :jamie_root, :chef_home + + 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 + scp.upload!(cookbooks_dir, "#{chef_home}/cookbooks", + :recursive => true + ) do |ch, name, sent, total| + file = name.sub(%r{^#{cookbooks_dir}/}, '') + puts " #{file}: #{sent}/#{total}" + end + ensure + FileUtils.rmtree(cookbooks_dir) + end + + def upload_data_bags(scp) + data_bags_dir = instance.suite.data_bags_path + scp.upload!(data_bags_dir, "#{chef_home}/data_bags", + :recursive => true + ) do |ch, name, sent, total| + file = name.sub(%r{^#{data_bags_dir}/}, '') + puts " #{file}: #{sent}/#{total}" + 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 << %{log_level :info} + solo.join("\n") + end + + def local_cookbooks + if File.exists?(File.join(jamie_root, "Berksfile")) + tmpdir = Dir.mktmpdir(instance.name) + run_berks(tmpdir) + tmpdir + elsif File.exists?(File.join(jamie_root, "Cheffile")) + tmpdir = Dir.mktmpdir(instance.name) + run_librarian(tmpdir) + tmpdir + else + abort "Berksfile or Cheffile must exist in #{jamie_root}" + end + end + + def run_berks(tmpdir) + begin + run "if ! command -v berks >/dev/null ; then exit 1 ; fi" + rescue Mixlib::ShellOut::ShellCommandFailed + abort ">>>>>> Berkshelf must be installed, add it to your Gemfile." + end + run "berks install --path #{tmpdir}" + end + + def run_librarian(tmpdir) + begin + run "if ! command -v librarian-chef >/dev/null ; then exit 1 ; fi" + rescue Mixlib::ShellOut::ShellCommandFailed + abort ">>>>>> Librarian must be installed, add it to your Gemfile." + end + run "librarian-chef install --path #{tmpdir}" + end + + def run(cmd) + puts " [local command] '#{cmd}'" + sh = Mixlib::ShellOut.new(cmd, :live_stream => STDOUT) + sh.run_command + puts " [local command] ran in #{sh.execution_time} seconds." + sh.error! + rescue Mixlib::ShellOut::ShellCommandFailed => ex + raise ActionFailed, ex.message end end end