From dbffcf9a1a3b0bf79d9c4141790e55904c7afdef Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Tue, 11 Nov 2014 17:25:18 -0800 Subject: [PATCH 1/7] Add Export Service Provides library code to generate a Chef Zero compatible chef repo from a Policyfile.lock.json. The intended use case for this is to copy the code so Test Kitchen can test it, but it can also be used for chef-solo-esque workflows with policyfiles. --- .../policyfile_services/export_repo.rb | 159 ++++++++++++ lib/chef-dk/service_exceptions.rb | 3 + .../policyfile_services/export_repo_spec.rb | 233 ++++++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 lib/chef-dk/policyfile_services/export_repo.rb create mode 100644 spec/unit/policyfile_services/export_repo_spec.rb diff --git a/lib/chef-dk/policyfile_services/export_repo.rb b/lib/chef-dk/policyfile_services/export_repo.rb new file mode 100644 index 000000000..f70bc8e1c --- /dev/null +++ b/lib/chef-dk/policyfile_services/export_repo.rb @@ -0,0 +1,159 @@ +# +# Copyright:: Copyright (c) 2014 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 'fileutils' + +require 'chef-dk/service_exceptions' +require 'chef-dk/policyfile_lock' +require 'chef-dk/policyfile/storage_config' + +module ChefDK + module PolicyfileServices + + class ExportRepo + + # Policy groups provide namespaces for policies so that a Chef Server can + # have multiple active iterations of a policy at once, but we don't need + # this when serving a single exported policy via Chef Zero, so hardcode + # it to a "well known" value: + POLICY_GROUP = 'local'.freeze + + include Policyfile::StorageConfigDelegation + + attr_reader :storage_config + attr_reader :root_dir + attr_reader :export_dir + + def initialize(policyfile: nil, export_dir: nil, root_dir: nil) + @root_dir = root_dir + @export_dir = File.expand_path(export_dir) + + @policy_data = nil + @policyfile_lock = nil + + 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) + end + + def run + unless File.exist?(policyfile_lock_expanded_path) + raise LockfileNotFound, "No lockfile at #{policyfile_lock_expanded_path} - you need to run `install` before `push`" + end + + 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 export + create_repo_structure + copy_cookbooks + create_policyfile_data_item + rescue => error + msg = "Failed to export policy (in #{policyfile_filename}) to #{export_dir}" + raise PolicyfileExportRepoError.new(msg, error) + end + + private + + def create_repo_structure + FileUtils.mkdir_p(export_dir) + FileUtils.mkdir_p(File.join(export_dir, "cookbooks")) + FileUtils.mkdir_p(File.join(export_dir, "data_bags", "policyfiles")) + 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.dotted_decimal_identifier}" + export_path = File.join(export_dir, "cookbooks", dirname) + metadata_rb_path = File.join(export_path, "metadata.rb") + FileUtils.cp_r(lock.cookbook_path, export_path) + FileUtils.rm_f(metadata_rb_path) + metadata = lock.cookbook_version.metadata + metadata.version(lock.dotted_decimal_identifier) + + 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 create_policyfile_data_item + # TODO: duplicates c/policyfile/uploader, move logic to PolicyfileLock + + policy_id = "#{policyfile_lock.name}-#{POLICY_GROUP}" + item_path = File.join(export_dir, "data_bags", "policyfiles", "#{policy_id}.json") + + lock_data = policyfile_lock.to_lock.dup + + lock_data["id"] = policy_id + + data_item = { + "id" => policy_id, + "name" => "data_bag_item_policyfiles_#{policy_id}", + "data_bag" => "policyfiles", + "raw_data" => lock_data, + # we'd prefer to leave this out, but the "compatibility mode" + # implementation in chef-client relies on magical class inflation + "json_class" => "Chef::DataBagItem" + } + + File.open(item_path, "wb+") do |f| + f.print(FFI_Yajl::Encoder.encode(data_item, pretty: true )) + end + end + + def validate_lockfile + return @policyfile_lock if @policyfile_lock + @policyfile_lock = ChefDK::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 + + end + + end +end + diff --git a/lib/chef-dk/service_exceptions.rb b/lib/chef-dk/service_exceptions.rb index b3b55b0e2..3bf2e1828 100644 --- a/lib/chef-dk/service_exceptions.rb +++ b/lib/chef-dk/service_exceptions.rb @@ -73,5 +73,8 @@ class PolicyfileInstallError < PolicyfileNestedException class PolicyfilePushError < PolicyfileNestedException end + class PolicyfileExportRepoError < PolicyfileNestedException + end + end diff --git a/spec/unit/policyfile_services/export_repo_spec.rb b/spec/unit/policyfile_services/export_repo_spec.rb new file mode 100644 index 000000000..e4a05ffea --- /dev/null +++ b/spec/unit/policyfile_services/export_repo_spec.rb @@ -0,0 +1,233 @@ +# +# Copyright:: Copyright (c) 2014 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 'spec_helper' +require 'chef-dk/policyfile_services/export_repo' + +describe ChefDK::PolicyfileServices::ExportRepo do + + let(:working_dir) do + path = File.join(tempdir, "policyfile_services_test_working_dir") + Dir.mkdir(path) + path + end + + let(:export_dir) { File.join(tempdir, "export_repo_export_dir") } + + let(:policyfile_rb_explicit_name) { nil } + + let(:policyfile_rb_name) { policyfile_rb_explicit_name || "Policyfile.rb" } + + let(:expanded_policyfile_path) { File.join(working_dir, policyfile_rb_name) } + + let(:policyfile_lock_name) { "Policyfile.lock.json" } + + let(:policyfile_lock_path) { File.join(working_dir, policyfile_lock_name) } + + subject(:export_service) { described_class.new(policyfile: policyfile_rb_explicit_name, + root_dir: working_dir, + export_dir: export_dir) } + + it "uses Policyfile.rb as the default Policyfile name" do + expect(export_service.policyfile_filename).to eq(expanded_policyfile_path) + end + + context "when given an explicit Policyfile name" do + + let(:policyfile_rb_explicit_name) { "MyPolicy.rb" } + + it "uses the given Policyfile name" do + expect(export_service.policyfile_filename).to eq(expanded_policyfile_path) + end + + end + + it "has a destination directory for the export" do + expect(export_service.export_dir).to eq(export_dir) + end + + context "when the policyfile lock is missing" do + + it "raises an error that suggests you run `chef install'" do + expect { export_service.run }.to raise_error(ChefDK::LockfileNotFound) + end + + end + + context "when a lockfile is present" do + + before do + File.open(policyfile_lock_path, "w+") { |f| f.print(lockfile_content) } + end + + context "and the lockfile has invalid JSON" do + + let(:lockfile_content) { ":::" } + + it "errors out" do + expect { export_service.run }.to raise_error(ChefDK::PolicyfileExportRepoError, /Error reading lockfile/) + end + + end + + context "and the lockfile is semantically invalid" do + + let(:lockfile_content) { '{ }' } + + it "errors out" do + expect { export_service.run }.to raise_error(ChefDK::PolicyfileExportRepoError, /Invalid lockfile data/) + end + + end + + context "and the lockfile is valid" do + + let(:local_cookbook_path) { File.join(fixtures_path, "local_path_cookbooks/local-cookbook") } + + let(:lockfile_content) do + <<-E +{ + "name": "install-example", + "run_list": [ + "recipe[local-cookbook::default]" + ], + "cookbook_locks": { + "local-cookbook": { + "version": "2.3.4", + "identifier": "fab501cfaf747901bd82c1bc706beae7dc3a350c", + "dotted_decimal_identifier": "70567763561641081.489844270461035.258281553147148", + "source": "#{local_cookbook_path}", + "cache_key": null, + "scm_info": null, + "source_options": { + "path": "#{local_cookbook_path}" + } + } + }, + "solution_dependencies": { + "Policyfile": [ + [ + "local-cookbook", + ">= 0.0.0" + ] + ], + "dependencies": { + "local-cookbook (2.3.4)": [ + + ] + } + } +} +E + end + + it "reads the lockfile data" do + lock = export_service.policyfile_lock + expect(lock).to be_an_instance_of(ChefDK::PolicyfileLock) + expect(lock.name).to eq("install-example") + expect(lock.cookbook_locks.size).to eq(1) + expect(lock.cookbook_locks).to have_key("local-cookbook") + end + + describe "writing updates to the policyfile lock" do + + let(:updated_lockfile_io) { StringIO.new } + + it "validates the lockfile and writes updates to disk" do + allow(File).to receive(:open).and_call_original + expect(File).to receive(:open).with(policyfile_lock_path, "wb+").and_yield(updated_lockfile_io) + + export_service.run + end + + end + + context "copying the cookbooks to the export dir" do + + before do + allow(export_service.policyfile_lock).to receive(:validate_cookbooks!).and_return(true) + export_service.run + end + + let(:cookbook_files) do + base_pathname = Pathname.new(local_cookbook_path) + Dir.glob("#{local_cookbook_path}/**/*").map do |full_path| + Pathname.new(full_path).relative_path_from(base_pathname) + end + end + + let(:expected_files_relative) do + metadata_rb = Pathname.new("metadata.rb") + expected = cookbook_files.delete_if { |p| p == metadata_rb } + expected << Pathname.new("metadata.json") + end + + let(:cookbook_with_version) { "local-cookbook-70567763561641081.489844270461035.258281553147148" } + + let(:exported_cookbook_root) { Pathname.new(File.join(export_dir, "cookbooks", cookbook_with_version)) } + + let(:expected_files) do + expected_files_relative.map do |file_rel_path| + exported_cookbook_root + file_rel_path + end + end + + it "copies cookbooks to the target dir in versioned_cookbooks format" do + expected_files.each do |expected_file| + expect(expected_file).to exist + end + end + + # This behavior does two things: + # * ensures that Chef Zero uses our hacked version number + # * works around external dependencies (e.g., using `git` in backticks) + # in metadata.rb issue + it "writes metadata.json in the exported cookbook, removing metadata.rb" do + metadata_json_path = File.join(exported_cookbook_root, "metadata.json") + metadata_json = FFI_Yajl::Parser.parse(IO.read(metadata_json_path)) + expect(metadata_json["version"]).to eq("70567763561641081.489844270461035.258281553147148") + end + + it "copies the policyfile lock in data item format to data_bags/policyfiles" do + data_bag_item_path = File.join(export_dir, "data_bags", "policyfiles", "install-example-local.json") + data_item_json = FFI_Yajl::Parser.parse(IO.read(data_bag_item_path)) + expect(data_item_json["id"]).to eq("install-example-local") + end + + context "When an error occurs creating the export" do + + before do + allow(export_service.policyfile_lock).to receive(:validate_cookbooks!).and_return(true) + expect(export_service).to receive(:create_repo_structure). + and_raise(Errno::EACCES.new("Permission denied @ rb_sysopen - /etc/foobarbaz.txt")) + end + + it "wraps the error in a custom error class" do + message = "Failed to export policy (in #{expanded_policyfile_path}) to #{export_dir}" + expect { export_service.run }.to raise_error(ChefDK::PolicyfileExportRepoError, message) + end + end + + end + end + + end + + + +end + From e1ef32c2d9f3d269c3e90ec7c3491ea283d7815b Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Fri, 21 Nov 2014 15:44:37 -0800 Subject: [PATCH 2/7] Add export CLI --- lib/chef-dk/builtin_commands.rb | 2 + lib/chef-dk/command/export.rb | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 lib/chef-dk/command/export.rb diff --git a/lib/chef-dk/builtin_commands.rb b/lib/chef-dk/builtin_commands.rb index 6c03f1398..ae0b102b0 100644 --- a/lib/chef-dk/builtin_commands.rb +++ b/lib/chef-dk/builtin_commands.rb @@ -31,5 +31,7 @@ c.builtin "push", :Push, desc: "Push a local policy lock to a policy group on the server" + c.builtin "export", :Export, desc: "Export a policy lock as a Chef Zero code repo" + c.builtin "verify", :Verify, desc: "Test the embedded ChefDK applications" end diff --git a/lib/chef-dk/command/export.rb b/lib/chef-dk/command/export.rb new file mode 100644 index 000000000..d2dc2ff13 --- /dev/null +++ b/lib/chef-dk/command/export.rb @@ -0,0 +1,125 @@ +# +# Copyright:: Copyright (c) 2014 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 'chef-dk/command/base' +require 'chef-dk/ui' +require 'chef-dk/policyfile_services/export_repo' + +module ChefDK + module Command + + class Export < Base + + banner(<<-E) +Usage: chef export [ POLICY_FILE ] DESTINATION_DIRECTORY [options] + +`chef export` creates a Chef Zero compatible Chef repository containing the +cookbooks described in a Policyfile.lock.json. Once the exported repo is copied +to the target machine, you can apply the policy to the machine with +`chef-client -z`. You will need at least the following config: + + use_policyfile true + deployment_group '$POLICY_NAME-local' + versioned_cookbooks true + +The Policyfile feature is incomplete and beta quality. See our detailed README +for more information. + +https://github.com/opscode/chef-dk/blob/master/POLICYFILE_README.md + +Options: + +E + + # TODO: --force option; overwrite existing data. + + option :debug, + short: "-D", + long: "--debug", + description: "Enable stacktraces and other debug output", + default: false + + attr_reader :policyfile_relative_path + attr_reader :export_dir + + attr_accessor :ui + + def initialize(*args) + super + @push = nil + @ui = nil + @policyfile_relative_path = nil + @export_dir = nil + @chef_config = nil + @ui = UI.new + end + + def run(params = []) + return 1 unless apply_params!(params) + export.run + ui.msg("Exported policy '#{export.policyfile_lock.name}' to #{export_dir}") + 0 + rescue PolicyfileServiceError => e + handle_error(e) + 1 + end + + def debug? + !!config[:debug] + end + + def chef_config + return @chef_config if @chef_config + Chef::WorkstationConfigLoader.new(config[:config_file]).load + @chef_config = Chef::Config + end + + def export + @export ||= PolicyfileServices::ExportRepo.new(policyfile: policyfile_relative_path, + export_dir: export_dir, + root_dir: Dir.pwd) + end + + def handle_error(error) + ui.err("Error: #{error.message}") + if error.respond_to?(:reason) + ui.err("Reason: #{error.reason}") + ui.err("") + ui.err(error.extended_error_info) if debug? + ui.err(error.cause.backtrace.join("\n")) if debug? + end + end + + def apply_params!(params) + remaining_args = parse_options(params) + case remaining_args.size + when 1 + @export_dir = remaining_args[0] + when 2 + @policyfile_relative_path, @export_dir = remaining_args + else + ui.err(banner) + return false + end + true + end + + end + end +end + + From 7764970af229552131fe55265ac59a795341cad1 Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Mon, 24 Nov 2014 15:24:13 -0800 Subject: [PATCH 3/7] Add force option to policyfile export Export now won't run if export dir isn't empty, unless force is given. --- .../policyfile_services/export_repo.rb | 26 +++- lib/chef-dk/service_exceptions.rb | 3 + .../policyfile_services/export_repo_spec.rb | 122 ++++++++++++------ 3 files changed, 107 insertions(+), 44 deletions(-) diff --git a/lib/chef-dk/policyfile_services/export_repo.rb b/lib/chef-dk/policyfile_services/export_repo.rb index f70bc8e1c..064f600e7 100644 --- a/lib/chef-dk/policyfile_services/export_repo.rb +++ b/lib/chef-dk/policyfile_services/export_repo.rb @@ -38,9 +38,10 @@ class ExportRepo attr_reader :root_dir attr_reader :export_dir - def initialize(policyfile: nil, export_dir: nil, root_dir: nil) + def initialize(policyfile: nil, export_dir: nil, root_dir: nil, force: false) @root_dir = root_dir @export_dir = File.expand_path(export_dir) + @force_export = force @policy_data = nil @policyfile_lock = nil @@ -51,9 +52,8 @@ def initialize(policyfile: nil, export_dir: nil, root_dir: nil) end def run - unless File.exist?(policyfile_lock_expanded_path) - raise LockfileNotFound, "No lockfile at #{policyfile_lock_expanded_path} - you need to run `install` before `push`" - end + assert_lockfile_exists! + assert_export_dir_empty! validate_lockfile write_updated_lockfile @@ -82,6 +82,7 @@ def export private def create_repo_structure + FileUtils.rm_rf(export_dir) FileUtils.mkdir_p(export_dir) FileUtils.mkdir_p(File.join(export_dir, "cookbooks")) FileUtils.mkdir_p(File.join(export_dir, "data_bags", "policyfiles")) @@ -152,6 +153,23 @@ def write_updated_lockfile 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_empty! + entries = Dir.glob(File.join(export_dir, "*")) + if !force_export? && !entries.empty? + raise ExportDirNotEmpty, "Export dir (#{export_dir}) not empty. Refusing to export." + end + end + + def force_export? + @force_export + end + end end diff --git a/lib/chef-dk/service_exceptions.rb b/lib/chef-dk/service_exceptions.rb index 3bf2e1828..c20d0a0e9 100644 --- a/lib/chef-dk/service_exceptions.rb +++ b/lib/chef-dk/service_exceptions.rb @@ -36,6 +36,9 @@ class PolicyfileNotFound < PolicyfileServiceError class LockfileNotFound < PolicyfileServiceError end + class ExportDirNotEmpty < PolicyfileServiceError + end + class PolicyfileNestedException < PolicyfileServiceError attr_reader :cause diff --git a/spec/unit/policyfile_services/export_repo_spec.rb b/spec/unit/policyfile_services/export_repo_spec.rb index e4a05ffea..2aa6b4a6f 100644 --- a/spec/unit/policyfile_services/export_repo_spec.rb +++ b/spec/unit/policyfile_services/export_repo_spec.rb @@ -38,9 +38,14 @@ let(:policyfile_lock_path) { File.join(working_dir, policyfile_lock_name) } - subject(:export_service) { described_class.new(policyfile: policyfile_rb_explicit_name, - root_dir: working_dir, - export_dir: export_dir) } + let(:force_export) { false } + + subject(:export_service) do + described_class.new(policyfile: policyfile_rb_explicit_name, + root_dir: working_dir, + export_dir: export_dir, + force: force_export) + end it "uses Policyfile.rb as the default Policyfile name" do expect(export_service.policyfile_filename).to eq(expanded_policyfile_path) @@ -158,54 +163,57 @@ context "copying the cookbooks to the export dir" do - before do - allow(export_service.policyfile_lock).to receive(:validate_cookbooks!).and_return(true) - export_service.run - end + context "when the export dir is empty" do + before do + allow(export_service.policyfile_lock).to receive(:validate_cookbooks!).and_return(true) + export_service.run + end - let(:cookbook_files) do - base_pathname = Pathname.new(local_cookbook_path) - Dir.glob("#{local_cookbook_path}/**/*").map do |full_path| - Pathname.new(full_path).relative_path_from(base_pathname) + let(:cookbook_files) do + base_pathname = Pathname.new(local_cookbook_path) + Dir.glob("#{local_cookbook_path}/**/*").map do |full_path| + Pathname.new(full_path).relative_path_from(base_pathname) + end end - end - let(:expected_files_relative) do - metadata_rb = Pathname.new("metadata.rb") - expected = cookbook_files.delete_if { |p| p == metadata_rb } - expected << Pathname.new("metadata.json") - end + let(:expected_files_relative) do + metadata_rb = Pathname.new("metadata.rb") + expected = cookbook_files.delete_if { |p| p == metadata_rb } + expected << Pathname.new("metadata.json") + end - let(:cookbook_with_version) { "local-cookbook-70567763561641081.489844270461035.258281553147148" } + let(:cookbook_with_version) { "local-cookbook-70567763561641081.489844270461035.258281553147148" } - let(:exported_cookbook_root) { Pathname.new(File.join(export_dir, "cookbooks", cookbook_with_version)) } + let(:exported_cookbook_root) { Pathname.new(File.join(export_dir, "cookbooks", cookbook_with_version)) } - let(:expected_files) do - expected_files_relative.map do |file_rel_path| - exported_cookbook_root + file_rel_path + let(:expected_files) do + expected_files_relative.map do |file_rel_path| + exported_cookbook_root + file_rel_path + end end - end - it "copies cookbooks to the target dir in versioned_cookbooks format" do - expected_files.each do |expected_file| - expect(expected_file).to exist + it "copies cookbooks to the target dir in versioned_cookbooks format" do + expected_files.each do |expected_file| + expect(expected_file).to exist + end end - end - # This behavior does two things: - # * ensures that Chef Zero uses our hacked version number - # * works around external dependencies (e.g., using `git` in backticks) - # in metadata.rb issue - it "writes metadata.json in the exported cookbook, removing metadata.rb" do - metadata_json_path = File.join(exported_cookbook_root, "metadata.json") - metadata_json = FFI_Yajl::Parser.parse(IO.read(metadata_json_path)) - expect(metadata_json["version"]).to eq("70567763561641081.489844270461035.258281553147148") - end + # This behavior does two things: + # * ensures that Chef Zero uses our hacked version number + # * works around external dependencies (e.g., using `git` in backticks) + # in metadata.rb issue + it "writes metadata.json in the exported cookbook, removing metadata.rb" do + metadata_json_path = File.join(exported_cookbook_root, "metadata.json") + metadata_json = FFI_Yajl::Parser.parse(IO.read(metadata_json_path)) + expect(metadata_json["version"]).to eq("70567763561641081.489844270461035.258281553147148") + end + + it "copies the policyfile lock in data item format to data_bags/policyfiles" do + data_bag_item_path = File.join(export_dir, "data_bags", "policyfiles", "install-example-local.json") + data_item_json = FFI_Yajl::Parser.parse(IO.read(data_bag_item_path)) + expect(data_item_json["id"]).to eq("install-example-local") + end - it "copies the policyfile lock in data item format to data_bags/policyfiles" do - data_bag_item_path = File.join(export_dir, "data_bags", "policyfiles", "install-example-local.json") - data_item_json = FFI_Yajl::Parser.parse(IO.read(data_bag_item_path)) - expect(data_item_json["id"]).to eq("install-example-local") end context "When an error occurs creating the export" do @@ -220,6 +228,40 @@ message = "Failed to export policy (in #{expanded_policyfile_path}) to #{export_dir}" expect { export_service.run }.to raise_error(ChefDK::PolicyfileExportRepoError, message) end + + end + + context "When the export dir is already populated" do + + let(:file_in_export_dir) { File.join(export_dir, "some_random_cruft") } + + before do + FileUtils.mkdir_p(export_dir) + File.open(file_in_export_dir, "wb+") { |f| f.print "some random cruft" } + end + + it "raises a PolicyfileExportRepoError" do + message = "Export dir (#{export_dir}) not empty. Refusing to export." + expect { export_service.run }.to raise_error(ChefDK::ExportDirNotEmpty, message) + expect(File).to be_file(file_in_export_dir) + expect(File).to_not exist(File.join(export_dir, "cookbooks")) + expect(File).to_not exist(File.join(export_dir, "data_bags")) + end + + context "and the force option is set" do + + let(:force_export) { true } + + it "clears the export dir and exports" do + export_service.run + + expect(File).to_not exist(file_in_export_dir) + expect(File).to be_directory(File.join(export_dir, "cookbooks")) + expect(File).to be_directory(File.join(export_dir, "data_bags")) + end + + end + end end From f352ab074e185dfe8baf51e5352055126e3a1072 Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Mon, 24 Nov 2014 15:39:01 -0800 Subject: [PATCH 4/7] Add --force option to export command --- lib/chef-dk/command/export.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/chef-dk/command/export.rb b/lib/chef-dk/command/export.rb index d2dc2ff13..3dd72df3f 100644 --- a/lib/chef-dk/command/export.rb +++ b/lib/chef-dk/command/export.rb @@ -45,7 +45,11 @@ class Export < Base E - # TODO: --force option; overwrite existing data. + option :force, + short: "-f", + long: "--force", + description: "If the DESTINATION_DIRECTORY is not empty, remove its content.", + default: false option :debug, short: "-D", @@ -73,6 +77,10 @@ def run(params = []) export.run ui.msg("Exported policy '#{export.policyfile_lock.name}' to #{export_dir}") 0 + rescue ExportDirNotEmpty => e + ui.err("ERROR: " + e.message) + ui.err("Use --force to force export") + 1 rescue PolicyfileServiceError => e handle_error(e) 1 @@ -91,7 +99,8 @@ def chef_config def export @export ||= PolicyfileServices::ExportRepo.new(policyfile: policyfile_relative_path, export_dir: export_dir, - root_dir: Dir.pwd) + root_dir: Dir.pwd, + force: config[:force]) end def handle_error(error) From 406e8c5490532748701881648b5fce27e51e25db Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Tue, 2 Dec 2014 13:18:42 -0800 Subject: [PATCH 5/7] Add tests for export CLI --- lib/chef-dk/command/export.rb | 8 +- spec/unit/command/export_spec.rb | 179 +++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 spec/unit/command/export_spec.rb diff --git a/lib/chef-dk/command/export.rb b/lib/chef-dk/command/export.rb index 3dd72df3f..b1c05c142 100644 --- a/lib/chef-dk/command/export.rb +++ b/lib/chef-dk/command/export.rb @@ -74,8 +74,8 @@ def initialize(*args) def run(params = []) return 1 unless apply_params!(params) - export.run - ui.msg("Exported policy '#{export.policyfile_lock.name}' to #{export_dir}") + export_service.run + ui.msg("Exported policy '#{export_service.policyfile_lock.name}' to #{export_dir}") 0 rescue ExportDirNotEmpty => e ui.err("ERROR: " + e.message) @@ -96,8 +96,8 @@ def chef_config @chef_config = Chef::Config end - def export - @export ||= PolicyfileServices::ExportRepo.new(policyfile: policyfile_relative_path, + def export_service + @export_service ||= PolicyfileServices::ExportRepo.new(policyfile: policyfile_relative_path, export_dir: export_dir, root_dir: Dir.pwd, force: config[:force]) diff --git a/spec/unit/command/export_spec.rb b/spec/unit/command/export_spec.rb new file mode 100644 index 000000000..1cc74d28b --- /dev/null +++ b/spec/unit/command/export_spec.rb @@ -0,0 +1,179 @@ +# +# Copyright:: Copyright (c) 2014 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 'spec_helper' +require 'chef-dk/command/export' + +describe ChefDK::Command::Export do + + let(:params) { [] } + + let(:command) do + described_class.new + end + + let(:policyfile_lock) do + instance_double(ChefDK::PolicyfileLock, name: "example-policy") + end + + let(:export_service) do + instance_double(ChefDK::PolicyfileServices::ExportRepo, + policyfile_lock: policyfile_lock) + end + + context "after evaluating params" do + + let(:params) { [ "path/to/export" ] } + + before do + command.apply_params!(params) + end + + it "disables debug by default" do + expect(command.debug?).to be(false) + end + + it "configures a default UI component" do + ui = command.ui + expect(ui.out_stream).to eq($stdout) + expect(ui.err_stream).to eq($stderr) + end + + context "when debug mode is set" do + + let(:params) { [ "path/to/export", "-D" ] } + + it "enables debug" do + expect(command.debug?).to be(true) + end + end + + context "when the path to the exported repo is given" do + + let(:params) { [ "path/to/export" ] } + + it "configures the export service with the export path" do + expect(command.export_service.export_dir).to eq(File.expand_path("path/to/export")) + end + + it "uses the default policyfile name" do + expect(command.export_service.policyfile_filename).to eq(File.expand_path("Policyfile.rb")) + end + + end + + context "when a Policyfile relative path and export path are given" do + + let(:params) { [ "CustomNamedPolicy.rb", "path/to/export" ] } + + it "configures the export service with the export path" do + expect(command.export_service.export_dir).to eq(File.expand_path("path/to/export")) + end + + it "configures the export service with the policyfile relative path" do + expect(command.export_service.policyfile_filename).to eq(File.expand_path("CustomNamedPolicy.rb")) + end + end + end + + describe "running the export" do + + let(:params) { [ "/path/to/export" ] } + + let(:ui) { TestHelpers::TestUI.new } + + before do + command.ui = ui + allow(command).to receive(:export_service).and_return(export_service) + end + + context "with no arguments" do + + it "exits non-zero and prints a help message" do + expect(command.run).to eq(1) + end + + end + + context "when the command is successful" do + + before do + expect(export_service).to receive(:run) + end + + it "returns 0" do + expect(command.run(params)).to eq(0) + end + end + + context "when the command is unsuccessful" do + + let(:backtrace) { caller[0...3] } + + let(:cause) do + e = StandardError.new("some operation failed") + e.set_backtrace(backtrace) + e + end + + let(:exception) do + ChefDK::PolicyfileExportRepoError.new("export failed", cause) + end + + before do + expect(export_service).to receive(:run).and_raise(exception) + end + + it "returns 1" do + expect(command.run(params)).to eq(1) + end + + it "displays the exception and cause" do + expected_error_text=<<-E +Error: export failed +Reason: (StandardError) some operation failed + +E + + command.run(params) + expect(ui.output).to eq(expected_error_text) + end + + context "and debug is enabled" do + + let(:params) { [ "path/to/export", "-D"] } + + it "displays the exception and cause with backtrace" do + expected_error_text=<<-E +Error: export failed +Reason: (StandardError) some operation failed + + +E + + expected_error_text << backtrace.join("\n") << "\n" + + command.run(params) + expect(ui.output).to eq(expected_error_text) + end + end + + end + + end +end + From 9e7ed80095ea2770fd890e497a5828b1bdcba456 Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Tue, 2 Dec 2014 14:00:36 -0800 Subject: [PATCH 6/7] Remove TODO for de-duplicating data item code. Correctly de-duplicating is a bit involved and the code is related to "compatibility mode," which will be removed once server support is added. So we can live with the duplication for now. --- lib/chef-dk/policyfile_services/export_repo.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/chef-dk/policyfile_services/export_repo.rb b/lib/chef-dk/policyfile_services/export_repo.rb index 064f600e7..3b7f6860b 100644 --- a/lib/chef-dk/policyfile_services/export_repo.rb +++ b/lib/chef-dk/policyfile_services/export_repo.rb @@ -111,8 +111,6 @@ def copy_cookbook(lock) end def create_policyfile_data_item - # TODO: duplicates c/policyfile/uploader, move logic to PolicyfileLock - policy_id = "#{policyfile_lock.name}-#{POLICY_GROUP}" item_path = File.join(export_dir, "data_bags", "policyfiles", "#{policy_id}.json") From 222b1f9c9bfeadbedc4307beb5682ac3bd2beafd Mon Sep 17 00:00:00 2001 From: danielsdeleo Date: Tue, 2 Dec 2014 14:30:27 -0800 Subject: [PATCH 7/7] Set CLI option to boolean --- lib/chef-dk/command/export.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/chef-dk/command/export.rb b/lib/chef-dk/command/export.rb index b1c05c142..f9db36055 100644 --- a/lib/chef-dk/command/export.rb +++ b/lib/chef-dk/command/export.rb @@ -49,7 +49,8 @@ class Export < Base short: "-f", long: "--force", description: "If the DESTINATION_DIRECTORY is not empty, remove its content.", - default: false + default: false, + boolean: true option :debug, short: "-D",