lib/taste_tester/commands.rb (308 lines of code) (raw):

# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2 # Copyright 2013-present Facebook # # 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 'taste_tester/server' require 'taste_tester/host' require 'taste_tester/config' require 'taste_tester/client' require 'taste_tester/logging' require 'taste_tester/exceptions' module TasteTester # Functionality dispatch module Commands extend TasteTester::Logging def self.start server = TasteTester::Server.new return if TasteTester::Server.running? server.start end def self.restart server = TasteTester::Server.new server.restart end def self.stop server = TasteTester::Server.new server.stop end def self.status server = TasteTester::Server.new if TasteTester::Server.running? logger.warn("Local taste-tester server running on port #{server.port}") if TasteTester::Config.no_repo && server.last_upload_time logger.warn("Last upload time was #{server.last_upload_time}") elsif !TasteTester::Config.no_repo && server.latest_uploaded_ref if server.last_upload_time logger.warn("Last upload time was #{server.last_upload_time}") end logger.warn('Latest uploaded revision is ' + server.latest_uploaded_ref) else logger.warn('No cookbooks/roles uploads found') end else logger.warn('Local taste-tester server not running') end end def self.test hosts = TasteTester::Config.servers unless hosts logger.warn('You must provide a hostname') exit(1) end unless TasteTester::Config.yes printf("Set #{TasteTester::Config.servers} to test mode? [y/N] ") ans = STDIN.gets.chomp exit(1) unless ans =~ /^[yY](es)?$/ end if TasteTester::Config.linkonly && TasteTester::Config.really logger.warn('Skipping upload at user request... potentially dangerous!') else if TasteTester::Config.linkonly logger.warn('Ignoring --linkonly because --really not set') end upload end server = TasteTester::Server.new unless TasteTester::Config.linkonly if TasteTester::Config.no_repo repo = nil else repo = BetweenMeals::Repo.get( TasteTester::Config.repo_type, TasteTester::Config.repo, logger, ) end if repo && !repo.exists? fail "Could not open repo from #{TasteTester::Config.repo}" end end unless TasteTester::Config.skip_pre_test_hook || TasteTester::Config.linkonly TasteTester::Hooks.pre_test(TasteTester::Config.dryrun, repo, hosts) end tested_hosts = [] hosts.each do |hostname| host = TasteTester::Host.new(hostname, server) begin host.test tested_hosts << hostname rescue TasteTester::Exceptions::AlreadyTestingError => e logger.error("User #{e.username} is already testing on #{hostname}") rescue StandardError => e # Call error handling hook and re-raise TasteTester::Hooks.post_error(TasteTester::Config.dryrun, e, __method__, hostname) raise end end unless TasteTester::Config.skip_post_test_hook || TasteTester::Config.linkonly TasteTester::Hooks.post_test(TasteTester::Config.dryrun, repo, tested_hosts) end # Strictly: hosts and tested_hosts should be sets to eliminate variance in # order or duplicates. The exact comparison works here because we're # building tested_hosts from hosts directly. if tested_hosts == hosts # No exceptions, complete success: every host listed is now configured # to use our chef-zero instance. exit(0) end if tested_hosts.empty? # All requested hosts are being tested by another user. We didn't change # their configuration. exit(3) end # Otherwise, we got a mix of success and failure due to being tested by # another user. We'll be pessemistic and return an error because the # intent to taste test the complete list was not successful. # code. exit(2) end def self.untest hosts = TasteTester::Config.servers unless hosts logger.error('You must provide a hostname') exit(1) end server = TasteTester::Server.new hosts.each do |hostname| host = TasteTester::Host.new(hostname, server) begin host.untest rescue StandardError => e # Call error handling hook and re-raise TasteTester::Hooks.post_error(TasteTester::Config.dryrun, e, __method__, hostname) raise end end end def self.runchef hosts = TasteTester::Config.servers unless hosts logger.warn('You must provide a hostname') exit(1) end server = TasteTester::Server.new hosts.each do |hostname| host = TasteTester::Host.new(hostname, server) begin host.runchef rescue StandardError => e # Call error handling hook and re-raise TasteTester::Hooks.post_error(TasteTester::Config.dryrun, e, __method__, hostname) raise end end end def self.keeptesting hosts = TasteTester::Config.servers unless hosts logger.warn('You must provide a hostname') exit(1) end server = TasteTester::Server.new hosts.each do |hostname| host = TasteTester::Host.new(hostname, server) begin host.keeptesting rescue StandardError => e # Call error handling hook and re-raise TasteTester::Hooks.post_error(TasteTester::Config.dryrun, e, __method__, hostname) raise end end end def self.upload server = TasteTester::Server.new # On a force-upload rather than try to clean up whatever's on the server # we'll restart chef-zero which will clear everything and do a full # upload if TasteTester::Config.force_upload server.restart else server.start end client = TasteTester::Client.new(server) client.skip_checks = true if TasteTester::Config.skip_repo_checks client.force = true if TasteTester::Config.force_upload client.upload rescue StandardError => exception # We're trying to recover from common chef-zero errors # Most of them happen due to half finished uploads, which leave # chef-zero in undefined state errors = [ 'Cannot find a cookbook named', 'Connection reset by peer', 'Object not found', ] if errors.any? { |e| exception.to_s.match(/#{e}/im) } TasteTester::Config.force_upload = true unless @already_retried @already_retried = true retry end end logger.error('Upload failed') logger.error(exception.to_s) logger.error(exception.backtrace.join("\n")) exit 1 end def self.impact # Use the repository specified in config.rb to calculate the changes # that may affect Chef. These changes will be further analyzed to # determine specific roles which may change due to modifed dependencies. repo = BetweenMeals::Repo.get( TasteTester::Config.repo_type, TasteTester::Config.repo, logger, ) if repo && !repo.exists? fail "Could not open repo from #{TasteTester::Config.repo}" end changes = _find_changeset(repo) # Perform preliminary impact analysis. By default, use Knife to find # the roles dependent on modified cookbooks. Custom logic may provide # additional information by defining the find_impact plugin method. basic_impact = TasteTester::Hooks.find_impact(changes) basic_impact ||= _find_roles(changes) # Do any post processing required on the list of impacted roles, such # as looking up hostnames associated with each role. By default, pass # the preliminary results through unmodified. final_impact = TasteTester::Hooks.post_impact(basic_impact) final_impact ||= basic_impact # Print the calculated impact. If a print hook is defined that # returns true, then the default print function is skipped. unless TasteTester::Hooks.print_impact(final_impact) _print_impact(final_impact) end end def self._find_changeset(repo) # We want to compare changes in the current directory (working set) with # the "most recent" commit in the VCS. For SVN, this will be the latest # commit on the checked out repository (i.e. 'trunk'). Git/Hg may have # different tags or labels assigned to the default branch, (i.e. 'main', # 'stable', etc.) and should be configured if different than the default. start_ref = case repo when BetweenMeals::Repo::Svn repo.latest_revision when BetweenMeals::Repo::Git TasteTester::Config.vcs_start_ref_git when BetweenMeals::Repo::Hg TasteTester::Config.vcs_start_ref_hg end end_ref = TasteTester::Config.vcs_end_ref changeset = BetweenMeals::Changeset.new( logger, repo, start_ref, end_ref, { :cookbook_dirs => TasteTester::Config.relative_cookbook_dirs, :role_dir => TasteTester::Config.relative_role_dir, :databag_dir => TasteTester::Config.relative_databag_dir, }, @track_symlinks, ) return changeset end def self._find_roles(changes) if TasteTester::Config.relative_cookbook_dirs.length > 1 logger.error('Knife deps does not support multiple cookbook paths.') logger.error('Please flatten the cookbooks into a single directory' + ' or define the find_impact method in a local plugin.') exit(1) end cookbooks = Set.new(changes.cookbooks) roles = Set.new(changes.roles) databags = Set.new(changes.databags) if cookbooks.empty? && roles.empty? unless TasteTester::Config.json logger.warn('No cookbooks or roles have been modified.') end return Set.new end unless cookbooks.empty? logger.info('Modified Cookbooks:') cookbooks.each { |cb| logger.info("\t#{cb}") } end unless roles.empty? logger.info('Modified Roles:') roles.each { |r| logger.info("\t#{r}") } end unless databags.empty? logger.info('Modified Databags:') databags.each { |db| logger.info("\t#{db}") } end # Use Knife to list the dependecies for each role in the roles directory. # This creates a recursive tree structure that is then searched for # instances of modified cookbooks. This can be slow since it must read # every line of the Knife output, then search all roles for dependencies. # If you have a custom way to calculate these reverse dependencies, this # is the part you would replace. logger.info('Finding dependencies (this may take a minute or two)...') knife = Mixlib::ShellOut.new( "knife deps /#{TasteTester::Config.role_dir}/*.rb" + " --config #{TasteTester::Config.knife_config}" + " --chef-repo-path #{TasteTester::Config.absolute_base_dir}" + ' --tree --recurse', ) knife.run_command knife.error! # Collapse the output from Knife into a hash structure that maps roles # to the set of their dependencies. This will ignore duplicates in the # Knife output, but must still process each line. logger.info('Processing Dependencies...') deps_hash = {} curr_role = nil knife.stdout.each_line do |line| elem = line.rstrip if elem.length == elem.lstrip.length curr_role = elem deps_hash[curr_role] = Set.new else deps_hash[curr_role].add(File.basename(elem, File.extname(elem))) end end # Now we can search for modified dependencies by iterating over each # role and checking the hash created earlier. Roles that have been # modified directly are automatically included in the impacted set. impacted_roles = Set.new(roles.map(&:name)) deps_hash.each do |role, deplist| cookbooks.each do |cb| if deplist.include?(cb.name) impacted_roles.add(role) logger.info("\tFound dependency: #{role} --> #{cb.name}") break end end end return impacted_roles end def self._print_impact(final_impact) if TasteTester::Config.json puts JSON.pretty_generate(final_impact.to_a) elsif final_impact.empty? logger.warn('No impacted roles were found.') else logger.warn('The following roles have modified dependencies.' + ' Please test a host in each of these roles.') final_impact.each { |r| logger.warn("\t#{r}") } end end end end