docs/dev/design_documents/resource_load_and_converge_methods.md
With the introduction of action on resources, it becomes useful to have a
blessed way to get the actual value of the resource. This proposal adds
load_current_value and converge_if_changed to help with this purpose, enabling:
As a Chef resource writer,
I want to be able to read the current value of my resource at converge time,
so that it is easy to tell the difference between current and desired value.
As a Chef resource writer,
I want a converge model that compares current and desired values for me,
So that the easiest converge to write is the most correct one.
load_current_value: in-place resource loadWhen using action, one needs a way to load the actual system value of the resource, so that it can be compared to the desired value, and a decision made as to whether to change anything.
When the resource writer defines load_current_value on the resource class, it can be called to load the real system value into the resource. Before any action runs, this will be used by load_current_resource to load the resource. action will do some important work before calling the new method:
load_current_value on the new instance.class File < Chef::Resource
property :path, name_attribute: true
property :mode, default: 0666
property :content
load_current_value do
current_value_does_not_exist! unless File.exist?(path)
mode File.stat(path).mode
content IO.read(path)
end
action :create do
converge_if_changed do
File.chmod(mode, path)
IO.write(path, content)
end
end
end
file '/x.txt' do
# Before the change, the above code would have modified `mode` to be `0666`.
# After, it leaves `mode` alone.
content 'Hello World'
end
To appropriately handle actual value loading, the user needs a way to specify that the actual value legitimately does not exist, rather than simply not filling in the object and getting nils in it. If load_current_value raises Chef::Exceptions::ActualValueDoesNotExist, the new resource will be discarded and current_resource becomes nil. The current_value_does_not_exist! method can be called to raise this.
NOTE: The alternative was to have users return false if the resource does not exist, but I didn't want users to be forced into the ceremony of a trailing true line.
load_current_value do
# Check for existence before doing anything else.
current_value_does_not_exist! if !File.exist?(path)
# Set "mode" on the resource.
mode File.stat(path).mode
end
The block will also be passed the original (desired) resource as a parameter, in case it is needed.
super in load_current_value! will call the superclass's load_current_value! method.
The new resource is created with all properties copied over except desired state properties (properties in ResourceClass.state_properties). This means name, and properties with identity: true or desired_state: false are copied over. Normal property and attribute are not.
class DataBagItem < Chef::Resource
# Copied
attribute :item_name, name_attribute: true
attribute :data_bag_name, identity: true
attribute :recursively_delete, desired_state: false
# Not copied:
attribute :data
def load_current_value!
data Chef::DataBagItem.new(data_bag_name, item_name).data
end
end
converge_if_changed: automatic test-and-setThe new converge_if_changed do ... end syntax is added to actions, which enables a lot of help for resource writers to make safe, effective resources. It performs several key tasks common to nearly every resource (which are often not done correctly):
converge_if_changed block if why-run is enabled).class File < Chef::Resource
property :path, name_attribute: true
property :content
load_current_value do
current_value_does_not_exist! unless File.exist?(path)
content IO.read(path)
end
action :create do
converge_if_changed do
IO.write(path, content)
end
end
end
Here is a sample converge_if_changed statement from a hypothetical FooBarBaz resource with properties foo, bar and baz:
converge_if_changed do
if current_resource
FooBarBaz.update(new_resource.id, new_resource.foo, new_resource.bar, new_resource.baz)
else
FooBarBaz.create(new_resource.id, new_resource.foo, new_resource.bar, new_resource.baz)
end
end
This is what you would have to write to do the equivalent:
if current_resource
# We're updating; look for properties that the user wants to change (do the "test" part of test-and-set)
differences = []
if (new_resource.property_is_set?(:foo) && new_resource.foo != current_resource.foo)
differences << "foo = #{new_resource.foo}"
end
if (new_resource.property_is_set?(:bar) && new_resource.bar != current_resource.bar)
differences << "bar = #{new_resource.bar}"
end
if (new_resource.property_is_set?(:baz) && new_resource.baz != current_resource.baz)
differences << "baz = #{new_resource.baz}"
end
if !differences.empty?
converge_by "updating FooBarBaz #{new_resource.id}, setting #{differences.join(", ")}" do
FooBarBaz.create(new_resource.id, new_resource.foo, new_resource.bar, new_resource.baz)
end
end
else
# If the current resource doesn't exist, we're definitely creating it
converge_by "creating FooBarBaz #{new_resource.id} with foo = #{new_resource.foo}, bar = #{new_resource.bar}, baz = #{new_resource.baz}" do
FooBarBaz.update(new_resource.id, new_resource.foo, new_resource.bar, new_resource.baz)
end
end
The easiest way to write a resource must be the most correct one.
There is a subtle pitfall when updating a resource, where the user has set some values, but not all. One can easily end up writing a resource, which will overwrite perfectly good system properties with their defaults, and can cause instability. If the user does not specify a property, it is generally preferable to preserve its existing value rather than overwrite it.
To prevent this, referencing the bare property in an action will now yield the actual value if load_current_value succeeded, and the default value if we are creating a new resource (if load_current_value raised ActualValueDoesNotExist).
class File < Chef::Resource
property :path, name_attribute: true
property :mode, default: 0666
property :content
load_current_value do
current_value_does_not_exist! unless File.exist?(path)
mode File.stat(path).mode
content IO.read(path)
end
action :create do
converge_if_changed do
File.chmod(mode, path)
IO.write(path, content)
end
end
end
file '/x.txt' do
# Before the change, the above code would have modified `mode` to be `0666`.
# After, it leaves `mode` alone.
content 'Hello World'
end
There will be times when the old behavior of overwriting with defaults is desired. The resource writer can still find out whether mode was set with property_is_set?(:mode), and can still access the default value with new_resource.mode if it is not set.
There are no backwards-compatibility issues with this because it only applies to action, which has not been released yet.
Some resources perform several different (possibly expensive) operations, depending on what is set. converge_if_changed :attribute1, :attribute2, ... do allows the user to target different groups of changes based on exactly which attributes have changed:
class File < Chef::Resource
property :path, name_attribute: true
property :mode
property :content
load_current_value do
current_value_does_not_exist! unless File.exist?(path)
mode File.stat(path).mode
content IO.read(path)
end
action :create do
converge_if_changed :mode do
File.chmod(mode, path)
end
converge_if_changed :content do
IO.write(path, content)
end
end
end