lib/berkshelf/lockfile.rb in berkshelf-3.0.0.beta6 vs lib/berkshelf/lockfile.rb in berkshelf-3.0.0.beta7

- old
+ new

@@ -1,11 +1,8 @@ require_relative 'dependency' module Berkshelf - # The object representation of the Berkshelf lockfile. The lockfile is useful - # when working in teams where the same cookbook versions are desired across - # multiple workstations. class Lockfile class << self # Initialize a Lockfile from the given filepath # # @param [String] filepath @@ -22,22 +19,29 @@ filepath = File.join(File.dirname(File.expand_path(berksfile.filepath)), Lockfile::DEFAULT_FILENAME) new(berksfile: berksfile, filepath: filepath) end end - DEFAULT_FILENAME = "Berksfile.lock" + DEFAULT_FILENAME = 'Berksfile.lock' + DEPENDENCIES = 'DEPENDENCIES' + GRAPH = 'GRAPH' + 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 [Hash] + # 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 @@ -46,73 +50,88 @@ # the Berksfile associated with this Lockfile def initialize(options = {}) @filepath = options[:filepath].to_s @berksfile = options[:berksfile] @dependencies = {} + @graph = Graph.new(self) - load! if File.exists?(@filepath) + parse if File.exists?(@filepath) end - # Resolve this Berksfile and apply the locks found in the generated Berksfile.lock to the - # target Chef environment + # Parse the lockfile. # - # @param [String] environment_name + # @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 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? + berksfile.dependencies.all? do |dependency| + locked = find(dependency) + graphed = graph.find(dependency) + constraint = dependency.version_constraint + + locked && graphed && + dependency.location == locked.location && + constraint.satisfies?(graphed.version) + 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 # # @raise [EnvironmentNotFound] - # if the target environment was not found + # 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(environment_name, options = {}) - Berkshelf.ridley_connection(options) do |conn| - unless environment = conn.environment.find(environment_name) - raise EnvironmentNotFound.new(environment_name) - end + # if you are locking cookbooks with an invalid or not-specified client + # configuration + def apply(name, options = {}) + Berkshelf.ridley_connection(options) do |connection| + environment = connection.environment.find(name) - environment.cookbook_versions = {}.tap do |cookbook_versions| - dependencies.each do |dependency| - if dependency.locked_version.nil? - # A locked version must be present for each entry. Older versions of the lockfile - # may have contained dependencies with a special type of location that would attempt - # to dynamically determine the locked version. This is incorrect and the Lockfile - # should be regenerated if that is the case. - raise InvalidLockFile, "Your lockfile contains a dependency without a locked version. This " + - "may be because you have an old lockfile. Regenerate your lockfile and try again." - end + raise EnvironmentNotFound.new(name) if environment.nil? - cookbook_versions[dependency.name] = "= #{dependency.locked_version.to_s}" - end + locks = graph.locks.inject({}) do |hash, (name, dependency)| + hash[name] = "= #{dependency.locked_version.to_s}" + hash end + environment.cookbook_versions = locks environment.save end end - # Load the lockfile from file system. - def load! - contents = File.read(filepath).strip - hash = parse(contents) - - hash[:dependencies].each do |name, options| - # Dynamically calculate paths relative to the Berksfile if a path is given - options[:path] &&= File.expand_path(options[:path], File.dirname(filepath)) - - begin - dependency = Berkshelf::Dependency.new(berksfile, name.to_s, options) - next if dependency.location && !dependency.location.valid? - add(dependency) - rescue Berkshelf::CookbookNotFound - # It's possible that a source is locked that contains a path location, and - # that path location was renamed or no longer exists. When loading the - # lockfile, Berkshelf will throw an error if it can't find a cookbook that - # previously existed at a path location. - end - end - end - # The list of dependencies constrained in this lockfile. # # @return [Array<Berkshelf::Dependency>] # the list of dependencies in this lockfile def dependencies @@ -127,234 +146,447 @@ # 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[cookbook_name(dependency).to_s] + @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 has_dependency?(dependency) + def dependency?(dependency) !find(dependency).nil? end + alias_method :has_dependency?, :dependency? - # Replace the current list of dependencies with `dependencies`. This method does - # not write out the lockfile - it only changes the state of the object. + # 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 + + # 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 downloaded + # + # @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.downloaded? + raise CookbookNotFound, "Could not find cookbook '#{locked.to_s}'. " \ + "Run `berks install` to download and install the missing cookbook." + end + + locked.cached_cookbook + end + + # Replace the list of dependencies. + # # @param [Array<Berkshelf::Dependency>] dependencies # the list of dependencies to update def update(dependencies) - reset_dependencies! + @dependencies = {} - dependencies.each { |dependency| append(dependency) } - save + dependencies.each do |dependency| + @dependencies[Dependency.name(dependency)] = dependency + end end - # Add the given dependency to the `dependencies` list, if it doesn't already exist. + # 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. # - # @param [Berkshelf::Dependency] dependency - # the dependency to append to the dependencies list - def add(dependency) - @dependencies[cookbook_name(dependency)] = dependency - end - alias_method :append, :add - - # Remove the given dependency from this lockfile. This method accepts a dependency - # attribute which may either be the name of a cookbook (String) or an - # actual cookbook dependency. + # 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, Berkshelf::Dependency] dependency - # the cookbook dependency/name to remove - # # @raise [Berkshelf::CookbookNotFound] # if the provided dependency does not exist - def remove(dependency) - unless has_dependency?(dependency) - raise Berkshelf::CookbookNotFound, "'#{cookbook_name(dependency)}' does not exist in this lockfile!" + # + # @param [String] dependency + # the name of the cookbook to remove + def unlock(dependency) + unless dependency?(dependency) + raise Berkshelf::CookbookNotFound, "'#{dependency}' does not exist in this lockfile!" end - @dependencies.delete(cookbook_name(dependency)) + @dependencies.delete(Dependency.name(dependency)) + graph.remove(dependency) end - alias_method :unlock, :remove - # @return [String] - # the string representation of the lockfile + # 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(DEPENDENCIES) + tempfile.write("\n") + dependencies.sort.each do |dependency| + tempfile.write(dependency.to_lock) + end + + tempfile.write("\n") + tempfile.write(graph.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_s "#<Berkshelf::Lockfile #{Pathname.new(filepath).basename}>" end - # @return [String] - # the detailed string representation of the lockfile + # @private def inspect "#<Berkshelf::Lockfile #{Pathname.new(filepath).basename}, dependencies: #{dependencies.inspect}>" end - # Write the current lockfile to a hash - # - # @return [Hash] - # the hash representation of this lockfile - # * :dependencies [Array<Berkshelf::Dependency>] the list of dependencies - def to_hash - { - dependencies: @dependencies - } - end + private - # The JSON representation of this lockfile - # - # Relies on {#to_hash} to generate the json - # - # @return [String] - # the JSON representation of this lockfile - def to_json(options = {}) - JSON.pretty_generate(to_hash, options) - end + # The class responsible for parsing the lockfile and turning it into a + # useful data structure. + class LockfileParser + NAME_VERSION = '(?! )(.*?)(?: \(([^-]*)(?:-(.*))?\))?' + DEPENDENCY_PATTERN = /^ {2}#{NAME_VERSION}$/ + DEPENDENCIES_PATTERN = /^ {4}#{NAME_VERSION}$/ + OPTION_PATTERN = /^ {4}(.+)\: (.+)/ - private + # Create a new lockfile parser. + # + # @param [Lockfile] + def initialize(lockfile) + @lockfile = lockfile + @berksfile = lockfile.berksfile + end - # Parse the given string as JSON. + # Parse the lockfile contents, adding the correct things to the lockfile. # - # @param [String] contents - # - # @return [Hash] - def parse(contents) - # Ruby's JSON.parse cannot handle an empty string/file - return { dependencies: [] } if contents.strip.empty? + # @return [true] + def run + @parsed_dependencies = {} - hash = JSON.parse(contents, symbolize_names: true) + contents = File.read(@lockfile.filepath) - # Legacy support for 2.0 lockfiles - # @todo Remove in 4.0 - if hash[:sources] - LockfileLegacy.warn! - hash[:dependencies] = hash.delete(:sources) + 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 - return hash - rescue Exception => e - # Legacy support for 1.0 lockfiles - # @todo Remove in 4.0 - if e.class == JSON::ParserError && contents =~ /^cookbook ["'](.+)["']/ - LockfileLegacy.warn! - return LockfileLegacy.parse(berksfile, contents) - else - raise Berkshelf::LockfileParserError.new(filepath, e) + 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| + dependency = Dependency.new(@berksfile, name, options) + @lockfile.add(dependency) + end + + true end - # Save the contents of the lockfile to disk. - def save - File.open(filepath, 'w') do |file| - file.write to_json + "\n" + 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 - def reset_dependencies! - @dependencies = {} + # 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 - # Return the name of this cookbook (because it's the key in our - # table). + # The class representing an internal graph. + class Graph + # Create a new Lockfile graph. # - # @param [Berkshelf::Dependency, #to_s] dependency - # the dependency to find the name from + # Some clarifying terminology: # - # @return [String] + # yum-epel (0.2.0) <- lock + # yum (~> 3.0) <- dependency + # + # @return [Graph] + def initialize(lockfile) + @lockfile = lockfile + @berksfile = lockfile.berksfile + @graph = {} + end + + # The list of locks for this graph. Dependencies are retrieved from the + # lockfile, then the Berksfile, and finally a new dependency object is + # created if none of those exist. + # + # @return [Hash<String, Dependency>] + # 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) + dependency.locked_version = item.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 + def dependency?(dependency) + @graph.values.any? do |item| + item.dependencies.key?(Dependency.name(dependency)) + end + end + alias_method :has_dependency?, :dependency? + + # Add each a new {GraphItem} to the graph. + # + # @param [#to_s] name # the name of the cookbook - def cookbook_name(dependency) - dependency.is_a?(Berkshelf::Dependency) ? dependency.name : dependency.to_s + # @param [#to_s] version + # the version of the lock + # + # @return [GraphItem] + def add(name, version) + @graph[name.to_s] = GraphItem.new(name, version) end - # Legacy support for old lockfiles + # Recursively remove any dependencies from the graph unless they exist as + # top-level dependencies or nested dependencies. # - # @todo Remove this class in Berkshelf 3.0.0 - class LockfileLegacy - class << self - # Read the old lockfile content and instance eval in context. - # - # @param [Berkshelf::Berksfile] berksfile - # the associated berksfile - # @param [String] content - # the string content read from a legacy lockfile - def parse(berksfile, content) - dependencies = {}.tap do |hash| - content.split("\n").each do |line| - next if line.empty? - source = new(berksfile, line) - hash[source.name] = source.options - end - end + # @param [Dependency, String] dependency + # the name/dependency to remove + def remove(dependency) + name = Dependency.name(dependency) - { - dependencies: dependencies, - } - end + return if @lockfile.dependency?(name) || dependency?(name) - # Warn the user they he/she is using an old Lockfile format. - # - # This automatically outputs to the {Berkshelf.ui}; nothing is - # returned. - # - # @return [nil] - def warn! - Berkshelf.ui.warn(warning_message) - 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 || [] - private - # @return [String] - def warning_message - 'You are using the old lockfile format. Attempting to convert...' + # 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<CachedCookbook>] + # 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.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 - # @return [Hash] - # the hash of options - attr_reader :options + out + end + private + + # A single item inside the graph. + class GraphItem + # The name of the cookbook that corresponds to this graph item. + # # @return [String] - # the name of this cookbook + # the name of the cookbook attr_reader :name - # @return [Berkshelf::Berksfile] - # the berksfile - attr_reader :berksfile + # The locked version for this graph item. + # + # @return [String] + # the locked version of the graph item (as a string) + attr_reader :version - # Create a new legacy lockfile for processing + # The list of dependencies and their constraints. # - # @param [String] content - # the content to parse out and convert to a hash - def initialize(berksfile, content) - @berksfile = berksfile - instance_eval(content).to_hash + # @return [Hash<String, String>] + # 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 - # Method defined in legacy lockfiles (since we are using instance_eval). + # Add a new dependency to the list. # - # @param [String] name - # the name of this cookbook - # @option options [String] :locked_version - # the locked version of this cookbook - def cookbook(name, options = {}) - @name = name - @options = manipulate(options) + # @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 - - private - - # Perform various manipulations on the hash. - # - # @param [Hash] options - def manipulate(options = {}) - if options[:path] - options[:path] = berksfile.find(name).instance_variable_get(:@options)[:path] || options[:path] - end - options - end end + end end end