summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Keiser <john@johnkeiser.com>2015-07-27 09:38:47 -0600
committerJohn Keiser <john@johnkeiser.com>2015-07-27 09:38:47 -0600
commit28e20d62f3dcb29f34c2f67b41a1b486108f388d (patch)
tree75b842d58b932412e8d6d34ed9fe48053169d83f
parentca8a8bdb000c7cb82588cc56ddfb32464babf3c3 (diff)
parentfbb483ec880264262494e908a00445c007aacc59 (diff)
downloadchef-28e20d62f3dcb29f34c2f67b41a1b486108f388d.tar.gz
Merge branch 'jk/resource_load'
-rw-r--r--DOC_CHANGES.md268
-rw-r--r--lib/chef/property.rb2
-rw-r--r--lib/chef/provider.rb21
-rw-r--r--lib/chef/resource.rb55
-rw-r--r--spec/integration/recipes/resource_load_spec.rb206
5 files changed, 548 insertions, 4 deletions
diff --git a/DOC_CHANGES.md b/DOC_CHANGES.md
index 92a178e20b..2073b4c089 100644
--- a/DOC_CHANGES.md
+++ b/DOC_CHANGES.md
@@ -22,3 +22,271 @@ will set the node's environment to `"pre-production"`.
> *Note that the environment specified by `chef_environment` in your JSON will
take precedence over an environment specified by `-E ENVIROMENT` when both options
are provided.*
+
+### Resources Made Easy
+
+Resources are central to Chef. The system is extensible so that you can write
+your own reusable resources, and use them in your recipes, and even publish them
+so that others can use them too!
+
+However, writing these resources has not been as easy as we would have liked. In
+Chef 12.5, we are fixing this with a large number of DSL improvements designed
+to reduce the number of things you need to type and think about when you create
+a resource. Resources should be your go-to solution for many Chef problems, and
+these changes make them easy enough to dash off in an instant, while retaining
+all the power you're accustomed to.
+
+The process to create a resource is now:
+
+1. Make a resource file in your cookbook (like `resources/my_resource.rb`).
+2. Add the recipes defining your actions using the `action :create <recipe>` DSL.
+3. Add properties so the user can tweak some knobs on your resource (like paths,
+ or preferences), using the `property :my_property, <type>, <options>` DSL.
+4. Use the resource in your recipe!
+
+There are other things you can do, but this is the most basic (and the first)
+thing you will start with.
+
+Let's demonstrate the new features by taking a simple recipe from the awesome
+[learnchef tutorial](https://learn.chef.io/learn-the-basics/rhel/configure-a-package-and-service/),
+and turning it into a reusable resource:
+
+```ruby
+package 'httpd'
+
+service 'httpd' do
+ action [:enable, :start]
+end
+
+file '/var/www/html/index.html' do
+ content '<html>
+ <body>
+ <h1>hello world</h1>
+ </body>
+</html>'
+end
+
+service 'iptables' do
+ action :stop
+end
+```
+
+We'll design a resource that lets you write this recipe instead:
+
+```ruby
+single_page_website 'mysite' do
+ homepage '<html>
+ <body>
+ <h1>hello world</h1>
+ </body>
+ </html>'
+end
+```
+
+#### Declaring the Resource
+
+The first thing we do is declare the resource. We can do that by creating an
+empty file, `resources/single_page_website.rb`, in our cookbook.
+
+When you do this, the `single_page_website` resource will work in all recipes!
+
+```ruby
+single_page_website 'mysite'
+```
+
+It won't do anything yet, though :)
+
+#### Declaring an Action
+
+Let's make our resource do something. To start with, we'll just have it do exactly
+what the learnchef tutorial does, but in the resource. Put this in
+`resources/single_page_website.rb`:
+
+```ruby
+action :create do
+ package 'httpd'
+
+ service 'httpd' do
+ action [:enable, :start]
+ end
+
+ file '/var/www/html/index.html' do
+ content '<html>
+ <body>
+ <h1>hello world</h1>
+ </body>
+ </html>'
+ end
+
+ service 'iptables' do
+ action :stop
+ end
+end
+```
+
+Now, your simple recipe can use this resource to do what learnchef did:
+
+```ruby
+single_page_website 'mysite'
+```
+
+We've got ourselves an httpd!
+
+You will notice the only thing we've done is to add `action :create` around the
+recipe. The `action` keyword lets you declare a recipe inline, which will be
+executed when the user uses your resource in a recipe.
+
+#### Declaring a resource property: "homepage"
+
+This isn't super reusable yet--you might want your webpage to say something other
+than "hello world". Let's add a couple of properties for that, by putting this
+at the top of `resources/single_page_website`, and modifying the recipe to use
+"title" and "body":
+
+```ruby
+property :homepage, String, default: '<h1>hello world</h1>'
+
+action :create do
+ package 'httpd'
+
+ service 'httpd' do
+ action [:enable, :start]
+ end
+
+ file '/var/www/html/index.html' do
+ content homepage
+ end
+
+ service 'iptables' do
+ action :stop
+ end
+end
+```
+
+Now you can run this recipe:
+
+```ruby
+single_page_website 'mysite' do
+ homepage '<h1>My own page</h1>'
+end
+```
+
+And you've got a website with your stuff!
+
+What you've done here is add *properties*. Properties are the *desired state* of
+a resource, in this case, `homepage` defines the text on the website. When you
+add a property, you're letting a user give it whatever value they want.
+
+When you define a property, there are three bits:
+`property :<name>, <type>, <options>`. *Name* defines the name of the property,
+so that people can set the property using `name <value>` when they use your
+resource. *Type* defines the type of the property: for example, String, Integer
+and Array are all possible types. Type is optional. *Options* define a large
+number of validation and other options. You've seen `default` already now,
+but there are a ton of others.
+
+#### Adding another property: "not_found_page"
+
+What if we want a custom 404 page for when people try to go to other pages in
+our website? Let's add one more property, to make this even nicer:
+
+```ruby
+property :homepage, String, default: '<h1>hello world</h1>'
+property :not_found_page, String, default: '<h1>No such page! Sorry. 404.</h1>'
+
+action :create do
+ package 'httpd'
+
+ service 'httpd' do
+ action [:enable, :start]
+ end
+
+ file '/var/www/html/index.html' do
+ content homepage
+ end
+
+ # These together tell Apache to use your custom 404 page:
+ file '/var/www/html/404.html' do
+ content not_found_page
+ end
+ file '/var/www/html/.htaccess' do
+ content 'ErrorDocument 404 /404.html'
+ end
+
+ service 'iptables' do
+ action :stop
+ end
+end
+```
+
+Now you can run this recipe:
+
+```ruby
+single_page_website 'mysite' do
+ homepage '<h1>My own page</h1>'
+ not_found_page '<h1>Grr. Page not found. Sorry. (404)</h1>'
+end
+```
+
+#### Adding another action: "stop"
+
+What if we want to stop the website? Just add another action into the bottom of
+`resources/single_page_website.rb`:
+
+```ruby
+action :stop do
+ service 'httpd' do
+ action :stop
+ end
+end
+```
+
+This action looks a lot like the other.
+
+There are a ton of other things you can do to create resources, but this should
+give you a pretty basic idea.
+
+### Advanced Resource Capabilities
+
+#### Ruby Developers: Resources as Classes
+
+If you are a Ruby developer, we've made it easier to create a Resource outside
+of a cookbook (or in a library) by declaring a class! Declare
+`class SinglePageWebsite < Chef::Resource` and put the entire resource
+declaration inside, and the `single_page_website` resource will work!
+
+#### Reading the current value: load_current_value
+
+There is a pitfall inherent in a resource, where users will sometimes omit a
+property from a resource, and become surprised when the system overwrites it
+with the default! For example, if your website already exists, this recipe
+will replace the *homepage* with "hello world":
+
+```ruby
+single_page_website 'mysite' do
+ not_found_page '<h1>nice</h1>'
+end
+```
+
+It's not at all clear that that's what the user wanted--they didn't say anything
+about the homepage, so why did something happen to it?
+
+To guard against this, you can implement `load_current_value` in your resource.
+Put this in `resources/single_page_website.rb`:
+
+```ruby
+load_current_value do
+ if File.exist?('/var/www/html/index.html')
+ homepage IO.read('/var/www/html/index.html')
+ end
+ if File.exist?('/var/www/html/404.html')
+ not_found_page IO.read('/var/www/html/404.html')
+ end
+end
+```
+
+Now, the above recipe knows what the current homepage is, and will not change it!
+
+This capability is also used for several other things, including reporting (to
+describe what changed) and deeper custom resources (ones that don't use recipes,
+which we'll cover later).
diff --git a/lib/chef/property.rb b/lib/chef/property.rb
index 408090d37b..6579eb06a8 100644
--- a/lib/chef/property.rb
+++ b/lib/chef/property.rb
@@ -86,7 +86,7 @@ class Chef
#
def initialize(**options)
options.each { |k,v| options[k.to_sym] = v if k.is_a?(String) }
- options[:name_property] = options.delete(:name_attribute) unless options.has_key?(:name_property)
+ options[:name_property] = options.delete(:name_attribute) if options.has_key?(:name_attribute) && !options.has_key?(:name_property)
@options = options
if options.has_key?(:default)
diff --git a/lib/chef/provider.rb b/lib/chef/provider.rb
index dcfc92645b..43d58740f2 100644
--- a/lib/chef/provider.rb
+++ b/lib/chef/provider.rb
@@ -211,10 +211,29 @@ class Chef
extend Forwardable
define_singleton_method(:to_s) { "#{resource_class} forwarder module" }
define_singleton_method(:inspect) { to_s }
+ # Add a delegator for each explicit property that will get the *current* value
+ # of the property by default instead of the *actual* value.
+ resource.class.properties.each do |name, property|
+ class_eval(<<-EOM, __FILE__, __LINE__)
+ def #{name}(*args, &block)
+ # If no arguments were passed, we process "get" by defaulting
+ # the value to current_resource, not new_resource. This helps
+ # avoid issues where resources accidentally overwrite perfectly
+ # valid stuff with default values.
+ if args.empty? && !block
+ if !new_resource.property_is_set?(__method__) && current_resource
+ return current_resource.public_send(__method__)
+ end
+ end
+ new_resource.public_send(__method__, *args, &block)
+ end
+ EOM
+ end
dsl_methods =
resource.class.public_instance_methods +
resource.class.protected_instance_methods -
- provider_class.instance_methods
+ provider_class.instance_methods -
+ resource.class.properties.keys
def_delegators(:new_resource, *dsl_methods)
end
include @included_resource_dsl_module
diff --git a/lib/chef/resource.rb b/lib/chef/resource.rb
index dca0033409..bb5c6050fe 100644
--- a/lib/chef/resource.rb
+++ b/lib/chef/resource.rb
@@ -1375,6 +1375,34 @@ class Chef
end
#
+ # Define a method to load up this resource's properties with the current
+ # actual values.
+ #
+ # @param load_block The block to load. Will be run in the context of a newly
+ # created resource with its identity values filled in.
+ #
+ def self.load_current_value(&load_block)
+ define_method(:load_current_value!, &load_block)
+ end
+
+ #
+ # Get the current actual value of this resource.
+ #
+ # This does not cache--a new value will be returned each time.
+ #
+ # @return A new copy of the resource, with values filled in from the actual
+ # current value.
+ #
+ def current_resource
+ provider = provider_for_action(Array(action).first)
+ if provider.whyrun_mode? && !provider.whyrun_supported?
+ raise "Cannot retrieve #{self.class.current_resource} in why-run mode: #{provider} does not support why-run"
+ end
+ provider.load_current_resource
+ provider.current_resource
+ end
+
+ #
# The action provider class is an automatic `Provider` created to handle
# actions declared by `action :x do ... end`.
#
@@ -1414,8 +1442,31 @@ class Chef
use_inline_resources
include_resource_dsl true
define_singleton_method(:to_s) { "#{resource_class} action provider" }
- define_singleton_method(:inspect) { to_s }
- define_method(:load_current_resource) {}
+ def self.inspect
+ to_s
+ end
+ def load_current_resource
+ if new_resource.respond_to?(:load_current_value!)
+ # dup the resource and then reset desired-state properties.
+ current_resource = new_resource.dup
+
+ # We clear desired state in the copy, because it is supposed to be actual state.
+ # We keep identity properties and non-desired-state, which are assumed to be
+ # "control" values like `recurse: true`
+ current_resource.class.properties.each do |name,property|
+ if property.desired_state? && !property.identity? && !property.name_property?
+ property.reset(current_resource)
+ end
+ end
+
+ if current_resource.method(:load_current_value!).arity > 0
+ current_resource.load_current_value!(new_resource)
+ else
+ current_resource.load_current_value!
+ end
+ end
+ @current_resource = current_resource
+ end
end
end
diff --git a/spec/integration/recipes/resource_load_spec.rb b/spec/integration/recipes/resource_load_spec.rb
new file mode 100644
index 0000000000..c29b877b59
--- /dev/null
+++ b/spec/integration/recipes/resource_load_spec.rb
@@ -0,0 +1,206 @@
+require 'support/shared/integration/integration_helper'
+
+describe "Resource.load_current_value" do
+ include IntegrationSupport
+
+ module Namer
+ extend self
+ attr_accessor :current_index
+ def incrementing_value
+ @incrementing_value += 1
+ @incrementing_value
+ end
+ attr_writer :incrementing_value
+ end
+
+ before(:all) { Namer.current_index = 1 }
+ before { Namer.current_index += 1 }
+ before { Namer.incrementing_value = 0 }
+
+ let(:resource_name) { :"load_current_value_dsl#{Namer.current_index}" }
+ let(:resource_class) {
+ result = Class.new(Chef::Resource) do
+ def self.to_s; resource_name; end
+ def self.inspect; resource_name.inspect; end
+ property :x, default: lazy { "default #{Namer.incrementing_value}" }
+ def self.created_x=(value)
+ @created = value
+ end
+ def self.created_x
+ @created
+ end
+ action :create do
+ new_resource.class.created_x = x
+ end
+ end
+ result.resource_name resource_name
+ result
+ }
+
+ # Pull on resource_class to initialize it
+ before { resource_class }
+
+ context "with a resource with load_current_value" do
+ before :each do
+ resource_class.load_current_value do
+ x "loaded #{Namer.incrementing_value} (#{self.class.properties.sort_by { |name,p| name }.
+ select { |name,p| p.is_set?(self) }.
+ map { |name,p| "#{name}=#{p.get(self)}" }.
+ join(", ") })"
+ end
+ end
+
+ context "and a resource with x set to a desired value" do
+ let(:resource) do
+ e = self
+ r = nil
+ converge {
+ r = public_send(e.resource_name, 'blah') do
+ x 'desired'
+ end
+ }
+ r
+ end
+
+ it "current_resource is passed name but not x" do
+ expect(resource.current_resource.x).to eq 'loaded 2 (name=blah)'
+ end
+
+ it "resource.current_resource returns a different resource" do
+ expect(resource.current_resource.x).to eq 'loaded 2 (name=blah)'
+ expect(resource.x).to eq 'desired'
+ end
+
+ it "resource.current_resource constructs the resource anew each time" do
+ expect(resource.current_resource.x).to eq 'loaded 2 (name=blah)'
+ expect(resource.current_resource.x).to eq 'loaded 3 (name=blah)'
+ end
+
+ it "the provider accesses the current value of x" do
+ expect(resource.class.created_x).to eq 'desired'
+ end
+
+ context "and identity: :i and :d with desired_state: false" do
+ before {
+ resource_class.class_eval do
+ property :i, identity: true
+ property :d, desired_state: false
+ end
+ }
+
+ before {
+ resource.i 'desired_i'
+ resource.d 'desired_d'
+ }
+
+ it "i, name and d are passed to load_current_value, but not x" do
+ expect(resource.current_resource.x).to eq 'loaded 2 (d=desired_d, i=desired_i, name=blah)'
+ end
+ end
+
+ context "and name_property: :i and :d with desired_state: false" do
+ before {
+ resource_class.class_eval do
+ property :i, name_property: true
+ property :d, desired_state: false
+ end
+ }
+
+ before {
+ resource.i 'desired_i'
+ resource.d 'desired_d'
+ }
+
+ it "i, name and d are passed to load_current_value, but not x" do
+ expect(resource.current_resource.x).to eq 'loaded 2 (d=desired_d, i=desired_i, name=blah)'
+ end
+ end
+ end
+
+ context "and a resource with no values set" do
+ let(:resource) do
+ e = self
+ r = nil
+ converge {
+ r = public_send(e.resource_name, 'blah') do
+ end
+ }
+ r
+ end
+
+ it "the provider accesses values from load_current_value" do
+ expect(resource.class.created_x).to eq 'loaded 1 (name=blah)'
+ end
+ end
+
+ let (:subresource_name) {
+ :"load_current_value_subresource_dsl#{Namer.current_index}"
+ }
+ let (:subresource_class) {
+ r = Class.new(resource_class) do
+ property :y, default: lazy { "default_y #{Namer.incrementing_value}" }
+ end
+ r.resource_name subresource_name
+ r
+ }
+
+ # Pull on subresource_class to initialize it
+ before { subresource_class }
+
+ let(:subresource) do
+ e = self
+ r = nil
+ converge {
+ r = public_send(e.subresource_name, 'blah') do
+ x 'desired'
+ end
+ }
+ r
+ end
+
+ context "and a child resource class with no load_current_value" do
+ it "the parent load_current_value is used" do
+ expect(subresource.current_resource.x).to eq 'loaded 2 (name=blah)'
+ end
+ it "load_current_value yields a copy of the child class" do
+ expect(subresource.current_resource).to be_kind_of(subresource_class)
+ end
+ end
+
+ context "And a child resource class with load_current_value" do
+ before {
+ subresource_class.load_current_value do
+ y "loaded_y #{Namer.incrementing_value} (#{self.class.properties.sort_by { |name,p| name }.
+ select { |name,p| p.is_set?(self) }.
+ map { |name,p| "#{name}=#{p.get(self)}" }.
+ join(", ") })"
+ end
+ }
+
+ it "the overridden load_current_value is used" do
+ current_resource = subresource.current_resource
+ expect(current_resource.x).to eq 'default 3'
+ expect(current_resource.y).to eq 'loaded_y 2 (name=blah)'
+ end
+ end
+
+ context "and a child resource class with load_current_value calling super()" do
+ before {
+ subresource_class.load_current_value do
+ super()
+ y "loaded_y #{Namer.incrementing_value} (#{self.class.properties.sort_by { |name,p| name }.
+ select { |name,p| p.is_set?(self) }.
+ map { |name,p| "#{name}=#{p.get(self)}" }.
+ join(", ") })"
+ end
+ }
+
+ it "the original load_current_value is called as well as the child one" do
+ current_resource = subresource.current_resource
+ expect(current_resource.x).to eq 'loaded 3 (name=blah)'
+ expect(current_resource.y).to eq 'loaded_y 4 (name=blah, x=loaded 3 (name=blah))'
+ end
+ end
+ end
+
+end