# # Copyright:: 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. # autoload :Set, "set" require "forwardable" unless defined?(Forwardable) require "solve" require "chef/run_list" require "chef/mixin/deep_merge" require_relative "policyfile/dsl" require_relative "policyfile/attribute_merge_checker" require_relative "policyfile/included_policies_cookbook_source" require_relative "policyfile_lock" require_relative "ui" require_relative "policyfile/reports/install" require_relative "exceptions" module ChefCLI class PolicyfileCompiler extend Forwardable DEFAULT_DEMAND_CONSTRAINT = ">= 0.0.0".freeze # Cookbooks from these sources lock that cookbook to exactly one version SOURCE_TYPES_WITH_FIXED_VERSIONS = %i{git path}.freeze def self.evaluate(policyfile_string, policyfile_filename, ui: nil, chef_config: nil) compiler = new(ui: ui, chef_config: chef_config) compiler.evaluate_policyfile(policyfile_string, policyfile_filename) compiler end def_delegator :@dsl, :name def_delegator :@dsl, :run_list def_delegator :@dsl, :named_run_list def_delegator :@dsl, :named_run_lists def_delegator :@dsl, :errors def_delegator :@dsl, :cookbook_location_specs def_delegator :@dsl, :included_policies attr_reader :dsl attr_reader :storage_config attr_reader :install_report def initialize(ui: nil, chef_config: nil) @storage_config = Policyfile::StorageConfig.new @dsl = Policyfile::DSL.new(storage_config, chef_config: chef_config) @artifact_server_cookbook_location_specs = {} @merged_graph = nil @ui = ui || UI.null @install_report = Policyfile::Reports::Install.new(ui: @ui, policyfile_compiler: self) end def default_source(source_type = nil, source_argument = nil, &block) if source_type.nil? prepend_array = if included_policies.length > 0 [included_policies_cookbook_source] else [] end prepend_array + dsl.default_source else dsl.default_source(source_type, source_argument, &block) end end def error! unless errors.empty? raise PolicyfileError, errors.join("\n") end end def cookbook_location_spec_for(cookbook_name) cookbook_location_specs[cookbook_name] end def expanded_run_list # doesn't support roles yet... concated_runlist = Chef::RunList.new included_policies.each do |policy_spec| lock = policy_spec.policyfile_lock lock.run_list.each do |run_list_item| concated_runlist << run_list_item end end run_list.each do |run_list_item| concated_runlist << run_list_item end concated_runlist end # copy of the expanded_run_list, properly formatted for use in a lockfile def normalized_run_list expanded_run_list.map { |i| normalize_recipe(i) } end def expanded_named_run_lists included_policies_named_runlists = included_policies.inject({}) do |acc, policy_spec| lock = policy_spec.policyfile_lock lock.named_run_lists.inject(acc) do |expanded, (name, run_list_items)| expanded[name] ||= Chef::RunList.new run_list_items.each do |run_list_item| expanded[name] << run_list_item end expanded end acc end named_run_lists.inject(included_policies_named_runlists) do |expanded, (name, run_list_items)| expanded[name] ||= Chef::RunList.new run_list_items.each do |run_list_item| expanded[name] << run_list_item end expanded end end def normalized_named_run_lists expanded_named_run_lists.inject({}) do |normalized, (name, run_list)| normalized[name] = run_list.map { |i| normalize_recipe(i) } normalized end end def default_attributes check_for_default_attribute_conflicts! included_policies.map(&:policyfile_lock).inject( dsl.node_attributes.combined_default.to_hash ) do |acc, lock| Chef::Mixin::DeepMerge.merge(acc, lock.default_attributes) end end def override_attributes check_for_override_attribute_conflicts! included_policies.map(&:policyfile_lock).inject( dsl.node_attributes.combined_override.to_hash ) do |acc, lock| Chef::Mixin::DeepMerge.merge(acc, lock.override_attributes) end end def lock @policyfile_lock ||= PolicyfileLock.build_from_compiler(self, storage_config) end def install ensure_cache_dir_exists cookbook_and_recipe_list = combined_run_lists.map(&:name).map do |recipe_spec| cookbook, _separator, recipe = recipe_spec.partition("::") recipe = "default" if recipe.empty? [cookbook, recipe] end missing_recipes_by_cb_spec = {} graph_solution.each do |cookbook_name, version| spec = cookbook_location_spec_for(cookbook_name) if spec.nil? || !spec.version_fixed? spec = create_spec_for_cookbook(cookbook_name, version) install_report.installing_cookbook(spec) spec.ensure_cached end required_recipes = cookbook_and_recipe_list.select { |cb_name, _recipe| cb_name == spec.name } missing_recipes = required_recipes.select { |_cb_name, recipe| !spec.cookbook_has_recipe?(recipe) } unless missing_recipes.empty? missing_recipes_by_cb_spec[spec] = missing_recipes end end unless missing_recipes_by_cb_spec.empty? message = "The installed cookbooks do not contain all the recipes required by your run list(s):\n" missing_recipes_by_cb_spec.each do |spec, missing_items| message << "#{spec}\nis missing the following required recipes:\n" missing_items.each { |_cb, recipe| message << "* #{recipe}\n" } end message << "\n" message << "You may have specified an incorrect recipe in your run list,\nor this recipe may not be available in that version of the cookbook\n" raise CookbookDoesNotContainRequiredRecipe, message end end def create_spec_for_cookbook(cookbook_name, version) matching_source = best_source_for(cookbook_name) source_options = matching_source.source_options_for(cookbook_name, version) spec = Policyfile::CookbookLocationSpecification.new(cookbook_name, "= #{version}", source_options, storage_config) @artifact_server_cookbook_location_specs[cookbook_name] = spec end def all_cookbook_location_specs # in the installation process, we create "artifact_server_cookbook_location_specs" # for any cookbook that isn't sourced from a single-version source (e.g., # path and git only support one version at a time), but we might have # specs for them to track additional version constraint demands. Merging # in this order ensures the artifact_server_cookbook_location_specs "win". cookbook_location_specs.merge(@artifact_server_cookbook_location_specs) end ## # Compilation Methods ## def graph_solution return @solution if @solution cache_fixed_version_cookbooks @solution = Solve.it!(graph, graph_demands) end def graph @graph ||= Solve::Graph.new.tap do |g| artifacts_graph.each do |name, dependencies_by_version| dependencies_by_version.each do |version, dependencies| artifact = g.artifact(name, version) dependencies.each do |dep_name, constraint| artifact.dependency(dep_name, constraint) end end end end end def solution_dependencies solution_deps = Policyfile::SolutionDependencies.new all_cookbook_location_specs.each do |name, spec| solution_deps.add_policyfile_dep(name, spec.version_constraint) end graph_solution.each do |name, version| transitive_deps = artifacts_graph[name][version] solution_deps.add_cookbook_dep(name, version, transitive_deps) end solution_deps end def graph_demands ## TODO: By merging cookbooks from the current policyfile and included policies, # we lose the ability to know where a conflict came from (cookbook_demands_from_current + cookbook_demands_from_policies) end def artifacts_graph remote_artifacts_graph.merge(local_artifacts_graph) end # Gives a dependency graph for cookbooks that are source from an alternate # location. These cookbooks could have a different set of dependencies # compared to an unmodified copy upstream. For example, the community site # may have a cookbook "apache2" at version "1.10.4", which the user has # forked on github and modified the dependencies without changing the # version number. To accommodate this, the local_artifacts_graph should be # merged over the upstream's artifacts graph. def local_artifacts_graph cookbook_location_specs.inject({}) do |local_artifacts, (cookbook_name, cookbook_location_spec)| if cookbook_location_spec.version_fixed? local_artifacts[cookbook_name] = { cookbook_location_spec.version => cookbook_location_spec.dependencies } end local_artifacts end end def remote_artifacts_graph @merged_graph ||= begin conflicting_cb_names = [] merged = {} default_source.each do |source| merged.merge!(source.universe_graph) do |conflicting_cb_name, _old, _new| if (preference = preferred_source_for_cookbook(conflicting_cb_name)) preference.universe_graph[conflicting_cb_name] elsif cookbook_could_appear_in_solution?(conflicting_cb_name) conflicting_cb_names << conflicting_cb_name {} # return empty set of versions else {} # return empty set of versions end end end handle_conflicting_cookbooks(conflicting_cb_names) merged end end def version_constraint_for(cookbook_name) if (cookbook_location_spec = cookbook_location_spec_for(cookbook_name)) && cookbook_location_spec.version_fixed? version = cookbook_location_spec.version "= #{version}" else DEFAULT_DEMAND_CONSTRAINT end end def cookbook_version_fixed?(cookbook_name) if ( cookbook_location_spec = cookbook_location_spec_for(cookbook_name) ) cookbook_location_spec.version_fixed? else false end end def cookbooks_in_run_list recipes = combined_run_lists.map(&:name) recipes.map { |r| r[/^([^:]+)/, 1] } end def combined_run_lists expanded_named_run_lists.values.inject(expanded_run_list.to_a) do |accum_run_lists, run_list| accum_run_lists | run_list.to_a end end def combined_run_lists_by_cb_name combined_run_lists.inject({}) do |by_name_accum, run_list_item| by_name_accum end end def build yield @dsl self end def evaluate_policyfile(policyfile_string, policyfile_filename) storage_config.use_policyfile(policyfile_filename) @dsl.eval_policyfile(policyfile_string) self end def fixed_version_cookbooks_specs @fixed_version_cookbooks_specs ||= cookbook_location_specs.select do |_cookbook_name, cookbook_location_spec| cookbook_location_spec.version_fixed? end end private def normalize_recipe(run_list_item) name = run_list_item.name name = "#{name}::default" unless name.include?("::") "recipe[#{name}]" end def cookbooks_for_demands (cookbooks_in_run_list + cookbook_location_specs.keys).uniq end def cache_fixed_version_cookbooks ensure_cache_dir_exists fixed_version_cookbooks_specs.each do |name, cookbook_location_spec| install_report.installing_fixed_version_cookbook(cookbook_location_spec) cookbook_location_spec.ensure_cached end end def ensure_cache_dir_exists unless File.exist?(cache_path) FileUtils.mkdir_p(cache_path) end end def cache_path CookbookOmnifetch.storage_path end def best_source_for(cookbook_name) preferred = default_source.find { |s| s.preferred_source_for?(cookbook_name) } if preferred.nil? default_source.find do |s| s.universe_graph.key?(cookbook_name) end else preferred end end def preferred_source_for_cookbook(conflicting_cb_name) default_source.find { |s| s.preferred_source_for?(conflicting_cb_name) } end def handle_conflicting_cookbooks(conflicting_cookbooks) # ignore any cookbooks that have a source set. cookbooks_wo_source = conflicting_cookbooks.select do |cookbook_name| location_spec = cookbook_location_spec_for(cookbook_name) location_spec.nil? || location_spec.source_options.empty? end if cookbooks_wo_source.empty? nil else raise CookbookSourceConflict.new(cookbooks_wo_source, default_source) end end def cookbook_could_appear_in_solution?(cookbook_name) all_possible_dep_names.include?(cookbook_name) end # Traverses the dependency graph in a simple manner to find the set of # cookbooks that could be considered in the dependency solution. Version # constraints are not considered so this could include extra cookbooks. def all_possible_dep_names @all_possible_dep_names ||= cookbooks_for_demands.inject(Set.new) do |deps_set, demand_cookbook| deps_set_for_source = default_source.inject(Set.new) do |deps_set_for_cb, source| possible_deps = possible_dependencies_of(demand_cookbook, source) deps_set_for_cb.merge(possible_deps) end deps_set.merge(deps_set_for_source) end end def possible_dependencies_of(cookbook_name, source, dependency_set = Set.new) return dependency_set if dependency_set.include?(cookbook_name) return dependency_set unless source.universe_graph.key?(cookbook_name) dependency_set << cookbook_name deps_by_version = source.universe_graph[cookbook_name] dep_cookbook_names = deps_by_version.values.inject(Set.new) do |names, constraint_list| names.merge(constraint_list.map(&:first)) end dep_cookbook_names.each do |dep_cookbook_name| possible_dependencies_of(dep_cookbook_name, source, dependency_set) end dependency_set end def check_for_default_attribute_conflicts! checker = Policyfile::AttributeMergeChecker.new checker.with_attributes("user-specified", dsl.node_attributes.combined_default) included_policies.map do |policy_spec| lock = policy_spec.policyfile_lock checker.with_attributes(policy_spec.name, lock.default_attributes) end checker.check! end def check_for_override_attribute_conflicts! checker = Policyfile::AttributeMergeChecker.new checker.with_attributes("user-specified", dsl.node_attributes.combined_override) included_policies.map do |policy_spec| lock = policy_spec.policyfile_lock checker.with_attributes(policy_spec.name, lock.override_attributes) end checker.check! end def cookbook_demands_from_policies included_policies.flat_map do |policy_spec| lock = policy_spec.policyfile_lock lock.solution_dependencies.to_lock["Policyfile"] end end def cookbook_demands_from_current cookbooks_for_demands.map do |cookbook_name| spec = cookbook_location_spec_for(cookbook_name) if spec.nil? [ cookbook_name, DEFAULT_DEMAND_CONSTRAINT ] elsif spec.version_fixed? [ cookbook_name, "= #{spec.version}" ] else [ cookbook_name, spec.version_constraint.to_s ] end end end def included_policies_cookbook_source @included_policies_cookbook_source ||= begin source = Policyfile::IncludedPoliciesCookbookSource.new(included_policies) handle_included_policies_preferred_cookbook_conflicts(source) source end end def handle_included_policies_preferred_cookbook_conflicts(included_policies_source) # All cookbooks in the included policies are preferred. conflicting_source_messages = [] dsl.default_source.reject(&:null?).each do |source_b| conflicting_preferences = included_policies_source.preferred_cookbooks & source_b.preferred_cookbooks next if conflicting_preferences.empty? next if conflicting_preferences.all? do |cookbook_name| version = included_policies_source.universe_graph[cookbook_name].keys.first if included_policies_source.source_options_for(cookbook_name, version) == source_b.source_options_for(cookbook_name, version) true else false end end conflicting_source_messages << "#{source_b.desc} sets a preferred for cookbook(s) #{conflicting_preferences.join(", ")}. This conflicts with an included policy." end unless conflicting_source_messages.empty? msg = "You may not override the cookbook sources for any cookbooks required by included policies.\n" msg << conflicting_source_messages.join("\n") << "\n" raise IncludePolicyCookbookSourceConflict.new(msg) end end end end