lib/taste_tester/client.rb (246 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 'minitar'
require 'find'
require 'taste_tester/logging'
require 'between_meals/repo'
require 'between_meals/knife'
require 'between_meals/changeset'
require 'chef/log'
require 'chef/cookbook/chefignore'
module TasteTester
# Client side upload functionality
# Ties together Repo/Changeset diff logic
# and Server/Knife uploads
class Client
include TasteTester::Logging
include BetweenMeals::Util
attr_accessor :force, :skip_checks
def initialize(server)
path = File.expand_path(TasteTester::Config.repo)
logger.warn("Using #{path}")
@server = server
@knife = BetweenMeals::Knife.new(
:logger => logger,
:user => @server.user,
:ssl => TasteTester::Config.use_ssl,
:host => @server.host,
:port => @server.port,
:role_dir => TasteTester::Config.roles,
:cookbook_dirs => TasteTester::Config.cookbooks,
:databag_dir => TasteTester::Config.databags,
:checksum_dir => TasteTester::Config.checksum_dir,
:role_type => TasteTester::Config.role_type,
:config => TasteTester::Config.knife_config,
)
@knife.write_user_config
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
@track_symlinks = TasteTester::Config.track_symlinks
end
def checks
unless @skip_checks
TasteTester::Hooks.repo_checks(TasteTester::Config.dryrun, @repo)
end
end
def upload
head_rev = nil
if @repo
head_rev = @repo.head_rev
checks unless @skip_checks
logger.info("Last commit: #{head_rev} " +
"'#{@repo.last_msg.split("\n").first}'" +
" by #{@repo.last_author[:email]}")
end
if @force || !@server.latest_uploaded_ref || !@repo
logger.info('Full upload forced') if @force
logger.info('No repo, doing full upload') unless @repo
unless TasteTester::Config.skip_pre_upload_hook
TasteTester::Hooks.pre_upload(TasteTester::Config.dryrun,
@repo,
nil,
head_rev)
end
time(logger) { full }
unless TasteTester::Config.skip_post_upload_hook
TasteTester::Hooks.post_upload(TasteTester::Config.dryrun,
@repo,
nil,
head_rev)
end
else
# Since we also upload the index, we always need to run the
# diff even if the version we're on is the same as the last
# revision
unless TasteTester::Config.skip_pre_upload_hook
TasteTester::Hooks.pre_upload(TasteTester::Config.dryrun,
@repo,
@server.latest_uploaded_ref,
head_rev)
end
begin
time(logger) { partial }
rescue BetweenMeals::Changeset::ReferenceError
logger.warn('Something changed with your repo, doing full upload')
time(logger) { full }
end
unless TasteTester::Config.skip_post_upload_hook
TasteTester::Hooks.post_upload(TasteTester::Config.dryrun,
@repo,
@server.latest_uploaded_ref,
head_rev)
end
end
@server.latest_uploaded_ref = head_rev
@server.last_upload_time = Time.new.strftime('%Y-%m-%d %H:%M:%S')
end
private
def populate(stream, writer, path, destination)
full_path = File.join(File.join(TasteTester::Config.repo, path))
return unless File.directory?(full_path)
chefignores = Chef::Cookbook::Chefignore.new(full_path)
# everything is relative to the repo dir. chdir makes handling all the
# paths within this simpler
Dir.chdir(full_path) do
look_at = ['']
while (prefix = look_at.pop)
Dir.glob(File.join("#{prefix}**", '*'), File::FNM_DOTMATCH) do |p|
minus_first = p.split(
File::SEPARATOR,
)[1..-1].join(File::SEPARATOR)
next if chefignores.ignored?(p) ||
chefignores.ignored?(minus_first)
name = File.join(destination, p)
if File.directory?(p)
# we don't store directories in the tar, but we do want to follow
# top level symlinked directories as they are used to share
# cookbooks between codebases.
if minus_first == '' && File.symlink?(p)
look_at.push("#{p}#{File::SEPARATOR}")
end
elsif File.symlink?(p)
# tar handling of filenames > 100 characters gets complex. We'd
# use split_name from Minitar, but it's a private method. It's
# reasonable to assume that all symlink names in the bundle are
# less than 100 characters long. Long term, the version of minitar
# in chefdk should be upgraded.
fail 'Add support for long symlink paths' if name.size > 100
# The version of Minitar included in chefdk does not support
# symlinks directly. Therefore we use direct writes to the
# underlying stream to reproduce the symlinks
symlink = {
:name => name,
:mode => 0644,
:typeflag => '2',
:size => 0,
:linkname => File.readlink(p),
:prefix => '',
}
stream.write(Minitar::PosixHeader.new(symlink))
else
File.open(p, 'rb') do |r|
writer.add_file_simple(
name, :mode => 0644, :size => File.size(r)
) do |d, _opts|
IO.copy_stream(r, d)
end
end
end
end
end
end
end
def bundle_upload
dest = File.join(@server.bundle_dir, 'tt.tgz')
begin
Tempfile.create(['tt', '.tgz'], @server.bundle_dir) do |tempfile|
stream = Zlib::GzipWriter.new(tempfile)
Minitar::Writer.open(stream) do |writer|
TasteTester::Config.relative_cookbook_dirs.each do |cb_dir|
populate(stream, writer, cb_dir, 'cookbooks')
end
populate(
stream, writer, TasteTester::Config.relative_role_dir, 'roles'
)
populate(
stream, writer, TasteTester::Config.relative_databag_dir,
'data_bags'
)
end
stream.close
File.rename(tempfile.path, dest)
end
rescue Errno::ENOENT
# Normally the temporary file is renamed to the dest name. If this
# happens, then the cleanup of of the temporary file doesn't work,
# but this is fine and expected.
nil
end
end
def full
logger.warn('Doing full upload')
if TasteTester::Config.bundle
logger.warn('Creating bundle...')
bundle_upload
# only leave early if true (strictly bundle mode only)
return if TasteTester::Config.bundle == true
end
logger.warn('Uploading cookbooks...')
@knife.cookbook_upload_all
logger.warn('Uploading roles...')
@knife.role_upload_all
logger.warn('Uploading databags...')
@knife.databag_upload_all
end
def partial
if TasteTester::Config.bundle
logger.info('No partial support for bundle mode, doing full upload')
bundle_upload
return if TasteTester::Config.bundle == true
end
logger.info('Doing differential upload from ' +
@server.latest_uploaded_ref)
changeset = BetweenMeals::Changeset.new(
logger,
@repo,
@server.latest_uploaded_ref,
nil,
{
:cookbook_dirs =>
TasteTester::Config.relative_cookbook_dirs,
:role_dir =>
TasteTester::Config.relative_role_dir,
:databag_dir =>
TasteTester::Config.relative_databag_dir,
},
@track_symlinks,
)
cbs = changeset.cookbooks
deleted_cookbooks = cbs.select { |x| x.status == :deleted }
modified_cookbooks = cbs.select { |x| x.status == :modified }
roles = changeset.roles
deleted_roles = roles.select { |x| x.status == :deleted }
modified_roles = roles.select { |x| x.status == :modified }
databags = changeset.databags
deleted_databags = databags.select { |x| x.status == :deleted }
modified_databags = databags.select { |x| x.status == :modified }
didsomething = false
unless deleted_cookbooks.empty?
didsomething = true
logger.warn("Deleting cookbooks: [#{deleted_cookbooks.join(' ')}]")
@knife.cookbook_delete(deleted_cookbooks)
end
unless modified_cookbooks.empty?
didsomething = true
logger.warn("Uploading cookbooks: [#{modified_cookbooks.join(' ')}]")
@knife.cookbook_upload(modified_cookbooks)
end
unless deleted_roles.empty?
didsomething = true
logger.warn("Deleting roles: [#{deleted_roles.join(' ')}]")
@knife.role_delete(deleted_roles)
end
unless modified_roles.empty?
didsomething = true
logger.warn("Uploading roles: [#{modified_roles.join(' ')}]")
@knife.role_upload(modified_roles)
end
unless deleted_databags.empty?
didsomething = true
logger.warn("Deleting databags: [#{deleted_databags.join(' ')}]")
@knife.databag_delete(deleted_databags)
end
unless modified_databags.empty?
didsomething = true
logger.warn("Uploading databags: [#{modified_databags.join(' ')}]")
@knife.databag_upload(modified_databags)
end
logger.warn('Nothing to upload!') unless didsomething
end
end
end