# # Copyright:: Copyright (c) Chef Software Inc. # License:: Apache License, Version 2.0 # # 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 "pathname" unless defined?(Pathname) require "fileutils" unless defined?(FileUtils) require "tmpdir" unless defined?(Dir.mktmpdir) require "zlib" unless defined?(Zlib) require "archive/tar/minitar" require "chef/cookbook/chefignore" require_relative "../service_exceptions" require_relative "../policyfile_lock" require_relative "../policyfile/storage_config" module ChefCLI module PolicyfileServices class ExportRepo include Policyfile::StorageConfigDelegation attr_reader :storage_config attr_reader :root_dir attr_reader :export_dir attr_reader :ui attr_reader :policy_group def initialize(policyfile: nil, export_dir: nil, root_dir: nil, archive: false, force: false, policy_group: nil) @root_dir = root_dir @export_dir = File.expand_path(export_dir) @archive = archive @force_export = force @ui = UI.new @policy_data = nil @policyfile_lock = nil @policy_group = policy_group @policy_group ||= "local".freeze policyfile_rel_path = policyfile || "Policyfile.rb" policyfile_full_path = File.expand_path(policyfile_rel_path, root_dir) @storage_config = Policyfile::StorageConfig.new.use_policyfile(policyfile_full_path) @staging_dir = nil end def archive? @archive end def policy_name policyfile_lock.name end def run assert_lockfile_exists! assert_export_dir_clean! validate_lockfile write_updated_lockfile export end def policy_data @policy_data ||= FFI_Yajl::Parser.parse(IO.read(policyfile_lock_expanded_path)) rescue => error raise PolicyfileExportRepoError.new("Error reading lockfile #{policyfile_lock_expanded_path}", error) end def policyfile_lock @policyfile_lock || validate_lockfile end def archive_file_location return nil unless archive? filename = "#{policyfile_lock.name}-#{policyfile_lock.revision_id}.tgz" File.join(export_dir, filename) end def export with_staging_dir do create_repo_structure copy_cookbooks create_policyfile_repo_item create_policy_group_repo_item copy_policyfile_lock create_client_rb create_readme_md if archive? create_archive else mv_staged_repo end end rescue => error msg = "Failed to export policy (in #{policyfile_filename}) to #{export_dir}" raise PolicyfileExportRepoError.new(msg, error) end private def with_staging_dir p = Process.pid t = Time.new.utc.strftime("%Y%m%d%H%M%S") Dir.mktmpdir("chefcli-export-#{p}-#{t}") do |d| begin @staging_dir = d yield ensure @staging_dir = nil end end end def create_archive Dir.chdir(staging_dir) do targets = Find.find(".").collect { |e| e } Mixlib::Archive.new(archive_file_location).create(targets, gzip: true) end end def staging_dir @staging_dir end def create_repo_structure FileUtils.mkdir_p(export_dir) FileUtils.mkdir_p(dot_chef_staging_dir) FileUtils.mkdir_p(cookbook_artifacts_staging_dir) FileUtils.mkdir_p(policies_staging_dir) FileUtils.mkdir_p(policy_groups_staging_dir) end def copy_cookbooks policyfile_lock.cookbook_locks.each do |name, lock| copy_cookbook(lock) end end def copy_cookbook(lock) dirname = "#{lock.name}-#{lock.identifier}" export_path = File.join(staging_dir, "cookbook_artifacts", dirname) metadata_rb_path = File.join(export_path, "metadata.rb") FileUtils.mkdir(export_path) unless File.directory?(export_path) copy_unignored_cookbook_files(lock, export_path) FileUtils.rm_f(metadata_rb_path) if lock.cookbook_version.nil? ui.msg "Unable to get the cookbook version/metadata for #{lock}" end metadata = lock.cookbook_version.metadata metadata_json_path = File.join(export_path, "metadata.json") File.open(metadata_json_path, "wb+") do |f| f.print(FFI_Yajl::Encoder.encode(metadata.to_hash, pretty: true )) end end def copy_unignored_cookbook_files(lock, export_path) cookbook_files_to_copy(lock.cookbook_path).each do |rel_path| full_source_path = File.join(lock.cookbook_path, rel_path) full_dest_path = File.join(export_path, rel_path) dest_dirname = File.dirname(full_dest_path) FileUtils.mkdir_p(dest_dirname) unless File.directory?(dest_dirname) FileUtils.cp(full_source_path, full_dest_path) end end def cookbook_files_to_copy(cookbook_path) cookbook = cookbook_loader_for(cookbook_path).cookbook_version root = Pathname.new(cookbook.root_dir) cookbook.all_files.map do |full_path| Pathname.new(full_path).relative_path_from(root).to_s end end def cookbook_loader_for(cookbook_path) loader = Chef::Cookbook::CookbookVersionLoader.new(cookbook_path, chefignore_for(cookbook_path)) loader.load! loader end def chefignore_for(cookbook_path) Chef::Cookbook::Chefignore.new(File.join(cookbook_path, "chefignore")) end def create_policyfile_repo_item File.open(policyfile_repo_item_path, "wb+") do |f| f.print(FFI_Yajl::Encoder.encode(policyfile_lock.to_lock, pretty: true )) end end def create_policy_group_repo_item data = { "policies" => { policyfile_lock.name => { "revision_id" => policyfile_lock.revision_id, }, }, } File.open(policy_group_repo_item_path, "wb+") do |f| f.print(FFI_Yajl::Encoder.encode(data, pretty: true )) end end def copy_policyfile_lock File.open(lockfile_staging_path, "wb+") do |f| f.print(FFI_Yajl::Encoder.encode(policyfile_lock.to_lock, pretty: true )) end end def create_client_rb File.open(client_rb_staging_path, "wb+") do |f| f.print( <<~CONFIG ) ### Chef Infra Client Configuration ### # The settings in this file will configure chef to apply the exported policy in # this directory. To use it, run: # # chef-client -z # policy_name '#{policy_name}' policy_group '#{policy_group}' use_policyfile true policy_document_native_api true # In order to use this repo, you need a version of Chef Infra Client and Chef Zero # that supports policyfile "native mode" APIs: current_version = Gem::Version.new(Chef::VERSION) unless Gem::Requirement.new(">= 12.7").satisfied_by?(current_version) puts("!" * 80) puts(<<-MESSAGE) This Chef Repo requires features introduced in Chef Infra Client 12.7, but you are using Chef \#{Chef::VERSION}. Please upgrade to Chef Infra Client 12.7 or later. MESSAGE puts("!" * 80) exit!(1) end CONFIG end end def create_readme_md File.open(readme_staging_path, "wb+") do |f| f.print( <<~README ) # Exported Chef Infra Repository for Policy '#{policy_name}' Policy revision: #{policyfile_lock.revision_id} This directory contains all the cookbooks and configuration necessary for Chef to converge a system using this exported policy. To converge a system with the exported policy, use a privileged account to run `chef-client -z` from the directory containing the exported policy. ## Contents: ### Policyfile.lock.json A copy of the exported policy, used by the `chef push-archive` command. ### .chef/config.rb A configuration file for Chef Infra Client. This file configures Chef Infra Client to use the correct `policy_name` and `policy_group` for this exported repository. Chef Infra Client will use this configuration automatically if you've set your working directory properly. ### cookbook_artifacts/ All of the cookbooks required by the policy will be stored in this directory. ### policies/ A different copy of the exported policy, used by the `chef-client` command. ### policy_groups/ Policy groups are used by Chef Infra Server to manage multiple revisions of the same policy. The default "local" policy is recommended for export use since there can be no different revisions when not utilizing a server. README end end def mv_staged_repo # If we got here, either these dirs are empty/don't exist or force is # set to true. FileUtils.rm_rf(cookbook_artifacts_dir) FileUtils.rm_rf(policies_dir) FileUtils.rm_rf(policy_groups_dir) FileUtils.rm_rf(dot_chef_dir) FileUtils.mv(cookbook_artifacts_staging_dir, export_dir) FileUtils.mv(policies_staging_dir, export_dir) FileUtils.mv(policy_groups_staging_dir, export_dir) FileUtils.mv(lockfile_staging_path, export_dir) FileUtils.mv(dot_chef_staging_dir, export_dir) FileUtils.mv(readme_staging_path, export_dir) end def validate_lockfile return @policyfile_lock if @policyfile_lock @policyfile_lock = ChefCLI::PolicyfileLock.new(storage_config).build_from_lock_data(policy_data) # TODO: enumerate any cookbook that have been updated @policyfile_lock.validate_cookbooks! @policyfile_lock rescue PolicyfileExportRepoError raise rescue => error raise PolicyfileExportRepoError.new("Invalid lockfile data", error) end def write_updated_lockfile File.open(policyfile_lock_expanded_path, "wb+") do |f| f.print(FFI_Yajl::Encoder.encode(policyfile_lock.to_lock, pretty: true )) end end def assert_lockfile_exists! unless File.exist?(policyfile_lock_expanded_path) raise LockfileNotFound, "No lockfile at #{policyfile_lock_expanded_path} - you need to run `install` before `push`" end end def assert_export_dir_clean! if !force_export? && !conflicting_fs_entries.empty? && !archive? msg = "Export dir (#{export_dir}) not clean. Refusing to export. (Conflicting files: #{conflicting_fs_entries.join(", ")})" raise ExportDirNotEmpty, msg end end def force_export? @force_export end def conflicting_fs_entries Dir.glob(File.join(cookbook_artifacts_dir, "*")) + Dir.glob(File.join(policies_dir, "*")) + Dir.glob(File.join(policy_groups_dir, "*")) + Dir.glob(File.join(export_dir, "Policyfile.lock.json")) end def cookbook_artifacts_dir File.join(export_dir, "cookbook_artifacts") end def policies_dir File.join(export_dir, "policies") end def policy_groups_dir File.join(export_dir, "policy_groups") end def dot_chef_dir File.join(export_dir, ".chef") end def policyfile_repo_item_path basename = "#{policyfile_lock.name}-#{policyfile_lock.revision_id}" File.join(staging_dir, "policies", "#{basename}.json") end def policy_group_repo_item_path File.join(staging_dir, "policy_groups", "#{policy_group}.json") end def dot_chef_staging_dir File.join(staging_dir, ".chef") end def cookbook_artifacts_staging_dir File.join(staging_dir, "cookbook_artifacts") end def policies_staging_dir File.join(staging_dir, "policies") end def policy_groups_staging_dir File.join(staging_dir, "policy_groups") end def lockfile_staging_path File.join(staging_dir, "Policyfile.lock.json") end def client_rb_staging_path File.join(dot_chef_staging_dir, "config.rb") end def readme_staging_path File.join(staging_dir, "README.md") end end end end