module AutomateIt # == Interpreter # # The Interpreter runs AutomateIt commands. # # The TUTORIAL.txt[link:files/TUTORIAL_txt.html] file provides hands-on examples # for using the Interpreter. # # === Aliased methods # # The Interpreter provides shortcut aliases for certain plugin commands. # # For example, the following commands will run the same method: # # shell_manager.sh "ls" # # sh "ls" # # The full set of aliased methods: # # * cd -- AutomateIt::ShellManager#cd # * chmod -- AutomateIt::ShellManager#chmod # * chmod_R -- AutomateIt::ShellManager#chmod_R # * chown -- AutomateIt::ShellManager#chown # * chown_R -- AutomateIt::ShellManager#chown_R # * chperm -- AutomateIt::ShellManager#chperm # * cp -- AutomateIt::ShellManager#cp # * cp_r -- AutomateIt::ShellManager#cp_r # * edit -- AutomateIt::EditManager#edit # * download -- AutomateIt::DownloadManager#download # * hosts_tagged_with -- AutomateIt::TagManager#hosts_tagged_with # * install -- AutomateIt::ShellManager#install # * ln -- AutomateIt::ShellManager#ln # * ln_s -- AutomateIt::ShellManager#ln_s # * ln_sf -- AutomateIt::ShellManager#ln_sf # * lookup -- AutomateIt::FieldManager#lookup # * mkdir -- AutomateIt::ShellManager#mkdir # * mkdir_p -- AutomateIt::ShellManager#mkdir_p # * mktemp -- AutomateIt::ShellManager#mktemp # * mktempdir -- AutomateIt::ShellManager#mktempdir # * mktempdircd -- AutomateIt::ShellManager#mktempdircd # * mv -- AutomateIt::ShellManager#mv # * pwd -- AutomateIt::ShellManager#pwd # * render -- AutomateIt::TemplateManager#render # * rm -- AutomateIt::ShellManager#rm # * rm_r -- AutomateIt::ShellManager#rm_r # * rm_rf -- AutomateIt::ShellManager#rm_rf # * rmdir -- AutomateIt::ShellManager#rmdir # * sh -- AutomateIt::ShellManager#sh # * tagged? -- AutomateIt::TagManager#tagged? # * tags -- AutomateIt::TagManager#tags # * tags_for -- AutomateIt::TagManager#tags_for # * touch -- AutomateIt::ShellManager#touch # * umask -- AutomateIt::ShellManager#umask # * which -- AutomateIt::ShellManager#which # * which! -- AutomateIt::ShellManager#which! # # [[[ ]]] # === Embedding the Interpreter # [[[ ]]] # # The AutomateIt Interpreter can be embedded inside a Ruby program: # # require 'rubygems' # require 'automateit' # # interpreter = AutomateIt.new # # # Use the interpreter as an object: # interpreter.sh "ls -la" # # # Have it execute a recipe: # interpreter.invoke "myrecipe.rb" # # # Or execute recipes within a block # interpreter.instance_eval do # puts superuser? # sh "ls -la" # end # # See the #include_in and #add_method_missing_to methods for instructions on # how to more easily dispatch commands from your program to the Interpreter # instance. class Interpreter < Common # Plugin instance that instantiated the Interpreter. attr_accessor :parent private :parent private :parent= # Access IRB instance from an interactive shell. attr_accessor :irb # Project path for this Interpreter. If no path is available, nil. attr_accessor :project # Hash of parameters to make available to the Interpreter. Mostly useful # when needing to pass arguments to an embedded Interpreter before doing an # #instance_eval. attr_accessor :params # The Interpreter throws friendly error messages by default that make it # easier to see what's wrong with a recipe. These friendly messages display # the cause, a snapshot of the problematic code, shortened paths, and only # the relevant stack frames. # # However, if there's a bug in the AutomateIt internals, these friendly # messages may inadvertently hide the cause, and it may be necessary to # turn them off to figure out what's wrong. # # To turn off friendly exceptions: # # # From a recipe or the AutomateIt interactive shell: # self.friendly_exceptions = false # # # For an embedded interpreter at instantiation: # AutomateIt.new(:friendly_exceptions => false) # # # From the UNIX command line when invoking a recipe: # automateit --trace myrecipe.rb attr_accessor :friendly_exceptions # Setup the Interpreter. This method is also called from Interpreter#new. # # Options for users: # * :verbosity -- Alias for :log_level # * :log_level -- Log level to use, defaults to Logger::INFO. # * :preview -- Turn on preview mode, defaults to false. # * :project -- Project directory to use. # * :tags -- Array of tags to add to this run. # # Options for internal use: # * :parent -- Parent plugin instance. # * :log -- QueuedLogger instance. # * :guessed_project -- Boolean of whether the project path was guessed. If # guessed, won't throw exceptions if project wasn't found at the # specified path. If not guessed, will throw exception in such a # situation. # * :friendly_exceptions -- Throw user-friendly exceptions that make it # easier to see errors in recipes, defaults to true. def setup(opts={}) super(opts.merge(:interpreter => self)) self.params ||= {} if opts[:irb] @irb = opts[:irb] end if opts[:parent] @parent = opts[:parent] end if opts[:log] @log = opts[:log] elsif not defined?(@log) or @log.nil? @log = QueuedLogger.new($stdout) @log.level = Logger::INFO end if opts[:log_level] or opts[:verbosity] @log.level = opts[:log_level] || opts[:verbosity] end if opts[:preview].nil? # can be false self.preview = false unless preview? else self.preview = opts[:preview] end if opts[:friendly_exceptions].nil? @friendly_exceptions = true unless defined?(@friendly_exceptions) else @friendly_exceptions = opts[:friendly_exceptions] end # Instantiate core plugins so they're available to the project _instantiate_plugins tags.merge(opts[:tags]) if opts[:tags] if project_path = opts[:project] || ENV["AUTOMATEIT_PROJECT"] || ENV["AIP"] # Only load a project if we find its env file env_file = File.join(project_path, "config", "automateit_env.rb") if File.exists?(env_file) @project = File.expand_path(project_path) log.debug(PNOTE+"Loading project from path: #{@project}") lib_files = Dir[File.join(@project, "lib", "*.rb")] + Dir[File.join(@project, "lib", "**", "init.rb")] lib_files.each do |lib| log.debug(PNOTE+"Loading project library: #{lib}") invoke(lib) end tag_file = File.join(@project, "config", "tags.yml") if File.exists?(tag_file) log.debug(PNOTE+"Loading project tags: #{tag_file}") tag_manager[:yaml].setup(:file => tag_file) end field_file = File.join(@project, "config", "fields.yml") if File.exists?(field_file) log.debug(PNOTE+"Loading project fields: #{field_file}") field_manager[:yaml].setup(:file => field_file) end # Instantiate project's plugins so they're available to the environment _instantiate_plugins if File.exists?(env_file) log.debug(PNOTE+"Loading project env: #{env_file}") invoke(env_file) end elsif not opts[:guessed_project] raise ArgumentError.new("Couldn't find project at: #{project_path}") end end end # Hash of plugin tokens to plugin instances for this Interpreter. attr_accessor :plugins def _instantiate_plugins @plugins ||= {} # If a parent is defined, use it to prep the list and avoid re-instantiating it. if defined?(@parent) and @parent and Plugin::Manager === @parent @plugins[@parent.class.token] = @parent end plugin_classes = AutomateIt::Plugin::Manager.classes.reject{|t| t == @parent if @parent} for klass in plugin_classes _instantiate_plugin(klass) end end private :_instantiate_plugins def _instantiate_plugin(klass) token = klass.token unless plugin = @plugins[token] plugin = @plugins[token] = klass.new(:interpreter => self) #puts "!!! ip #{token}" unless respond_to?(token.to_sym) self.class.send(:define_method, token) do @plugins[token] end end _expose_plugin_methods(plugin) end plugin.instantiate_drivers end private :_instantiate_plugin def _expose_plugin_methods(plugin) return unless plugin.class.aliased_methods plugin.class.aliased_methods.each do |method| #puts "!!! epm #{method}" unless respond_to?(method.to_sym) # Must use instance_eval because methods created with define_method # can't accept block as argument. This is a known Ruby 1.8 bug. self.instance_eval <<-EOB def #{method}(*args, &block) @plugins[:#{plugin.class.token}].send(:#{method}, *args, &block) end EOB end end end private :_expose_plugin_methods # Set the QueuedLogger instance for the Interpreter. attr_writer :log # Get or set the QueuedLogger instance for the Interpreter, a special # wrapper around the Ruby Logger. def log(value=nil) if value.nil? return defined?(@log) ? @log : nil else @log = value end end # Set preview mode to +value+. See warnings in ShellManager to learn how to # correctly write code for preview mode. def preview(value) self.preview = value end # Is Interpreter running in preview mode? def preview? @preview end # Preview a block of custom commands. When in preview mode, displays the # +message+ but doesn't execute the +block+. When not previewing, will # execute the block and not display the +message+. # # For example: # # preview_for("FOO") do # puts "BAR" # end # # In preview mode, this displays: # # => FOO # # When not previewing, displays: # # BAR def preview_for(message, &block) if preview? log.info(message) :preview else block.call end end # Set preview mode to +value. def preview=(value) @preview = value end # Set noop (no-operation mode) to +value+. Alias for #preview. def noop(value) self.noop = value end # Set noop (no-operation mode) to +value+. Alias for #preview=. def noop=(value) self.preview = value end # Are we in noop (no-operation) mode? Alias for #preview?. def noop? preview? end # Set writing to +value+. This is the opposite of #preview. def writing(value) self.writing = value end # Set writing to +value+. This is the opposite of #preview=. def writing=(value) self.preview = !value end # Is Interpreter writing? This is the opposite of #preview?. def writing? !preview? end # Does this platform provide euid (Effective User ID)? def euid? begin euid return true rescue return false end end # Return the effective user id. def euid begin return Process.euid rescue NoMethodError => e output = `id -u 2>&1` raise e unless output and $?.exitstatus.zero? begin return output.match(/(\d+)/)[1].to_i rescue IndexError raise e end end end # Does the current user have superuser (root) privileges? def superuser? euid.zero? end # Create an Interpreter with the specified +opts+ and invoke # the +recipe+. The opts are passed to #setup for parsing. def self.invoke(recipe, opts={}) opts[:project] ||= File.join(File.dirname(recipe), "..") AutomateIt.new(opts).invoke(recipe) end # Invoke the +recipe+. The recipe may be expressed as a relative or fully # qualified path. When invoked within a project, the recipe can also be the # name of a recipe. # # Example: # invoke "/tmp/recipe.rb" # Run "/tmp/recipe.rb" # invoke "recipe.rb" # Run "./recipe.rb". If not found and in a # # project, will try running "recipes/recipe.rb" # invoke "recipe" # Run "recipes/recipe.rb" in a project def invoke(recipe) filenames = [recipe] filenames << File.join(project, "recipes", recipe) if project filenames << File.join(project, "recipes", recipe + ".rb") if project for filename in filenames log.debug(PNOTE+" invoking "+filename) if File.exists?(filename) data = File.read(filename) begin return instance_eval(data, filename, 1) rescue Exception => e if @friendly_exceptions # TODO Extract this routine and its companion in HelpfulERB # Capture initial stack in case we add a debug/breakpoint after this stack = caller # Extract trace for recipe after the Interpreter#invoke call preresult = [] for line in e.backtrace # Stop at the Interpreter#invoke call break if line == stack.first preresult << line end # Extract the recipe filename preresult.last.match(/^([^:]+):(\d+):in `invoke'/) recipe = $1 # Extract trace for most recent block result = [] for line in preresult # Ignore manager wrapper and dispatch methods next if line =~ %r{lib/automateit/.+manager\.rb:\d+:in `.+'$} result << line # Stop at the first mention of this recipe break if line =~ /^#{recipe}/ end # Extract line number if e.is_a?(SyntaxError) line_number = e.message.match(/^[^:]+:(\d+):/)[1].to_i else result.last.match(/^([^:]+):(\d+):in `invoke'/) line_number = $2.to_i end msg = "Problem with recipe '#{recipe}' at line #{line_number}\n" # Extract recipe text begin lines = File.read(recipe).split(/\n/) min = line_number - 7 min = 0 if min < 0 max = line_number + 1 max = lines.size if max > lines.size width = max.to_s.size for i in min..max n = i+1 marker = n == line_number ? "*" : "" msg << "\n%2s %#{width}i %s" % [marker, n, lines[i]] end msg << "\n" rescue Exception => e # Ignore end msg << "\n(#{e.exception.class}) #{e.message}" # Append shortened trace for line in result msg << "\n "+line end # Remove project path msg.gsub!(/#{@project}\/?/, '') if @project raise AutomateIt::Error.new(msg, e) else raise e end end end end raise Errno::ENOENT.new(recipe) end # Path of this project's "dist" directory. If a project isn't available or # the directory doesn't exist, this will throw a NotImplementedError. def dist if @project result = File.join(@project, "dist/") if File.directory?(result) return result else raise NotImplementedError.new("can't find dist directory at: #{result}") end else raise NotImplementedError.new("can't use dist without a project") end end # Set value to share throughout the Interpreter. Use this instead of # globals so that different Interpreters don't see each other's variables. # Creates a method that returns the value and also adds a #params entry. # # Example: # set :asdf, 9 # => 9 # asdf # => 9 # # This is best used for frequently-used variables, like paths. For # infrequently-used variables, use #lookup and #params. A good place to use # the #set is in the Project's config/automateit_env.rb file so # that paths are exposed to all recipes like this: # # set :helpers, project+"/helpers" def set(key, value) key = key.to_sym params[key] = value eval <<-HERE def #{key} return params[:#{key}] end HERE value end # Retrieve a #params entry. # # Example: # params[:foo] = "bar" # => "bar" # get :foo # => "bar" def get(key) params[key.to_sym] end # Creates wrapper methods in +object+ to dispatch calls to an Interpreter instance. # # *WARNING*: This will overwrite all methods and variables in the target +object+ that have the same names as the Interpreter's methods. You should considerer specifying the +methods+ to limit the number of methods included to minimize surprises due to collisions. If +methods+ is left blank, will create wrappers for all Interpreter methods. # # For example, include an Interpreter instance into a Rake session, which will override the FileUtils commands with AutomateIt equivalents: # # # Rakefile # # require 'automateit' # @ai = AutomateIt.new # @ai.include_in(self, %w(preview? sh)) # Include #preview? and #sh methods # # task :default do # puts preview? # Uses Interpreter#preview? # sh "id" # Uses Interpreter#sh, not FileUtils#sh # cp "foo", "bar" # Uses FileUtils#cp, not Interpreter#cp # end # # For situations where you don't want to override any existing methods, consider using #add_method_missing_to. def include_in(object, *methods) methods = [methods].flatten methods = unique_methods.reject{|t| t =~ /^_/} if methods.empty? object.instance_variable_set(:@__automateit, self) for method in methods object.instance_eval <<-HERE def #{method}(*args, &block) @__automateit.send(:#{method}, *args, &block) end HERE end end # Creates #method_missing in +object+ that dispatches calls to an Interpreter instance. If a #method_missing is already present, it will be preserved as a fall-back using #alias_method_chain. # # For example, add #method_missing to a Rake session to provide direct access to Interpreter instance's methods whose names don't conflict with the names existing variables and methods: # # # Rakefile # # require 'automateit' # @ai = AutomateIt.new # @ai.add_method_missing_to(self) # # task :default do # puts preview? # Uses Interpreter#preview? # sh "id" # Uses FileUtils#sh, not Interpreter#sh # end # # For situations where it's necessary to override existing methods, such as the +sh+ call in the example, consider using #include_in. def add_method_missing_to(object) object.instance_variable_set(:@__automateit, self) chain = object.respond_to?(:method_missing) # XXX The solution below is evil and ugly, but I don't know how else to solve this. The problem is that I want to *only* alter the +object+ instance, and NOT its class. Unfortunately, #alias_method and #alias_method_chain only operate on classes, not instances, which makes them useless for this task. template = <<-HERE def method_missing<%=chain ? '_with_automateit' : ''%>(method, *args, &block) ### puts "mm+a(%s, %s)" % [method, args.inspect] if @__automateit.respond_to?(method) @__automateit.send(method, *args, &block) else <%-if chain-%> method_missing_without_automateit(method, *args, &block) <%-else-%> super <%-end-%> end end <%-if chain-%> @__method_missing_without_automateit = self.method(:method_missing) def method_missing_without_automateit(*args) ### puts "mm-a %s" % args.inspect @__method_missing_without_automateit.call(*args) end def method_missing(*args) ### puts "mm %s" % args.inspect method_missing_with_automateit(*args) end <%-end-%> HERE text = ::HelpfulERB.new(template).result(binding) object.instance_eval(text) end # Use to manage nitpick message for debugging AutomateIt internals. # # Arguments: # * nil -- Returns boolean of whether nitpick messages will be displayed. # * Boolean -- Sets nitpick state. # * String or Symbol -- Displays nitpick message if state is on. # # Example: # nitpick true # nitpick "I'm nitpicking" def nitpick(value=nil) case value when NilClass: @nitpick when TrueClass, FalseClass: @nitpick = value when String, Symbol: puts "%% #{value}" if @nitpick else raise TypeError.new("Unknown nitpick type: #{value.class}") end end end end