lib/jamie.rb in jamie-0.1.0.alpha1 vs lib/jamie.rb in jamie-0.1.0.alpha2

- old
+ new

@@ -1,112 +1,388 @@ # -*- encoding: utf-8 -*- -require 'hashie/dash' -require 'mixlib/shellout' require 'yaml' -require "jamie/version" +require 'jamie/core_ext' +require 'jamie/version' module Jamie - class Platform < Hashie::Dash - property :name, :required => true - property :vagrant_box - property :vagrant_box_url - property :base_run_list, :default => [] - end - class Suite < Hashie::Dash - property :name, :required => true - property :run_list, :required => true - property :json, :default => Hash.new - end - + # Base configuration class for Jamie. This class exposes configuration such + # as the location of the Jamie YAML file, instances, log_levels, etc. class Config - attr_writer :yaml + + attr_writer :yaml_file attr_writer :platforms attr_writer :suites - attr_writer :backend attr_writer :log_level attr_writer :data_bags_base_path - def yaml - @yaml ||= File.join(Dir.pwd, '.jamie.yml') + # Default path to the Jamie YAML file + DEFAULT_YAML_FILE = File.join(Dir.pwd, '.jamie.yml').freeze + + # Default log level verbosity + DEFAULT_LOG_LEVEL = :info + + # Default driver plugin to use + DEFAULT_DRIVER_PLUGIN = "vagrant".freeze + + # Default base path which may contain `data_bags/` directories + DEFAULT_DATA_BAGS_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<Platform>] all defined platforms which will be used in + # convergence integration def platforms - @platforms ||= - Array(yaml_data["platforms"]).map { |hash| Platform.new(hash) } + @platforms ||= Array(yaml["platforms"]).map { |hash| new_platform(hash) } end + # @return [Array<Suite>] all defined suites which will be used in + # convergence integration def suites - @suites ||= - Array(yaml_data["suites"]).map { |hash| Suite.new(hash) } + @suites ||= Array(yaml["suites"]).map { |hash| Suite.new(hash) } end - def backend - @backend ||= backend_for(yaml_data["backend"] || "vagrant") + # @return [Array<Instance>] all instances, resulting from all platform and + # suite combinations + def instances + @instances ||= suites.map { |suite| + platforms.map { |platform| Instance.new(suite, platform) } + }.flatten 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 ||= :info + @log_level ||= DEFAULT_LOG_LEVEL end + # @return [String] base path that may contain a common `data_bags/` + # directory or an instance's `data_bags/` directory def data_bags_base_path - default_path = File.join(Dir.pwd, 'test/integration') + @data_bags_path ||= DEFAULT_DATA_BAGS_BASE_PATH + end - @data_bags_path ||= File.directory?(default_path) ? default_path : nil + private + + def new_platform(hash) + mpc = merge_platform_config(hash) + mpc['driver'] = new_driver(mpc['driver_plugin'], mpc['driver_config']) + Platform.new(mpc) end - def instances - result = [] - suites.each do |suite| - platforms.each do |platform| - result << instance_name(suite, platform) - end + def new_driver(plugin, config) + Driver.for_plugin(plugin, config) + end + + def yaml + @yaml ||= YAML.load_file(File.expand_path(yaml_file)) + end + + def merge_platform_config(platform_config) + default_driver_config.rmerge(common_driver_config.rmerge(platform_config)) + end + + def default_driver_config + { 'driver_plugin' => DEFAULT_DRIVER_PLUGIN } + end + + def common_driver_config + yaml.select { |key, value| %w(driver_plugin driver_config).include?(key) } + end + end + + # A Chef run_list and attribute hash that will be used in a convergence + # integration. + 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 :json + + # 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 + def initialize(options = {}) + validate_options(options) + + @name = options['name'] + @run_list = options['run_list'] + @json = options['json'] || Hash.new + end + + private + + def validate_options(options) + if options['name'].nil? + raise ArgumentError, "The option 'name' is required." end - result + if options['run_list'].nil? + raise ArgumentError, "The option 'run_list' is required." + 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. + class Platform + + # @return [String] logical name of this platform + attr_reader :name + + # @return [Driver::Base] driver object which will manage this platform's + # lifecycle actions + attr_reader :driver + + # @return [Array] Array of Chef run_list items + attr_reader :run_list + + # @return [Hash] Hash of Chef node attributes + attr_reader :json + + # 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 + def initialize(options = {}) + validate_options(options) + + @name = options['name'] + @driver = options['driver'] + @run_list = Array(options['run_list']) + @json = options['json'] || Hash.new + end + private - def yaml_data - @yaml_data ||= YAML.load_file(yaml) + def validate_options(options) + if options['name'].nil? + raise ArgumentError, "The option 'name' is required." + end + if options['driver'].nil? + raise ArgumentError, "The option 'driver' is required." + end end + end - def instance_name(suite, platform) + # 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. + class Instance + + # @return [Suite] the test suite configuration + attr_reader :suite + + # @return [Platform] the target platform configuration + attr_reader :platform + + # Creates a new instance, given a suite and a platform. + # + # @param suite [Suite] a suite + # @param platform [Platform] a platform + def initialize(suite, platform) + @suite = suite + @platform = platform + end + + # @return [String] name of this instance + def name "#{suite.name}-#{platform.name}".gsub(/_/, '-').gsub(/\./, '') end - def backend_for(backend) - klass = Jamie::Backend.const_get(backend.capitalize) - klass.new + # 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 json + platform.json.rmerge(suite.json) + 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 + puts "-----> Creating instance #{name}" + platform.driver.create(self) + puts " Creation of instance #{name} complete." + self + 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 + puts "-----> Converging instance #{name}" + platform.driver.converge(self) + puts " Convergence of instance #{name} complete." + self + end + + # Verifies this converged instance by executing 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 + puts "-----> Verifying instance #{name}" + platform.driver.verify(self) + puts " Verification of instance #{name} complete." + self + 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 + puts "-----> Destroying instance #{name}" + platform.driver.destroy(self) + puts " Destruction of instance #{name} complete." + self + 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. + # + # @see #destroy + # @see #create + # @see #converge + # @see #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 test + puts "-----> Cleaning up any prior instances of #{name}" + destroy + puts "-----> Testing instance #{name}" + create + converge + verify + puts " Testing of instance #{name} complete." + self + end end - module Backend - class CommandFailed < StandardError ; end + module Driver - class Vagrant - def up(instance) - exec! "vagrant up #{instance}" - rescue Mixlib::ShellOut::ShellCommandFailed => ex - raise CommandFailed, ex.message + # Wrapped exception for any internally raised driver exceptions. + class ActionFailed < StandardError ; end + + # 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 + def self.for_plugin(plugin, config) + require "jamie/driver/#{plugin}" + + klass = self.const_get(plugin.capitalize) + klass.new(config) + 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. + class Base + + def initialize(config) + @config = config + self.class.defaults.each do |attr, value| + @config[attr] = value unless @config[attr] + end end - def destroy(instance) - exec! "vagrant destroy #{instance} -f" - rescue Mixlib::ShellOut::ShellCommandFailed => ex - raise CommandFailed, ex.message + # Provides hash-like access to configuration keys. + # + # @param attr [Object] configuration key + # @return [Object] value at configuration key + def [](attr) + @config[attr] end - def exec!(cmd) - puts "-----> [vagrant command] #{cmd}" - shellout = Mixlib::ShellOut.new( - cmd, :live_stream => STDOUT, :timeout => 60000 - ) - shellout.run_command - puts "-----> Command '#{cmd}' ran in #{shellout.execution_time} seconds." - shellout.error! + # Creates an instance. + # + # @param instance [Instance] an instance + # @raise [ActionFailed] if the action could not be completed + def 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 + + # Destroys an instance. + # + # @param instance [Instance] an instance + # @raise [ActionFailed] if the action could not be completed + def destroy(instance) ; end + + # Verifies a converged instance. + # + # @param instance [Instance] an instance + # @raise [ActionFailed] if the action could not be completed + def verify(instance) + # Subclass may choose to implement + puts " Nothing to do!" + end + + private + + def self.defaults + @defaults ||= Hash.new + end + + def self.default_config(attr, value) + defaults[attr] = value end end end end