# # Author:: Tim Hinderliter () # Copyright:: Copyright (c) 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 'dep_selector' class Chef module CookbookVersionSelector # This method replaces verbiage from DepSelector messages with # Chef-domain-specific verbiage, such as replacing package with # cookbook. # # TODO [cw, 2011/2/25]: this is a near-term hack. In the long run, # we'll do this better. def self.filter_dep_selector_message(message) m = message m.gsub!("Package", "Cookbook") m.gsub!("package", "cookbook") m.gsub!("Solution constraint", "Run list item") m.gsub!("solution constraint", "run list item") m end # all_cookbooks - a hash mapping cookbook names to an array of # available CookbookVersions. # # Creates a DependencyGraph from CookbookVersion objects def self.create_dependency_graph_from_cookbooks(all_cookbooks) dep_graph = DepSelector::DependencyGraph.new all_cookbooks.each do |cb_name, cb_versions| cb_versions.each do |cb_version| cb_version_deps = cb_version.metadata.dependencies # TODO [cw. 2011/2/10]: CookbookVersion#version returns a # String even though we're storing as a DepSelector::Version # object underneath. This should be changed so that we # return the object and handle proper serialization and # de-serialization. For now, I'm just going to create a # Version object from the String representation. pv = dep_graph.package(cb_name).add_version(Chef::Version.new(cb_version.version)) cb_version_deps.each_pair do |dep_name, constraint_str| # if the dependency is specified as cookbook::recipe, # extract the cookbook component dep_cb_name = dep_name.split("::").first constraint = Chef::VersionConstraint.new(constraint_str) pv.dependencies << DepSelector::Dependency.new(dep_graph.package(dep_cb_name), constraint) end end end dep_graph end # Return a hash mapping cookbook names to a CookbookVersion # object. If there is no solution that satisfies the constraints, # the first run list item that caused unsatisfiability is # returned. # # This is the final version-resolved list of cookbooks for the # RunList. # # all_cookbooks - a hash mapping cookbook names to an array of # available CookbookVersions. # # recipe_constraints - an array of hashes describing the expanded # run list. Each element is a hash containing keys :name and # :version_constraint. The :name component is either the # fully-qualified recipe name (e.g. "cookbook1::non_default_recipe") # or just a cookbook name, indicating the default recipe is to be # run (e.g. "cookbook1"). def self.constrain(all_cookbooks, recipe_constraints) dep_graph = create_dependency_graph_from_cookbooks(all_cookbooks) # extract cookbook names from (possibly) fully-qualified recipe names cookbook_constraints = recipe_constraints.map do |recipe_spec| cookbook_name = (recipe_spec[:name][/^(.+)::/, 1] || recipe_spec[:name]) DepSelector::SolutionConstraint.new(dep_graph.package(cookbook_name), recipe_spec[:version_constraint]) end # Pass in the list of all available cookbooks (packages) so that # DepSelector can distinguish between "no version available for # cookbook X" and "no such cookbook X" when an environment # filters out all versions for a given cookbook. all_packages = all_cookbooks.inject([]) do |acc, (cookbook_name, cookbook_versions)| acc << dep_graph.package(cookbook_name) acc end # find a valid assignment of CoookbookVersions. If no valid # assignment exists, indicate which run_list_item causes the # unsatisfiability and try to hint at what might be wrong. soln = begin DepSelector::Selector.new(dep_graph).find_solution(cookbook_constraints, all_packages) rescue DepSelector::Exceptions::InvalidSolutionConstraints => e non_existent_cookbooks = e.non_existent_packages.map {|constraint| constraint.package.name} cookbooks_with_no_matching_versions = e.constrained_to_no_versions.map {|constraint| constraint.package.name} # Spend a whole lot of effort for pluralizing and # prettifying the message. message = "" if non_existent_cookbooks.length > 0 message += "no such " + (non_existent_cookbooks.length > 1 ? "cookbooks" : "cookbook") message += " #{non_existent_cookbooks.join(", ")}" end if cookbooks_with_no_matching_versions.length > 0 if message.length > 0 message += "; " end message += "no versions match the constraints on " + (cookbooks_with_no_matching_versions.length > 1 ? "cookbooks" : "cookbook") message += " #{cookbooks_with_no_matching_versions.join(", ")}" end message = "Run list contains invalid items: #{message}." raise Chef::Exceptions::CookbookVersionSelection::InvalidRunListItems.new(message, non_existent_cookbooks, cookbooks_with_no_matching_versions) rescue DepSelector::Exceptions::NoSolutionExists => e raise Chef::Exceptions::CookbookVersionSelection::UnsatisfiableRunListItem.new(filter_dep_selector_message(e.message), e.unsatisfiable_solution_constraint, e.disabled_non_existent_packages, e.disabled_most_constrained_packages) end # map assignment back to CookbookVersion objects selected_cookbooks = {} soln.each_pair do |cb_name, cb_version| # TODO [cw, 2011/2/10]: related to the TODO in # create_dependency_graph_from_cookbooks, cbv.version # currently returns a String, so we must compare to # cb_version.to_s, since it's a for-real Version object. selected_cookbooks[cb_name] = all_cookbooks[cb_name].find{|cbv| cbv.version == cb_version.to_s} end selected_cookbooks end # Expands the run_list, constrained to the environment's CookbookVersion # constraints. # # Returns: # Hash of: name to CookbookVersion def self.expand_to_cookbook_versions(run_list, environment, couchdb=nil) # expand any roles in this run_list. expanded_run_list = run_list.expand(environment, 'couchdb', :couchdb => couchdb).recipes.with_version_constraints cookbooks_for_environment = Chef::Environment.cdb_minimal_filtered_versions(environment, couchdb) cookbook_collection = constrain(cookbooks_for_environment, expanded_run_list) full_cookbooks = Chef::MinimalCookbookVersion.load_full_versions_of(cookbook_collection.values, couchdb) full_cookbooks.inject({}) do |cb_map, cookbook_version| cb_map[cookbook_version.name] = cookbook_version cb_map end end end end