# # Copyright 2010, Opscode, Inc. # # 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 'sinatra/base' require 'chef' require 'chef/node' require 'chef/mixin/xml_escape' require 'chef/role' require 'chef/environment' require 'chef/data_bag' require 'chef/data_bag_item' require 'partial_search' REQUIRED_ATTRS = [ :kernel, :fqdn, :platform, :platform_version ] class MissingAttribute < StandardError attr_reader :name def initialize(name) @name = name end end class ChefRundeck < Sinatra::Base include Chef::Mixin::XMLEscape class << self attr_accessor :config_file attr_accessor :username attr_accessor :web_ui_url attr_accessor :api_url attr_accessor :client_key attr_accessor :project_config attr_accessor :cache_timeout attr_accessor :partial_search def configure Chef::Config.from_file(ChefRundeck.config_file) Chef::Log.level = Chef::Config[:log_level] unless ChefRundeck.api_url ChefRundeck.api_url = Chef::Config[:chef_server_url] end unless ChefRundeck.client_key ChefRundeck.client_key = Chef::Config[:client_key] end if (File.exists?(ChefRundeck.project_config)) then Chef::Log.info("Using JSON project file #{ChefRundeck.project_config}") projects = File.open(ChefRundeck.project_config, "r") { |f| JSON.parse(f.read) } projects.keys.each do | project | get "/#{project}" do content_type 'text/xml' Chef::Log.info("Loading nodes for /#{project}") # TODO: Validate project data before rendering the document? send_file build_project project, projects[project]['pattern'], (projects[project]['username'].nil? ? ChefRundeck.username : projects[project]['username']), (projects[project]['hostname'].nil? ? "fqdn" : projects[project]['hostname']), projects[project]['attributes'] end cache_file = "#{Dir.tmpdir}/chef-rundeck-#{project}.xml" at_exit { File.delete(cache_file) if File.exist?(cache_file) } end end get '/' do content_type 'text/xml' Chef::Log.info("Loading all nodes for /") send_file build_project end cache_file = "#{Dir.tmpdir}/chef-rundeck-default.xml" at_exit { File.delete(cache_file) if File.exist?(cache_file) } end end def build_project (project="default", pattern="*:*", username=ChefRundeck.username, hostname="fqdn", custom_attributes=nil) response = nil begin # file is too new use it again if (File.exists?("#{Dir.tmpdir}/chef-rundeck-#{project}.xml") && (Time.now - File.atime("#{Dir.tmpdir}/chef-rundeck-#{project}.xml") < ChefRundeck.cache_timeout)) then return "#{Dir.tmpdir}/chef-rundeck-#{project}.xml" end results = [] if ChefRundeck.partial_search then keys = { 'name' => ['name'], 'kernel_machine' => [ 'kernel', 'machine' ], 'kernel_os' => [ 'kernel', 'os' ], 'fqdn' => [ 'fqdn' ], 'run_list' => [ 'run_list' ], 'roles' => [ 'roles' ], 'recipes' => [ 'recipes' ], 'chef_environment' => [ 'chef_environment' ], 'platform' => [ 'platform'], 'platform_version' => [ 'platform_version' ], 'tags' => [ 'tags' ], 'hostname' => [hostname] } if !custom_attributes.nil? then custom_attributes.each do |attr| attr_name = attr.gsub('.', '_') attr_value = attr.split('.') keys[attr_name] = attr_value end end # do search Chef::Log.info("partial search started (project: '#{project}')") results = partial_search(:node,pattern, :keys => keys) Chef::Log.info("partial search finshed (project: '#{project}', count: #{results.length})") else q = Chef::Search::Query.new Chef::Log.info("search started (project: '#{project}')") results = q.search("node",pattern)[0] Chef::Log.info("search finshed (project: '#{project}', count: #{results.length})") results = convert_results(results, hostname, custom_attributes) end response = File.open("#{Dir.tmpdir}/chef-rundeck-#{project}.xml", 'w') response.write '' response.write '' response.write '' Chef::Log.info("building nodes (project: '#{project}')") failed = 0 results.each do |node| begin # validate the node begin node_is_valid? node rescue ArgumentError => ae Chef::Log.warn("invalid node element: #{ae}") failed = failed + 1 next end #write the node to the project response.write build_node(node, username, hostname, custom_attributes) rescue Exception => e Chef::Log.error("=== could not generate xml for #{node}: #{e.message}") Chef::Log.debug(e.backtrace.join('\n')) end end Chef::Log.info("nodes complete (project: '#{project}', total: #{results.length - failed}, failed: #{failed})") response.write "" Chef::Log.debug(response) ensure response.close unless response == nil end return response.path end end def build_node (node, username, hostname, custom_attributes) #-- # Certain features in Rundeck require the osFamily value to be set to 'unix' to work appropriately. - SRK #++ data = '' os_family = node['kernel_os'] =~ /winnt|windows/i ? 'winnt' : 'unix' nodeexec = node['kernel_os'] =~ /winnt|windows/i ? "node-executor=\"overthere-winrm\"" : '' data << <<-EOH EOH if !custom_attributes.nil? then custom_attributes.each do |attr| attr_name = attr attr_value = node[attr.gsub('.','_')] data << <<-EOH EOH end data << "" end return data end def get_custom_attr (obj, params) value = obj Chef::Log.debug("loading custom attributes for node: #{obj['name']} with #{params}") params.each do |p| value = value[p.to_sym] if value.nil? then break end end return value.nil? ? "" : value.to_s end # Convert results to be compatiable with Chef 11 format def convert_results(results, hostname, custom_attributes) new_results = [] results.each do |node| n = {} n['name'] = node.name n['chef_environment'] = node.chef_environment n['run_list'] = node.run_list n['recipes'] = !node.run_list.nil? ? node.run_list.recipes : nil n['roles'] = !node.run_list.nil? ? node.run_list.roles : nil n['fqdn'] = node['fqdn'] n['hostname'] = get_custom_attr(node, hostname.split('.')) n['kernel_machine'] = !node['kernel'].nil? ? node['kernel']['machine'] : nil n['kernel_os'] = !node['kernel'].nil? ? node['kernel']['os'] : nil n['platform'] = node['platform'] n['platform_version'] = node['platform_version'] n['tags'] = node['tags'] if !custom_attributes.nil? then custom_attributes.each do |attr| ps_name = attr.gsub('.','_') n[ps_name] = get_custom_attr(node, attr.split('.')) end end new_results << n end return new_results end # Helper def to validate the node def node_is_valid?(node) raise ArgumentError, "#{node} missing 'name'" if !node['name'] raise ArgumentError, "#{node} missing 'chef_environment'" if !node['chef_environment'] raise ArgumentError, "#{node} missing 'run_list'" if !node['run_list'] raise ArgumentError, "#{node} missing 'recipes'" if !node['recipes'] raise ArgumentError, "#{node} missing 'roles'" if !node['roles'] raise ArgumentError, "#{node} missing 'fqdn'" if !node['fqdn'] raise ArgumentError, "#{node} missing 'hostname'" if !node['hostname'] raise ArgumentError, "#{node} missing 'kernel.machine'" if !node['kernel_machine'] raise ArgumentError, "#{node} missing 'kernel.os'" if !node['kernel_os'] raise ArgumentError, "#{node} missing 'platform'" if !node['platform'] raise ArgumentError, "#{node} missing 'platform_version'" if !node['platform_version'] end # partial_search(type, query, options, &block) # # Searches for nodes, roles, etc. and returns the results. This method may # perform more than one search request, if there are a large number of results. # # ==== Parameters # * +type+: index type (:role, :node, :client, :environment, data bag name) # * +query+: SOLR query. "*:*", "role:blah", "not role:blah", etc. Defaults to '*:*' # * +options+: hash with options: # ** +:start+: First row to return (:start => 50, :rows => 100 means "return the # 50th through 150th result") # ** +:rows+: Number of rows to return. Defaults to 1000. # ** +:sort+: a SOLR sort specification. Defaults to 'X_CHEF_id_CHEF_X asc'. # ** +:keys+: partial search keys. If this is not specified, the search will # not be partial. # # ==== Returns # # This method returns an array of search results. Partial search results will # be JSON hashes with the structure specified in the +keys+ option. Other # results include +Chef::Node+, +Chef::Role+, +Chef::Client+, +Chef::Environment+, # +Chef::DataBag+ and +Chef::DataBagItem+ objects, depending on the search type. # # If a block is specified, the block will be called with each result instead of # returning an array. This method will not block if it returns # # If start or row is specified, and no block is given, the result will be a # triple containing the list, the start and total: # # [ [ row1, row2, ... ], start, total ] # # ==== Example # # partial_search(:node, 'role:webserver', # keys: { # name: [ 'name' ], # ip: [ 'amazon', 'ip', 'public' ] # } # ).each do |node| # puts "#{node[:name]}: #{node[:ip]}" # end # def partial_search(type, query='*:*', *args, &block) # Support both the old (positional args) and new (hash args) styles of calling if args.length == 1 && args[0].is_a?(Hash) args_hash = args[0] else args_hash = {} args_hash[:sort] = args[0] if args.length >= 1 args_hash[:start] = args[1] if args.length >= 2 args_hash[:rows] = args[2] if args.length >= 3 end # If you pass a block, or have the start or rows arguments, do raw result parsing if Kernel.block_given? || args_hash[:start] || args_hash[:rows] PartialSearch.new.search(type, query, args_hash, &block) # Otherwise, do the iteration for the end user else results = Array.new PartialSearch.new.search(type, query, args_hash) do |o| results << o end results end end