module Ridley::Chef class Cookbook # Borrowed and modified from: {https://raw.github.com/opscode/chef/11.4.0/lib/chef/cookbook/metadata.rb} # # Copyright:: Copyright 2008-2010 Opscode, Inc. # # 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. # # == Chef::Cookbook::Metadata # Chef::Cookbook::Metadata provides a convenient DSL for declaring metadata # about Chef Cookbooks. class Metadata class << self def from_hash(hash) new.from_hash(hash) end def from_json(json) new.from_json(json) end end NAME = 'name'.freeze DESCRIPTION = 'description'.freeze LONG_DESCRIPTION = 'long_description'.freeze MAINTAINER = 'maintainer'.freeze MAINTAINER_EMAIL = 'maintainer_email'.freeze LICENSE = 'license'.freeze PLATFORMS = 'platforms'.freeze DEPENDENCIES = 'dependencies'.freeze RECOMMENDATIONS = 'recommendations'.freeze SUGGESTIONS = 'suggestions'.freeze CONFLICTING = 'conflicting'.freeze PROVIDING = 'providing'.freeze REPLACING = 'replacing'.freeze ATTRIBUTES = 'attributes'.freeze GROUPINGS = 'groupings'.freeze RECIPES = 'recipes'.freeze VERSION = 'version'.freeze SOURCE_URL = 'source_url'.freeze ISSUES_URL = 'issues_url'.freeze PRIVACY = "privacy".freeze CHEF_VERSIONS = "chef_versions".freeze OHAI_VERSIONS = "ohai_versions".freeze GEMS = "gems".freeze COMPILED_FILE_NAME = "metadata.json".freeze RAW_FILE_NAME = "metadata.rb".freeze COMPARISON_FIELDS = [ :name, :description, :long_description, :maintainer, :maintainer_email, :license, :platforms, :dependencies, :recommendations, :suggestions, :conflicting, :providing, :replacing, :attributes, :groupings, :recipes, :version, :source_url, :issues_url, :privacy, :chef_versions, :ohai_versions, :gems ] include Ridley::Mixin::ParamsValidate include Ridley::Mixin::FromFile attr_reader :cookbook attr_reader :platforms attr_reader :dependencies attr_reader :recommendations attr_reader :suggestions attr_reader :conflicting attr_reader :providing attr_reader :replacing attr_reader :attributes attr_reader :groupings attr_reader :recipes attr_reader :version # @return [Array] Array of supported Chef versions attr_reader :chef_versions # @return [Array] Array of supported Ohai versions attr_reader :ohai_versions # @return [Array] Array of gems to install with *args as an Array attr_reader :gems # Builds a new Chef::Cookbook::Metadata object. # # === Parameters # cookbook:: An optional cookbook object # maintainer:: An optional maintainer # maintainer_email:: An optional maintainer email # license::An optional license. Default is Apache v2.0 # # === Returns # metadata def initialize(cookbook = nil, maintainer = 'YOUR_COMPANY_NAME', maintainer_email = 'YOUR_EMAIL', license = 'none') @cookbook = cookbook @name = cookbook ? cookbook.name : "" @long_description = "" self.maintainer(maintainer) self.maintainer_email(maintainer_email) self.license(license) self.description('A fabulous new cookbook') @platforms = Hashie::Mash.new @dependencies = Hashie::Mash.new @recommendations = Hashie::Mash.new @suggestions = Hashie::Mash.new @conflicting = Hashie::Mash.new @providing = Hashie::Mash.new @replacing = Hashie::Mash.new @attributes = Hashie::Mash.new @groupings = Hashie::Mash.new @recipes = Hashie::Mash.new @version = Semverse::Version.new("0.0.0") @source_url = '' @issues_url = '' @privacy = false @chef_versions = [] @ohai_versions = [] @gems = [] if cookbook @recipes = cookbook.fully_qualified_recipe_names.inject({}) do |r, e| e = self.name if e =~ /::default$/ r[e] = "" self.provides e r end end end def ==(other) COMPARISON_FIELDS.inject(true) do |equal_so_far, field| equal_so_far && other.respond_to?(field) && (other.send(field) == send(field)) end end # Ensure that we don't have to update Ridley every time we add # a new metadata field to Chef def method_missing(method_sym, *args) Ridley::Logging.logger.warn "Ignoring unknown metadata" end # Sets the cookbooks maintainer, or returns it. # # === Parameters # maintainer:: The maintainers name # # === Returns # maintainer:: Returns the current maintainer. def maintainer(arg = nil) set_or_return( :maintainer, arg, :kind_of => [ String ] ) end # Sets the maintainers email address, or returns it. # # === Parameters # maintainer_email:: The maintainers email address # # === Returns # maintainer_email:: Returns the current maintainer email. def maintainer_email(arg = nil) set_or_return( :maintainer_email, arg, :kind_of => [ String ] ) end # Sets the current license, or returns it. # # === Parameters # license:: The current license. # # === Returns # license:: Returns the current license def license(arg = nil) set_or_return( :license, arg, :kind_of => [ String ] ) end # Sets the current description, or returns it. Should be short - one line only! # # === Parameters # description:: The new description # # === Returns # description:: Returns the description def description(arg = nil) set_or_return( :description, arg, :kind_of => [ String ] ) end # Sets the current long description, or returns it. Might come from a README, say. # # === Parameters # long_description:: The new long description # # === Returns # long_description:: Returns the long description def long_description(arg = nil) set_or_return( :long_description, arg, :kind_of => [ String ] ) end # Sets the current cookbook version, or returns it. Can be two or three digits, seperated # by dots. ie: '2.1', '1.5.4' or '0.9'. # # === Parameters # version:: The curent version, as a string # # === Returns # version:: Returns the current version def version(arg = nil) if arg @version = Semverse::Version.new(arg) end @version.to_s end # Sets the name of the cookbook, or returns it. # # === Parameters # name:: The curent cookbook name. # # === Returns # name:: Returns the current cookbook name. def name(arg = nil) set_or_return( :name, arg, :kind_of => [ String ] ) end # Adds a supported platform, with version checking strings. # # === Parameters # platform,:: The platform (like :ubuntu or :mac_os_x) # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has # the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def supports(platform, *version_args) version = version_args.first @platforms[platform] = Semverse::Constraint.new(version).to_s @platforms[platform] end # Adds a dependency on another cookbook, with version checking strings. # # === Parameters # cookbook:: The cookbook # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has # the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def depends(cookbook, *version_args) version = version_args.first @dependencies[cookbook] = Semverse::Constraint.new(version).to_s @dependencies[cookbook] end # Adds a recommendation for another cookbook, with version checking strings. # # === Parameters # cookbook:: The cookbook # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has # the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def recommends(cookbook, *version_args) version = version_args.first @recommendations[cookbook] = Semverse::Constraint.new(version).to_s @recommendations[cookbook] end # Adds a suggestion for another cookbook, with version checking strings. # # === Parameters # cookbook:: The cookbook # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has the # formx.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def suggests(cookbook, *version_args) version = version_args.first @suggestions[cookbook] = Semverse::Constraint.new(version).to_s @suggestions[cookbook] end # Adds a conflict for another cookbook, with version checking strings. # # === Parameters # cookbook:: The cookbook # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has # the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def conflicts(cookbook, *version_args) version = version_args.first @conflicting[cookbook] = Semverse::Constraint.new(version).to_s @conflicting[cookbook] end # Adds a recipe, definition, or resource provided by this cookbook. # # Recipes are specified as normal # Definitions are followed by (), and can include :params for prototyping # Resources are the stringified version (service[apache2]) # # === Parameters # recipe, definition, resource:: The thing we provide # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has # the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def provides(cookbook, *version_args) version = version_args.first @providing[cookbook] = Semverse::Constraint.new(version).to_s @providing[cookbook] end # Adds a cookbook that is replaced by this one, with version checking strings. # # === Parameters # cookbook:: The cookbook we replace # version:: A version constraint of the form "OP VERSION", # where OP is one of < <= = > >= ~> and VERSION has the form x.y.z or x.y. # # === Returns # versions:: Returns the list of versions for the platform def replaces(cookbook, *version_args) version = version_args.first @replacing[cookbook] = Semverse::Constraint.new(version).to_s @replacing[cookbook] end # Metadata DSL to set a valid chef_version. May be declared multiple times # with the result being 'OR'd such that if any statements match, the version # is considered supported. Uses Gem::Requirement for its implementation. # # @param version_args [Array] Version constraint in String form # @return [Array] Current chef_versions array def chef_version(*version_args) @chef_versions << Gem::Dependency.new("chef", *version_args) unless version_args.empty? @chef_versions end # Metadata DSL to set a valid ohai_version. May be declared multiple times # with the result being 'OR'd such that if any statements match, the version # is considered supported. Uses Gem::Requirement for its implementation. # # @param version_args [Array] Version constraint in String form # @return [Array] Current ohai_versions array def ohai_version(*version_args) @ohai_versions << Gem::Dependency.new("ohai", *version_args) unless version_args.empty? @ohai_versions end # Metadata DSL to set a gem to install from the cookbook metadata. May be declared # multiple times. All the gems from all the cookbooks are combined into one Gemfile # and depsolved together. Uses Bundler's DSL for its implementation. # # @param args [Array] Gem name and options to pass to Bundler's DSL # @return [Array] Array of gem statements as args def gem(*args) @gems << args unless args.empty? @gems end # Adds a description for a recipe. # # === Parameters # recipe:: The recipe # description:: The description of the recipe # # === Returns # description:: Returns the current description def recipe(name, description) @recipes[name] = description end # Adds an attribute )hat a user needs to configure for this cookbook. Takes # a name (with the / notation for a nested attribute), followed by any of # these options # # display_name:: What a UI should show for this attribute # description:: A hint as to what this attr is for # choice:: An array of choices to present to the user. # calculated:: If true, the default value is calculated by the recipe and cannot be displayed. # type:: "string" or "array" - default is "string" ("hash" is supported for backwards compatibility) # required:: Whether this attr is 'required', 'recommended' or 'optional' - default 'optional' (true/false values also supported for backwards compatibility) # recipes:: An array of recipes which need this attr set. # default,,:: The default value # # === Parameters # name:: The name of the attribute ('foo', or 'apache2/log_dir') # options:: The description of the options # # === Returns # options:: Returns the current options hash def attribute(name, options) validate( options, { :display_name => { :kind_of => String }, :description => { :kind_of => String }, :choice => { :kind_of => [ Array ], :default => [] }, :calculated => { :equal_to => [ true, false ], :default => false }, :type => { :equal_to => [ "string", "array", "hash", "symbol", "boolean", "numeric" ], :default => "string" }, :required => { :equal_to => [ "required", "recommended", "optional", true, false ], :default => "optional" }, :recipes => { :kind_of => [ Array ], :default => [] }, :default => { :kind_of => [ String, Array, Hash, Symbol, Numeric, TrueClass, FalseClass ] }, :source_url => { :kind_of => String }, :issues_url => { :kind_of => String }, :privacy => { :kind_of => [ TrueClass, FalseClass ] }, } ) options[:required] = remap_required_attribute(options[:required]) unless options[:required].nil? validate_choice_array(options) validate_calculated_default_rule(options) validate_choice_default_rule(options) @attributes[name] = options @attributes[name] end def grouping(name, options) validate( options, { :title => { :kind_of => String }, :description => { :kind_of => String } } ) @groupings[name] = options @groupings[name] end # Convert an Array of Gem::Dependency objects (chef_version/ohai_version) to an Array. # # Gem::Dependencey#to_s is not useful, and there is no #to_json defined on it or its component # objets, so we have to write our own rendering method. # # [ Gem::Dependency.new(">= 12.5"), Gem::Dependency.new(">= 11.18.0", "< 12.0") ] # # results in: # # [ [ ">= 12.5" ], [ ">= 11.18.0", "< 12.0" ] ] # # @param deps [Array] Multiple Gem-style version constraints # @return [Array]] Simple object representation of version constraints (for json) def gem_requirements_to_array(*deps) deps.map do |dep| dep.requirement.requirements.map do |op, version| "#{op} #{version}" end.sort end end # Convert an Array of Gem::Dependency objects (chef_version/ohai_version) to a hash. # # This is the inverse of #gem_requirements_to_array # # @param what [String] What version constraint we are constructing ('chef' or 'ohai' presently) # @param array [Array]] Simple object representation of version constraints (from json) # @return [Array] Multiple Gem-style version constraints def gem_requirements_from_array(what, array) array.map do |dep| Gem::Dependency.new(what, *dep) end end def to_hash { NAME => self.name, DESCRIPTION => self.description, LONG_DESCRIPTION => self.long_description, MAINTAINER => self.maintainer, MAINTAINER_EMAIL => self.maintainer_email, LICENSE => self.license, PLATFORMS => self.platforms, DEPENDENCIES => self.dependencies, RECOMMENDATIONS => self.recommendations, SUGGESTIONS => self.suggestions, CONFLICTING => self.conflicting, PROVIDING => self.providing, REPLACING => self.replacing, ATTRIBUTES => self.attributes, GROUPINGS => self.groupings, RECIPES => self.recipes, VERSION => self.version, SOURCE_URL => self.source_url, ISSUES_URL => self.issues_url, PRIVACY => self.privacy, CHEF_VERSIONS => gem_requirements_to_array(*self.chef_versions), OHAI_VERSIONS => gem_requirements_to_array(*self.ohai_versions), GEMS => self.gems, } end # @return [String] def to_json # Switched from fast to pretty generate here # to match `knife cookbook metadata from file` format # See https://github.com/RiotGames/ridley/pull/287 JSON.pretty_generate(to_hash) end def from_hash(o) @name = o[NAME] if o.has_key?(NAME) @description = o[DESCRIPTION] if o.has_key?(DESCRIPTION) @long_description = o[LONG_DESCRIPTION] if o.has_key?(LONG_DESCRIPTION) @maintainer = o[MAINTAINER] if o.has_key?(MAINTAINER) @maintainer_email = o[MAINTAINER_EMAIL] if o.has_key?(MAINTAINER_EMAIL) @license = o[LICENSE] if o.has_key?(LICENSE) @platforms = handle_deprecated_constraints(o[PLATFORMS]) if o.has_key?(PLATFORMS) @dependencies = handle_deprecated_constraints(o[DEPENDENCIES]) if o.has_key?(DEPENDENCIES) @recommendations = handle_deprecated_constraints(o[RECOMMENDATIONS]) if o.has_key?(RECOMMENDATIONS) @suggestions = handle_deprecated_constraints(o[SUGGESTIONS]) if o.has_key?(SUGGESTIONS) @conflicting = handle_deprecated_constraints(o[CONFLICTING]) if o.has_key?(CONFLICTING) @providing = o[PROVIDING] if o.has_key?(PROVIDING) @replacing = handle_deprecated_constraints(o[REPLACING]) if o.has_key?(REPLACING) @attributes = o[ATTRIBUTES] if o.has_key?(ATTRIBUTES) @groupings = o[GROUPINGS] if o.has_key?(GROUPINGS) @recipes = o[RECIPES] if o.has_key?(RECIPES) @version = o[VERSION] if o.has_key?(VERSION) @source_url = o[SOURCE_URL] if o.has_key?(SOURCE_URL) @issues_url = o[ISSUES_URL] if o.has_key?(ISSUES_URL) @privacy = o[PRIVACY] if o.has_key?(PRIVACY) @chef_versions = gem_requirements_from_array("chef", o[CHEF_VERSIONS]) if o.has_key?(CHEF_VERSIONS) @ohai_versions = gem_requirements_from_array("ohai", o[OHAI_VERSIONS]) if o.has_key?(OHAI_VERSIONS) @gems = o[GEMS] if o.has_key?(GEMS) self end def from_json(json) from_hash JSON.parse(json) end # Sets the cookbook's source URL, or returns it. # # === Parameters # maintainer:: The source URL # # === Returns # source_url:: Returns the current source URL. def source_url(arg = nil) set_or_return( :source_url, arg, :kind_of => [ String ] ) end # Sets the cookbook's issues URL, or returns it. # # === Parameters # issues_url:: The issues URL # # === Returns # issues_url:: Returns the current issues URL. def issues_url(arg = nil) set_or_return( :issues_url, arg, :kind_of => [ String ] ) end # # Sets the cookbook's privacy flag, or returns it. # # === Parameters # privacy:: Whether this cookbook is private or not # # === Returns # privacy:: Whether this cookbook is private or not # def privacy(arg = nil) set_or_return( :privacy, arg, :kind_of => [ TrueClass, FalseClass ], ) end private # Verify that the given array is an array of strings # # Raise an exception if the members of the array are not Strings # # === Parameters # arry:: An array to be validated def validate_string_array(arry) if arry.kind_of?(Array) arry.each do |choice| validate( {:choice => choice}, {:choice => {:kind_of => String}} ) end end end # Validate the choice of the options hash # # Raise an exception if the members of the array do not match the defaults # === Parameters # opts:: The options hash def validate_choice_array(opts) if opts[:choice].kind_of?(Array) case opts[:type] when "string" validator = [ String ] when "array" validator = [ Array ] when "hash" validator = [ Hash ] when "symbol" validator = [ Symbol ] when "boolean" validator = [ TrueClass, FalseClass ] when "numeric" validator = [ Numeric ] end opts[:choice].each do |choice| validate( {:choice => choice}, {:choice => {:kind_of => validator}} ) end end end # For backwards compatibility, remap Boolean values to String # true is mapped to "required" # false is mapped to "optional" # # === Parameters # required_attr:: The value of options[:required] # # === Returns # required_attr:: "required", "recommended", or "optional" def remap_required_attribute(value) case value when true value = "required" when false value = "optional" end value end def validate_calculated_default_rule(options) calculated_conflict = ((options[:default].is_a?(Array) && !options[:default].empty?) || (options[:default].is_a?(String) && !options[:default] != "")) && options[:calculated] == true raise ArgumentError, "Default cannot be specified if calculated is true!" if calculated_conflict end def validate_choice_default_rule(options) return if !options[:choice].is_a?(Array) || options[:choice].empty? if options[:default].is_a?(String) && options[:default] != "" raise ArgumentError, "Default must be one of your choice values!" if options[:choice].index(options[:default]) == nil end if options[:default].is_a?(Array) && !options[:default].empty? options[:default].each do |val| raise ArgumentError, "Default values must be a subset of your choice values!" if options[:choice].index(val) == nil end end end # This method translates version constraint strings from # cookbooks with the old format. # # Before we began respecting version constraints, we allowed # multiple constraints to be placed on cookbooks, as well as the # << and >> operators, which are now just < and >. For # specifications with more than one constraint, this method switches to # the default constraint from SemVerse. If there is only one # constraint, we are replacing the old << and >> with the new < # and >. def handle_deprecated_constraints(specification) specification.inject(Hashie::Mash.new) do |acc, (cb, constraints)| constraints = Array(constraints) acc[cb] = if constraints.size == 1 constraints.first.gsub(/>>/, '>').gsub(/<