diff options
author | Lamont Granquist <lamont@scriptkiddie.org> | 2016-10-26 15:52:35 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-10-26 15:52:35 -0700 |
commit | e21d9e586276b922877e5048848ace1ec84f270a (patch) | |
tree | 3d23e6552e610f3be6b860c487385e77c5b04cf4 | |
parent | f1ee3c3ad99a39bcecc631ce411378b60ec878f8 (diff) | |
parent | 9edd0113f2d046e051845cf1916fcf733d392791 (diff) | |
download | chef-e21d9e586276b922877e5048848ace1ec84f270a.tar.gz |
Merge pull request #5378 from chef/lcg/node-path-tracking
Add Chef::Node __path tracking, change root to __root, drop top_level_breadcrumb hack, fix long-standing Chef 12.0 deep merge cache bugs
-rw-r--r-- | lib/chef/node.rb | 4 | ||||
-rw-r--r-- | lib/chef/node/attribute.rb | 166 | ||||
-rw-r--r-- | lib/chef/node/attribute_collections.rb | 43 | ||||
-rw-r--r-- | lib/chef/node/common_api.rb | 6 | ||||
-rw-r--r-- | lib/chef/node/immutable_collections.rb | 95 | ||||
-rw-r--r-- | lib/chef/node/mixin/deep_merge_cache.rb | 61 | ||||
-rw-r--r-- | lib/chef/node/mixin/immutablize_array.rb | 67 | ||||
-rw-r--r-- | lib/chef/node/mixin/immutablize_hash.rb | 54 | ||||
-rw-r--r-- | lib/chef/node/mixin/state_tracking.rb | 71 | ||||
-rw-r--r-- | spec/unit/node/vivid_mash_spec.rb | 116 | ||||
-rw-r--r-- | spec/unit/node_spec.rb | 103 |
11 files changed, 491 insertions, 295 deletions
diff --git a/lib/chef/node.rb b/lib/chef/node.rb index 212b1ced14..34a92d325b 100644 --- a/lib/chef/node.rb +++ b/lib/chef/node.rb @@ -197,7 +197,6 @@ class Chef # Set a normal attribute of this node, but auto-vivify any Mashes that # might be missing def normal - attributes.top_level_breadcrumb = nil attributes.normal end @@ -209,14 +208,12 @@ class Chef # Set a default of this node, but auto-vivify any Mashes that might # be missing def default - attributes.top_level_breadcrumb = nil attributes.default end # Set an override attribute of this node, but auto-vivify any Mashes that # might be missing def override - attributes.top_level_breadcrumb = nil attributes.override end @@ -237,7 +234,6 @@ class Chef end def automatic_attrs - attributes.top_level_breadcrumb = nil attributes.automatic end diff --git a/lib/chef/node/attribute.rb b/lib/chef/node/attribute.rb index a4a07275c0..2d6aff0b21 100644 --- a/lib/chef/node/attribute.rb +++ b/lib/chef/node/attribute.rb @@ -17,6 +17,9 @@ # limitations under the License. # +require "chef/node/mixin/deep_merge_cache" +require "chef/node/mixin/immutablize_hash" +require "chef/node/mixin/state_tracking" require "chef/node/immutable_collections" require "chef/node/attribute_collections" require "chef/decorator/unchain" @@ -34,9 +37,18 @@ class Chef class Attribute < Mash include Immutablize - + # FIXME: what is include Enumerable doing up here, when down below we delegate + # most of the Enumerable/Hash things to the underlying merged ImmutableHash. That + # is, in fact, the correct, thing to do, while including Enumerable to try to create + # a hash-like API gets lots of things wrong because of the difference between the + # Hash `each do |key, value|` vs the Array-like `each do |value|` API that Enumerable + # expects. This include should probably be deleted? include Enumerable + include Chef::Node::Mixin::DeepMergeCache + include Chef::Node::Mixin::StateTracking + include Chef::Node::Mixin::ImmutablizeHash + # List of the component attribute hashes, in order of precedence, low to # high. COMPONENTS = [ @@ -175,39 +187,21 @@ class Chef # return the automatic level attribute component attr_reader :automatic - # This is used to track the top level key as we descend through method chaining into - # a precedence level (e.g. node.default['foo']['bar']['baz']= results in 'foo' here). We - # need this so that when we hit the end of a method chain which results in a mutator method - # that we can invalidate the whole top-level deep merge cache for the top-level key. It is - # the responsibility of the accessor on the Chef::Node object to reset this to nil, and then - # the first VividMash#[] call can ||= and set this to the first key we encounter. - attr_accessor :top_level_breadcrumb - - # Cache of deep merged values by top-level key. This is a simple hash which has keys that are the - # top-level keys of the node object, and we save the computed deep-merge for that key here. There is - # no cache of subtrees. - attr_accessor :deep_merge_cache - def initialize(normal, default, override, automatic) - @default = VividMash.new(self, default) - @env_default = VividMash.new(self, {}) - @role_default = VividMash.new(self, {}) - @force_default = VividMash.new(self, {}) - - @normal = VividMash.new(self, normal) + @default = VividMash.new(default, self) + @env_default = VividMash.new({}, self) + @role_default = VividMash.new({}, self) + @force_default = VividMash.new({}, self) - @override = VividMash.new(self, override) - @role_override = VividMash.new(self, {}) - @env_override = VividMash.new(self, {}) - @force_override = VividMash.new(self, {}) + @normal = VividMash.new(normal, self) - @automatic = VividMash.new(self, automatic) + @override = VividMash.new(override, self) + @role_override = VividMash.new({}, self) + @env_override = VividMash.new({}, self) + @force_override = VividMash.new({}, self) - @merged_attributes = nil - @combined_override = nil - @combined_default = nil - @top_level_breadcrumb = nil - @deep_merge_cache = {} + @automatic = VividMash.new(automatic, self) + super() end # Debug what's going on with an attribute. +args+ is a path spec to the @@ -235,76 +229,62 @@ class Chef end end - # Invalidate a key in the deep_merge_cache. If called with nil, or no arg, this will invalidate - # the entire deep_merge cache. In the case of the user doing node.default['foo']['bar']['baz']= - # that eventually results in a call to reset_cache('foo') here. A node.default=hash_thing call - # must invalidate the entire cache and re-deep-merge the entire node object. - def reset_cache(path = nil) - if path.nil? - @deep_merge_cache = {} - else - deep_merge_cache.delete(path.to_s) - end - end - - alias :reset :reset_cache - # Set the cookbook level default attribute component to +new_data+. def default=(new_data) reset - @default = VividMash.new(self, new_data) + @default = VividMash.new(new_data, self) end # Set the role level default attribute component to +new_data+ def role_default=(new_data) reset - @role_default = VividMash.new(self, new_data) + @role_default = VividMash.new(new_data, self) end # Set the environment level default attribute component to +new_data+ def env_default=(new_data) reset - @env_default = VividMash.new(self, new_data) + @env_default = VividMash.new(new_data, self) end # Set the force_default (+default!+) level attributes to +new_data+ def force_default=(new_data) reset - @force_default = VividMash.new(self, new_data) + @force_default = VividMash.new(new_data, self) end # Set the normal level attribute component to +new_data+ def normal=(new_data) reset - @normal = VividMash.new(self, new_data) + @normal = VividMash.new(new_data, self) end # Set the cookbook level override attribute component to +new_data+ def override=(new_data) reset - @override = VividMash.new(self, new_data) + @override = VividMash.new(new_data, self) end # Set the role level override attribute component to +new_data+ def role_override=(new_data) reset - @role_override = VividMash.new(self, new_data) + @role_override = VividMash.new(new_data, self) end # Set the environment level override attribute component to +new_data+ def env_override=(new_data) reset - @env_override = VividMash.new(self, new_data) + @env_override = VividMash.new(new_data, self) end def force_override=(new_data) reset - @force_override = VividMash.new(self, new_data) + @force_override = VividMash.new(new_data, self) end def automatic=(new_data) reset - @automatic = VividMash.new(self, new_data) + @automatic = VividMash.new(new_data, self) end # @@ -413,36 +393,6 @@ class Chef write(:force_override, *args, value) end - # method-style access to attributes - - def read(*path) - merged_attributes.read(*path) - end - - def read!(*path) - merged_attributes.read!(*path) - end - - def exist?(*path) - merged_attributes.exist?(*path) - end - - def write(level, *args, &block) - self.send(level).write(*args, &block) - end - - def write!(level, *args, &block) - self.send(level).write!(*args, &block) - end - - def unlink(level, *path) - self.send(level).unlink(*path) - end - - def unlink!(level, *path) - self.send(level).unlink!(*path) - end - # # Accessing merged attributes. # @@ -484,24 +434,39 @@ class Chef write(:normal, *args) if read(*args[0...-1]).nil? end - def [](key) - if deep_merge_cache.has_key?(key.to_s) - # return the cache of the deep merged values by top-level key - deep_merge_cache[key.to_s] - else - # save all the work of computing node[key] - deep_merge_cache[key.to_s] = merged_attributes(key) + def has_key?(key) + COMPONENTS.any? do |component_ivar| + instance_variable_get(component_ivar).has_key?(key) end end + # method-style access to attributes (has to come after the prepended ImmutablizeHash) - def []=(key, value) - raise Exceptions::ImmutableAttributeModification + def read(*path) + merged_attributes.read(*path) end - def has_key?(key) - COMPONENTS.any? do |component_ivar| - instance_variable_get(component_ivar).has_key?(key) - end + def read!(*path) + merged_attributes.read!(*path) + end + + def exist?(*path) + merged_attributes.exist?(*path) + end + + def write(level, *args, &block) + self.send(level).write(*args, &block) + end + + def write!(level, *args, &block) + self.send(level).write!(*args, &block) + end + + def unlink(level, *path) + self.send(level).unlink(*path) + end + + def unlink!(level, *path) + self.send(level).unlink!(*path) end alias :attribute? :has_key? @@ -600,7 +565,7 @@ class Chef return nil if components.compact.empty? - components.inject(ImmutableMash.new({})) do |merged, component| + components.inject(ImmutableMash.new({}, self)) do |merged, component| Chef::Mixin::DeepMerge.hash_only_merge!(merged, component) end end @@ -631,6 +596,11 @@ class Chef end end + # needed for __path__ + def convert_key(key) + key.kind_of?(Symbol) ? key.to_s : key + end + end end diff --git a/lib/chef/node/attribute_collections.rb b/lib/chef/node/attribute_collections.rb index 1bd31bceb0..b01b447978 100644 --- a/lib/chef/node/attribute_collections.rb +++ b/lib/chef/node/attribute_collections.rb @@ -17,6 +17,7 @@ # require "chef/node/common_api" +require "chef/node/mixin/state_tracking" class Chef class Node @@ -63,15 +64,12 @@ class Chef MUTATOR_METHODS.each do |mutator| define_method(mutator) do |*args, &block| ret = super(*args, &block) - root.reset_cache(root.top_level_breadcrumb) + send_reset_cache ret end end - attr_reader :root - - def initialize(root, data) - @root = root + def initialize(data = []) super(data) map! { |e| convert_value(e) } end @@ -96,14 +94,20 @@ class Chef when AttrArray value when Hash - VividMash.new(root, value) + VividMash.new(value, __root__) when Array - AttrArray.new(root, value) + AttrArray.new(value, __root__) else value end end + # needed for __path__ + def convert_key(key) + key + end + + prepend Chef::Node::Mixin::StateTracking end # == VividMash @@ -117,8 +121,6 @@ class Chef # #fetch, work as normal). # * attr_accessor style element set and get are supported via method_missing class VividMash < Mash - attr_reader :root - include CommonAPI # Methods that mutate a VividMash. Each of them is overridden so that it @@ -126,7 +128,6 @@ class Chef # object. MUTATOR_METHODS = [ :clear, - :delete, :delete_if, :keep_if, :merge!, @@ -140,23 +141,27 @@ class Chef # For all of the mutating methods on Mash, override them so that they # also invalidate the cached `merged_attributes` on the root Attribute # object. + + def delete(key, &block) + send_reset_cache(__path__ + [ key ]) + super + end + MUTATOR_METHODS.each do |mutator| define_method(mutator) do |*args, &block| - root.reset_cache(root.top_level_breadcrumb) + send_reset_cache super(*args, &block) end end - def initialize(root, data = {}) - @root = root + def initialize(data = {}) super(data) end def [](key) - root.top_level_breadcrumb ||= key value = super if !key?(key) - value = self.class.new(root) + value = self.class.new({}, __root__) self[key] = value else value @@ -164,9 +169,8 @@ class Chef end def []=(key, value) - root.top_level_breadcrumb ||= key ret = super - root.reset_cache(root.top_level_breadcrumb) + send_reset_cache(__path__ + [ key ]) ret end @@ -205,9 +209,9 @@ class Chef when AttrArray value when Hash - VividMash.new(root, value) + VividMash.new(value, __root__) when Array - AttrArray.new(root, value) + AttrArray.new(value, __root__) else value end @@ -217,6 +221,7 @@ class Chef Mash.new(self) end + prepend Chef::Node::Mixin::StateTracking end end end diff --git a/lib/chef/node/common_api.rb b/lib/chef/node/common_api.rb index ce2c6b6878..9bb83a5178 100644 --- a/lib/chef/node/common_api.rb +++ b/lib/chef/node/common_api.rb @@ -32,7 +32,6 @@ class Chef # - autovivifying / autoreplacing writer # - non-container-ey intermediate objects are replaced with hashes def write(*args, &block) - root.top_level_breadcrumb = nil if respond_to?(:root) value = block_given? ? yield : args.pop last = args.pop prev_memo = prev_key = nil @@ -56,7 +55,6 @@ class Chef # something that is not a container ("schema violation" issues). # def write!(*args, &block) - root.top_level_breadcrumb = nil if respond_to?(:root) value = block_given? ? yield : args.pop last = args.pop obj = args.inject(self) do |memo, key| @@ -71,7 +69,6 @@ class Chef # return true or false based on if the attribute exists def exist?(*path) - root.top_level_breadcrumb = nil if respond_to?(:root) path.inject(self) do |memo, key| return false unless valid_container?(memo, key) if memo.is_a?(Hash) @@ -103,7 +100,6 @@ class Chef # non-autovivifying reader that throws an exception if the attribute does not exist def read!(*path) raise Chef::Exceptions::NoSuchAttribute unless exist?(*path) - root.top_level_breadcrumb = nil if respond_to?(:root) path.inject(self) do |memo, key| memo[key] end @@ -112,10 +108,8 @@ class Chef # FIXME:(?) does anyone really like the autovivifying reader that we have and wants the same behavior? readers that write? ugh... def unlink(*path, last) - root.top_level_breadcrumb = nil if respond_to?(:root) hash = path.empty? ? self : read(*path) return nil unless hash.is_a?(Hash) || hash.is_a?(Array) - root.top_level_breadcrumb ||= last hash.delete(last) end diff --git a/lib/chef/node/immutable_collections.rb b/lib/chef/node/immutable_collections.rb index d4623ace2a..938135cbee 100644 --- a/lib/chef/node/immutable_collections.rb +++ b/lib/chef/node/immutable_collections.rb @@ -16,6 +16,9 @@ # require "chef/node/common_api" +require "chef/node/mixin/state_tracking" +require "chef/node/mixin/immutablize_array" +require "chef/node/mixin/immutablize_hash" class Chef class Node @@ -24,9 +27,9 @@ class Chef def immutablize(value) case value when Hash - ImmutableMash.new(value) + ImmutableMash.new(value, __root__) when Array - ImmutableArray.new(value) + ImmutableArray.new(value, __root__) else value end @@ -49,55 +52,12 @@ class Chef alias :internal_push :<< private :internal_push - # A list of methods that mutate Array. Each of these is overridden to - # raise an error, making this instances of this class more or less - # immutable. - DISALLOWED_MUTATOR_METHODS = [ - :<<, - :[]=, - :clear, - :collect!, - :compact!, - :default=, - :default_proc=, - :delete, - :delete_at, - :delete_if, - :fill, - :flatten!, - :insert, - :keep_if, - :map!, - :merge!, - :pop, - :push, - :update, - :reject!, - :reverse!, - :replace, - :select!, - :shift, - :slice!, - :sort!, - :sort_by!, - :uniq!, - :unshift, - ] - - def initialize(array_data) + def initialize(array_data = []) array_data.each do |value| internal_push(immutablize(value)) end end - # Redefine all of the methods that mutate a Hash to raise an error when called. - # This is the magic that makes this object "Immutable" - DISALLOWED_MUTATOR_METHODS.each do |mutator_method_name| - define_method(mutator_method_name) do |*args, &block| - raise Exceptions::ImmutableAttributeModification - end - end - # For elements like Fixnums, true, nil... def safe_dup(e) e.dup @@ -125,6 +85,13 @@ class Chef a end + # for consistency's sake -- integers 'converted' to integers + def convert_key(key) + key + end + + prepend Chef::Node::Mixin::StateTracking + prepend Chef::Node::Mixin::ImmutablizeArray end # == ImmutableMash @@ -140,36 +107,13 @@ class Chef # it is stale. # * Values can be accessed in attr_reader-like fashion via method_missing. class ImmutableMash < Mash - include Immutablize include CommonAPI alias :internal_set :[]= private :internal_set - DISALLOWED_MUTATOR_METHODS = [ - :[]=, - :clear, - :collect!, - :default=, - :default_proc=, - :delete, - :delete_if, - :keep_if, - :map!, - :merge!, - :update, - :reject!, - :replace, - :select!, - :shift, - :write, - :write!, - :unlink, - :unlink!, - ] - - def initialize(mash_data) + def initialize(mash_data = {}) mash_data.each do |key, value| internal_set(key, immutablize(value)) end @@ -181,14 +125,6 @@ class Chef alias :attribute? :has_key? - # Redefine all of the methods that mutate a Hash to raise an error when called. - # This is the magic that makes this object "Immutable" - DISALLOWED_MUTATOR_METHODS.each do |mutator_method_name| - define_method(mutator_method_name) do |*args, &block| - raise Exceptions::ImmutableAttributeModification - end - end - def method_missing(symbol, *args) if symbol == :to_ary super @@ -238,7 +174,8 @@ class Chef h end + prepend Chef::Node::Mixin::StateTracking + prepend Chef::Node::Mixin::ImmutablizeHash end - end end diff --git a/lib/chef/node/mixin/deep_merge_cache.rb b/lib/chef/node/mixin/deep_merge_cache.rb new file mode 100644 index 0000000000..b18d6b10cb --- /dev/null +++ b/lib/chef/node/mixin/deep_merge_cache.rb @@ -0,0 +1,61 @@ +#-- +# Copyright:: Copyright 2016, Chef Software, 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. +# + +class Chef + class Node + module Mixin + module DeepMergeCache + # Cache of deep merged values by top-level key. This is a simple hash which has keys that are the + # top-level keys of the node object, and we save the computed deep-merge for that key here. There is + # no cache of subtrees. + attr_accessor :deep_merge_cache + + def initialize + @merged_attributes = nil + @combined_override = nil + @combined_default = nil + @deep_merge_cache = {} + end + + # Invalidate a key in the deep_merge_cache. If called with nil, or no arg, this will invalidate + # the entire deep_merge cache. In the case of the user doing node.default['foo']['bar']['baz']= + # that eventually results in a call to reset_cache('foo') here. A node.default=hash_thing call + # must invalidate the entire cache and re-deep-merge the entire node object. + def reset_cache(path = nil) + if path.nil? + deep_merge_cache.clear + else + deep_merge_cache.delete(path.to_s) + end + end + + alias :reset :reset_cache + + def [](key) + if deep_merge_cache.has_key?(key.to_s) + # return the cache of the deep merged values by top-level key + deep_merge_cache[key.to_s] + else + # save all the work of computing node[key] + deep_merge_cache[key.to_s] = merged_attributes(key) + end + end + + end + end + end +end diff --git a/lib/chef/node/mixin/immutablize_array.rb b/lib/chef/node/mixin/immutablize_array.rb new file mode 100644 index 0000000000..cfa7266b9a --- /dev/null +++ b/lib/chef/node/mixin/immutablize_array.rb @@ -0,0 +1,67 @@ +#-- +# Copyright:: Copyright 2016, Chef Software, 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. +# + +class Chef + class Node + module Mixin + module ImmutablizeArray + # A list of methods that mutate Array. Each of these is overridden to + # raise an error, making this instances of this class more or less + # immutable. + DISALLOWED_MUTATOR_METHODS = [ + :<<, + :[]=, + :clear, + :collect!, + :compact!, + :default=, + :default_proc=, + :delete, + :delete_at, + :delete_if, + :fill, + :flatten!, + :insert, + :keep_if, + :map!, + :merge!, + :pop, + :push, + :update, + :reject!, + :reverse!, + :replace, + :select!, + :shift, + :slice!, + :sort!, + :sort_by!, + :uniq!, + :unshift, + ] + + # Redefine all of the methods that mutate a Hash to raise an error when called. + # This is the magic that makes this object "Immutable" + DISALLOWED_MUTATOR_METHODS.each do |mutator_method_name| + define_method(mutator_method_name) do |*args, &block| + raise Exceptions::ImmutableAttributeModification + end + end + end + end + end +end diff --git a/lib/chef/node/mixin/immutablize_hash.rb b/lib/chef/node/mixin/immutablize_hash.rb new file mode 100644 index 0000000000..f09e6944fc --- /dev/null +++ b/lib/chef/node/mixin/immutablize_hash.rb @@ -0,0 +1,54 @@ +#-- +# Copyright:: Copyright 2016, Chef Software, 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. +# + +class Chef + class Node + module Mixin + module ImmutablizeHash + DISALLOWED_MUTATOR_METHODS = [ + :[]=, + :clear, + :collect!, + :default=, + :default_proc=, + :delete, + :delete_if, + :keep_if, + :map!, + :merge!, + :update, + :reject!, + :replace, + :select!, + :shift, + :write, + :write!, + :unlink, + :unlink!, + ] + + # Redefine all of the methods that mutate a Hash to raise an error when called. + # This is the magic that makes this object "Immutable" + DISALLOWED_MUTATOR_METHODS.each do |mutator_method_name| + define_method(mutator_method_name) do |*args, &block| + raise Exceptions::ImmutableAttributeModification + end + end + end + end + end +end diff --git a/lib/chef/node/mixin/state_tracking.rb b/lib/chef/node/mixin/state_tracking.rb new file mode 100644 index 0000000000..9be102eeeb --- /dev/null +++ b/lib/chef/node/mixin/state_tracking.rb @@ -0,0 +1,71 @@ +#-- +# Copyright:: Copyright 2016, Chef Software, 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. +# + +class Chef + class Node + module Mixin + module StateTracking + attr_reader :__path__ + attr_reader :__root__ + + NULL = Object.new + + def initialize(data = NULL, root = self) + # __path__ and __root__ must be nil when we call super so it knows + # to avoid resetting the cache on construction + data == NULL ? super() : super(data) + @__path__ = [] + @__root__ = root + end + + def [](key) + ret = super + if ret.is_a?(StateTracking) + ret.__path__ = __path__ + [ convert_key(key) ] + ret.__root__ = __root__ + end + ret + end + + def []=(key, value) + ret = super + if ret.is_a?(StateTracking) + ret.__path__ = __path__ + [ convert_key(key) ] + ret.__root__ = __root__ + end + ret + end + + protected + + def __path__=(path) + @__path__ = path + end + + def __root__=(root) + @__root__ = root + end + + private + + def send_reset_cache(path = __path__) + __root__.reset_cache(path.first) if !__root__.nil? && __root__.respond_to?(:reset_cache) && !path.nil? + end + end + end + end +end diff --git a/spec/unit/node/vivid_mash_spec.rb b/spec/unit/node/vivid_mash_spec.rb index 206b15ef6c..017e6206fc 100644 --- a/spec/unit/node/vivid_mash_spec.rb +++ b/spec/unit/node/vivid_mash_spec.rb @@ -19,36 +19,46 @@ require "spec_helper" require "chef/node/attribute_collections" describe Chef::Node::VividMash do - class Root - attr_accessor :top_level_breadcrumb - end - - let(:root) { Root.new } + let(:root) { instance_double(Chef::Node::Attribute) } let(:vivid) do - expect(root).to receive(:reset_cache).at_least(:once).with(nil) - Chef::Node::VividMash.new(root, - { "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil } + Chef::Node::VividMash.new( + { "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }, + root ) end - def with_breadcrumb(key) - expect(root).to receive(:top_level_breadcrumb=).with(nil).at_least(:once).and_call_original - expect(root).to receive(:top_level_breadcrumb=).with(key).at_least(:once).and_call_original + context "without a root node" do + let(:vivid) do + Chef::Node::VividMash.new( + { "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil } + ) + end + + it "sets the root to the root object" do + expect(vivid["one"]["two"].__root__).to eql(vivid) + end + + it "does not send reset cache" do + # if we setup the expectation here then the object winds up responding to :reset_cache and then it fails... + # expect(vivid).not_to receive(:reset_cache) + # but even so we expect to blow up here with NoMethodError if we screw up and send :reset_cache to a root VividMash + vivid["one"]["foo"] = "bar" + end end context "#[]=" do it "deep converts values through arrays" do - allow(root).to receive(:reset_cache) - vivid[:foo] = [ { :bar => true } ] + expect(root).to receive(:reset_cache).with("foo") + vivid["foo"] = [ { :bar => true } ] expect(vivid["foo"].class).to eql(Chef::Node::AttrArray) expect(vivid["foo"][0].class).to eql(Chef::Node::VividMash) expect(vivid["foo"][0]["bar"]).to be true end it "deep converts values through nested arrays" do - allow(root).to receive(:reset_cache) - vivid[:foo] = [ [ { :bar => true } ] ] + expect(root).to receive(:reset_cache).with("foo") + vivid["foo"] = [ [ { :bar => true } ] ] expect(vivid["foo"].class).to eql(Chef::Node::AttrArray) expect(vivid["foo"][0].class).to eql(Chef::Node::AttrArray) expect(vivid["foo"][0][0].class).to eql(Chef::Node::VividMash) @@ -56,8 +66,8 @@ describe Chef::Node::VividMash do end it "deep converts values through hashes" do - allow(root).to receive(:reset_cache) - vivid[:foo] = { baz: { :bar => true } } + expect(root).to receive(:reset_cache).with("foo") + vivid["foo"] = { baz: { :bar => true } } expect(vivid["foo"]).to be_an_instance_of(Chef::Node::VividMash) expect(vivid["foo"]["baz"]).to be_an_instance_of(Chef::Node::VividMash) expect(vivid["foo"]["baz"]["bar"]).to be true @@ -66,182 +76,144 @@ describe Chef::Node::VividMash do context "#read" do before do - # vivify the vividmash, then we're read-only so the cache should never be cleared afterwards - vivid expect(root).not_to receive(:reset_cache) end it "reads hashes deeply" do - with_breadcrumb("one") expect(vivid.read("one", "two", "three")).to eql("four") end it "does not trainwreck when hitting hash keys that do not exist" do - with_breadcrumb("one") expect(vivid.read("one", "five", "six")).to eql(nil) end it "does not trainwreck when hitting an array with an out of bounds index" do - with_breadcrumb("array") expect(vivid.read("array", 5, "one")).to eql(nil) end it "does not trainwreck when hitting an array with a string key" do - with_breadcrumb("array") expect(vivid.read("array", "one", "two")).to eql(nil) end it "does not trainwreck when traversing a nil" do - with_breadcrumb("nil") expect(vivid.read("nil", "one", "two")).to eql(nil) end end context "#exist?" do before do - # vivify the vividmash, then we're read-only so the cache should never be cleared afterwards - vivid expect(root).not_to receive(:reset_cache) end it "true if there's a hash key there" do - with_breadcrumb("one") expect(vivid.exist?("one", "two", "three")).to be true end it "true for intermediate hashes" do - with_breadcrumb("one") expect(vivid.exist?("one")).to be true end it "true for arrays that exist" do - with_breadcrumb("array") expect(vivid.exist?("array", 1)).to be true end it "true when the value of the key is nil" do - with_breadcrumb("nil") expect(vivid.exist?("nil")).to be true end it "false when attributes don't exist" do - with_breadcrumb("one") expect(vivid.exist?("one", "five", "six")).to be false end it "false when traversing a non-container" do - with_breadcrumb("one") expect(vivid.exist?("one", "two", "three", "four")).to be false end it "false when an array index does not exist" do - with_breadcrumb("array") expect(vivid.exist?("array", 3)).to be false end it "false when traversing a nil" do - with_breadcrumb("nil") expect(vivid.exist?("nil", "foo", "bar")).to be false end end context "#read!" do before do - # vivify the vividmash, then we're read-only so the cache should never be cleared afterwards - vivid expect(root).not_to receive(:reset_cache) end it "reads hashes deeply" do - with_breadcrumb("one") expect(vivid.read!("one", "two", "three")).to eql("four") end it "reads arrays deeply" do - with_breadcrumb("array") expect(vivid.read!("array", 1)).to eql(1) end it "throws an exception when attributes do not exist" do - with_breadcrumb("one") expect { vivid.read!("one", "five", "six") }.to raise_error(Chef::Exceptions::NoSuchAttribute) end it "throws an exception when traversing a non-container" do - with_breadcrumb("one") expect { vivid.read!("one", "two", "three", "four") }.to raise_error(Chef::Exceptions::NoSuchAttribute) end it "throws an exception when an array element does not exist" do - with_breadcrumb("array") expect { vivid.read!("array", 3) }.to raise_error(Chef::Exceptions::NoSuchAttribute) end end context "#write" do - before do - vivid - expect(root).not_to receive(:reset_cache).with(nil) - end - it "should write into hashes" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write("one", "five", "six") expect(vivid["one"]["five"]).to eql("six") end it "should deeply autovivify" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write("one", "five", "six", "seven", "eight", "nine", "ten") expect(vivid["one"]["five"]["six"]["seven"]["eight"]["nine"]).to eql("ten") end it "should raise an exception if you overwrite an array with a hash" do - with_breadcrumb("array") expect(root).to receive(:reset_cache).at_least(:once).with("array") vivid.write("array", "five", "six") expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => { "five" => "six" }, "nil" => nil }) end it "should raise an exception if you traverse through an array with a hash" do - with_breadcrumb("array") expect(root).to receive(:reset_cache).at_least(:once).with("array") vivid.write("array", "five", "six", "seven") expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => { "five" => { "six" => "seven" } }, "nil" => nil }) end it "should raise an exception if you overwrite a string with a hash" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write("one", "two", "three", "four", "five") expect(vivid).to eql({ "one" => { "two" => { "three" => { "four" => "five" } } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you traverse through a string with a hash" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write("one", "two", "three", "four", "five", "six") expect(vivid).to eql({ "one" => { "two" => { "three" => { "four" => { "five" => "six" } } } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you overwrite a nil with a hash" do - with_breadcrumb("nil") expect(root).to receive(:reset_cache).at_least(:once).with("nil") vivid.write("nil", "one", "two") expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => { "one" => "two" } }) end it "should raise an exception if you traverse through a nil with a hash" do - with_breadcrumb("nil") expect(root).to receive(:reset_cache).at_least(:once).with("nil") vivid.write("nil", "one", "two", "three") expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => { "one" => { "two" => "three" } } }) end it "writes with a block" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write("one", "five") { "six" } expect(vivid["one"]["five"]).to eql("six") @@ -249,69 +221,55 @@ describe Chef::Node::VividMash do end context "#write!" do - before do - vivid - expect(root).not_to receive(:reset_cache).with(nil) - end - it "should write into hashes" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write!("one", "five", "six") expect(vivid["one"]["five"]).to eql("six") end it "should deeply autovivify" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write!("one", "five", "six", "seven", "eight", "nine", "ten") expect(vivid["one"]["five"]["six"]["seven"]["eight"]["nine"]).to eql("ten") end it "should raise an exception if you overwrite an array with a hash" do - with_breadcrumb("array") expect(root).not_to receive(:reset_cache) expect { vivid.write!("array", "five", "six") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you traverse through an array with a hash" do - with_breadcrumb("array") expect(root).not_to receive(:reset_cache) expect { vivid.write!("array", "five", "six", "seven") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you overwrite a string with a hash" do - with_breadcrumb("one") expect(root).not_to receive(:reset_cache) expect { vivid.write!("one", "two", "three", "four", "five") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you traverse through a string with a hash" do - with_breadcrumb("one") expect(root).not_to receive(:reset_cache) expect { vivid.write!("one", "two", "three", "four", "five", "six") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you overwrite a nil with a hash" do - with_breadcrumb("nil") expect(root).not_to receive(:reset_cache) expect { vivid.write!("nil", "one", "two") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should raise an exception if you traverse through a nil with a hash" do - with_breadcrumb("nil") expect(root).not_to receive(:reset_cache) expect { vivid.write!("nil", "one", "two", "three") }.to raise_error(Chef::Exceptions::AttributeTypeMismatch) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "writes with a block" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") vivid.write!("one", "five") { "six" } expect(vivid["one"]["five"]).to eql("six") @@ -319,41 +277,31 @@ describe Chef::Node::VividMash do end context "#unlink" do - before do - vivid - expect(root).not_to receive(:reset_cache).with(nil) - end - it "should return nil if the keys don't already exist" do - expect(root).to receive(:top_level_breadcrumb=).with(nil).at_least(:once).and_call_original expect(root).not_to receive(:reset_cache) expect(vivid.unlink("five", "six", "seven", "eight")).to eql(nil) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should unlink hashes" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") expect( vivid.unlink("one") ).to eql({ "two" => { "three" => "four" } }) expect(vivid).to eql({ "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should unlink array elements" do - with_breadcrumb("array") expect(root).to receive(:reset_cache).at_least(:once).with("array") expect(vivid.unlink("array", 2)).to eql(2) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1 ], "nil" => nil }) end it "should unlink nil" do - with_breadcrumb("nil") expect(root).to receive(:reset_cache).at_least(:once).with("nil") expect(vivid.unlink("nil")).to eql(nil) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ] }) end it "should traverse a nil and safely do nothing" do - with_breadcrumb("nil") expect(root).not_to receive(:reset_cache) expect(vivid.unlink("nil", "foo")).to eql(nil) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) @@ -361,41 +309,31 @@ describe Chef::Node::VividMash do end context "#unlink!" do - before do - vivid - expect(root).not_to receive(:reset_cache).with(nil) - end - it "should raise an exception if the keys don't already exist" do - expect(root).to receive(:top_level_breadcrumb=).with(nil).at_least(:once).and_call_original expect(root).not_to receive(:reset_cache) expect { vivid.unlink!("five", "six", "seven", "eight") }.to raise_error(Chef::Exceptions::NoSuchAttribute) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should unlink! hashes" do - with_breadcrumb("one") expect(root).to receive(:reset_cache).at_least(:once).with("one") expect( vivid.unlink!("one") ).to eql({ "two" => { "three" => "four" } }) expect(vivid).to eql({ "array" => [ 0, 1, 2 ], "nil" => nil }) end it "should unlink! array elements" do - with_breadcrumb("array") expect(root).to receive(:reset_cache).at_least(:once).with("array") expect(vivid.unlink!("array", 2)).to eql(2) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1 ], "nil" => nil }) end it "should unlink! nil" do - with_breadcrumb("nil") expect(root).to receive(:reset_cache).at_least(:once).with("nil") expect(vivid.unlink!("nil")).to eql(nil) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ] }) end it "should raise an exception if it traverses a nil" do - with_breadcrumb("nil") expect(root).not_to receive(:reset_cache) expect { vivid.unlink!("nil", "foo") }.to raise_error(Chef::Exceptions::NoSuchAttribute) expect(vivid).to eql({ "one" => { "two" => { "three" => "four" } }, "array" => [ 0, 1, 2 ], "nil" => nil }) diff --git a/spec/unit/node_spec.rb b/spec/unit/node_spec.rb index a78ad2c076..cfc19db480 100644 --- a/spec/unit/node_spec.rb +++ b/spec/unit/node_spec.rb @@ -1709,4 +1709,107 @@ describe Chef::Node do end end + describe "path tracking via __path__" do + it "works through hash keys" do + node.default["foo"] = { "bar" => { "baz" => "qux" } } + expect(node["foo"]["bar"].__path__).to eql(%w{foo bar}) + end + + it "works through the default level" do + node.default["foo"] = { "bar" => { "baz" => "qux" } } + expect(node.default["foo"]["bar"].__path__).to eql(%w{foo bar}) + end + + it "works through arrays" do + node.default["foo"] = [ { "bar" => { "baz" => "qux" } } ] + expect(node["foo"][0].__path__).to eql(["foo", 0]) + expect(node["foo"][0]["bar"].__path__).to eql(["foo", 0, "bar"]) + end + + it "works through arrays at the default level" do + node.default["foo"] = [ { "bar" => { "baz" => "qux" } } ] + expect(node.default["foo"][0].__path__).to eql(["foo", 0]) + expect(node.default["foo"][0]["bar"].__path__).to eql(["foo", 0, "bar"]) + end + + # if we set __path__ in the initializer we'd get this wrong, this is why we + # update the path on every #[] or #[]= operator + it "works on access when the node has been rearranged" do + node.default["foo"] = { "bar" => { "baz" => "qux" } } + a = node.default["foo"] + node.default["fizz"] = a + expect(node["fizz"]["bar"].__path__).to eql(%w{fizz bar}) + expect(node["foo"]["bar"].__path__).to eql(%w{foo bar}) + end + + # We have a problem because the __path__ is stored on in each node, but the + # node can be wired up at multiple locations in the tree via pointers. One + # solution would be to deep-dup the value in `#[]=(key, value)` and fix the + # __path__ on all the dup'd nodes. The problem is that this would create an + # unusual situation where after assignment, you couldn't mutate the thing you + # hand a handle on. I'm not entirely positive this behavior is the correct + # thing to support, but it is more hash-like (although if we start with a hash + # then convert_value does its thing and we *do* get dup'd on assignment). This + # behavior likely makes any implementation of a deep merge cache built over the + # top of __path__ tracking have edge conditions where it will fail. + # + # Removing this support would be a breaking change. The test is included here + # because it seems most likely that someone would break this behavior while trying + # to fix __path__ behavior. + it "does not dup in the background when a node is assigned" do + # get a handle on a vividmash (can't be a hash or else we convert_value it) + node.default["foo"] = { "bar" => { "baz" => "qux" } } + a = node.default["foo"] + # assign that somewhere else in the tree + node.default["fizz"] = a + # now upate the source + a["duptest"] = true + # the tree should have been updated + expect(node.default["fizz"]["duptest"]).to be true + expect(node["fizz"]["duptest"]).to be true + end + end + + describe "root tracking via __root__" do + it "works through hash keys" do + node.default["foo"] = { "bar" => { "baz" => "qux" } } + expect(node["foo"]["bar"].__root__).to eql(node.attributes) + end + + it "works through the default level" do + node.default["foo"] = { "bar" => { "baz" => "qux" } } + expect(node.default["foo"]["bar"].__root__).to eql(node.attributes) + end + + it "works through arrays" do + node.default["foo"] = [ { "bar" => { "baz" => "qux" } } ] + expect(node["foo"][0].__root__).to eql(node.attributes) + expect(node["foo"][0]["bar"].__root__).to eql(node.attributes) + end + + it "works through arrays at the default level" do + node.default["foo"] = [ { "bar" => { "baz" => "qux" } } ] + expect(node.default["foo"][0].__root__).to eql(node.attributes) + expect(node.default["foo"][0]["bar"].__root__).to eql(node.attributes) + end + end + + describe "ways of abusing Chef 12 node state" do + # these tests abuse the top_level_breadcrumb state in Chef 12 + it "derived attributes work correctly" do + node.default["v1"] = 1 + expect(node["a"]).to eql(nil) + node.default["a"] = node["v1"] + expect(node["a"]).to eql(1) + end + + it "works when saving nodes to variables" do + a = node.default["a"] + expect(node["a"]).to eql({}) + node.default["b"] = 0 + a["key"] = 1 + + expect(node["a"]["key"]).to eql(1) + end + end end |