# # 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) } let(:force_export) { false } let(:archive) { false } subject(:export_service) do described_class.new(policyfile: policyfile_rb_explicit_name, root_dir: working_dir, export_dir: export_dir, archive: archive, force: force_export) end 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(:revision_id) { "7da81d2c7bb97f904637f97e7f8b487fa4bb1ed682edea7087743dec84c254ec" } let(:lockfile_content) do <<-E { "revision_id": "#{revision_id}", "name": "install-example", "run_list": [ "recipe[local-cookbook::default]" ], "cookbook_locks": { "local-cookbook": { "version": "2.3.4", "identifier": "1e9dfd1134735385b425c056cb5decef9081b92c", "dotted_decimal_identifier": "42704157235437826.6970356709321892.63549625984142", "source": "#{local_cookbook_path}", "cache_key": null, "scm_info": null, "source_options": { "path": "#{local_cookbook_path}" } } }, "default_attributes": {}, "override_attributes": {}, "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 it "delegates #policy_name to the lockfile" do expect(export_service.policy_name).to eq("install-example") end context "when using archive mode" do let(:archive) { true } # TODO: also support a full file name context "when the given 'export_dir' is a directory" do it "sets the archive file location to $policy_name-$revision.tgz" do expected = File.join(export_dir, "install-example-7da81d2c7bb97f904637f97e7f8b487fa4bb1ed682edea7087743dec84c254ec.tgz") expect(export_service.archive_file_location).to eq(expected) end end 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 shared_examples_for "successful_export" 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 } # Berksfile is chefignored berksfile = Pathname.new("Berksfile") expected = expected.delete_if { |p| p == berksfile } expected << Pathname.new("metadata.json") end let(:cookbook_with_version) { "local-cookbook-1e9dfd1134735385b425c056cb5decef9081b92c" } let(:exported_cookbook_root) { Pathname.new(File.join(export_dir, "cookbook_artifacts", 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 # Using JSON form of metadata ensures that we don't rely on anything # in the ruby code in metadata.rb; commonly folks will do things like # shell out to git for the version number, etc. 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("2.3.4") end it "copies the policyfile lock to policies/POLICY_NAME.json" do exported_policy_path = File.join(export_dir, "policies", "install-example-#{revision_id}.json") exported_policy_json = IO.read(exported_policy_path) expect(exported_policy_json).to eq(FFI_Yajl::Encoder.encode(export_service.policyfile_lock.to_lock, pretty: true)) end it "creates a policy_group file for the local policy group with the revision id of the exported policy" do exported_policy_group_path = File.join(export_dir, "policy_groups", "local.json") exported_policy_group_data = FFI_Yajl::Parser.parse(IO.read(exported_policy_group_path)) expected_data = { "policies" => { "install-example" => { "revision_id" => revision_id } } } expect(exported_policy_group_data).to eq(expected_data) end it "copies the policyfile lock in standard format to Policyfile.lock.json" do policyfile_lock_path = File.join(export_dir, "Policyfile.lock.json") policyfile_lock_data = FFI_Yajl::Parser.parse(IO.read(policyfile_lock_path)) expected_lock_data = export_service.policyfile_lock.to_lock # stringify keys in source_options path = expected_lock_data["cookbook_locks"]["local-cookbook"]["source_options"].delete(:path) expected_lock_data["cookbook_locks"]["local-cookbook"]["source_options"]["path"] = path expect(policyfile_lock_data).to eq(expected_lock_data) end it "creates a working local mode configuration file" do expected_config_text = <<-CONFIG ### Chef 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 'install-example' policy_group 'local' use_policyfile true policy_document_native_api true # In order to use this repo, you need a version of Chef 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 12.7, but you are using Chef \#{Chef::VERSION}. Please upgrade to Chef 12.7 or later. MESSAGE puts("!" * 80) exit!(1) end CONFIG config_path = File.join(export_dir, ".chef", "config.rb") expect(File).to exist(config_path) expect(IO.read(config_path)).to eq(expected_config_text) end it "generates a README.md in the exported repo" do readme_path = File.join(export_dir, "README.md") expect(File).to exist(readme_path) end end context "when the export dir is empty" do include_examples "successful_export" 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 context "When the export dir has non-conflicting content" do let(:file_in_export_dir) { File.join(export_dir, "some_random_cruft") } let(:extra_data_bag_dir) { File.join(export_dir, "data_bags", "extraneous") } let(:extra_data_bag_item) { File.join(extra_data_bag_dir, "an_item.json") } before do FileUtils.mkdir_p(export_dir) File.open(file_in_export_dir, "wb+") { |f| f.print "some random cruft" } FileUtils.mkdir_p(extra_data_bag_dir) File.open(extra_data_bag_item, "wb+") { |f| f.print "some random cruft" } end it "ignores the non-conflicting content and exports" do expect(File).to exist(file_in_export_dir) expect(File).to exist(extra_data_bag_item) expect(File).to be_directory(File.join(export_dir, "cookbook_artifacts")) expect(File).to be_directory(File.join(export_dir, "policies")) expect(File).to be_directory(File.join(export_dir, "policy_groups")) end include_examples "successful_export" end context "When the export dir has conflicting content" do let(:non_conflicting_file_in_export_dir) { File.join(export_dir, "some_random_cruft") } let(:cookbook_artifacts_dir) { File.join(export_dir, "cookbook_artifacts") } let(:file_in_cookbook_artifacts_dir) { File.join(cookbook_artifacts_dir, "some_random_cruft") } let(:policies_dir) { File.join(export_dir, "policies") } let(:policy_groups_dir) { File.join(export_dir, "policy_groups") } let(:extra_policy_item) { File.join(policies_dir, "leftover-policy.json") } let(:extra_policy_group_item) { File.join(policy_groups_dir, "leftover-policy-group.json") } let(:conflicting_policyfile_lock) { File.join(export_dir, "Policyfile.lock.json") } before do FileUtils.mkdir_p(export_dir) FileUtils.mkdir_p(cookbook_artifacts_dir) FileUtils.mkdir_p(policies_dir) FileUtils.mkdir_p(policy_groups_dir) File.open(non_conflicting_file_in_export_dir, "wb+") { |f| f.print "some random cruft" } File.open(file_in_cookbook_artifacts_dir, "wb+") { |f| f.print "some random cruft" } File.open(extra_policy_item, "wb+") { |f| f.print "some random cruft" } File.open(extra_policy_group_item, "wb+") { |f| f.print "some random cruft" } File.open(conflicting_policyfile_lock, "wb+") { |f| f.print "some random cruft" } end it "raises a PolicyfileExportRepoError" do message = "Export dir (#{export_dir}) not clean. Refusing to export. (Conflicting files: #{file_in_cookbook_artifacts_dir}, #{extra_policy_item}, #{extra_policy_group_item}, #{conflicting_policyfile_lock})" expect { export_service.run }.to raise_error(ChefDK::ExportDirNotEmpty, message) expect(File).to exist(non_conflicting_file_in_export_dir) expect(File).to exist(file_in_cookbook_artifacts_dir) expect(File).to exist(extra_policy_item) expect(File).to exist(extra_policy_group_item) 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_cookbook_artifacts_dir) expect(File).to_not exist(extra_policy_item) expect(File).to_not exist(extra_policy_group_item) expect(File).to exist(non_conflicting_file_in_export_dir) expect(File).to be_directory(File.join(export_dir, "cookbook_artifacts")) expect(File).to be_directory(File.join(export_dir, "policies")) expect(File).to be_directory(File.join(export_dir, "policy_groups")) end end end # When the export dir has conflicting content context "when archive mode is enabled" do let(:archive) { true } let(:expected_archive_path) do File.join(export_dir, "install-example-7da81d2c7bb97f904637f97e7f8b487fa4bb1ed682edea7087743dec84c254ec.tgz") end it "exports the repo as a tgz archive" do expect(File).to exist(expected_archive_path) end include_examples "successful_export" do # explode the tarball so the assertions can find the files before do Zlib::GzipReader.open(expected_archive_path) do |gz_file| tar = Archive::Tar::Minitar::Input.new(gz_file) tar.each do |e| tar.extract_entry(export_dir, e) end end end end context "when the target dir has a cookbooks or data_bags dir" do let(:cookbooks_dir) { File.join(export_dir, "cookbooks") } let(:file_in_cookbooks_dir) { File.join(cookbooks_dir, "some_random_cruft") } let(:policyfiles_data_bag_dir) { File.join(export_dir, "data_bags", "policyfiles") } let(:extra_policyfile_data_item) { File.join(policyfiles_data_bag_dir, "leftover-policy.json") } before do FileUtils.mkdir_p(export_dir) FileUtils.mkdir_p(cookbooks_dir) FileUtils.mkdir_p(policyfiles_data_bag_dir) File.open(file_in_cookbooks_dir, "wb+") { |f| f.print "some random cruft" } File.open(extra_policyfile_data_item, "wb+") { |f| f.print "some random cruft" } end it "exports successfully" do expect { export_service.run }.to_not raise_error expect(File).to exist(expected_archive_path) end end end # when archive mode is enabled end # copying the cookbooks to the export dir end end end