require_relative "dependency" module Berkshelf class Lockfile class << self # Initialize a Lockfile from the given filepath # # @param [String] filepath # filepath to the lockfile def from_file(filepath) new(filepath: filepath) end # Initialize a Lockfile from the given Berksfile # # @param [Berkshelf::Berksfile] berksfile # the Berksfile associated with the Lockfile def from_berksfile(berksfile) parent = File.expand_path(File.dirname(berksfile.filepath)) lockfile_name = "#{File.basename(berksfile.filepath)}.lock" filepath = File.join(parent, lockfile_name) new(berksfile: berksfile, filepath: filepath) end end DEFAULT_FILENAME = "Berksfile.lock".freeze DEPENDENCIES = "DEPENDENCIES".freeze GRAPH = "GRAPH".freeze include Berkshelf::Mixin::Logging # @return [Pathname] # the path to this Lockfile attr_reader :filepath # @return [Berkshelf::Berksfile] # the Berksfile for this Lockfile attr_reader :berksfile # @return [Lockfile::Graph] # the dependency graph attr_reader :graph # Create a new lockfile instance associated with the given Berksfile. If a # Lockfile exists, it is automatically loaded. Otherwise, an empty instance is # created and ready for use. # # @option options [String] :filepath # filepath to the lockfile # @option options [Berkshelf::Berksfile] :berksfile # the Berksfile associated with this Lockfile def initialize(options = {}) @filepath = options[:filepath].to_s @berksfile = options[:berksfile] @dependencies = {} @graph = Graph.new(self) parse if File.exists?(@filepath) end # Parse the lockfile. # # @return true def parse LockfileParser.new(self).run true rescue => e raise LockfileParserError.new(e) end # Determine if this lockfile actually exists on disk. # # @return [Boolean] # true if this lockfile exists on the disk, false otherwise def present? File.exists?(filepath) && !File.read(filepath).strip.empty? end # Determine if we can "trust" this lockfile. A lockfile is trustworthy if: # # 1. All dependencies defined in the Berksfile are present in this # lockfile # 2. Each dependency's transitive dependencies are contained and locked # in the lockfile # 3. Each dependency's constraint in the Berksfile is still satisifed by # the currently locked version # # This method does _not_ account for leaky dependencies (i.e. dependencies # defined in the lockfile that are no longer present in the Berksfile); this # edge case is handed by the installer. # # @return [Boolean] # true if this lockfile is trusted, false otherwise def trusted? Berkshelf.log.info "Checking if lockfile is trusted" checked = {} berksfile.dependencies.each do |dependency| Berkshelf.log.debug "Checking #{dependency}" locked = find(dependency) if locked.nil? Berkshelf.log.debug " Not in lockfile - cannot be trusted!" return false end graphed = graph.find(dependency) if graphed.nil? Berkshelf.log.debug " Not in graph - cannot be trusted!" return false end if cookbook = locked.cached_cookbook Berkshelf.log.debug " Detected there is a cached cookbook" unless (cookbook.dependencies.keys - graphed.dependencies.keys).empty? Berkshelf.log.debug " Cached cookbook has different dependencies - cannot be trusted!" return false end end unless dependency.location == locked.location Berkshelf.log.debug " Different location - cannot be trusted!" Berkshelf.log.debug " Dependency location: #{dependency.location.inspect}" Berkshelf.log.debug " Locked location: #{locked.location.inspect}" return false end unless dependency.version_constraint.satisfies?(graphed.version) Berkshelf.log.debug " Version constraint is not satisified - cannot be trusted!" return false end unless satisfies_transitive?(graphed, checked) Berkshelf.log.debug " Transitive dependencies not satisfies - cannot be trusted!" return false end end true end # Recursive helper method for checking if transitive dependencies (i.e. # those dependencies defined in the metadata) are satisfied. This method is # used in calculating the trustworthiness of a lockfile. # # @param [GraphItem] graph_item # the graph item to check transitive dependencies for # @param [Hash] checked # the list of already checked dependencies # # @return [Boolean] def satisfies_transitive?(graph_item, checked, level = 0) indent = " " * (level + 2) Berkshelf.log.debug "#{indent}Checking transitive dependencies for #{graph_item}" if checked[graph_item.name] Berkshelf.log.debug "#{indent} Already checked - skipping" return true end graph_item.dependencies.each do |name, constraint| Berkshelf.log.debug "#{indent} Checking #{name} (#{constraint})" graphed = graph.find(name) if graphed.nil? Berkshelf.log.debug "#{indent} Not graphed - cannot be satisifed" return false end unless Semverse::Constraint.new(constraint).satisfies?(graphed.version) Berkshelf.log.debug "#{indent} Version constraint is not satisfied" return false end checked[name] = true unless satisfies_transitive?(graphed, checked, level + 2) Berkshelf.log.debug "#{indent} Transitive are not satisifed" return false end end end # Resolve this Berksfile and apply the locks found in the generated # +Berksfile.lock+ to the target Chef environment # # @param [String] name # the name of the environment to apply the locks to # # @option options [Hash] :ssl_verify (true) # Disable/Enable SSL verification during uploads # @option options [String] :envfile # Environment file to update # # @raise [EnvironmentNotFound] # if the target environment was not found on the remote Chef Server # @raise [ChefConnectionError] # if you are locking cookbooks with an invalid or not-specified client # configuration def apply(name, options = {}) locks = graph.locks.inject({}) do |hash, (name, dependency)| hash[name] = "= #{dependency.locked_version}" hash end if options[:envfile] update_environment_file(options[:envfile], locks) if options[:envfile] else Berkshelf.ridley_connection(options) do |connection| environment = connection.environment.find(name) raise EnvironmentNotFound.new(name) if environment.nil? environment.cookbook_versions = locks environment.save unless options[:envfile] end end end # @return [Array] def cached graph.locks.values.collect { |dependency| dependency.cached_cookbook } end # The list of dependencies constrained in this lockfile. # # @return [Array] # the list of dependencies in this lockfile def dependencies @dependencies.values end # Find the given dependency in this lockfile. This method accepts a dependency # attribute which may either be the name of a cookbook (String) or an # actual cookbook dependency. # # @param [String, Berkshelf::Dependency] dependency # the cookbook dependency/name to find # # @return [Berkshelf::Dependency, nil] # the cookbook dependency from this lockfile or nil if one was not found def find(dependency) @dependencies[Dependency.name(dependency)] end # Determine if this lockfile contains the given dependency. # # @param [String, Berkshelf::Dependency] dependency # the cookbook dependency/name to determine existence of # # @return [Boolean] # true if the dependency exists, false otherwise def dependency?(dependency) !find(dependency).nil? end alias_method :has_dependency?, :dependency? # Add a new cookbok to the lockfile. If an entry already exists by the # given name, it will be overwritten. # # @param [Dependency] dependency # the dependency to add # # @return [Dependency] def add(dependency) @dependencies[Dependency.name(dependency)] = dependency end def locks graph.locks end # Retrieve information about a given cookbook that is in this lockfile. # # @raise [DependencyNotFound] # if this lockfile does not have the given dependency # @raise [CookbookNotFound] # if this lockfile has the dependency, but the cookbook is not installed # # @param [String, Dependency] dependency # the dependency or name of the dependency to find # # @return [CachedCookbook] # the CachedCookbook that corresponds to the given name parameter def retrieve(dependency) locked = graph.locks[Dependency.name(dependency)] if locked.nil? raise DependencyNotFound.new(Dependency.name(dependency)) end unless locked.installed? name = locked.name version = locked.locked_version || locked.version_constraint raise CookbookNotFound.new(name, version, "in the cookbook store") end locked.cached_cookbook end # Update local environment file # # @param [String] environment_file # path to the envfile to update # # @param [Hash] locks # A hash of cookbooks and versions to update the environment with # # @raise [EnvironmentFileNotFound] # If environment file doesn't exist def update_environment_file(environment_file, locks) unless File.exists?(environment_file) raise EnvironmentFileNotFound.new(environment_file) end json_environment = JSON.parse(File.read(environment_file)) json_environment["cookbook_versions"] = locks json = JSON.pretty_generate(json_environment) File.open(environment_file, "w") { |f| f.puts(json) } Berkshelf.log.info "Updated environment file #{environment_file}" end # Replace the list of dependencies. # # @param [Array] dependencies # the list of dependencies to update def update(dependencies) @dependencies = {} dependencies.each do |dependency| @dependencies[Dependency.name(dependency)] = dependency end end # Remove the given dependency from this lockfile. This method accepts a # +dependency+ attribute which may either be the name of a cookbook, as a # String or an actual {Dependency} object. # # This method first removes the dependency from the list of top-level # dependencies. Then it uses a recursive algorithm to safely remove any # other dependencies from the graph that are no longer needed. # # @param [String] dependency # the name of the cookbook to remove def unlock(dependency, force = false) @dependencies.delete(Dependency.name(dependency)) if force graph.remove(dependency, ignore: graph.locks.keys) else graph.remove(dependency) end end # Completely remove all dependencies from the lockfile and underlying graph. def unlock_all @dependencies = {} @graph = Graph.new(self) end # Iterate over each top-level dependency defined in the lockfile and # check if that dependency is still defined in the Berksfile. # # If the dependency is no longer present in the Berksfile, it is "safely" # removed using {Lockfile#unlock} and {Lockfile#remove}. This prevents # the lockfile from "leaking" dependencies when they have been removed # from the Berksfile, but still remained locked in the lockfile. # # If the dependency exists, a constraint comparison is conducted to verify # that the locked dependency still satisifes the original constraint. This # handles the edge case where a user has updated or removed a constraint # on a dependency that already existed in the lockfile. # # @raise [OutdatedDependency] # if the constraint exists, but is no longer satisifed by the existing # locked version # # @return [Array] def reduce! Berkshelf.log.info "Reducing lockfile" Berkshelf.log.debug "Current lockfile:" Berkshelf.log.debug "" to_lock.each_line do |line| Berkshelf.log.debug " #{line.chomp}" end Berkshelf.log.debug "" # Unlock any locked dependencies that are no longer in the Berksfile Berkshelf.log.debug "Unlocking dependencies no longer in the Berksfile" dependencies.each do |dependency| Berkshelf.log.debug " Checking #{dependency}" if berksfile.has_dependency?(dependency.name) Berkshelf.log.debug " Skipping unlock for #{dependency.name} (exists in the Berksfile)" else Berkshelf.log.debug " Unlocking #{dependency.name}" unlock(dependency, true) end end # Remove any transitive dependencies Berkshelf.log.debug "Removing transitive dependencies" berksfile.dependencies.each do |dependency| Berkshelf.log.debug " Checking #{dependency}" graphed = graph.find(dependency) if graphed.nil? Berkshelf.log.debug " Skipping (not graphed)" next end unless dependency.version_constraint.satisfies?(graphed.version) Berkshelf.log.debug " Constraints are not satisfied!" raise OutdatedDependency.new(graphed, dependency) end # Locking dependency version to the graphed version if # constraints are satisfied by it. dependency.locked_version = graphed.version if cookbook = dependency.cached_cookbook Berkshelf.log.debug " Cached cookbook exists" Berkshelf.log.debug " Updating cookbook dependencies if required" graphed.set_dependencies(cookbook.dependencies) end end # Iteratively remove orphan dependencies orphans = true while orphans orphans = false graph.each do |cookbook| name = cookbook.name unless dependency?(name) || graph.dependency?(name) Berkshelf.log.debug "#{cookbook} identified as orphan; removing it" unlock(name) orphans = true end end end Berkshelf.log.debug "New lockfile:" Berkshelf.log.debug "" to_lock.each_line do |line| Berkshelf.log.debug " #{line.chomp}" end Berkshelf.log.debug "" end # Write the contents of the current statue of the lockfile to disk. This # method uses an atomic file write. A temporary file is created, written, # and then copied over the existing one. This ensures any partial updates # or failures do no affect the lockfile. The temporary file is ensured # deletion. # # @return [true, false] # true if the lockfile was saved, false otherwise def save return false if dependencies.empty? tempfile = Tempfile.new(["Berksfile", ".lock"]) tempfile.write(to_lock) tempfile.rewind tempfile.close # Move the lockfile into place FileUtils.cp(tempfile.path, filepath) true ensure tempfile.unlink if tempfile end # @private def to_lock out = "#{DEPENDENCIES}\n" dependencies.sort.each do |dependency| out << dependency.to_lock end out << "\n" out << graph.to_lock out end # @private def to_s "#" end # @private def inspect "#" end # The class responsible for parsing the lockfile and turning it into a # useful data structure. class LockfileParser NAME_VERSION = '(?! )(.*?)(?: \(([^-]*)(?:-(.*))?\))?'.freeze DEPENDENCY_PATTERN = /^ {2}#{NAME_VERSION}$/ DEPENDENCIES_PATTERN = /^ {4}#{NAME_VERSION}$/ OPTION_PATTERN = /^ {4}(.+)\: (.+)/ # Create a new lockfile parser. # # @param [Lockfile] def initialize(lockfile) @lockfile = lockfile @berksfile = lockfile.berksfile end # Parse the lockfile contents, adding the correct things to the lockfile. # # @return [true] def run @parsed_dependencies = {} contents = File.read(@lockfile.filepath) if contents.strip.empty? Berkshelf.formatter.warn "Your lockfile at '#{@lockfile.filepath}' " \ "is empty. I am going to parse it anyway, but there is a chance " \ "that a larger problem is at play. If you manually edited your " \ "lockfile, you may have corrupted it." end if contents.strip[0] == "{" Berkshelf.formatter.warn "It looks like you are using an older " \ "version of the lockfile. Attempting to convert..." dependencies = "#{Lockfile::DEPENDENCIES}\n" graph = "#{Lockfile::GRAPH}\n" begin hash = JSON.parse(contents) rescue JSON::ParserError Berkshelf.formatter.warn "Could not convert lockfile! This is a " \ "problem. You see, previous versions of the lockfile were " \ "actually a lie. It lied to you about your version locks, and we " \ "are really sorry about that.\n\n" \ "Here's the good news - we fixed it!\n\n" \ "Here's the bad news - you probably should not trust your old " \ "lockfile. You should manually delete your old lockfile and " \ "re-run the installer." end hash["dependencies"] && hash["dependencies"].sort .each do |name, info| dependencies << " #{name} (>= 0.0.0)\n" info.each do |key, value| unless key == "locked_version" dependencies << " #{key}: #{value}\n" end end graph << " #{name} (#{info['locked_version']})\n" end contents = "#{dependencies}\n#{graph}" end contents.split(/(?:\r?\n)+/).each do |line| if line == Lockfile::DEPENDENCIES @state = :dependency elsif line == Lockfile::GRAPH @state = :graph else send("parse_#{@state}", line) end end @parsed_dependencies.each do |name, options| graph_item = @lockfile.graph.find(name) options[:locked_version] = graph_item.version if graph_item dependency = Dependency.new(@berksfile, name, options) @lockfile.add(dependency) end true end private # Parse a dependency line. # # @param [String] line def parse_dependency(line) if line =~ DEPENDENCY_PATTERN name, version = $1, $2 @parsed_dependencies[name] ||= {} @parsed_dependencies[name][:constraint] = version if version @current_dependency = @parsed_dependencies[name] elsif line =~ OPTION_PATTERN key, value = $1, $2 @current_dependency[key.to_sym] = value end end # Parse a graph line. # # @param [String] line def parse_graph(line) if line =~ DEPENDENCY_PATTERN name, version = $1, $2 @lockfile.graph.find(name) || @lockfile.graph.add(name, version) @current_lock = name elsif line =~ DEPENDENCIES_PATTERN name, constraint = $1, $2 @lockfile.graph.find(@current_lock).add_dependency(name, constraint) end end end # The class representing an internal graph. class Graph include Enumerable # Create a new Lockfile graph. # # Some clarifying terminology: # # yum-epel (0.2.0) <- lock # yum (~> 3.0) <- dependency # # @return [Graph] def initialize(lockfile) @lockfile = lockfile @berksfile = lockfile.berksfile @graph = {} end # @yield [Hash] # a key-value hash where the key is the name of the cookbook and the # value is the locked dependency def locks @graph.sort.inject({}) do |hash, (name, item)| dependency = @lockfile.find(name) || @berksfile && @berksfile.find(name) || Dependency.new(@berksfile, name) # We need to make a copy of the dependency, or else we could be # modifying an existing object that other processes depend on! dependency = dependency.dup dependency.locked_version = item.version unless dependency.locked_version hash[item.name] = dependency hash end end # Find a given dependency in the graph. # # @param [Dependency, String] # the name/dependency to find # # @return [GraphItem, nil] # the item for the name def find(dependency) @graph[Dependency.name(dependency)] end # Find if the given lock exists? # # @param [Dependency, String] # the name/dependency to find # # @return [true, false] def lock?(dependency) !find(dependency).nil? end alias_method :has_lock?, :lock? # Determine if this graph contains the given dependency. This method is # used by the lockfile when adding or removing dependencies to see if a # dependency can be safely removed. # # @param [Dependency, String] dependency # the name/dependency to find # # @option options [String, Array] :ignore # the list of dependencies to ignore def dependency?(dependency, options = {}) name = Dependency.name(dependency) ignore = Hash[*Array(options[:ignore]).map { |i| [i, true] }.flatten] @graph.values.each do |item| next if ignore[item.name] if item.dependencies.key?(name) return true end end false end alias_method :has_dependency?, :dependency? # Add each a new {GraphItem} to the graph. # # @param [#to_s] name # the name of the cookbook # @param [#to_s] version # the version of the lock # # @return [GraphItem] def add(name, version) @graph[name.to_s] = GraphItem.new(name, version) end # Recursively remove any dependencies from the graph unless they exist as # top-level dependencies or nested dependencies. # # @param [Dependency, String] dependency # the name/dependency to remove # # @option options [String, Array] :ignore # the list of dependencies to ignore def remove(dependency, options = {}) name = Dependency.name(dependency) if @lockfile.dependency?(name) return end if dependency?(name, options) return end # Grab the nested dependencies for this particular entry so we can # recurse and try to remove them from the graph. locked = @graph[name] nested_dependencies = locked && locked.dependencies.keys || [] # Now delete the entry @graph.delete(name) # Recursively try to delete the remaining dependencies for this item nested_dependencies.each(&method(:remove)) end # Update the graph with the given cookbooks. This method destroys the # existing dependency graph with this new result! # # @param [Array] # the list of cookbooks to populate the graph with def update(cookbooks) @graph = {} cookbooks.each do |cookbook| @graph[cookbook.cookbook_name.to_s] = GraphItem.new( cookbook.cookbook_name, cookbook.version, cookbook.dependencies ) end end # Write the contents of the graph to the lockfile format. # # The resulting format looks like: # # GRAPH # apache2 (1.8.14) # yum-epel (0.2.0) # yum (~> 3.0) # # @example lockfile.graph.to_lock #=> "GRAPH\n apache2 (1.18.14)\n..." # # @return [String] # def to_lock out = "#{Lockfile::GRAPH}\n" @graph.sort.each do |name, item| out << " #{name} (#{item.version})\n" unless item.dependencies.empty? item.dependencies.sort.each do |name, constraint| out << " #{name} (#{constraint})\n" end end end out end # A single item inside the graph. class GraphItem # The name of the cookbook that corresponds to this graph item. # # @return [String] # the name of the cookbook attr_reader :name # The locked version for this graph item. # # @return [String] # the locked version of the graph item (as a string) attr_reader :version # The list of dependencies and their constraints. # # @return [Hash] # the list of dependencies for this graph item, where the key # corresponds to the name of the dependency and the value is the # version constraint. attr_reader :dependencies # Create a new graph item. def initialize(name, version, dependencies = {}) @name = name.to_s @version = version.to_s @dependencies = dependencies end # Add a new dependency to the list. # # @param [#to_s] name # the name to use # @param [#to_s] constraint # the version constraint to use def add_dependency(name, constraint) @dependencies[name.to_s] = constraint.to_s end def set_dependencies(dependencies) @dependencies = dependencies.to_hash end # @private def to_s "#{name} (#{version})" end end end end end