diff options
Diffstat (limited to 'lib/chef/cookbook_version.rb')
-rw-r--r-- | lib/chef/cookbook_version.rb | 782 |
1 files changed, 782 insertions, 0 deletions
diff --git a/lib/chef/cookbook_version.rb b/lib/chef/cookbook_version.rb new file mode 100644 index 0000000000..0e11174a07 --- /dev/null +++ b/lib/chef/cookbook_version.rb @@ -0,0 +1,782 @@ +# Author:: Adam Jacob (<adam@opscode.com>) +# Author:: Nuo Yan (<nuo@opscode.com>) +# Author:: Christopher Walters (<cw@opscode.com>) +# Author:: Tim Hinderliter (<tim@opscode.com>) +# Author:: Seth Falcon (<seth@opscode.com>) +# Author:: Daniel DeLeo (<dan@opscode.com>) +# 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/node' +require 'chef/resource_definition_list' +require 'chef/recipe' +require 'chef/cookbook/file_vendor' +require 'chef/cookbook/metadata' +require 'chef/version_class' + +class Chef + + # == 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, :definitions, :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 + + def self.cache + Chef::FileCache + end + + # Synchronizes all the cookbooks from the chef-server. + # + # === Returns + # true:: Always returns true + def self.sync_cookbooks(cookbook_hash) + Chef::Log.info("Loading cookbooks [#{cookbook_hash.keys.sort.join(', ')}]") + Chef::Log.debug("Cookbooks detail: #{cookbook_hash.inspect}") + + clear_obsoleted_cookbooks(cookbook_hash) + + # Synchronize each of the node's cookbooks, and add to the + # valid_cache_entries hash. + cookbook_hash.values.each do |cookbook| + sync_cookbook_file_cache(cookbook) + end + + true + 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 + + # Update the file caches for a given cache segment. Takes a segment name + # and a hash that matches one of the cookbooks/_attribute_files style + # remote file listings. + # + # === Parameters + # cookbook<Chef::Cookbook>:: The cookbook to update + # valid_cache_entries<Hash>:: Out-param; Added to this hash are the files that + # were referred to by this cookbook + def self.sync_cookbook_file_cache(cookbook) + Chef::Log.debug("Synchronizing cookbook #{cookbook.name}") + + # files and templates are lazily loaded, and will be done later. + eager_segments = COOKBOOK_SEGMENTS.dup + + unless Chef::Config[:no_lazy_load] then + eager_segments.delete(:files) + eager_segments.delete(:templates) + end + + eager_segments.each do |segment| + segment_filenames = Array.new + cookbook.manifest[segment].each do |manifest_record| + # segment = cookbook segment + # remote_list = list of file hashes + # + # We need the list of known good attribute files, so we can delete any that are + # just laying about. + + cache_filename = File.join("cookbooks", cookbook.name, manifest_record['path']) + valid_cache_entries[cache_filename] = true + + current_checksum = nil + if cache.has_key?(cache_filename) + current_checksum = checksum_cookbook_file(cache.load(cache_filename, false)) + end + + # If the checksums are different between on-disk (current) and on-server + # (remote, per manifest), do the update. This will also execute if there + # is no current checksum. + if current_checksum != manifest_record['checksum'] + raw_file = chef_server_rest.get_rest(manifest_record[:url], true) + + Chef::Log.info("Storing updated #{cache_filename} in the cache.") + cache.move_to(raw_file.path, cache_filename) + else + Chef::Log.debug("Not storing #{cache_filename}, as the cache is up to date.") + end + + # make the segment filenames a full path. + full_path_cache_filename = cache.load(cache_filename, false) + segment_filenames << full_path_cache_filename + end + + # replace segment filenames with a full-path one. + if segment.to_sym == :recipes + cookbook.recipe_filenames = segment_filenames + elsif segment.to_sym == :attributes + cookbook.attribute_filenames = segment_filenames + else + cookbook.segment_filenames(segment).replace(segment_filenames) + end + end + end + + def self.cleanup_file_cache + unless Chef::Config[:solo] + # Delete each file in the cache that we didn't encounter in the + # manifest. + cache.find(File.join(%w{cookbooks ** *})).each do |cache_filename| + unless valid_cache_entries[cache_filename] + Chef::Log.info("Removing #{cache_filename} from the cache; it is no longer needed by chef-client.") + cache.delete(cache_filename) + end + end + end + end + + # Creates a new Chef::CookbookVersion object. + # + # === Returns + # object<Chef::CookbookVersion>:: 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 Chef::Exceptions::RecipeNotFound, "could not find recipe #{recipe_name} for 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_name} files 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 :definitions + @definition_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_json_file + File.join(root_dir, "metadata.json") + end + + def metadata_rb_file + File.join(root_dir, "metadata.rb") + end + + def reload_metadata! + if File.exists?(metadata_json_file) + metadata.from_json(IO.read(metadata_json_file)) + end + end + + ## + # REST API + ## + def self.chef_server_rest + Chef::REST.new(Chef::Config[:chef_server_url]) + end + + def chef_server_rest + self.class.chef_server_rest + end + + # Return the URL to save (PUT) this object to the server via the + # REST api. If there is an existing document on the server and it + # is marked frozen, a PUT will result in a 409 Conflict. + def save_url + "cookbooks/#{name}/#{version}" + end + + # Adds the `force=true` parameter to the upload URL. This allows + # the user to overwrite a frozen cookbook (a PUT against the + # normal #save_url raises a 409 Conflict in this case). + def force_save_url + "cookbooks/#{name}/#{version}?force=true" + end + + def destroy + chef_server_rest.delete_rest("cookbooks/#{name}/#{version}") + self + end + + def self.load(name, version="_latest") + version = "_latest" if version == "latest" + chef_server_rest.get_rest("cookbooks/#{name}/#{version}") + end + + # The API returns only a single version of each cookbook in the result from the cookbooks method + def self.list + chef_server_rest.get_rest('cookbooks') + end + + def self.list_all_versions + chef_server_rest.get_rest('cookbooks?num_versions=all') + end + + ## + # Given a +cookbook_name+, get a list of all versions that exist on the + # server. + # ===Returns + # [String]:: Array of cookbook versions, which are strings like 'x.y.z' + # nil:: if the cookbook doesn't exist. an error will also be logged. + def self.available_versions(cookbook_name) + chef_server_rest.get_rest("cookbooks/#{cookbook_name}")[cookbook_name]["versions"].map do |cb| + cb["version"] + end + rescue Net::HTTPServerException => e + if e.to_s =~ /^404/ + Chef::Log.error("Cannot find a cookbook named #{cookbook_name}") + nil + else + raise + end + end + + # Get the newest version of all cookbooks + def self.latest_cookbooks + chef_server_rest.get_rest('cookbooks/_latest') + 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, + :definitions => 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 |