#
# Copyright:: Copyright (c) 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.
#
require_relative "cookbook_sources"
require_relative "cookbook_location_specification"
require_relative "storage_config"
require_relative "policyfile_location_specification"
require "chef/node/attribute"
require "chef/run_list/run_list_item"
module ChefCLI
module Policyfile
class DSL
RUN_LIST_ITEM_COMPONENT = /^[.[:alnum:]_-]+$/.freeze
include StorageConfigDelegation
attr_writer :name
attr_reader :errors
attr_writer :run_list
attr_writer :default_source
attr_reader :cookbook_location_specs
attr_reader :included_policies
attr_reader :named_run_lists
attr_reader :node_attributes
attr_reader :storage_config
attr_reader :chef_config
def initialize(storage_config, chef_config: nil)
@name = nil
@errors = []
@run_list = []
@named_run_lists = {}
@included_policies = []
@default_source = [ NullCookbookSource.new ]
@cookbook_location_specs = {}
@storage_config = storage_config
@chef_config = chef_config
@node_attributes = Chef::Node::Attribute.new({}, {}, {}, {})
end
def name(name = nil)
unless name.nil?
@name = name
end
@name
end
def run_list(*run_list_items)
run_list_items = run_list_items.flatten
unless run_list_items.empty?
validate_run_list_items(run_list_items)
@run_list = run_list_items
end
@run_list
end
def named_run_list(name, *run_list_items)
run_list_items = run_list_items.flatten
unless run_list_items.empty?
validate_run_list_items(run_list_items, name)
@named_run_lists[name] = run_list_items
end
@named_run_lists[name]
end
def default_source(source_type = nil, source_argument = nil, &block)
return @default_source if source_type.nil?
case source_type
when :community, :supermarket
set_default_community_source(source_argument, &block)
when :delivery_supermarket
set_default_delivery_supermarket_source(source_argument, &block)
when :chef_server
set_default_chef_server_source(source_argument, &block)
when :chef_repo
set_default_chef_repo_source(source_argument, &block)
when :artifactory
set_default_artifactory_source(source_argument, &block)
else
@errors << "Invalid default_source type '#{source_type.inspect}'"
end
end
def cookbook(name, *version_and_source_opts)
source_options =
if version_and_source_opts.last.is_a?(Hash)
version_and_source_opts.pop
else
{}
end
constraint = version_and_source_opts.first || ">= 0.0.0"
spec = CookbookLocationSpecification.new(name, constraint, source_options, storage_config)
if ( existing_source = @cookbook_location_specs[name] )
err = "Cookbook '#{name}' assigned to conflicting sources\n\n"
err << "Previous source: #{existing_source.source_options.inspect}\n"
err << "Conflicts with: #{source_options.inspect}\n"
@errors << err
else
@cookbook_location_specs[name] = spec
@errors += spec.errors
end
end
def include_policy(name, source_options = {})
if ( existing = included_policies.find { |p| p.name == name } )
err = "Included policy '#{name}' assigned conflicting locations or was already specified\n\n"
err << "Previous source: #{existing.source_options.inspect}\n"
err << "Conflicts with: #{source_options.inspect}\n"
@errors << err
else
spec = PolicyfileLocationSpecification.new(name, source_options, storage_config, chef_config)
included_policies << spec
@errors += spec.errors
end
end
def metadata
cookbook_root = storage_config.relative_paths_root
unless File.exist?(File.join(cookbook_root, "metadata.rb")) || File.exist?(File.join(cookbook_root, "metadata.json"))
raise PolicyfileMissingCookbookMetadata.new(cookbook_root)
end
begin
cookbook_name = CookbookMetadata.from_path(cookbook_root).cookbook_name
rescue Exception => e
raise PolicyfileBadCookbookMetadata.new(cookbook_root, e)
end
name cookbook_name if name.nil?
cookbook(cookbook_name, path: ".")
end
def default
@node_attributes.default
end
def override
@node_attributes.override
end
def eval_policyfile(policyfile_string)
@policyfile_filename = policyfile_filename
instance_eval(policyfile_string, policyfile_filename)
validate!
self
rescue SyntaxError => e
@errors << "Invalid Ruby syntax in Policyfile '#{policyfile_filename}':\n\n#{e.message}"
rescue SignalException, SystemExit
# allow signal from kill, ctrl-C, etc. to bubble up:
raise
rescue Exception => e
error_message = "Evaluation of policyfile '#{policyfile_filename}' raised an exception\n"
error_message << " Exception: #{e.class.name} \"#{e}\"\n\n"
trace = filtered_bt(policyfile_filename, e)
error_message << " Relevant Code:\n"
error_message << " #{error_context(policyfile_string, policyfile_filename, e)}\n\n"
unless trace.empty?
error_message << " Backtrace:\n"
# TODO: need a way to disable filtering
error_message << filtered_bt(policyfile_filename, e).inject("") { |formatted_trace, line| formatted_trace << " #{line}\n" }
end
@errors << error_message
end
private
def set_default_community_source(source_uri, &block)
set_default_source(CommunityCookbookSource.new(source_uri, &block))
end
def set_default_delivery_supermarket_source(source_uri, &block)
if source_uri.nil?
@errors << "You must specify the server's URI when using a default_source :delivery_supermarket"
else
set_default_source(DeliverySupermarketSource.new(source_uri, &block))
end
end
def set_default_chef_server_source(source_uri, &block)
if source_uri.nil?
@errors << "You must specify the server's URI when using a default_source :chef_server"
else
set_default_source(ChefServerCookbookSource.new(source_uri, chef_config: chef_config, &block))
end
end
def set_default_artifactory_source(source_uri, &block)
if source_uri.nil?
@errors << "You must specify the server's URI when using a default_source :artifactory"
else
set_default_source(ArtifactoryCookbookSource.new(source_uri, chef_config: chef_config, &block))
end
end
def set_default_chef_repo_source(path, &block)
if path.nil?
@errors << "You must specify the path to the chef-repo when using a default_source :chef_repo"
else
set_default_source(ChefRepoCookbookSource.new(File.expand_path(path, storage_config.relative_paths_root), &block))
end
end
def set_default_source(source)
@default_source.delete_at(0) if @default_source[0].null?
@default_source << source
end
def validate!
if @run_list.empty?
@errors << "Invalid run_list. run_list cannot be empty"
end
handle_preferred_cookbooks_conflicts
end
def validate_run_list_items(items, run_list_name = nil)
items.each do |item|
run_list_desc = run_list_name.nil? ? "Run List Item '#{item}'" : "Named Run List '#{run_list_name}' Item '#{item}'"
item_name = Chef::RunList::RunListItem.new(item).name
cookbook, separator, recipe = item_name.partition("::")
if RUN_LIST_ITEM_COMPONENT.match(cookbook).nil?
message = "#{run_list_desc} has invalid cookbook name '#{cookbook}'.\nCookbook names can only contain alphanumerics, hyphens, and underscores."
# Special case when there's only one colon instead of two:
if /[^:]:[^:]/.match?(cookbook)
message << "\nDid you mean '#{item.sub(":", "::")}'?"
end
@errors << message
end
unless separator.empty?
# we have a cookbook and recipe
if RUN_LIST_ITEM_COMPONENT.match(recipe).nil?
@errors << "#{run_list_desc} has invalid recipe name '#{recipe}'.\nRecipe names can only contain alphanumerics, hyphens, and underscores."
end
end
end
end
def handle_preferred_cookbooks_conflicts
conflicting_source_messages = []
default_source.combination(2).each do |source_a, source_b|
conflicting_preferences = source_a.preferred_cookbooks & source_b.preferred_cookbooks
next if conflicting_preferences.empty?
conflicting_source_messages << "#{source_a.desc} and #{source_b.desc} are both set as the preferred source for cookbook(s) '#{conflicting_preferences.join(", ")}'"
end
unless conflicting_source_messages.empty?
msg = "Multiple sources are marked as the preferred source for some cookbooks. Only one source can be preferred for a cookbook.\n"
msg << conflicting_source_messages.join("\n") << "\n"
@errors << msg
end
end
def error_context(policyfile_string, policyfile_filename, exception)
if ( line_number_to_show = culprit_line_number(policyfile_filename, exception) )
code = policyfile_string.lines.to_a[line_number_to_show - 1].strip
"#{line_number_to_show}: #{code}"
else
"Could not find relevant code from backtrace"
end
end
def culprit_line_number(policyfile_filename, exception)
if ( most_proximate_backtrace_line = filtered_bt(policyfile_filename, exception).first )
most_proximate_backtrace_line[/^(?:.\:)?[^:]+:([\d]+)/, 1].to_i
else
nil
end
end
def filtered_bt(policyfile_filename, exception)
policyfile_filename_matcher = /^#{Regexp.escape(policyfile_filename)}/
exception.backtrace.select { |line| line =~ policyfile_filename_matcher }
end
end
end
end