#
# 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.
#
require "digest/sha2" unless defined?(Digest::SHA2)
require_relative "policyfile/storage_config"
require_relative "policyfile/cookbook_locks"
require_relative "policyfile/solution_dependencies"
require_relative "ui"
module ChefCLI
class PolicyfileLock
class InstallReport
attr_reader :ui
attr_reader :policyfile_lock
def initialize(ui: nil, policyfile_lock: nil)
@ui = ui
@policyfile_lock = policyfile_lock
@cookbook_name_width = nil
@cookbook_version_width = nil
end
def installing_fixed_version_cookbook(cookbook_spec)
verb = cookbook_spec.installed? ? "Using " : "Installing"
ui.msg("#{verb} #{format_fixed_version_cookbook(cookbook_spec)}")
end
def installing_cookbook(cookbook_lock)
verb = cookbook_lock.installed? ? "Using " : "Installing"
ui.msg("#{verb} #{format_cookbook(cookbook_lock)}")
end
private
def format_cookbook(cookbook_lock)
"#{cookbook_lock.name.ljust(cookbook_name_width)} #{cookbook_lock.version.to_s.ljust(cookbook_version_width)}"
end
def cookbook_name_width
policyfile_lock.cookbook_locks.map { |name, _| name.size }.max
end
def cookbook_version_width
policyfile_lock.cookbook_locks.map { |_, lock| lock.version.size }.max
end
end
RUN_LIST_ITEM_FORMAT = /\Arecipe\[[^\s]+::[^\s]+\]\Z/
def self.build(storage_config)
lock = new(storage_config)
yield lock
lock
end
def self.build_from_compiler(compiler, storage_config)
lock = new(storage_config)
lock.build_from_compiler(compiler)
lock
end
include Policyfile::StorageConfigDelegation
attr_accessor :name
attr_accessor :run_list
attr_accessor :named_run_lists
attr_accessor :default_attributes
attr_accessor :override_attributes
attr_reader :solution_dependencies
attr_reader :storage_config
attr_reader :cookbook_locks
attr_reader :included_policy_locks
attr_reader :install_report
def initialize(storage_config, ui: nil)
@name = nil
@run_list = []
@named_run_lists = {}
@cookbook_locks = {}
@relative_paths_root = Dir.pwd
@storage_config = storage_config
@ui = ui || UI.null
@default_attributes = {}
@override_attributes = {}
@solution_dependencies = Policyfile::SolutionDependencies.new
@included_policy_locks = []
@install_report = InstallReport.new(ui: @ui, policyfile_lock: self)
end
def lock_data_for(cookbook_name)
@cookbook_locks[cookbook_name]
end
def cached_cookbook(name)
cached_cookbook = Policyfile::CachedCookbook.new(name, storage_config)
yield cached_cookbook if block_given?
@cookbook_locks[name] = cached_cookbook
end
def local_cookbook(name)
local_cookbook = Policyfile::LocalCookbook.new(name, storage_config)
yield local_cookbook if block_given?
@cookbook_locks[name] = local_cookbook
end
def dependencies
yield solution_dependencies
end
def to_lock
{}.tap do |lock|
lock["revision_id"] = revision_id
lock["name"] = name
lock["run_list"] = run_list
lock["named_run_lists"] = named_run_lists unless named_run_lists.empty?
lock["included_policy_locks"] = included_policy_locks
lock["cookbook_locks"] = cookbook_locks_for_lockfile
lock["default_attributes"] = default_attributes
lock["override_attributes"] = override_attributes
lock["solution_dependencies"] = solution_dependencies.to_lock
end
end
# Returns a fingerprint of the PolicyfileLock by computing the SHA1 hash of
# #canonical_revision_string
def revision_id
Digest::SHA256.new.hexdigest(canonical_revision_string)
end
# Generates a string representation of the lock data in a specialized
# format suitable for generating a checksum of the lock itself. Only data
# that modifies the behavior of a chef-client using the lockfile is
# included in this format; for example, a modification to the source
# options in a `Policyfile.rb` that yields identical code (such as
# switching to a github fork at the same revision) will not cause a change
# in the PolicyfileLock's canonical_revision_string.
#
# This format is intended to be used only for generating an identifier for
# a particular revision of a PolicyfileLock. It should not be used as a
# serialization format, and is not guaranteed to be a stable interface.
def canonical_revision_string
canonical_rev_text = ""
canonical_rev_text << "name:#{name}\n"
run_list.each do |item|
canonical_rev_text << "run-list-item:#{item}\n"
end
named_run_lists.each do |name, run_list|
run_list.each do |item|
canonical_rev_text << "named-run-list:#{name};run-list-item:#{item}\n"
end
end
cookbook_locks_for_lockfile.each do |name, lock|
canonical_rev_text << "cookbook:#{name};id:#{lock["identifier"]}\n"
end
canonical_rev_text << "default_attributes:#{canonicalize(default_attributes)}\n"
canonical_rev_text << "override_attributes:#{canonicalize(override_attributes)}\n"
canonical_rev_text
end
def cookbook_locks_for_lockfile
cookbook_locks.inject({}) do |locks_map, (name, location_spec)|
location_spec.validate!
location_spec.gather_profile_data
locks_map[name] = location_spec.to_lock
locks_map
end.sort.to_h
end
def validate_cookbooks!
cookbook_locks.each do |name, cookbook_lock|
cookbook_lock.validate!
cookbook_lock.refresh!
end
# Check that versions and dependencies are still valid. First we need to
# refresh the dependency info for everything that has changed, then we
# check that the new versions and dependencies are valid for the working
# set of cookbooks. We can't do this in a single loop because the user
# may have modified two cookbooks such that the versions and constraints
# are only valid when both changes are considered together.
cookbook_locks.each do |name, cookbook_lock|
if cookbook_lock.updated?
solution_dependencies.update_cookbook_dep(name, cookbook_lock.version, cookbook_lock.dependencies)
end
end
cookbook_locks.each do |name, cookbook_lock|
if cookbook_lock.updated?
solution_dependencies.test_conflict!(cookbook_lock.name, cookbook_lock.version)
end
end
true
end
def build_from_compiler(compiler)
@name = compiler.name
@run_list = compiler.normalized_run_list
@named_run_lists = compiler.normalized_named_run_lists
compiler.all_cookbook_location_specs.each do |cookbook_name, spec|
if spec.mirrors_canonical_upstream?
cached_cookbook(cookbook_name) do |cached_cb|
cached_cb.cache_key = spec.cache_key
cached_cb.origin = spec.uri
cached_cb.source_options = spec.source_options_for_lock
end
else
local_cookbook(cookbook_name) do |local_cb|
local_cb.source = spec.relative_path
local_cb.source_options = spec.source_options_for_lock
end
end
end
@default_attributes = compiler.default_attributes
@override_attributes = compiler.override_attributes
@solution_dependencies = compiler.solution_dependencies
@included_policy_locks = compiler.included_policies.map do |policy|
{
"name" => policy.name,
"revision_id" => policy.revision_id,
"source_options" => policy.source_options_for_lock,
}
end
self
end
def build_from_lock_data(lock_data)
set_name_from_lock_data(lock_data)
set_run_list_from_lock_data(lock_data)
set_named_run_lists_from_lock_data(lock_data)
set_cookbook_locks_from_lock_data(lock_data)
set_attributes_from_lock_data(lock_data)
set_solution_dependencies_from_lock_data(lock_data)
set_included_policy_locks_from_lock_data(lock_data)
self
end
def build_from_archive(lock_data)
set_name_from_lock_data(lock_data)
set_run_list_from_lock_data(lock_data)
set_named_run_lists_from_lock_data(lock_data)
set_cookbook_locks_as_archives_from_lock_data(lock_data)
set_attributes_from_lock_data(lock_data)
set_solution_dependencies_from_lock_data(lock_data)
set_included_policy_locks_from_lock_data(lock_data)
self
end
def install_cookbooks
# note: duplicates PolicyfileCompiler#ensure_cache_dir_exists
ensure_cache_dir_exists
cookbook_locks.each do |cookbook_name, cookbook_lock|
install_report.installing_cookbook(cookbook_lock)
cookbook_lock.install_locked
end
end
def ensure_cache_dir_exists
# note: duplicates PolicyfileCompiler#ensure_cache_dir_exists
unless File.exist?(cache_path)
FileUtils.mkdir_p(cache_path)
end
end
private
# Generates a canonical JSON representation of the attributes. Based on
# http://wiki.laptop.org/go/Canonical_JSON but not quite as strict, yet.
#
# In particular:
# - String encoding stuff isn't normalized
# - We allow floats that fit within the range/precision requirements of
# IEEE 754-2008 binary64 (double precision) numbers.
# - +/- Infinity and NaN are banned, but float/numeric size aren't checked.
# numerics should be in range [-(2**53)+1, (2**53)-1] to comply with
# IEEE 754-2008
#
# Recursive, so absurd nesting levels could cause a SystemError. Invalid
# input will cause an InvalidPolicyfileAttribute exception.
def canonicalize(attributes)
unless attributes.is_a?(Hash)
raise "Top level attributes must be a Hash (you gave: #{attributes})"
end
canonicalize_elements(attributes)
end
def canonicalize_elements(item)
case item
when Hash
# Hash keys will sort differently based on the encoding, but after a
# JSON round trip everything will be UTF-8, so we have to normalize the
# keys to UTF-8 first so that the sort order uses the UTF-8 strings.
item_with_normalized_keys = item.inject({}) do |normalized_item, (key, value)|
validate_attr_key(key)
normalized_item[key.encode("utf-8")] = value
normalized_item
end
elements = item_with_normalized_keys.keys.sort.map do |key|
k = '"' << key << '":'
v = canonicalize_elements(item_with_normalized_keys[key])
k << v
end
"{" << elements.join(",") << "}"
when String
'"' << item.encode("utf-8") << '"'
when Array
elements = item.map { |i| canonicalize_elements(i) }
"[" << elements.join(",") << "]"
when Integer
item.to_s
when Float
unless item.finite?
raise InvalidPolicyfileAttribute, "Floating point numbers cannot be infinite or NaN. You gave #{item.inspect}"
end
# Support for floats assumes that any implementation of our JSON
# canonicalization routine will use IEEE-754 doubles. In decimal terms,
# doubles give 15-17 digits of precision, so we err on the safe side
# and only use 15 digits in the string conversion. We use the `g`
# format, which is a documented-enough "do what I mean" where floats
# >= 0.1 and < precision are represented as floating point literals, and
# other numbers use the exponent notation with a lowercase 'e'. Note
# that both Ruby and Erlang document what their `g` does but have some
# differences both subtle and non-subtle:
#
# ```ruby
# format("%.15g", 0.1) #=> "0.1"
# format("%.15g", 1_000_000_000.0) #=> "1000000000"
# ```
#
# Whereas:
#
# ```erlang
# lists:flatten(io_lib:format("~.15g", [0.1])). %=> "0.100000000000000"
# lists:flatten(io_lib:format("~.15e", [1000000000.0])). %=> "1.00000000000000e+9"
# ```
#
# Other implementations should normalize to ruby's %.15g behavior.
Kernel.format("%.15g", item)
when NilClass
"null"
when TrueClass
"true"
when FalseClass
"false"
else
raise InvalidPolicyfileAttribute,
"Invalid type in attributes. Only Hash, Array, String, Integer, Float, true, false, and nil are accepted. You gave #{item.inspect} (#{item.class})"
end
end
def validate_attr_key(key)
unless key.is_a?(String)
raise InvalidPolicyfileAttribute,
"Attribute keys must be Strings (other types are not allowed in JSON). You gave: #{key.inspect} (#{key.class})"
end
end
def set_name_from_lock_data(lock_data)
name_attribute = lock_data["name"]
raise InvalidLockfile, "lockfile does not have a `name' attribute" if name_attribute.nil?
unless name_attribute.is_a?(String)
raise InvalidLockfile, "lockfile's name attribute must be a String (got: #{name_attribute.inspect})"
end
if name_attribute.empty?
raise InvalidLockfile, "lockfile's name attribute cannot be an empty string"
end
@name = name_attribute
end
def set_run_list_from_lock_data(lock_data)
run_list_attribute = lock_data["run_list"]
raise InvalidLockfile, "lockfile does not have a run_list attribute" if run_list_attribute.nil?
unless run_list_attribute.is_a?(Array)
raise InvalidLockfile, "lockfile's run_list must be an array of run list items (got: #{run_list_attribute.inspect})"
end
bad_run_list_items = run_list_attribute.select { |e| e !~ RUN_LIST_ITEM_FORMAT }
unless bad_run_list_items.empty?
msg = "lockfile's run_list items must be formatted like `recipe[$COOKBOOK_NAME::$RECIPE_NAME]'. Invalid items: `#{bad_run_list_items.join("' `")}'"
raise InvalidLockfile, msg
end
@run_list = run_list_attribute
end
def set_named_run_lists_from_lock_data(lock_data)
return unless lock_data.key?("named_run_lists")
lock_data_named_run_lists = lock_data["named_run_lists"]
unless lock_data_named_run_lists.is_a?(Hash)
msg = "lockfile's named_run_lists must be a Hash (JSON object). (got: #{lock_data_named_run_lists.inspect})"
raise InvalidLockfile, msg
end
lock_data_named_run_lists.each do |name, run_list|
unless name.is_a?(String)
msg = "Keys in lockfile's named_run_lists must be Strings. (got: #{name.inspect})"
raise InvalidLockfile, msg
end
unless run_list.is_a?(Array)
msg = "Values in lockfile's named_run_lists must be Arrays. (got: #{run_list.inspect})"
raise InvalidLockfile, msg
end
bad_run_list_items = run_list.select { |e| e !~ RUN_LIST_ITEM_FORMAT }
unless bad_run_list_items.empty?
msg = "lockfile's run_list items must be formatted like `recipe[$COOKBOOK_NAME::$RECIPE_NAME]'. Invalid items: `#{bad_run_list_items.join("' `")}'"
raise InvalidLockfile, msg
end
end
@named_run_lists = lock_data_named_run_lists
end
def set_cookbook_locks_from_lock_data(lock_data)
cookbook_lock_data = lock_data["cookbook_locks"]
if cookbook_lock_data.nil?
raise InvalidLockfile, "lockfile does not have a cookbook_locks attribute"
end
unless cookbook_lock_data.is_a?(Hash)
raise InvalidLockfile, "lockfile's cookbook_locks attribute must be a Hash (JSON object). (got: #{cookbook_lock_data.inspect})"
end
lock_data["cookbook_locks"].each do |name, lock_info|
build_cookbook_lock_from_lock_data(name, lock_info)
end
end
def set_cookbook_locks_as_archives_from_lock_data(lock_data)
cookbook_lock_data = lock_data["cookbook_locks"]
if cookbook_lock_data.nil?
raise InvalidLockfile, "lockfile does not have a cookbook_locks attribute"
end
unless cookbook_lock_data.is_a?(Hash)
raise InvalidLockfile, "lockfile's cookbook_locks attribute must be a Hash (JSON object). (got: #{cookbook_lock_data.inspect})"
end
lock_data["cookbook_locks"].each do |name, lock_info|
build_cookbook_lock_as_archive_from_lock_data(name, lock_info)
end
end
def set_attributes_from_lock_data(lock_data)
default_attr_data = lock_data["default_attributes"]
if default_attr_data.nil?
raise InvalidLockfile, "lockfile does not have a `default_attributes` attribute"
end
unless default_attr_data.is_a?(Hash)
raise InvalidLockfile, "lockfile's `default_attributes` attribute must be a Hash (JSON object). (got: #{default_attr_data.inspect})"
end
override_attr_data = lock_data["override_attributes"]
if override_attr_data.nil?
raise InvalidLockfile, "lockfile does not have a `override_attributes` attribute"
end
unless override_attr_data.is_a?(Hash)
raise InvalidLockfile, "lockfile's `override_attributes` attribute must be a Hash (JSON object). (got: #{override_attr_data.inspect})"
end
@default_attributes = default_attr_data
@override_attributes = override_attr_data
end
def set_solution_dependencies_from_lock_data(lock_data)
soln_deps = lock_data["solution_dependencies"]
if soln_deps.nil?
raise InvalidLockfile, "lockfile does not have a solution_dependencies attribute"
end
unless soln_deps.is_a?(Hash)
raise InvalidLockfile, "lockfile's solution_dependencies attribute must be a Hash (JSON object). (got: #{soln_deps.inspect})"
end
s = Policyfile::SolutionDependencies.from_lock(lock_data["solution_dependencies"])
@solution_dependencies = s
end
def set_included_policy_locks_from_lock_data(lock_data)
locks = lock_data["included_policy_locks"]
if locks.nil?
@included_policy_locks = []
else
locks.each do |lock_info|
unless %w{revision_id name source_options}.all? { |key| !lock_info[key].nil? }
raise InvalidLockfile, "lockfile included policy missing one of the required keys"
end
end
@included_policy_locks = locks
end
end
def build_cookbook_lock_from_lock_data(name, lock_info)
unless lock_info.is_a?(Hash)
raise InvalidLockfile, "lockfile cookbook_locks entries must be a Hash (JSON object). (got: #{lock_info.inspect})"
end
if lock_info["cache_key"].nil?
local_cookbook(name).build_from_lock_data(lock_info)
else
cached_cookbook(name).build_from_lock_data(lock_info)
end
end
def build_cookbook_lock_as_archive_from_lock_data(name, lock_info)
unless lock_info.is_a?(Hash)
raise InvalidLockfile, "lockfile cookbook_locks entries must be a Hash (JSON object). (got: #{lock_info.inspect})"
end
if lock_info["cache_key"].nil?
local_cookbook = Policyfile::LocalCookbook.new(name, storage_config)
local_cookbook.build_from_lock_data(lock_info)
archived = Policyfile::ArchivedCookbook.new(local_cookbook, storage_config)
@cookbook_locks[name] = archived
else
cached_cookbook = Policyfile::CachedCookbook.new(name, storage_config)
cached_cookbook.build_from_lock_data(lock_info)
archived = Policyfile::ArchivedCookbook.new(cached_cookbook, storage_config)
@cookbook_locks[name] = archived
end
end
end
end