summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--chef-server-api/app/controllers/nodes.rb65
-rw-r--r--chef/lib/chef/exceptions.rb1
-rw-r--r--chef/lib/chef/node.rb5
-rw-r--r--chef/lib/chef/run_list.rb102
-rw-r--r--chef/spec/unit/run_list_spec.rb150
-rw-r--r--features/chef-client/cookbook_sync.feature1
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