require 'securerandom' require 'shellwords' require 'fileutils' require 'tempfile' require 'json' require 'git' require 'ridley' require 'berkshelf' require 'sinatra/base' require 'pmap' require_relative 'helpers/sync_servers' Celluloid.logger = nil Berkshelf.logger = Logger.new $stdout module KitchenHooks class App < Sinatra::Application def self.pluralize n, singular, plural=nil plural = "#{singular}s" if plural.nil? return "no #{plural}" if n.zero? return "1 #{singular}" if n == 1 "#{n} #{plural}" end # http://stackoverflow.com/questions/4136248/how-to-generate-a-human-readable-time-range-using-ruby-on-rails def self.humanize_seconds secs [ [ 60, :seconds ], [ 60, :minutes ], [ 24, :hours ], [ 1000, :days ] ].map { |count, name| if secs > 0 secs, n = secs.divmod(count) "#{n.to_i} #{name}" end }.compact.reverse.join(' ') end def self.sync_servers knives SyncServers.new knives end def self.report_error e, msg=nil msg = e.message if msg.nil? $stdout.puts msg $stdout.puts e.message $stdout.puts e.backtrace.inspect msg end def self.perform_constraint_application event, knives $stdout.puts 'started perform_constraint_application event=%s, knives=%s' % [ event['after'], knives.inspect ] tmp_clone event, :tagged_commit do |clone| Dir.chdir clone do $stdout.puts 'Applying constraints' constraints = lockfile_constraints 'Berksfile.lock' environment = tag_name event knives.peach do |k| apply_constraints constraints, environment, k verify_constraints constraints, environment, k end end end $stdout.puts "finished perform_constraint_application: #{event['after']}" return # no error end def self.perform_kitchen_upload event, knives return false unless commit_to_master?(event) $stdout.puts 'started perform_kitchen_upload event=%s, knives=%s' % [ event['after'], knives.inspect ] tmp_clone event, :latest_commit do |clone| Dir.chdir clone do kitchen_upload knives end end $stdout.puts "finished perform_kitchen_upload: #{event['after']}" return # no error end def self.perform_cookbook_upload event, knives $stdout.puts 'started perform_cookbook_upload event=%s, knives=%s' % [ event['after'], knives.inspect ] tmp_clone event, :tagged_commit do |clone| Dir.chdir clone do tagged_version = tag_name(event).delete('v') cookbook_version = File.read('VERSION').strip unless tagged_version == cookbook_version raise 'Tagged version does not match cookbook version' end $stdout.puts 'Uploading cookbook' with_each_knife_do "cookbook upload #{cookbook_name event} -o .. --freeze", knives $stdout.puts 'Uploading bundled roles, environments, and data bags' kitchen_upload knives end berksfile = File::join clone, 'Berksfile' berksfile_lock = berksfile + '.lock' if File::exist? berksfile_lock $stdout.puts 'Uploading dependencies' berks_install berksfile knives.peach do |knife| berks_upload berksfile, knife end end end $stdout.puts "finished cookbook_upload: #{event['after']}" return # no error end def self.kitchen_upload knives $stdout.puts 'Uploading data_bags' with_each_knife_do 'upload data_bags --chef-repo-path .', knives $stdout.puts 'Uploading roles' with_each_knife_do 'upload roles --chef-repo-path .', knives $stdout.puts 'Uploading environments' Dir['environments/*'].peach do |e| knives.peach do |k| upload_environment e, k end end end def self.berkshelf_config knife ridley = Ridley::from_chef_config knife config = { chef: { node_name: ridley.client_name, client_key: ridley.client_key, chef_server_url: ridley.server_url }, ssl: { verify: false } } config_path = File.join tmp, "#{SecureRandom.hex}-berkshelf.json" File.open(config_path, 'w') do |f| f.puts JSON::pretty_generate config end return config_path end def self.berks_install berksfile $stdout.puts 'started berks_install berksfile=%s' % berksfile.inspect env_git_dir = ENV.delete 'GIT_DIR' env_git_work_tree = ENV.delete 'GIT_WORK_TREE' cmd = "berks install --debug --berksfile %s" % [ Shellwords::escape(berksfile) ] $stdout.puts "berks_install: %s" % cmd system cmd raise 'Could not perform berks_install with config %s' % [ berksfile.inspect ] unless $?.exitstatus.zero? ENV['GIT_DIR'] = env_git_dir ENV['GIT_WORK_TREE'] = env_git_work_tree $stdout.puts 'finished berks_install: %s' % berksfile end def self.berks_upload berksfile, knife, options={} $stdout.puts 'started berks_upload berksfile=%s, knife=%s' % [ berksfile.inspect, knife.inspect ] config_path = berkshelf_config(knife) cmd = "berks upload --debug --berksfile %s --config %s" % [ Shellwords::escape(berksfile), Shellwords::escape(config_path) ] $stdout.puts "berks_upload: %s" % cmd system cmd raise 'Could not perform berks_upload with config %s, knife %s' % [ berksfile.inspect, knife.inspect ] unless $?.exitstatus.zero? FileUtils.rm_rf config_path $stdout.puts 'finished berks_upload: %s' % berksfile end def self.tmp_clone event, commit_method, &block $stdout.puts 'starting tmp_clone event=%s, commit_method=%s' % [ event['after'], commit_method.inspect ] root = File::join tmp, SecureRandom.hex dir = File::join root, Time.now.to_f.to_s, cookbook_name(event) FileUtils.mkdir_p dir repo = Git.clone git_daemon_style_url(event), dir, log: $stdout commit = self.send(commit_method, event) $stdout.puts 'creating tmp_clone dir=%s, commit=%s' % [ dir.inspect, commit.inspect ] repo.checkout commit yield dir FileUtils.rm_rf root $stdout.puts 'finished tmp_clone' end def self.with_each_knife_do command, knives with_each_knife "knife #{command} --config %{knife}", knives end def self.with_each_knife command, knives knives.pmap do |k| cmd = command % { knife: Shellwords::escape(k) } $stdout.puts 'with_each_knife: %s' % cmd system cmd # No error handling here; do that on "berks upload" end end def self.get_environment environment, knife ridley = Ridley::from_chef_config knife ridley.environment.find environment end def self.verify_constraints constraints, environment, knife $stdout.puts 'started verify_constraints environment=%s, knife=%s' % [ environment.inspect, knife.inspect ] chef_environment = get_environment environment, knife unless constraints == chef_environment.cookbook_versions raise 'Environment did not match constraints' end $stdout.puts 'finished verify_constraints: %s' % environment end def self.apply_constraints constraints, environment, knife # Ripped from Berkshelf::Cli::apply and Berkshelf::Lockfile::apply # https://github.com/berkshelf/berkshelf/blob/master/lib/berkshelf/cli.rb # https://github.com/berkshelf/berkshelf/blob/master/lib/berkshelf/lockfile.rb $stdout.puts 'started apply_constraints environment=%s, knife=%s' % [ environment.inspect, knife.inspect ] chef_environment = get_environment environment, knife raise 'Could not find environment "%s"' % environment if chef_environment.nil? chef_environment.cookbook_versions = constraints chef_environment.save $stdout.puts 'finished apply_constraints: %s' % environment end def self.lockfile_constraints lockfile_path # Ripped from Berkshelf::Cli::apply and Berkshelf::Lockfile::apply # https://github.com/berkshelf/berkshelf/blob/master/lib/berkshelf/cli.rb # https://github.com/berkshelf/berkshelf/blob/master/lib/berkshelf/lockfile.rb lockfile = Berkshelf::Lockfile.from_file lockfile_path constraints = lockfile.graph.locks.inject({}) do |hash, (name, dependency)| hash[name] = "= #{dependency.locked_version.to_s}" hash end $stdout.puts 'constraints: %s -> %s' % [ lockfile_path, constraints ] return constraints end def self.upload_environment environment, knife $stdout.puts 'started upload_environment environment=%s, knife=%s' % [ environment.inspect, knife.inspect ] # Load the local environment from a JSON file local_environment = JSON::parse File.read(environment) local_environment.delete 'chef_type' local_environment.delete 'json_class' local_environment.delete 'cookbook_versions' # Load existing environment object on Chef server Celluloid.logger = nil ridley = Ridley::from_chef_config knife chef_environment = ridley.environment.find(local_environment['name']) # Create environment object if it doesn't exist if chef_environment.nil? chef_environment = ridley.environment.create(local_environment) end # Merge the local environment into the existing object local_environment.each do |k, v| chef_environment.send "#{k}=".to_sym, v end # Make it so! chef_environment.save $stdout.puts 'finished upload_environment: %s' % environment end def notification e ; App.notification e end def self.notification entry return entry[:error] if entry[:error] event = entry[:event] case entry[:type] when 'synced', 'unsynced' if event.is_a? String event else 'Synced %d of %d nodes (%s, %s elapsed)' % [ event[:num_successes], event[:num_nodes], pluralize(event[:num_failures], 'failure'), humanize_seconds(event[:elapsed]) ] end when 'kitchen upload' %Q| #{author(event)} updated the Kitchen | when 'cookbook upload' %Q| #{author(event)} released #{tag_name(event)} of #{cookbook_name(event)} | when 'constraint application' %Q| #{author(event)} constrained #{tag_name(event)} with #{cookbook_name(event)} | when 'release' %Q| Kitchen Hooks v#{event} released! | end.strip end def generic_details e ; App.generic_details e end def self.generic_details event return if event.nil? %Q| #{author(event)} pushed #{push_details(event)} |.strip end def push_details e ; App.push_details e end def self.push_details event return if event.nil? %Q| #{event['after']} to #{repo_name(event)} |.strip end def self.author event event['user_name'] end def self.repo_name event File::basename event['repository']['url'], '.git' end def self.cookbook_name event repo_name(event).sub /^(app|base|realm|fork)_/, 'bjn_' end def self.cookbook_repo? event repo_name(event) =~ /^(app|base|realm|fork)_/ end def self.repo_url event git_daemon_style_url(event).sub(/^git/, 'http').sub(/\.git$/, '') end def self.git_daemon_style_url event event['repository']['url'].sub(':', '/').sub('@', '://') end def self.gitlab_url event url = git_daemon_style_url(event).sub(/^git/, 'http').sub(/\.git$/, '') "#{url}/commit/#{event['after']}" end def self.gitlab_tag_url event url = git_daemon_style_url(event).sub(/^git/, 'http').sub(/\.git$/, '') "#{url}/commits/#{tag_name(event)}" end def self.latest_commit event event['commits'].last['id'] end def self.tagged_commit event event['ref'] =~ %r{/tags/(.*)$} return $1 # First regex capture end def self.tag_name event tagged_commit event end def self.commit_to_master? event event['ref'] == 'refs/heads/master' end def self.not_deleted? event event['after'] != '0000000000000000000000000000000000000000' end def self.commit_to_kitchen? event repo_name(event) == 'kitchen' && not_deleted?(event) end def self.tagged_commit_to_cookbook? event cookbook_repo?(event) && event['ref'] =~ %r{/tags/} && not_deleted?(event) end def self.tagged_commit_to_realm? event tagged_commit_to_cookbook?(event) && repo_name(event) =~ /^realm_/ end end end