diff options
-rw-r--r-- | chef-server-api/app/controllers/nodes.rb | 65 | ||||
-rw-r--r-- | chef/lib/chef/exceptions.rb | 1 | ||||
-rw-r--r-- | chef/lib/chef/node.rb | 5 | ||||
-rw-r--r-- | chef/lib/chef/run_list.rb | 102 | ||||
-rw-r--r-- | chef/spec/unit/run_list_spec.rb | 150 | ||||
-rw-r--r-- | features/chef-client/cookbook_sync.feature | 1 |
6 files changed, 261 insertions, 63 deletions
diff --git a/chef-server-api/app/controllers/nodes.rb b/chef-server-api/app/controllers/nodes.rb index 701bc57f31..3a93db5f54 100644 --- a/chef-server-api/app/controllers/nodes.rb +++ b/chef-server-api/app/controllers/nodes.rb @@ -19,6 +19,7 @@ require 'chef' / 'node' require 'chef/version_class' +require 'chef/version_constraint' class Nodes < Application @@ -106,69 +107,11 @@ class Nodes < Application # returns name -> CookbookVersion for all cookbooks included on the given node. def cookbooks_for_node(all_cookbooks) - # expand returns a RunListExpansion which contains recipes, default and override attrs [cb] - # TODO: check for this on the client side before we make the http request [stephen 9/1/10] begin - recipes = @node.run_list.expand('couchdb', :environment => @node.chef_environment).recipes.with_versions - rescue Chef::Exceptions::RecipeVersionConflict => e - raise PreconditionFailed, "#Conflict: #{e.message}" - end - - # TODO: make cookbook loading respect environment's versions [stephen 8/25/10] - # walk run list and accumulate included dependencies - recipes.inject({}) do |included_cookbooks, recipe| - expand_cookbook_deps(included_cookbooks, all_cookbooks, recipe, "Run list") - included_cookbooks + @node.constrain_cookbooks(all_cookbooks, 'couchdb') + rescue Chef::Exceptions::CookbookVersionConflict, Chef::Exceptions::CookbookVersionUnavailable => e + raise PreconditionFailed, e.message end end - # Accumulates transitive cookbook dependencies no more than once in included_cookbooks - # included_cookbooks == hash of name -> CookbookVersion, which is used for returning - # result as well as for tracking which cookbooks we've already - # recursed into - # all_cookbooks == hash of name -> [ CookbookVersion ... ] , all cookbooks available, sorted by version number - # recipe == hash of :name => recipe_name, :version => recipe_version to include - # parent_name == the name of the parent cookbook (or run_list), for reporting broken dependencies - def expand_cookbook_deps(included_cookbooks, all_cookbooks, recipe, parent_name) - # determine the recipe's parent cookbook, which might be the - # recipe name in the default case - cookbook_name = (recipe[:name][/^(.+)::/, 1] || recipe[:name]) - if recipe[:version] - version = Chef::Version.new(recipe[:version]) - Chef::Log.debug "Node requires #{cookbook_name} at version #{version}" - # detect the correct cookbook version from the list of available cookbook versions - cookbook = all_cookbooks[cookbook_name].detect { |cb| Chef::Version.new(cb.version) == version } - else - Chef::Log.debug "Node requires #{cookbook_name} at latest version" - cookbook_versions = all_cookbooks[cookbook_name] - Chef::Log.debug { "Available versions of cookbook #{cookbook_name}: [#{Array(cookbook_versions).map {|v| v.version}.join(',')}]" } - # Chef::Environment.cdb_load_filtered_cookbook_versions returns cookbooks in DESCENDING order - # so the newest one is the FIRST one. - cookbook = cookbook_versions ? all_cookbooks[cookbook_name].first : nil - end - unless cookbook - msg = "#{parent_name} depends on cookbook #{cookbook_name} #{version}, which is not available to this node" - raise PreconditionFailed, msg - end - - # we can't load more than one version of the same cookbook - if included_cookbooks[cookbook_name] - a = Chef::Version.new(included_cookbooks[cookbook_name].version) - b = Chef::Version.new(cookbook.version) - raise PreconditionFailed, "Conflict: Node requires cookbook #{cookbook_name} at versions #{a.to_s} and #{b.to_s}" if a != b - else - included_cookbooks[cookbook_name] = cookbook - end - - # TODO: - # In the past, we have ignored the version constraints from dependency metadata. - # We will continue to do so for the time being, until the Gem::Version - # sytax for the environments feature is replaced with something more permanent - # [stephen 9/1/10] - cookbook.metadata.dependencies.each do |dependency_name, dependency_version_constraints| - Chef::Log.debug [included_cookbooks, all_cookbooks, dependency_name, "Cookbook #{cookbook_name}"].join(", ") - recipe = {:name => dependency_name, :version => nil} - expand_cookbook_deps(included_cookbooks, all_cookbooks, recipe, "Cookbook #{cookbook_name}") - end - end end diff --git a/chef/lib/chef/exceptions.rb b/chef/lib/chef/exceptions.rb index dd8a0d2543..786dfd5c26 100644 --- a/chef/lib/chef/exceptions.rb +++ b/chef/lib/chef/exceptions.rb @@ -66,6 +66,7 @@ class Chef class IllegalChecksumRevert < RuntimeError; end class RecipeVersionConflict < StandardError; end class CookbookVersionConflict < StandardError; end + class CookbookVersionUnavailable < StandardError; end class CookbookVersionNameMismatch < ArgumentError; end class InvalidCookbookVersion < ArgumentError; end class InvalidVersionConstraint < ArgumentError; end diff --git a/chef/lib/chef/node.rb b/chef/lib/chef/node.rb index c390ea7cbb..3e8e4918a5 100644 --- a/chef/lib/chef/node.rb +++ b/chef/lib/chef/node.rb @@ -440,6 +440,11 @@ class Chef expansion.recipes end + def constrain_cookbooks(all_cookbooks, source) + cookbook_constraints = run_list.expand(source).recipes.with_version_constraints + run_list.constrain(all_cookbooks, cookbook_constraints) + end + # Transform the node to a Hash def to_hash index_hash = Hash.new diff --git a/chef/lib/chef/run_list.rb b/chef/lib/chef/run_list.rb index 07548d5cbc..86ae8c2660 100644 --- a/chef/lib/chef/run_list.rb +++ b/chef/lib/chef/run_list.rb @@ -3,6 +3,7 @@ # Author:: Nuo Yan (<nuoyan@opscode.com>) # Author:: Tim Hinderliter (<tim@opscode.com>) # Author:: Christopher Walters (<cw@opscode.com>) +# Author:: Seth Falcon (<seth@opscode.com>) # Copyright:: Copyright (c) 2008-2010 Opscode, Inc. # License:: Apache License, Version 2.0 # @@ -157,6 +158,107 @@ class Chef end end + # Return a hash mapping cookbook names to a CookbookVersion + # object. + # + # 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. + # + # cookbook_constraints - an array of hashes describing the + # expanded run list. Each element is a hash containing keys :name + # and :version_constraint. + # + def constrain(all_cookbooks, cookbook_constraints) + cookbooks = cookbook_constraints.inject({}) do |included_cookbooks, cookbook_constraint| + expand_cookbook_deps(included_cookbooks, all_cookbooks, cookbook_constraint, ["Run list"]) + included_cookbooks + end + ans = {} + cookbooks.each do |k, v| + ans[k] = v[:cookbook] + end + ans + end + + def path_to_s(path) + "[" + path.join(" -> ") + "]" + end + + # Accumulates transitive cookbook dependencies no more than once + # in included_cookbooks + # + # included_cookbooks - accumulator for return value, a hash + # mapping cookbook name to a hash with keys :cookbook, + # :version_constraint, and :parent. + # + # all_cookbooks - A hash mapping cookbook name to an array of + # CookbookVersion objects. These represent all the cookbooks that + # are available in a given environment. + # + # recipe - A hash with keys :name and :version_constraint. + # + # parent_path - A list of cookbook names (or "Run list" for + # top-level) that tracks where we are in the dependency + # tree. + # + def expand_cookbook_deps(included_cookbooks, all_cookbooks, recipe, parent_path) + # determine the recipe's parent cookbook, which might be the + # recipe name in the default case + cookbook_name = (recipe[:name][/^(.+)::/, 1] || recipe[:name]) + constraint = recipe[:version_constraint] + if included_cookbooks[cookbook_name] + # If the new constraint includes the cookbook we have already + # selected, we will continue. We can't swap at this point + # because we've already included the dependencies induced by + # the version we have. + already_selected = included_cookbooks[cookbook_name] + if !constraint.include?(already_selected[:cookbook]) + prev_constraint = already_selected[:version_constraint] + prev_version = already_selected[:cookbook].version + prev_path = already_selected[:parent] + msg = ["Unable to satisfy constraint #{cookbook_name} (#{constraint})", + "from #{path_to_s(parent_path)}.", + "Already already selected #{cookbook_name}@#{prev_version} via", + "#{cookbook_name} (#{prev_constraint}) #{path_to_s(prev_path)}" + ].join(" ") + raise Chef::Exceptions::CookbookVersionConflict, msg + end + # we've already processed this cookbook, no need to recurse + return + end + + choices = all_cookbooks[cookbook_name] || [] + choices = choices.select { |cb| constraint.include?(cb) } + Chef::Log.debug "Node requires #{cookbook_name} (#{constraint})" + if choices.empty? + msg = ("#{path_to_s(parent_path)} depends on cookbook #{cookbook_name} " + + "(#{constraint}), which is not available on this node") + raise Chef::Exceptions::CookbookVersionUnavailable, msg + end + # pick the highest version + cookbook = choices.sort.last + included_cookbooks[cookbook_name] = { + :cookbook => cookbook, + :version_constraint => constraint, + # store a copy of our path (not strictly necessary, but avoids + # a future code tweak from breaking) + :parent => parent_path.dup + } + # add current cookbook to a new copy of the parent path to + # pass to the recursive call + parent_path = parent_path.dup + parent_path << cookbook_name + cookbook.metadata.dependencies.each do |dep_name, version_constraint| + recipe = { + :name => dep_name, + :version_constraint => Chef::VersionConstraint.new(version_constraint) + } + expand_cookbook_deps(included_cookbooks, all_cookbooks, recipe, parent_path) + end + end end end diff --git a/chef/spec/unit/run_list_spec.rb b/chef/spec/unit/run_list_spec.rb index 9d4437481d..ec27c0336e 100644 --- a/chef/spec/unit/run_list_spec.rb +++ b/chef/spec/unit/run_list_spec.rb @@ -1,6 +1,7 @@ # # Author:: Adam Jacob (<adam@opscode.com>) -# Copyright:: Copyright (c) 2008 Opscode, Inc. +# Author:: Seth Falcon (<seth@opscode.com>) +# Copyright:: Copyright (c) 2008-2010 Opscode, Inc. # License:: Apache License, Version 2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -299,4 +300,151 @@ describe Chef::RunList do end + describe "constrain" do + @fake_db = Object.new + + def cookbook_maker(name, version, deps) + book = Chef::CookbookVersion.new(name, @fake_db) + book.version = version + deps.each { |dep_name, vc| book.metadata.depends(dep_name, vc) } + book + end + + def vc_maker(cookbook_name, version_constraint) + vc = Chef::VersionConstraint.new(version_constraint) + { :name => cookbook_name, :version_constraint => vc } + end + + before(:each) do + a = cookbook_maker("a", "1.0", [["c", "< 4.0"]]) + b = cookbook_maker("b", "1.0", [["c", "< 3.0"]]) + + c3 = cookbook_maker("c", "3.0", [["d", "> 2.0"], ["e", nil]]) + c2 = cookbook_maker("c", "2.0", [["d", "> 1.0"], ["f", nil]]) + + d1 = cookbook_maker("d", "1.1", []) + d2 = cookbook_maker("d", "2.1", []) + e = cookbook_maker("e", "1.0", []) + f = cookbook_maker("f", "1.0", []) + + @all_cookbooks = { + "a" => [a], + "b" => [b], + "c" => [c2, c3], + "d" => [d1, d2], + "e" => [e], + "f" => [f] + } + end + + it "pulls in transitive dependencies" do + constraints = [vc_maker("a", "~> 1.0")] + cookbooks = @run_list.constrain(@all_cookbooks, constraints) + %w(a c d e).each { |k| cookbooks.should have_key k } + cookbooks["c"].version.should == "3.0.0" + cookbooks["d"].version.should == "2.1.0" + end + + it "selects 'd 2.1.0' given constraint 'd > 1.2.3'" do + constraints = [vc_maker("d", "> 1.2.3")] + cookbooks = @run_list.constrain(@all_cookbooks, constraints) + cookbooks.size.should == 1 + cookbooks["d"].version.should == "2.1.0" + end + + it "selects largest version when constraint allows multiple" do + constraints = [vc_maker("d", "> 1.0")] + cookbooks = @run_list.constrain(@all_cookbooks, constraints) + cookbooks.size.should == 1 + cookbooks["d"].version.should == "2.1.0" + end + + it "selects 'd 1.1.0' given constraint 'd ~> 1.0'" do + constraints = [vc_maker("d", "~> 1.0")] + cookbooks = @run_list.constrain(@all_cookbooks, constraints) + cookbooks.size.should == 1 + cookbooks["d"].version.should == "1.1.0" + end + + it "raises CookbookVersionUnavailable for an unknown cookbook" do + constraints = [vc_maker("nosuch", "1.0.0")] + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionUnavailable) + end + + it "raises CookbookVersionUnavailable if constraint can't be met" do + constraints = [vc_maker("a", "99.9.9")] + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionUnavailable) + end + + it "raises CookbookVersionConflict for direct conflict" do + constraints = [vc_maker("d", "= 1.1.0"), vc_maker("d", ">= 2.0")] + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionConflict) + end + + it "raises CookbookVersionConflict a then b" do + # Cookbooks a and b both have a dependency on c, but with + # differing constraints. When a is pulled in first, we should + # get a version of c that is too new for the constraint on c + # that b declares. + constraints = [vc_maker("a", "1.0"), vc_maker("b", "1.0")] + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionConflict) + end + + it "resolves b then a" do + # See above comment for a then b. When b is pulled in first, we + # should get a version of c that satifies the constraints on the + # c dependency for both b and a. + constraints = [vc_maker("b", "1.0"), vc_maker("a", "1.0")] + cookbooks = @run_list.constrain(@all_cookbooks, constraints) + cookbooks.should have_key "a" + cookbooks.should have_key "b" + cookbooks["c"].version.should == "2.0.0" + end + + it "raises CookbookVersionConflict a then d" do + constraints = [vc_maker("a", "1.0"), vc_maker("d", "1.1")] + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionConflict) + end + + it "raises CookbookVersionConflict d then a" do + constraints = [vc_maker("a", "1.0"), vc_maker("d", "1.1")].reverse + fun = lambda { @run_list.constrain(@all_cookbooks, constraints) } + fun.should raise_error(Chef::Exceptions::CookbookVersionConflict) + end + + describe "expand_cookbook_deps (parent paths)" do + before(:each) do + # we test at this level to be able to verify that tracking of + # the parent path is correct. + a = cookbook_maker("a", "1.0", [["b", nil], ["d", nil]]) + b = cookbook_maker("b", "1.0", [["c", nil]]) + c = cookbook_maker("c", "1.0", []) + d = cookbook_maker("d", "1.0", [["e", nil]]) + e = cookbook_maker("e", "1.0", []) + @all_cookbooks = { } + constraint = { :name => "a", + :version_constraint => Chef::VersionConstraint.new } + [a, b, c, d, e].each { |cb| @all_cookbooks[cb.name] = [cb] } + @cookbooks = {} + @run_list.expand_cookbook_deps(@cookbooks, @all_cookbooks, constraint, ["Run list"]) + end + + # [cookbook, expected_parent_path] + [ + ["a", ["Run list"]], + ["b", ["Run list", "a"]], + ["c", ["Run list", "a", "b"]], + ["d", ["Run list", "a"]], + ["e", ["Run list", "a", "d"]]].each do |book, path| + it "parent path for #{book} is #{path.inspect}" do + @cookbooks[book][:parent].should == path + end + end + end + end end diff --git a/features/chef-client/cookbook_sync.feature b/features/chef-client/cookbook_sync.feature index 7f8799143d..d862388469 100644 --- a/features/chef-client/cookbook_sync.feature +++ b/features/chef-client/cookbook_sync.feature @@ -51,7 +51,6 @@ Feature: Synchronize cookbooks from the server And 'stdout' should have '412 Precondition Failed.*no_such_cookbook' Scenario: Utilise versioned dependencies - Given this test is not pending Given I am an administrator And I fully upload a sandboxed cookbook named 'versions' versioned '0.2.0' with 'versions' And a validated node |