# Author:: Adam Jacob () # Author:: Nuo Yan () # Author:: Christopher Walters () # Author:: Tim Hinderliter () # Author:: Seth Falcon () # Author:: Daniel DeLeo () # Copyright:: Copyright 2008-2011 Opscode, Inc. # License:: Apache License, Version 2.0 # # 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 'chef/log' require 'chef/client' require 'chef/node' require 'chef/recipe' require 'chef/cookbook/file_vendor' require 'chef/checksum' require 'chef/cookbook/metadata' require 'chef/version_class' class Chef #== Chef::MinimalCookbookVersion # MinimalCookbookVersion is a duck type of CookbookVersion, used # internally by Chef Server as an optimization when determining the # optimal cookbook set for a chef-client. # # MinimalCookbookVersion objects contain only enough information to # solve the cookbook collection for a given run list. They *do not* # contain enough information to generate the response. # # See also: Chef::CookbookVersionSelector class MinimalCookbookVersion include Comparable ID = "id".freeze NAME = 'name'.freeze KEY = 'key'.freeze VERSION = 'version'.freeze VALUE = 'value'.freeze DEPS = 'deps'.freeze DEPENDENCIES = 'dependencies'.freeze attr_reader :name attr_reader :version attr_reader :deps def initialize(params) @name = params[KEY] @version = params[VALUE][VERSION] @deps = params[VALUE][DEPS] end # Returns the Cookbook::MinimalMetadata object for this cookbook # version. def metadata @metadata ||= Cookbook::MinimalMetadata.new(@name, DEPENDENCIES => @deps) end def legit_version @legit_version ||= Chef::Version.new(@version) end def <=>(o) raise Chef::Exceptions::CookbookVersionNameMismatch if self.name != o.name raise "Unexpected comparison to #{o}" unless o.respond_to?(:legit_version) legit_version <=> o.legit_version end end # == Chef::CookbookVersion # CookbookVersion is a model object encapsulating the data about a Chef # cookbook. Chef supports maintaining multiple versions of a cookbook on a # single server; each version is represented by a distinct instance of this # class. #-- # TODO: timh/cw: 5-24-2010: mutators for files (e.g., recipe_filenames=, # recipe_filenames.insert) should dirty the manifest so it gets regenerated. class CookbookVersion include Comparable COOKBOOK_SEGMENTS = [ :resources, :providers, :recipes, :libraries, :attributes, :files, :templates, :root_files ] attr_accessor :root_dir attr_accessor :definition_filenames attr_accessor :template_filenames attr_accessor :file_filenames attr_accessor :library_filenames attr_accessor :resource_filenames attr_accessor :provider_filenames attr_accessor :root_filenames attr_accessor :name attr_accessor :metadata attr_accessor :metadata_filenames attr_accessor :status # attribute_filenames also has a setter that has non-default # functionality. attr_reader :attribute_filenames # recipe_filenames also has a setter that has non-default # functionality. attr_reader :recipe_filenames attr_reader :recipe_filenames_by_name attr_reader :attribute_filenames_by_short_filename # This is the one and only method that knows how cookbook files' # checksums are generated. def self.checksum_cookbook_file(filepath) Chef::ChecksumCache.generate_md5_checksum_for_file(filepath) rescue Errno::ENOENT Chef::Log.debug("File #{filepath} does not exist, so there is no checksum to generate") nil end # Keep track of the filenames that we use in both eager cookbook # downloading (during sync_cookbooks) and lazy (during the run # itself, through FileVendor). After the run is over, clean up the # cache. def self.valid_cache_entries @valid_cache_entries ||= {} end def self.reset_cache_validity @valid_cache_entries = nil end def self.cache Chef::FileCache end # Setup a notification to clear the valid_cache_entries when a Chef client # run starts Chef::Client.when_run_starts do |run_status| reset_cache_validity end # Iterates over cached cookbooks' files, removing files belonging to # cookbooks that don't appear in +cookbook_hash+ def self.clear_obsoleted_cookbooks(cookbook_hash) # Remove all cookbooks no longer relevant to this node cache.find(File.join(%w{cookbooks ** *})).each do |cache_file| cache_file =~ /^cookbooks\/([^\/]+)\// unless cookbook_hash.has_key?($1) Chef::Log.info("Removing #{cache_file} from the cache; its cookbook is no longer needed on this client.") cache.delete(cache_file) end end end def self.cleanup_file_cache end # Register a notification to cleanup unused files from cookbooks Chef::Client.when_run_completes_successfully do |run_status| cleanup_file_cache end # Creates a new Chef::CookbookVersion object. # # === Returns # object:: Duh. :) def initialize(name) @name = name @frozen = false @attribute_filenames = Array.new @definition_filenames = Array.new @template_filenames = Array.new @file_filenames = Array.new @recipe_filenames = Array.new @recipe_filenames_by_name = Hash.new @library_filenames = Array.new @resource_filenames = Array.new @provider_filenames = Array.new @metadata_filenames = Array.new @root_dir = nil @root_filenames = Array.new @status = :ready @manifest = nil @file_vendor = nil @metadata = Chef::Cookbook::Metadata.new end def version metadata.version end # Indicates if this version is frozen or not. Freezing a coobkook version # indicates that a new cookbook with the same name and version number # shoule def frozen_version? @frozen end def freeze_version @frozen = true end def version=(new_version) manifest["version"] = new_version metadata.version(new_version) end # A manifest is a Mash that maps segment names to arrays of manifest # records (see #preferred_manifest_record for format of manifest records), # as well as describing cookbook metadata. The manifest follows a form # like the following: # # { # :cookbook_name = "apache2", # :version = "1.0", # :name = "Apache 2" # :metadata = ???TODO: timh/cw: 5-24-2010: describe this format, # # :files => [ # { # :name => "afile.rb", # :path => "files/ubuntu-9.10/afile.rb", # :checksum => "2222", # :specificity => "ubuntu-9.10" # }, # ], # :templates => [ manifest_record1, ... ], # ... # } def manifest unless @manifest generate_manifest end @manifest end def manifest=(new_manifest) @manifest = Mash.new new_manifest @checksums = extract_checksums_from_manifest(@manifest) @manifest_records_by_path = extract_manifest_records_by_path(@manifest) COOKBOOK_SEGMENTS.each do |segment| next unless @manifest.has_key?(segment) filenames = @manifest[segment].map{|manifest_record| manifest_record['name']} if segment == :recipes self.recipe_filenames = filenames elsif segment == :attributes self.attribute_filenames = filenames else segment_filenames(segment).clear filenames.each { |filename| segment_filenames(segment) << filename } end end end # Returns a hash of checksums to either nil or the on disk path (which is # done by generate_manifest). def checksums unless @checksums generate_manifest end @checksums end def manifest_records_by_path @manifest_records_by_path || generate_manifest @manifest_records_by_path end def full_name "#{name}-#{version}" end def attribute_filenames=(*filenames) @attribute_filenames = filenames.flatten @attribute_filenames_by_short_filename = filenames_by_name(attribute_filenames) attribute_filenames end ## BACKCOMPAT/DEPRECATED - Remove these and fix breakage before release [DAN - 5/20/2010]## alias :attribute_files :attribute_filenames alias :attribute_files= :attribute_filenames= # Return recipe names in the form of cookbook_name::recipe_name def fully_qualified_recipe_names results = Array.new recipe_filenames_by_name.each_key do |rname| results << "#{name}::#{rname}" end results end def recipe_filenames=(*filenames) @recipe_filenames = filenames.flatten @recipe_filenames_by_name = filenames_by_name(recipe_filenames) recipe_filenames end ## BACKCOMPAT/DEPRECATED - Remove these and fix breakage before release [DAN - 5/20/2010]## alias :recipe_files :recipe_filenames alias :recipe_files= :recipe_filenames= # called from DSL def load_recipe(recipe_name, run_context) unless recipe_filenames_by_name.has_key?(recipe_name) raise ArgumentError, "Cannot find a recipe matching #{recipe_name} in cookbook #{name}" end Chef::Log.debug("Found recipe #{recipe_name} in cookbook #{name}") recipe = Chef::Recipe.new(name, recipe_name, run_context) recipe_filename = recipe_filenames_by_name[recipe_name] unless recipe_filename raise Chef::Exceptions::RecipeNotFound, "could not find recipe #{recipe_name} for cookbook #{name}" end recipe.from_file(recipe_filename) recipe end def segment_filenames(segment) unless COOKBOOK_SEGMENTS.include?(segment) raise ArgumentError, "invalid segment #{segment}: must be one of #{COOKBOOK_SEGMENTS.join(', ')}" end case segment.to_sym when :resources @resource_filenames when :providers @provider_filenames when :recipes @recipe_filenames when :libraries @library_filenames when :attributes @attribute_filenames when :files @file_filenames when :templates @template_filenames when :root_files @root_filenames end end # Determine the most specific manifest record for the given # segment/filename, given information in the node. Throws # FileNotFound if there is no such segment and filename in the # manifest. # # A manifest record is a Mash that follows the following form: # { # :name => "example.rb", # :path => "files/default/example.rb", # :specificity => "default", # :checksum => "1234" # } def preferred_manifest_record(node, segment, filename) preferences = preferences_for_path(node, segment, filename) # ensure that we generate the manifest, which will also generate # @manifest_records_by_path manifest # in order of prefernce, look for the filename in the manifest found_pref = preferences.find {|preferred_filename| @manifest_records_by_path[preferred_filename] } if found_pref @manifest_records_by_path[found_pref] else if segment == :files || segment == :templates error_message = "Cookbook '#{name}' (#{version}) does not contain a file at any of these locations:\n" error_locations = [ " #{segment}/#{node[:platform]}-#{node[:platform_version]}/#{filename}", " #{segment}/#{node[:platform]}/#{filename}", " #{segment}/default/#{filename}", ] error_message << error_locations.join("\n") existing_files = segment_filenames(segment) # Show the files that the cookbook does have. If the user made a typo, # hopefully they'll see it here. unless existing_files.empty? error_message << "\n\nThis cookbook _does_ contain: ['#{existing_files.join("','")}']" end raise Chef::Exceptions::FileNotFound, error_message else raise Chef::Exceptions::FileNotFound, "cookbook #{name} does not contain file #{segment}/#{filename}" end end end def preferred_filename_on_disk_location(node, segment, filename, current_filepath=nil) manifest_record = preferred_manifest_record(node, segment, filename) if current_filepath && (manifest_record['checksum'] == self.class.checksum_cookbook_file(current_filepath)) nil else file_vendor.get_filename(manifest_record['path']) end end def relative_filenames_in_preferred_directory(node, segment, dirname) preferences = preferences_for_path(node, segment, dirname) filenames_by_pref = Hash.new preferences.each { |pref| filenames_by_pref[pref] = Array.new } manifest[segment].each do |manifest_record| manifest_record_path = manifest_record[:path] # find the NON SPECIFIC filenames, but prefer them by filespecificity. # For example, if we have a file: # 'files/default/somedir/somefile.conf' we only keep # 'somedir/somefile.conf'. If there is also # 'files/$hostspecific/somedir/otherfiles' that matches the requested # hostname specificity, that directory will win, as it is more specific. # # This is clearly ugly b/c the use case is for remote directory, where # we're just going to make cookbook_files out of these and make the # cookbook find them by filespecificity again. but it's the shortest # path to "success" for now. if manifest_record_path =~ /(#{Regexp.escape(segment.to_s)}\/[^\/]+\/#{Regexp.escape(dirname)})\/.+$/ specificity_dirname = $1 non_specific_path = manifest_record_path[/#{Regexp.escape(segment.to_s)}\/[^\/]+\/#{Regexp.escape(dirname)}\/(.+)$/, 1] # Record the specificity_dirname only if it's in the list of # valid preferences if filenames_by_pref[specificity_dirname] filenames_by_pref[specificity_dirname] << non_specific_path end end end best_pref = preferences.find { |pref| !filenames_by_pref[pref].empty? } raise Chef::Exceptions::FileNotFound, "cookbook #{name} has no directory #{segment}/default/#{dirname}" unless best_pref filenames_by_pref[best_pref] end # Determine the manifest records from the most specific directory # for the given node. See #preferred_manifest_record for a # description of entries of the returned Array. def preferred_manifest_records_for_directory(node, segment, dirname) preferences = preferences_for_path(node, segment, dirname) records_by_pref = Hash.new preferences.each { |pref| records_by_pref[pref] = Array.new } manifest[segment].each do |manifest_record| manifest_record_path = manifest_record[:path] # extract the preference part from the path. if manifest_record_path =~ /(#{Regexp.escape(segment.to_s)}\/[^\/]+\/#{Regexp.escape(dirname)})\/.+$/ # Note the specificy_dirname includes the segment and # dirname argument as above, which is what # preferences_for_path returns. It could be # "files/ubuntu-9.10/dirname", for example. specificity_dirname = $1 # Record the specificity_dirname only if it's in the list of # valid preferences if records_by_pref[specificity_dirname] records_by_pref[specificity_dirname] << manifest_record end end end best_pref = preferences.find { |pref| !records_by_pref[pref].empty? } raise Chef::Exceptions::FileNotFound, "cookbook #{name} (#{version}) has no directory #{segment}/default/#{dirname}" unless best_pref records_by_pref[best_pref] end # Given a node, segment and path (filename or directory name), # return the priority-ordered list of preference locations to # look. def preferences_for_path(node, segment, path) # only files and templates can be platform-specific if segment.to_sym == :files || segment.to_sym == :templates begin platform, version = Chef::Platform.find_platform_and_version(node) rescue ArgumentError => e # Skip platform/version if they were not found by find_platform_and_version if e.message =~ /Cannot find a (?:platform|version)/ platform = "/unknown_platform/" version = "/unknown_platform_version/" else raise end end fqdn = node[:fqdn] # Break version into components, eg: "5.7.1" => [ "5.7.1", "5.7", "5" ] search_versions = [] parts = version.to_s.split('.') parts.size.times do search_versions << parts.join('.') parts.pop end # Most specific to least specific places to find the path search_path = [ File.join(segment.to_s, "host-#{fqdn}", path) ] search_versions.each do |v| search_path << File.join(segment.to_s, "#{platform}-#{v}", path) end search_path << File.join(segment.to_s, platform.to_s, path) search_path << File.join(segment.to_s, "default", path) search_path else [File.join(segment, path)] end end private :preferences_for_path def to_hash result = manifest.dup result['frozen?'] = frozen_version? result['chef_type'] = 'cookbook_version' result.to_hash end def to_json(*a) result = self.to_hash result['json_class'] = self.class.name result.to_json(*a) end def self.json_create(o) cookbook_version = new(o["cookbook_name"]) # We want the Chef::Cookbook::Metadata object to always be inflated cookbook_version.metadata = Chef::Cookbook::Metadata.from_hash(o["metadata"]) cookbook_version.manifest = o # We don't need the following step when we decide to stop supporting deprecated operators in the metadata (e.g. <<, >>) cookbook_version.manifest["metadata"] = JSON.parse(cookbook_version.metadata.to_json) cookbook_version.freeze_version if o["frozen?"] cookbook_version end def generate_manifest_with_urls(&url_generator) rendered_manifest = manifest.dup COOKBOOK_SEGMENTS.each do |segment| if rendered_manifest.has_key?(segment) rendered_manifest[segment].each do |manifest_record| url_options = { :cookbook_name => name.to_s, :cookbook_version => version, :checksum => manifest_record["checksum"] } manifest_record["url"] = url_generator.call(url_options) end end end rendered_manifest end def metadata_rb_file File.join(root_dir, "metadata.rb") end def <=>(o) raise Chef::Exceptions::CookbookVersionNameMismatch if self.name != o.name # FIXME: can we change the interface to the Metadata class such # that metadata.version returns a Chef::Version instance instead # of a string? Chef::Version.new(self.version) <=> Chef::Version.new(o.version) end private # For each filename, produce a mapping of base filename (i.e. recipe name # or attribute file) to on disk location def filenames_by_name(filenames) filenames.select{|filename| filename =~ /\.rb$/}.inject({}){|memo, filename| memo[File.basename(filename, '.rb')] = filename ; memo } end # See #manifest for a description of the manifest return value. # See #preferred_manifest_record for a description an individual manifest record. def generate_manifest manifest = Mash.new({ :recipes => Array.new, :libraries => Array.new, :attributes => Array.new, :files => Array.new, :templates => Array.new, :resources => Array.new, :providers => Array.new, :root_files => Array.new }) checksums_to_on_disk_paths = {} COOKBOOK_SEGMENTS.each do |segment| segment_filenames(segment).each do |segment_file| next if File.directory?(segment_file) file_name = nil path = nil specificity = "default" if segment == :root_files matcher = segment_file.match(".+/#{Regexp.escape(name.to_s)}/(.+)") file_name = matcher[1] path = file_name elsif segment == :templates || segment == :files matcher = segment_file.match("/#{Regexp.escape(name.to_s)}/(#{Regexp.escape(segment.to_s)}/(.+?)/(.+))") unless matcher Chef::Log.debug("Skipping file #{segment_file}, as it isn't in any of the proper directories (platform-version, platform or default)") Chef::Log.debug("You probably need to move #{segment_file} into the 'default' sub-directory") next end path = matcher[1] specificity = matcher[2] file_name = matcher[3] else matcher = segment_file.match("/#{Regexp.escape(name.to_s)}/(#{Regexp.escape(segment.to_s)}/(.+))") path = matcher[1] file_name = matcher[2] end csum = self.class.checksum_cookbook_file(segment_file) checksums_to_on_disk_paths[csum] = segment_file rs = Mash.new({ :name => file_name, :path => path, :checksum => csum }) rs[:specificity] = specificity manifest[segment] << rs end end manifest[:cookbook_name] = name.to_s manifest[:metadata] = metadata manifest[:version] = metadata.version manifest[:name] = full_name @checksums = checksums_to_on_disk_paths @manifest = manifest @manifest_records_by_path = extract_manifest_records_by_path(manifest) end def file_vendor unless @file_vendor @file_vendor = Chef::Cookbook::FileVendor.create_from_manifest(manifest) end @file_vendor end def extract_checksums_from_manifest(manifest) checksums = {} COOKBOOK_SEGMENTS.each do |segment| next unless manifest.has_key?(segment) manifest[segment].each do |manifest_record| checksums[manifest_record[:checksum]] = nil end end checksums end def extract_manifest_records_by_path(manifest) manifest_records_by_path = {} COOKBOOK_SEGMENTS.each do |segment| next unless manifest.has_key?(segment) manifest[segment].each do |manifest_record| manifest_records_by_path[manifest_record[:path]] = manifest_record end end manifest_records_by_path end end end