diff options
55 files changed, 1816 insertions, 426 deletions
@@ -8,6 +8,7 @@ InstalledFiles _yardoc coverage doc/ +binstubs/ lib/bundler/man pkg rdoc diff --git a/.travis.yml b/.travis.yml index c0b640e..4994db3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,34 @@ -rvm: 2.0 +rvm: 2.1 gemfile: Gemfile +# This prevents testing branches that are created just for PRs +branches: + only: + - master + +# Early warning system to catch if Rubygems breaks something +before_install: + - gem update --system + - gem install bundler + sudo: false -script: bundle exec rake pedant +script: + - bundle update + - bundle exec rake pedant matrix: include: - - rvm: 2.0 - rvm: 2.1 + env: PEDANT_KNIFE_TESTS=true PEDANT_ALLOW_RVM=1 - rvm: 2.1 env: SINGLE_ORG=true - rvm: 2.1 env: CHEF_FS=true - rvm: 2.1 + env: + - CHEF_FS=true + - "GEMFILE_MOD=\"gem 'chef', github: 'chef/chef'\"" + - rvm: 2.1 env: FILE_STORE=true - rvm: 2.1 script: bundle exec rake chef_spec @@ -21,11 +37,3 @@ matrix: script: bundle exec rake spec env: TEST=rake_spec - allow_failures: - - rvm: 2.1 - gemfile: gemfiles/latest-chef.gemfile - script: bundle exec rake chef_spec - enc: TEST=chef_spec_latest -# - rvm: 2.1.1 -# gemfile: gemfiles/berkshelf.gemfile -# script: bundle exec rake berkshelf_spec diff --git a/CHANGELOG.md b/CHANGELOG.md index f641b1f..8627141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,105 @@ # Change Log +## [4.7.1](https://github.com/chef/chef-zero/tree/4.7.1) (2016-07-07) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.7.0...4.7.1) + +**Implemented enhancements:** + +- Downgrade info log message to debug [\#221](https://github.com/chef/chef-zero/pull/221) ([stanhu](https://github.com/stanhu)) + +## [v4.7.0](https://github.com/chef/chef-zero/tree/v4.7.0) (2016-06-30) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.6.2...v4.7.0) + +**Implemented enhancements:** + +- Add external\_authentication\_uid to actors endpoint for querying [\#217](https://github.com/chef/chef-zero/pull/217) ([kmacgugan](https://github.com/kmacgugan)) + +**Merged pull requests:** + +- Depend on rack \< 2 to restore Ruby 2.1 compat [\#219](https://github.com/chef/chef-zero/pull/219) ([tas50](https://github.com/tas50)) + +## [v4.6.2](https://github.com/chef/chef-zero/tree/v4.6.2) (2016-04-28) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.6.1...v4.6.2) + +**Fixed bugs:** + +- Log responses only at debug log level [\#216](https://github.com/chef/chef-zero/pull/216) ([stevendanna](https://github.com/stevendanna)) + +## [v4.6.1](https://github.com/chef/chef-zero/tree/v4.6.1) (2016-04-14) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.6.0...v4.6.1) + +**Fixed bugs:** + +- Actually merge key data in user PUT response [\#214](https://github.com/chef/chef-zero/pull/214) ([jkeiser](https://github.com/jkeiser)) +- Fix users endpoint in OSC compat mode to use a data store URL [\#213](https://github.com/chef/chef-zero/pull/213) ([jkeiser](https://github.com/jkeiser)) + +## [v4.6.0](https://github.com/chef/chef-zero/tree/v4.6.0) (2016-04-14) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.5.0...v4.6.0) + +**Implemented enhancements:** + +- Enable listening on more than one address [\#208](https://github.com/chef/chef-zero/pull/208) ([jaymzh](https://github.com/jaymzh)) +- Implemented GET /orgs/ORG/users/USER/keys\(/key\) endpoint recently added to server. [\#205](https://github.com/chef/chef-zero/pull/205) ([tylercloke](https://github.com/tylercloke)) +- Implement APIv1 behaviors [\#201](https://github.com/chef/chef-zero/pull/201) ([danielsdeleo](https://github.com/danielsdeleo)) +- Make user and client keys endpoints pass Pedant specs [\#199](https://github.com/chef/chef-zero/pull/199) ([jrunning](https://github.com/jrunning)) +- fix necessary for metadata gem [\#197](https://github.com/chef/chef-zero/pull/197) ([lamont-granquist](https://github.com/lamont-granquist)) + +**Fixed bugs:** + +- Fix bugs related to Array vs Enumerator vs Port for options\[:port/host\]. [\#212](https://github.com/chef/chef-zero/pull/212) ([tylercloke](https://github.com/tylercloke)) + +## [v4.5.0](https://github.com/chef/chef-zero/tree/v4.5.0) (2016-01-29) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.4.2...v4.5.0) + +**Merged pull requests:** + +- Run chef-zero against master Chef in travis [\#195](https://github.com/chef/chef-zero/pull/195) ([jkeiser](https://github.com/jkeiser)) +- Make ACLs for policies/policy\_groups/cookbook\_artifacts work [\#194](https://github.com/chef/chef-zero/pull/194) ([jkeiser](https://github.com/jkeiser)) +- Return 410 on /controls so we stop skipping that pedant spec. [\#192](https://github.com/chef/chef-zero/pull/192) ([randomcamel](https://github.com/randomcamel)) +- Enable container specs. [\#191](https://github.com/chef/chef-zero/pull/191) ([randomcamel](https://github.com/randomcamel)) +- Enable headers pedant tests [\#190](https://github.com/chef/chef-zero/pull/190) ([danielsdeleo](https://github.com/danielsdeleo)) +- Enable knife pedant tests [\#189](https://github.com/chef/chef-zero/pull/189) ([danielsdeleo](https://github.com/danielsdeleo)) +- Start running policy and cookbook artifact tests [\#187](https://github.com/chef/chef-zero/pull/187) ([jkeiser](https://github.com/jkeiser)) + +## [v4.4.2](https://github.com/chef/chef-zero/tree/v4.4.2) (2016-01-15) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.4.1...v4.4.2) + +**Merged pull requests:** + +- Make hoovering and deleting parent dir work everywhere for cookbook\_artifacts [\#186](https://github.com/chef/chef-zero/pull/186) ([jkeiser](https://github.com/jkeiser)) +- Explain why omnibus/authz/authN/validation checks are skipped [\#185](https://github.com/chef/chef-zero/pull/185) ([danielsdeleo](https://github.com/danielsdeleo)) + +## [v4.4.1](https://github.com/chef/chef-zero/tree/v4.4.1) (2016-01-14) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.4.0...v4.4.1) + +**Merged pull requests:** + +- Only test master branch and PRs [\#184](https://github.com/chef/chef-zero/pull/184) ([danielsdeleo](https://github.com/danielsdeleo)) +- Internal orgs appears to be unused in oc-chef-pedant [\#183](https://github.com/chef/chef-zero/pull/183) ([danielsdeleo](https://github.com/danielsdeleo)) +- Fix cookbook\_artifact rspec [\#182](https://github.com/chef/chef-zero/pull/182) ([jkeiser](https://github.com/jkeiser)) +- Point chef-server back to master [\#180](https://github.com/chef/chef-zero/pull/180) ([thommay](https://github.com/thommay)) +- Ignore the universe endpoint tests in pedant [\#176](https://github.com/chef/chef-zero/pull/176) ([thommay](https://github.com/thommay)) + +## [v4.4.0](https://github.com/chef/chef-zero/tree/v4.4.0) (2015-12-11) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.3.2...v4.4.0) + +**Closed issues:** + +- Please bump hashie version if possible [\#97](https://github.com/chef/chef-zero/issues/97) + +**Merged pull requests:** + +- ChefZero::RSpec support for cookbook\_artifacts. [\#179](https://github.com/chef/chef-zero/pull/179) ([randomcamel](https://github.com/randomcamel)) +- /cookbook\_artifacts support for in-memory and FILE\_STORE backends \(not ChefFS\) [\#178](https://github.com/chef/chef-zero/pull/178) ([randomcamel](https://github.com/randomcamel)) +- Update and refactor policy and policy\_groups endpoints [\#177](https://github.com/chef/chef-zero/pull/177) ([jkeiser](https://github.com/jkeiser)) +- Point at master of oc-chef-pedant and chef [\#174](https://github.com/chef/chef-zero/pull/174) ([stevendanna](https://github.com/stevendanna)) +- Upgrade pedant, and enable running in ChefFS mode [\#173](https://github.com/chef/chef-zero/pull/173) ([randomcamel](https://github.com/randomcamel)) +- Implement the /policies and /policy\_groups API routes [\#172](https://github.com/chef/chef-zero/pull/172) ([randomcamel](https://github.com/randomcamel)) +- Add gemspec files to allow bundler to run from the gem [\#169](https://github.com/chef/chef-zero/pull/169) ([ksubrama](https://github.com/ksubrama)) + +## [v4.3.2](https://github.com/chef/chef-zero/tree/v4.3.2) (2015-09-30) +[Full Changelog](https://github.com/chef/chef-zero/compare/v4.3.1...v4.3.2) + ## [v4.3.1](https://github.com/chef/chef-zero/tree/v4.3.1) (2015-09-30) [Full Changelog](https://github.com/chef/chef-zero/compare/v4.3.0...v4.3.1) @@ -234,7 +334,7 @@ **Merged pull requests:** -- Fix clear data when no data was added to chef zero [\#46](https://github.com/chef/chef-zero/pull/46) ([alex-slynko-wonga](https://github.com/alex-slynko-wonga)) +- Fix clear data when no data was added to chef zero [\#46](https://github.com/chef/chef-zero/pull/46) ([alex-slynko](https://github.com/alex-slynko)) - Fix an issue with an incorrect number of parameters passed to build\_uri [\#45](https://github.com/chef/chef-zero/pull/45) ([sethvargo](https://github.com/sethvargo)) - Make playground items more semantic [\#44](https://github.com/chef/chef-zero/pull/44) ([sethvargo](https://github.com/sethvargo)) @@ -1,8 +1,20 @@ source 'https://rubygems.org' gemspec -gem 'rest-client', :github => 'chef/rest-client' +# gem 'rest-client', :github => 'chef/rest-client' -gem 'oc-chef-pedant', :github => 'chef/chef-server', branch: 'e9bf7fe4440afd34856401831b30636100be958b' +gem 'oc-chef-pedant', :github => 'chef/chef-server' -gem 'chef', :github => 'chef/chef', :tag => '12.4.1' +group :changelog do + gem "github_changelog_generator" +end + +# bundler resolve failure on "rspec_junit_formatter" +# gem 'chef-pedant', :github => 'opscode/chef-pedant', :ref => "server-cli-option" + +# gem 'chef', :github => 'chef/chef', :branch => 'jk/policies-acls' + +if ENV['GEMFILE_MOD'] + puts "GEMFILE_MOD: #{ENV['GEMFILE_MOD']}" + instance_eval(ENV['GEMFILE_MOD']) +end @@ -3,21 +3,43 @@ require 'bundler/gem_tasks' require 'chef_zero/version' +def run_oc_pedant(env={}) + ENV.update(env) + require File.expand_path('spec/run_oc_pedant') +end + +ENV_DOCS = <<END +Environment: + - RSPEC_OPTS Options to pass to RSpec + e.g. RSPEC_OPTS="--fail-fast --profile 5" + - PEDANT_OPTS Options to pass to oc-chef-pedant + e.g. PEDANT_OPTS="--focus-keys --skip-users" + - LOG_LEVEL Set the log level (default: warn) + e.g. LOG_LEVEL=debug +END + task :default => :pedant -desc "run specs" +desc "Run specs" task :spec do system('rspec spec/*_spec.rb') end -desc "run oc pedant" -task :pedant do - require File.expand_path('spec/run_oc_pedant') +desc "Run oc-chef-pedant\n\n#{ENV_DOCS}" +task :pedant => :oc_pedant + +desc "Run oc-chef-pedant with CHEF_FS set\n\n#{ENV_DOCS}" +task :cheffs do + run_oc_pedant('CHEF_FS' => 'yes') +end + +desc "Run oc-chef-pedant with FILE_STORE set\n\n#{ENV_DOCS}" +task :filestore do + run_oc_pedant('FILE_STORE' => 'yes') end -desc "run oc pedant" task :oc_pedant do - require File.expand_path('spec/run_oc_pedant') + run_oc_pedant end task :chef_spec do @@ -30,12 +52,15 @@ task :berkshelf_spec do system("cd #{gem_path} && thor spec:ci") end -require 'github_changelog_generator/task' +begin + require "github_changelog_generator/task" -GitHubChangelogGenerator::RakeTask.new :changelog do |config| - config.token = ENV['GITHUB_CHANGELOG_TOKEN'] - # config.future_release = ChefZero::VERSION - config.enhancement_labels = "enhancement,Enhancement,New Feature".split(',') - config.bug_labels = "bug,Bug,Improvement,Upstream Bug".split(',') - config.exclude_labels = "duplicate,question,invalid,wontfix,no_changelog".split(',') + GitHubChangelogGenerator::RakeTask.new :changelog do |config| + config.future_release = ChefZero::VERSION + config.enhancement_labels = "enhancement,Enhancement,New Feature,Feature".split(",") + config.bug_labels = "bug,Bug,Improvement,Upstream Bug".split(",") + config.exclude_labels = "duplicate,question,invalid,wontfix,no_changelog,Exclude From Changelog,Question,Discussion".split(",") + end +rescue LoadError + puts "github_changelog_generator is not available. gem install github_changelog_generator to generate changelogs" end diff --git a/bin/chef-zero b/bin/chef-zero index 54739bc..33fc0e1 100755 --- a/bin/chef-zero +++ b/bin/chef-zero @@ -31,7 +31,8 @@ OptionParser.new do |opts| opts.banner = "Usage: chef-zero [ARGS]" opts.on("-H", "--host HOST", "Host to bind to (default: 127.0.0.1)") do |value| - options[:host] = value + options[:host] ||= [] + options[:host] << value end opts.on("-p", "--port PORT", "Port to listen on (e.g. 8889, or 8500-8600 or 8885,8888)") do |value| @@ -55,6 +56,11 @@ OptionParser.new do |opts| options[:log_file] = value end + opts.on("--enterprise", "Whether to run in enterprise mode") do |value| + options[:single_org] = nil + options[:osc_compat] = false + end + opts.on("--multi-org", "Whether to run in multi-org mode") do |value| options[:single_org] = nil end @@ -93,7 +99,11 @@ if options[:daemon] Process.daemon(true) server.start(true) else - abort 'Process.daemon requires Ruby >= 1.9' + if ENV['OS'] == 'Windows_NT' + abort 'Daemonization is not supported on Windows. Running 'start chef-zero' will fork the process.' + else + abort 'Process.daemon requires Ruby >= 1.9' + end end else server.start(true) diff --git a/chef-zero.gemspec b/chef-zero.gemspec index 6c7e357..8114a81 100644 --- a/chef-zero.gemspec +++ b/chef-zero.gemspec @@ -8,19 +8,24 @@ Gem::Specification.new do |s| s.summary = 'Self-contained, easy-setup, fast-start in-memory Chef server for testing and solo setup purposes' s.description = s.summary s.author = 'John Keiser' - s.email = 'jkeiser@opscode.com' - s.homepage = 'http://www.opscode.com' + s.email = 'jkeiser@chef.io' + s.homepage = 'http://www.chef.io' s.license = 'Apache 2.0' - s.add_dependency 'mixlib-log', '~> 1.3' - s.add_dependency 'hashie', '>= 2.0', '< 4.0' + s.required_ruby_version = ">= 2.1.0" + + s.add_dependency 'mixlib-log', '~> 1.3' + s.add_dependency 'hashie', '>= 2.0', '< 4.0' s.add_dependency 'uuidtools', '~> 2.1' s.add_dependency 'ffi-yajl', '~> 2.2' - s.add_dependency 'rack' + s.add_dependency 'rack', '< 2' # 2.0 requires Ruby 2.2+ + s.add_development_dependency 'pry' + s.add_development_dependency 'pry-byebug' + s.add_development_dependency 'pry-stack_explorer' s.add_development_dependency 'rake' s.add_development_dependency 'rspec' - s.add_development_dependency 'github_changelog_generator' + s.add_development_dependency 'chef' s.bindir = 'bin' s.executables = ['chef-zero'] diff --git a/gemfiles/berkshelf.gemfile b/gemfiles/berkshelf.gemfile deleted file mode 100644 index ca99e91..0000000 --- a/gemfiles/berkshelf.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -source 'https://rubygems.org' - -gemspec :path => "../" - -gem 'berkshelf', :github => 'berkshelf' - -# development dependencies of berkshelf -gem 'aruba', '~> 0.5' -gem 'fuubar', '~> 1.1' -gem 'rake', '~> 0.9' -gem 'rspec', '~> 2.13' -gem 'spork', '~> 0.9' -gem 'test-kitchen', '~> 1.2' -gem 'webmock', '~> 1.11' -gem 'yard', '~> 0.8' diff --git a/gemfiles/latest-chef.gemfile b/gemfiles/latest-chef.gemfile deleted file mode 100644 index 47420d6..0000000 --- a/gemfiles/latest-chef.gemfile +++ /dev/null @@ -1,5 +0,0 @@ -source 'https://rubygems.org' - -gemspec :path => "../" - -gem 'chef', :github => 'chef/chef' diff --git a/gemfiles/no-pedant.gemfile b/gemfiles/no-pedant.gemfile deleted file mode 100644 index 7784f18..0000000 --- a/gemfiles/no-pedant.gemfile +++ /dev/null @@ -1,4 +0,0 @@ -source 'https://rubygems.org' - -gemspec :path => "../" - diff --git a/gemfiles/oc-chef-pedant.gemfile b/gemfiles/oc-chef-pedant.gemfile deleted file mode 100644 index dcdee27..0000000 --- a/gemfiles/oc-chef-pedant.gemfile +++ /dev/null @@ -1,6 +0,0 @@ -source 'https://rubygems.org' -gemspec :path => '../' - -gem 'rest-client', :github => 'chef/rest-client', :branch => 'lcg/1.6.7-version-lying' -gem 'oc-chef-pedant', :github => 'chef/oc-chef-pedant', :tag => '2.0.0' -gem 'chef', :github => 'chef/chef' diff --git a/lib/chef_zero/chef_data/acl_path.rb b/lib/chef_zero/chef_data/acl_path.rb index c783a1a..52b43d4 100644 --- a/lib/chef_zero/chef_data/acl_path.rb +++ b/lib/chef_zero/chef_data/acl_path.rb @@ -13,7 +13,8 @@ module ChefZero # specified on X, they are not inherited from X's parent # - stop adding pivotal to acls (he already has access to what he needs) module AclPath - ORG_DATA_TYPES = %w(clients cookbooks containers data environments groups nodes roles sandboxes) + ORG_DATA_TYPES = %w(clients cookbook_artifacts cookbooks containers data environments groups + nodes policies policy_groups roles sandboxes) TOP_DATA_TYPES = %w(containers organizations users) # ACL data paths for a partition are: diff --git a/lib/chef_zero/chef_data/cookbook_data.rb b/lib/chef_zero/chef_data/cookbook_data.rb index 172b9cd..4fd0320 100644 --- a/lib/chef_zero/chef_data/cookbook_data.rb +++ b/lib/chef_zero/chef_data/cookbook_data.rb @@ -108,6 +108,11 @@ module ChefZero cookbook_arg(:replacing, cookbook, version_constraints) end + def gem(*opts) + self[:gems] ||= [] + self[:gems] << opts + end + def recipe(recipe, description) self[:recipes][recipe] = description end diff --git a/lib/chef_zero/chef_data/data_normalizer.rb b/lib/chef_zero/chef_data/data_normalizer.rb index 2dff91e..e819f1d 100644 --- a/lib/chef_zero/chef_data/data_normalizer.rb +++ b/lib/chef_zero/chef_data/data_normalizer.rb @@ -8,7 +8,7 @@ module ChefZero def self.normalize_acls(acls) ChefData::DefaultCreator::PERMISSIONS.each do |perm| acls[perm] ||= {} - acls[perm]['actors'] ||= [] + (acls[perm]['actors'] ||= []).uniq! # this gets doubled sometimes, for reasons. acls[perm]['groups'] ||= [] end acls @@ -17,8 +17,8 @@ module ChefZero def self.normalize_client(client, name, orgname = nil) client['name'] ||= name client['clientname'] ||= name - client['admin'] = !!client['admin'] if client.has_key?('admin') - client['public_key'] ||= PUBLIC_KEY + client['admin'] = !!client['admin'] if client.key?('admin') + client['public_key'] = PUBLIC_KEY unless client.key?('public_key') client['orgname'] ||= orgname client['validator'] ||= false client['validator'] = !!client['validator'] @@ -36,7 +36,7 @@ module ChefZero def self.normalize_user(user, name, identity_keys, osc_compat, method=nil) user[identity_keys.first] ||= name - user['public_key'] ||= PUBLIC_KEY + user['public_key'] = PUBLIC_KEY unless user.key?('public_key') user['admin'] ||= false user['admin'] = !!user['admin'] user['openid'] ||= nil @@ -79,7 +79,8 @@ module ChefZero data_bag_item end - def self.normalize_cookbook(endpoint, org_prefix, cookbook, name, version, base_uri, method) + def self.normalize_cookbook(endpoint, org_prefix, cookbook, name, version, base_uri, method, + is_cookbook_artifact=false) # TODO I feel dirty if method != 'PUT' cookbook.each_pair do |key, value| @@ -92,24 +93,28 @@ module ChefZero end end cookbook['name'] ||= "#{name}-#{version}" - # TODO this feels wrong, but the real chef server doesn't expand this default - # cookbook['version'] ||= version - cookbook['cookbook_name'] ||= name + # TODO it feels wrong, but the real chef server doesn't expand 'version', so we don't either. + cookbook['frozen?'] ||= false cookbook['metadata'] ||= {} cookbook['metadata']['version'] ||= version - # Sad to not be expanding defaults just because Chef doesn't :( - # cookbook['metadata']['name'] ||= name - # cookbook['metadata']['description'] ||= "A fabulous new cookbook" + + # defaults set by the client and not the Server: + # metadata[name, description, maintainer, maintainer_email, license] + cookbook['metadata']['long_description'] ||= "" - # cookbook['metadata']['maintainer'] ||= "YOUR_COMPANY_NAME" - # cookbook['metadata']['maintainer_email'] ||= "YOUR_EMAIL" - # cookbook['metadata']['license'] ||= "none" cookbook['metadata']['dependencies'] ||= {} cookbook['metadata']['attributes'] ||= {} cookbook['metadata']['recipes'] ||= {} end - cookbook['json_class'] ||= 'Chef::CookbookVersion' + + if is_cookbook_artifact + cookbook.delete('json_class') + else + cookbook['cookbook_name'] ||= name + cookbook['json_class'] ||= 'Chef::CookbookVersion' + end + cookbook['chef_type'] ||= 'cookbook_version' if method == 'MIN' cookbook['metadata'].delete('attributes') @@ -158,7 +163,7 @@ module ChefZero node['chef_type'] ||= 'node' node['chef_environment'] ||= '_default' node['override'] ||= {} - node['normal'] ||= {} + node['normal'] ||= {"tags" => []} node['default'] ||= {} node['automatic'] ||= {} node['run_list'] ||= [] @@ -166,6 +171,20 @@ module ChefZero node end + def self.normalize_policy(policy, name, revision) + policy['name'] ||= name + policy['revision_id'] ||= revision + policy['run_list'] ||= [] + policy['cookbook_locks'] ||= {} + policy + end + + def self.normalize_policy_group(policy_group, name) + policy_group[name] ||= 'name' + policy_group['policies'] ||= {} + policy_group + end + def self.normalize_organization(org, name) org['name'] ||= name org['full_name'] ||= name diff --git a/lib/chef_zero/chef_data/default_creator.rb b/lib/chef_zero/chef_data/default_creator.rb index d1a0118..957018c 100644 --- a/lib/chef_zero/chef_data/default_creator.rb +++ b/lib/chef_zero/chef_data/default_creator.rb @@ -148,6 +148,7 @@ module ChefZero DEFAULT_ORG_SPINE = { 'clients' => {}, + 'cookbook_artifacts' => {}, 'cookbooks' => {}, 'data' => {}, 'environments' => %w(_default), @@ -155,12 +156,14 @@ module ChefZero 'checksums' => {} }, 'nodes' => {}, + 'policies' => {}, + 'policy_groups' => {}, 'roles' => {}, 'sandboxes' => {}, 'users' => {}, 'org' => {}, - 'containers' => %w(clients containers cookbooks data environments groups nodes roles sandboxes policies cookbook_artifacts), + 'containers' => %w(clients containers cookbook_artifacts cookbooks data environments groups nodes policies policy_groups roles sandboxes), 'groups' => %w(admins billing-admins clients users), 'association_requests' => {} } @@ -279,7 +282,7 @@ module ChefZero 'delete' => { 'groups' => %w(admins) }, 'grant' => { 'groups' => %w(admins) }, } - when 'containers/cookbooks', 'containers/environments', 'containers/roles' + when 'containers/environments', 'containers/roles', 'containers/policy_groups', 'containers/policies' { 'create' => { 'groups' => %w(admins users) }, 'read' => { 'groups' => %w(admins users clients) }, @@ -287,7 +290,7 @@ module ChefZero 'delete' => { 'groups' => %w(admins users) }, 'grant' => { 'groups' => %w(admins) }, } - when 'containers/cookbooks', 'containers/data' + when 'containers/cookbooks', 'containers/cookbook_artifacts', 'containers/data' { 'create' => { 'groups' => %w(admins users clients) }, 'read' => { 'groups' => %w(admins users clients) }, @@ -378,11 +381,12 @@ module ChefZero # Non-default containers do not get superusers added to them, # because reasons. unless path.size == 4 && path[0] == 'organizations' && path[2] == 'containers' && !exists?(path) - owners |= superusers + owners += superusers end end - owners.uniq + # we don't de-dup this list, because pedant expects to see ["pivotal", "pivotal"] in some cases. + owners end def default_acl(acl_path, acl={}) @@ -436,7 +440,7 @@ module ChefZero when 4 return path[0] == 'organizations' && ( (path[2] == 'acls' && path[1] != 'root') || - %w(cookbooks data).include?(path[2])) + %w(cookbooks cookbook_artifacts data policies policy_groups).include?(path[2])) else return false end diff --git a/lib/chef_zero/data_store/data_error.rb b/lib/chef_zero/data_store/data_error.rb index 9822a6b..b392e58 100644 --- a/lib/chef_zero/data_store/data_error.rb +++ b/lib/chef_zero/data_store/data_error.rb @@ -19,13 +19,14 @@ module ChefZero module DataStore class DataError < StandardError + attr_reader :path, :cause + def initialize(path, cause = nil) @path = path @cause = cause + path_for_msg = path.nil? ? "nil" : "/#{path.join('/')}" + super "Data path: #{path_for_msg}" end - - attr_reader :path - attr_reader :cause end end end diff --git a/lib/chef_zero/data_store/default_facade.rb b/lib/chef_zero/data_store/default_facade.rb index 0d4cf34..c941322 100644 --- a/lib/chef_zero/data_store/default_facade.rb +++ b/lib/chef_zero/data_store/default_facade.rb @@ -62,7 +62,7 @@ module ChefZero end options_hash = options.last.is_a?(Hash) ? options.last : {} - default_creator.created(path + [ name ], options_hash[:requestor], options.include?(:recursive)) + default_creator.created(path + [ name ], options_hash[:requestor], options.include?(:create_dir)) end def get(path, request=nil) diff --git a/lib/chef_zero/endpoints/actor_default_key_endpoint.rb b/lib/chef_zero/endpoints/actor_default_key_endpoint.rb new file mode 100644 index 0000000..3be1475 --- /dev/null +++ b/lib/chef_zero/endpoints/actor_default_key_endpoint.rb @@ -0,0 +1,77 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # ActorDefaultKeyEndpoint + # + # This class handles DELETE/GET/PUT requests for client/user default public + # keys, i.e. requests with identity key "default". All others are handled + # by ActorKeyEndpoint. + # + # Default public keys are stored with the actor (client or user) instead of + # under user/client_keys. Handling those in a separate endpoint offloads + # the branching logic onto the router rather than branching in every + # endpoint method (`if request.rest_path[-1] == "default" ...`). + # + # /users/USER/keys/default + # /organizations/ORG/clients/CLIENT/keys/default + class ActorDefaultKeyEndpoint < RestBase + DEFAULT_PUBLIC_KEY_NAME = "default".freeze + + def get(request) + # 404 if actor doesn't exist + actor_data = get_actor_data(request) + key_data = default_public_key_from_actor(actor_data) + + # 404 if the actor doesn't have a default key + if key_data["public_key"].nil? + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + end + + json_response(200, default_public_key_from_actor(actor_data)) + end + + def delete(request) + path = actor_path(request) + actor_data = get_actor_data(request) # 404 if actor doesn't exist + + default_public_key = delete_actor_default_public_key!(request, path, actor_data) + json_response(200, default_public_key) + end + + def put(request) + # 404 if actor doesn't exist + actor_data = get_actor_data(request) + + new_public_key = parse_json(request.body)["public_key"] + actor_data["public_key"] = new_public_key + + set_data(request, actor_path(request), to_json(actor_data)) + end + + private + + def actor_path(request) + return request.rest_path[0..3] if request.rest_path[2] == "clients" + request.rest_path[0..1] + end + + def get_actor_data(request) + path = actor_path(request) + parse_json(get_data(request, path)) + end + + def default_public_key_from_actor(actor_data) + { "name" => DEFAULT_PUBLIC_KEY_NAME, + "public_key" => actor_data["public_key"], + "expiration_date" => "infinity" } + end + + def delete_actor_default_public_key!(request, path, actor_data) + new_actor_data = actor_data.merge("public_key" => nil) + set_data(request, path, to_json(new_actor_data)) + default_public_key_from_actor(actor_data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/actor_endpoint.rb b/lib/chef_zero/endpoints/actor_endpoint.rb index 1572ac1..dd2caf2 100644 --- a/lib/chef_zero/endpoints/actor_endpoint.rb +++ b/lib/chef_zero/endpoints/actor_endpoint.rb @@ -8,8 +8,19 @@ module ChefZero # /organizations/ORG/users/NAME # /users/NAME class ActorEndpoint < RestObjectEndpoint + + def get(request) + result = super + user_data = parse_json(result[2]) + + user_data.delete("public_key") unless request.api_v0? + + json_response(200, user_data) + end + def delete(request) result = super + if request.rest_path[0] == 'users' list_data(request, [ 'organizations' ]).each do |org| begin @@ -18,12 +29,15 @@ module ChefZero end end end + + delete_actor_keys!(request) result end def put(request) # Find out if we're updating the public key. request_body = FFI_Yajl::Parser.parse(request.body, :create_additions => false) + if request_body['public_key'].nil? # If public_key is null, then don't overwrite it. Weird patchiness. body_modified = true @@ -33,18 +47,18 @@ module ChefZero end # Generate private_key if requested. - if request_body.has_key?('private_key') + if request_body.key?('private_key') body_modified = true - if request_body['private_key'] + + if request_body.delete('private_key') private_key, public_key = server.gen_key_pair updating_public_key = true request_body['public_key'] = public_key end - request_body.delete('private_key') end - # Save request - request.body = FFI_Yajl::Encoder.encode(request_body, :pretty => true) if body_modified + # Put modified body back in `request.body` + request.body = to_json(request_body) if body_modified # PUT /clients is patchy request.body = patch_request_body(request) @@ -53,27 +67,29 @@ module ChefZero # Inject private_key into response, delete public_key/password if applicable if result[0] == 200 || result[0] == 201 + client_or_user_name = identity_key_value(request) || request.rest_path[-1] + + if is_rename?(request) + rename_keys!(request, client_or_user_name) + end + if request.rest_path[0] == 'users' - key = nil - identity_keys.each do |identity_key| - key ||= request_body[identity_key] - end - key ||= request.rest_path[-1] response = { - 'uri' => build_uri(request.base_uri, [ 'users', key ]) + 'uri' => build_uri(request.base_uri, [ 'users', client_or_user_name ]) } else - response = FFI_Yajl::Parser.parse(result[2], :create_additions => false) + response = parse_json(result[2]) end - if request.rest_path[2] == 'clients' + if client?(request) response['private_key'] = private_key ? private_key : false else response['private_key'] = private_key if private_key + response.delete('public_key') unless updating_public_key end - response.delete('public_key') if !updating_public_key && request.rest_path[2] == 'users' response.delete('password') + json_response(result[0], response) else result @@ -81,13 +97,87 @@ module ChefZero end def populate_defaults(request, response_json) - response = FFI_Yajl::Parser.parse(response_json, :create_additions => false) - if request.rest_path[2] == 'clients' - response = ChefData::DataNormalizer.normalize_client(response,request.rest_path[3], request.rest_path[1]) + response = parse_json(response_json) + + populated_response = + if client?(request) + ChefData::DataNormalizer.normalize_client( + response, + response["name"] || request.rest_path[-1], + request.rest_path[1] + ) + else + ChefData::DataNormalizer.normalize_user( + response, + response["username"] || request.rest_path[-1], + identity_keys, + server.options[:osc_compat], + request.method + ) + end + + to_json(populated_response) + end + + private + + # Move key data to new path + def rename_keys!(request, new_client_or_user_name) + orig_keys_path = keys_path_base(request) + new_keys_path = orig_keys_path.dup + .tap {|path| path[-2] = new_client_or_user_name } + + key_names = list_data_or_else(request, orig_keys_path, nil) + return unless key_names # No keys to move + + key_names.each do |key_name| + # Get old data + orig_path = [ *orig_keys_path, key_name ] + data = get_data(request, orig_path, :data_store_exceptions) + + # Copy data to new path + create_data( + request, + new_keys_path, key_name, + data, + :create_dir + ) + end + + # Delete original data + delete_data_dir(request, orig_keys_path, :recursive, :data_store_exceptions) + end + + def delete_actor_keys!(request) + path = keys_path_base(request)[0..-2] + delete_data_dir(request, path, :recursive, :data_store_exceptions) + rescue DataStore::DataNotFoundError + end + + def client?(request, rest_path=nil) + rest_path ||= request.rest_path + request.rest_path[2] == "clients" + end + + # Return the data store keys path for the request client or user, e.g. + # + # /organizations/ORG/clients/CLIENT -> /organizations/ORG/client_keys/CLIENT/keys + # /organizations/ORG/users/USER -> /organizations/ORG/user_keys/USER/keys + # /users/USER -> /user_keys/USER + # + def keys_path_base(request, client_or_user_name=nil) + rest_path = (rest_path || request.rest_path).dup + rest_path = rest_path.dup + case rest_path[-2] + when "users" + rest_path[-2] = "user_keys" + when "clients" + rest_path[-2] = "client_keys" else - response = ChefData::DataNormalizer.normalize_user(response, request.rest_path[3], identity_keys, server.options[:osc_compat], request.method) + raise "Unexpected URL #{rest_path.join("/")}: cannot determine key path" end - FFI_Yajl::Encoder.encode(response, :pretty => true) + rest_path << "keys" + rest_path end end end diff --git a/lib/chef_zero/endpoints/actor_key_endpoint.rb b/lib/chef_zero/endpoints/actor_key_endpoint.rb new file mode 100644 index 0000000..f2b65ed --- /dev/null +++ b/lib/chef_zero/endpoints/actor_key_endpoint.rb @@ -0,0 +1,62 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # ActorKeyEndpoint + # + # This class handles DELETE/GET/PUT requests for all client/user keys + # **except** default public keys, i.e. requests with identity key + # "default". Those are handled by ActorDefaultKeyEndpoint. See that class + # for more information. + # + # /users/USER/keys/NAME + # /organizations/ORG/clients/CLIENT/keys/NAME + class ActorKeyEndpoint < RestBase + def get(request) + validate_actor!(request) + key_path = data_path(request) + already_json_response(200, get_data(request, key_path)) + end + + def delete(request) + validate_actor!(request) # 404 if actor doesn't exist + + key_path = data_path(request) + data = get_data(request, key_path) + delete_data(request, key_path) + + already_json_response(200, data) + end + + def put(request) + validate_actor!(request) # 404 if actor doesn't exist + set_data(request, data_path(request), request.body) + end + + private + + # Returns the keys data store path, which is the same as + # `request.rest_path` except with "client_keys" instead of "clients" or + # "user_keys" instead of "users." + def data_path(request) + request.rest_path.dup.tap do |path| + if client?(request) + path[2] = "client_keys" + else + path[0] = "user_keys" + end + end + end + + # Raises RestErrorResponse (404) if actor doesn't exist + def validate_actor!(request) + actor_path = request.rest_path[ client?(request) ? 0..3 : 0..1 ] + get_data(request, actor_path) + end + + def client?(request) + request.rest_path[2] == "clients" + end + end + end +end diff --git a/lib/chef_zero/endpoints/actor_keys_endpoint.rb b/lib/chef_zero/endpoints/actor_keys_endpoint.rb new file mode 100644 index 0000000..f3624d6 --- /dev/null +++ b/lib/chef_zero/endpoints/actor_keys_endpoint.rb @@ -0,0 +1,129 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /users/USER/keys + # /organizations/ORG/clients/CLIENT/keys + class ActorKeysEndpoint < RestBase + DEFAULT_PUBLIC_KEY_NAME = "default" + DATE_FORMAT = "%FT%TZ" # e.g. 2015-12-24T21:00:00Z + + def get(request, alt_uri_root=nil) + path = data_path(request) + + # Get actor or 404 if it doesn't exist + actor_json = get_data(request, actor_path(request)) + + key_names = list_data_or_else(request, path, []) + key_names.unshift(DEFAULT_PUBLIC_KEY_NAME) if actor_has_default_public_key?(actor_json) + + result = key_names.map do |key_name| + list_key(request, [ *path, key_name ], alt_uri_root) + end + + json_response(200, result) + end + + def post(request) + request_body = parse_json(request.body) + + # Try loading the client or user so a 404 is returned if it doesn't exist + actor_json = get_data(request, actor_path(request)) + + generate_keys = request_body["public_key"].nil? + + if generate_keys + private_key, public_key = server.gen_key_pair + else + public_key = request_body['public_key'] + end + + key_name = request_body["name"] + + if key_name == DEFAULT_PUBLIC_KEY_NAME + store_actor_default_public_key!(request, actor_json, public_key) + else + store_actor_public_key!(request, key_name, public_key, request_body["expiration_date"]) + end + + response_body = { "uri" => key_uri(request, key_name) } + response_body["private_key"] = private_key if generate_keys + + json_response(201, response_body, + headers: { "Location" => response_body["uri"] }) + end + + private + + def store_actor_public_key!(request, name, public_key, expiration_date) + data = to_json( + "name" => name, + "public_key" => public_key, + "expiration_date" => expiration_date + ) + + create_data(request, data_path(request), name, data, :create_dir) + end + + def store_actor_default_public_key!(request, actor_json, public_key) + actor_data = parse_json(actor_json) + + if actor_data["public_key"] + raise RestErrorResponse.new(409, "Object already exists: #{key_uri(request, DEFAULT_PUBLIC_KEY_NAME)}") + end + + actor_data["public_key"] = public_key + set_data(request, actor_path(request), to_json(actor_data)) + + end + + # Returns the keys data store path, which is the same as + # `request.rest_path` except with "user_keys" instead of "users" or + # "client_keys" instead of "clients." + def data_path(request) + request.rest_path.dup.tap do |path| + if client?(request) + path[2] = "client_keys" + else + path[0] = "user_keys" + end + end + end + + def list_key(request, data_path, alt_uri_root=nil) + key_name, expiration_date = + if data_path[-1] == DEFAULT_PUBLIC_KEY_NAME + [ DEFAULT_PUBLIC_KEY_NAME, "infinity" ] + else + parse_json(get_data(request, data_path)) + .values_at("name", "expiration_date") + end + + expired = expiration_date != "infinity" && + DateTime.now > DateTime.strptime(expiration_date, DATE_FORMAT) + + { "name" => key_name, + "uri" => key_uri(request, key_name, alt_uri_root), + "expired" => expired } + end + + def client?(request) + request.rest_path[2] == "clients" + end + + def key_uri(request, key_name, alt_uri_root=nil) + uri_root = alt_uri_root.nil? ? request.rest_path : alt_uri_root + build_uri(request.base_uri, [ *uri_root, key_name ]) + end + + def actor_path(request) + return request.rest_path[0..3] if client?(request) + request.rest_path[0..1] + end + + def actor_has_default_public_key?(actor_json) + !!parse_json(actor_json)["public_key"] + end + end + end +end diff --git a/lib/chef_zero/endpoints/actors_endpoint.rb b/lib/chef_zero/endpoints/actors_endpoint.rb index c6c676f..6297aed 100644 --- a/lib/chef_zero/endpoints/actors_endpoint.rb +++ b/lib/chef_zero/endpoints/actors_endpoint.rb @@ -8,30 +8,25 @@ module ChefZero def get(request) response = super(request) - if request.query_params['email'] - results = FFI_Yajl::Parser.parse(response[2], :create_additions => false) - new_results = {} - results.each do |name, url| - record = get_data(request, request.rest_path + [ name ], :nil) - if record - record = FFI_Yajl::Parser.parse(record, :create_additions => false) - new_results[name] = url if record['email'] == request.query_params['email'] - end - end - response[2] = FFI_Yajl::Encoder.encode(new_results, :pretty => true) + # apply query filters: if one applies, stop processing rest + # (precendence matches chef-server: https://github.com/chef/chef-server/blob/268a0c9/src/oc_erchef/apps/chef_objects/src/chef_user.erl#L554-L559) + if value = request.query_params['external_authentication_uid'] + response[2] = filter('external_authentication_uid', value, request, response[2]) + elsif value = request.query_params['email'] + response[2] = filter('email', value, request, response[2]) end if request.query_params['verbose'] - results = FFI_Yajl::Parser.parse(response[2], :create_additions => false) + results = parse_json(response[2]) results.each do |name, url| record = get_data(request, request.rest_path + [ name ], :nil) if record - record = FFI_Yajl::Parser.parse(record, :create_additions => false) + record = parse_json(record) record = ChefData::DataNormalizer.normalize_user(record, name, identity_keys, server.options[:osc_compat]) results[name] = record end end - response[2] = FFI_Yajl::Encoder.encode(results, :pretty => true) + response[2] = to_json(results) end response end @@ -39,26 +34,67 @@ module ChefZero def post(request) # First, find out if the user actually posted a public key. If not, make # one. - request_body = FFI_Yajl::Parser.parse(request.body, :create_additions => false) + request_body = parse_json(request.body) public_key = request_body['public_key'] - if !public_key + + skip_key_create = !request.api_v0? && !request_body["create_key"] + + if !public_key && !skip_key_create private_key, public_key = server.gen_key_pair request_body['public_key'] = public_key - request.body = FFI_Yajl::Encoder.encode(request_body, :pretty => true) + request.body = to_json(request_body) + elsif skip_key_create + request_body['public_key'] = nil + request.body = to_json(request_body) end result = super(request) if result[0] == 201 # If we generated a key, stuff it in the response. - response = FFI_Yajl::Parser.parse(result[2], :create_additions => false) - response['private_key'] = private_key if private_key - response['public_key'] = public_key unless request.rest_path[0] == 'users' + user_data = parse_json(result[2]) + + key_data = {} + key_data['private_key'] = private_key if private_key + key_data['public_key'] = public_key unless request.rest_path[0] == 'users' + + response = + if request.api_v0? + user_data.merge!(key_data) + elsif skip_key_create && !public_key + user_data + else + actor_name = request_body["name"] || request_body["username"] || request_body["clientname"] + + relpath_to_default_key = [ actor_name, "keys", "default" ] + key_data["uri"] = build_uri(request.base_uri, request.rest_path + relpath_to_default_key) + key_data["public_key"] = public_key + key_data["name"] = "default" + key_data["expiration_date"] = "infinity" + user_data["chef_key"] = key_data + user_data + end + json_response(201, response) else result end end + + private + + def filter(key, value, request, resp) + results = parse_json(resp) + new_results = {} + results.each do |name, url| + record = get_data(request, request.rest_path + [ name ], :nil) + if record + record = parse_json(record) + new_results[name] = url if record[key] == value + end + end + to_json(new_results) + end end end end diff --git a/lib/chef_zero/endpoints/containers_endpoint.rb b/lib/chef_zero/endpoints/containers_endpoint.rb index 3f7af87..8a4220f 100644 --- a/lib/chef_zero/endpoints/containers_endpoint.rb +++ b/lib/chef_zero/endpoints/containers_endpoint.rb @@ -8,6 +8,18 @@ module ChefZero def initialize(server) super(server, %w(id containername)) end + + # create a container. + # input: {"containername"=>"new-container", "containerpath"=>"/"} + def post(request) + data = parse_json(request.body) + # if they don't match, id wins. + container_name = data["id"] || data["containername"] + container_path_suffix = data["containerpath"].split("/").reject { |o| o.empty? } + create_data(request, request.rest_path, container_name, to_json({}), :create_dir) + + json_response(201, { uri: build_uri(request.base_uri, request.rest_path + container_path_suffix + [container_name]) }) + end end end end diff --git a/lib/chef_zero/endpoints/controls_endpoint.rb b/lib/chef_zero/endpoints/controls_endpoint.rb new file mode 100644 index 0000000..5790d7f --- /dev/null +++ b/lib/chef_zero/endpoints/controls_endpoint.rb @@ -0,0 +1,15 @@ +module ChefZero + module Endpoints + # /organizations/ORG/controls + class ControlsEndpoint < RestBase + # ours is not to wonder why; ours is but to make the pedant specs pass. + def get(request) + error(410, "Server says 410, chef-zero says 410.") + end + + def post(request) + error(410, "Server says 410, chef-zero says 410.") + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_artifact_endpoint.rb b/lib/chef_zero/endpoints/cookbook_artifact_endpoint.rb new file mode 100644 index 0000000..e17fea2 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbook_artifact_endpoint.rb @@ -0,0 +1,24 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + class CookbookArtifactEndpoint < RestBase + # GET /organizations/ORG/cookbook_artifacts/COOKBOOK + def get(request) + cookbook_name = request.rest_path.last + cookbook_url = build_uri(request.base_uri, request.rest_path) + response_data = {} + versions = [] + + list_data(request).each do |identifier| + artifact_url = build_uri(request.base_uri, request.rest_path + [cookbook_name, identifier]) + versions << { url: artifact_url, identifier: identifier } + end + + response_data[cookbook_name] = { url: cookbook_url, versions: versions } + + return json_response(200, response_data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_artifact_identifier_endpoint.rb b/lib/chef_zero/endpoints/cookbook_artifact_identifier_endpoint.rb new file mode 100644 index 0000000..b70e2c4 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbook_artifact_identifier_endpoint.rb @@ -0,0 +1,68 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + class CookbookArtifactIdentifierEndpoint < ChefZero::Endpoints::CookbookVersionEndpoint + # these endpoints are almost, but not quite, not entirely unlike the corresponding /cookbooks endpoints. + # it could all be refactored for maximum reuse, but they're short REST methods with well-defined + # behavioral specs (pedant), so there's not a huge benefit. + + # GET /organizations/ORG/cookbook_artifacts/NAME/IDENTIFIER + def get(request) + cookbook_data = normalize(request, parse_json(get_data(request))) + return json_response(200, cookbook_data) + end + + # PUT /organizations/ORG/cookbook_artifacts/COOKBOOK/IDENTIFIER + def put(request) + if exists_data?(request) + return error(409, "Cookbooks cannot be modified, and a cookbook with this identifier already exists.") + end + + set_data(request, nil, request.body, :create_dir) + + return already_json_response(201, request.body) + end + + # DELETE /organizations/ORG/cookbook_artifacts/COOKBOOK/IDENTIFIER + def delete(request) + begin + doomed_cookbook_json = get_data(request) + identified_cookbook_data = normalize(request, parse_json(doomed_cookbook_json)) + delete_data(request) + + # go through the recipes and delete stuff in the file store. + hoover_unused_checksums(get_checksums(doomed_cookbook_json), request) + + # if this was the last revision, delete the directory so future requests will 404, instead of + # returning 200 with an empty list. + # Last one out turns out the lights: delete /organizations/ORG/cookbooks/COOKBOOK if it no longer has versions + cookbook_path = request.rest_path[0..3] + if exists_data_dir?(request, cookbook_path) && list_data(request, cookbook_path).size == 0 + delete_data_dir(request, cookbook_path) + end + + json_response(200, identified_cookbook_data) + rescue RestErrorResponse => ex + if ex.response_code == 404 + error(404, "not_found") + else + raise + end + end + end + + private + + def make_file_store_path(rest_path, recipe) + rest_path.first(2) + ["file_store", "checksums", recipe["checksum"]] + end + + def normalize(request, cookbook_artifact_data) + ChefData::DataNormalizer.normalize_cookbook(self, request.rest_path[0..1], + cookbook_artifact_data, request.rest_path[3], request.rest_path[4], + request.base_uri, request.method, true) + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_artifacts_endpoint.rb b/lib/chef_zero/endpoints/cookbook_artifacts_endpoint.rb new file mode 100644 index 0000000..d9fdb20 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbook_artifacts_endpoint.rb @@ -0,0 +1,34 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + class CookbookArtifactsEndpoint < RestBase + # GET /organizations/ORG/cookbook_artifacts + def get(request) + data = {} + + artifacts = begin + list_data(request) + rescue Exception => e + if e.response_code == 404 + return already_json_response(200, "{}") + end + end + + artifacts.each do |cookbook_artifact| + cookbook_url = build_uri(request.base_uri, request.rest_path + [cookbook_artifact]) + + versions = [] + list_data(request, request.rest_path + [cookbook_artifact]).each do |identifier| + artifact_url = build_uri(request.base_uri, request.rest_path + [cookbook_artifact, identifier]) + versions << { url: artifact_url, identifier: identifier } + end + + data[cookbook_artifact] = { url: cookbook_url, versions: versions } + end + + return json_response(200, data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_version_endpoint.rb b/lib/chef_zero/endpoints/cookbook_version_endpoint.rb index 8dad508..5502ba0 100644 --- a/lib/chef_zero/endpoints/cookbook_version_endpoint.rb +++ b/lib/chef_zero/endpoints/cookbook_version_endpoint.rb @@ -58,8 +58,8 @@ module ChefZero deleted_cookbook = get_data(request) response = super(request) - cookbook_name = request.rest_path[3] - cookbook_path = request.rest_path[0..1] + ['cookbooks', cookbook_name] + # Last one out turns out the lights: delete /organizations/ORG/cookbooks/NAME if it no longer has versions + cookbook_path = request.rest_path[0..3] if exists_data_dir?(request, cookbook_path) && list_data(request, cookbook_path).size == 0 delete_data_dir(request, cookbook_path) end @@ -86,10 +86,19 @@ module ChefZero private def hoover_unused_checksums(deleted_checksums, request) - data_store.list(request.rest_path[0..1] + ['cookbooks']).each do |cookbook_name| - data_store.list(request.rest_path[0..1] + ['cookbooks', cookbook_name]).each do |version| - cookbook = data_store.get(request.rest_path[0..1] + ['cookbooks', cookbook_name, version], request) - deleted_checksums = deleted_checksums - get_checksums(cookbook) + %w(cookbooks cookbook_artifacts).each do |cookbook_type| + begin + cookbooks = data_store.list(request.rest_path[0..1] + [cookbook_type]) + rescue ChefZero::DataStore::DataNotFoundError + # Not all chef versions support cookbook_artifacts + raise unless cookbook_type == "cookbook_artifacts" + cookbooks = [] + end + cookbooks.each do |cookbook_name| + data_store.list(request.rest_path[0..1] + [cookbook_type, cookbook_name]).each do |version| + cookbook = data_store.get(request.rest_path[0..1] + [cookbook_type, cookbook_name, version], request) + deleted_checksums = deleted_checksums - get_checksums(cookbook) + end end end deleted_checksums.each do |checksum| diff --git a/lib/chef_zero/endpoints/dummy_endpoint.rb b/lib/chef_zero/endpoints/dummy_endpoint.rb new file mode 100644 index 0000000..fe16a7e --- /dev/null +++ b/lib/chef_zero/endpoints/dummy_endpoint.rb @@ -0,0 +1,31 @@ + +# pedant makes a couple of Solr-related calls from its search_utils.rb file that we can't work around (e.g. +# with monkeypatching). the necessary Pedant::Config values are set in run_oc_pedant.rb. --cdoherty +module ChefZero + module Endpoints + class DummyEndpoint < RestBase + # called by #direct_solr_query, once each for roles, nodes, and data bag items. each RSpec example makes + # 3 calls, with the expected sequence of return values [0, 1, 0]. + def get(request) + + # this could be made less brittle, but if things change to have more than 3 cycles, we should really + # be notified by a spec failure. + @mock_values ||= ([0, 1, 0] * 3).map { |val| make_response(val) } + + retval = @mock_values.shift + json_response(200, retval) + end + + # called by #force_solr_commit in pedant's , which doesn't check the return value. + def post(request) + # sure thing! + json_response(200, { message: "This dummy POST endpoint didn't do anything." }) + end + + def make_response(value) + { "response" => { "numFound" => value } } + end + end + end +end + diff --git a/lib/chef_zero/endpoints/node_endpoint.rb b/lib/chef_zero/endpoints/node_endpoint.rb index 223ec9f..f2bb8ba 100644 --- a/lib/chef_zero/endpoints/node_endpoint.rb +++ b/lib/chef_zero/endpoints/node_endpoint.rb @@ -6,6 +6,20 @@ module ChefZero module Endpoints # /nodes/ID class NodeEndpoint < RestObjectEndpoint + def put(request) + data = parse_json(request.body) + + if data.has_key?("policy_name") && policy_name_invalid?(data["policy_name"]) + return error(400, "Field 'policy_name' invalid", :pretty => false) + end + + if data.has_key?("policy_group") && policy_name_invalid?(data["policy_group"]) + return error(400, "Field 'policy_group' invalid", :pretty => false) + end + + super(request) + end + def populate_defaults(request, response_json) node = FFI_Yajl::Parser.parse(response_json, :create_additions => false) node = ChefData::DataNormalizer.normalize_node(node, request.rest_path[3]) diff --git a/lib/chef_zero/endpoints/nodes_endpoint.rb b/lib/chef_zero/endpoints/nodes_endpoint.rb new file mode 100644 index 0000000..8b9d852 --- /dev/null +++ b/lib/chef_zero/endpoints/nodes_endpoint.rb @@ -0,0 +1,35 @@ +require 'ffi_yajl' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /nodes + class NodesEndpoint < RestListEndpoint + + def post(request) + # /nodes validation + if request.rest_path.last == "nodes" + data = parse_json(request.body) + + if data.has_key?("policy_name") && policy_name_invalid?(data["policy_name"]) + return error(400, "Field 'policy_name' invalid", :pretty => false) + end + + if data.has_key?("policy_group") && policy_name_invalid?(data["policy_group"]) + return error(400, "Field 'policy_group' invalid", :pretty => false) + end + end + + super(request) + end + + def populate_defaults(request, response_json) + node = FFI_Yajl::Parser.parse(response_json, :create_additions => false) + node = ChefData::DataNormalizer.normalize_node(node, request.rest_path[3]) + FFI_Yajl::Encoder.encode(node, :pretty => true) + end + end + end +end + diff --git a/lib/chef_zero/endpoints/organization_user_default_key_endpoint.rb b/lib/chef_zero/endpoints/organization_user_default_key_endpoint.rb new file mode 100644 index 0000000..953edc1 --- /dev/null +++ b/lib/chef_zero/endpoints/organization_user_default_key_endpoint.rb @@ -0,0 +1,16 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # GET /organizations/ORG/users/USER/keys/default + class OrganizationUserDefaultKeyEndpoint < RestBase + def get(request) + # 404 if it doesn't exist + get_data(request, request.rest_path[0..3]) + # Just use the /users/USER/keys/default endpoint + request.rest_path = request.rest_path[2..-1] + ActorDefaultKeyEndpoint.new(server).get(request) + end + end + end +end diff --git a/lib/chef_zero/endpoints/organization_user_key_endpoint.rb b/lib/chef_zero/endpoints/organization_user_key_endpoint.rb new file mode 100644 index 0000000..e0c114c --- /dev/null +++ b/lib/chef_zero/endpoints/organization_user_key_endpoint.rb @@ -0,0 +1,17 @@ +require 'chef_zero/rest_base' +require 'chef_zero/endpoints/actor_keys_endpoint' + +module ChefZero + module Endpoints + # GET /organizations/ORG/users/USER/keys/NAME + class OrganizationUserKeyEndpoint < RestBase + def get(request) + # 404 if not a member of the org + get_data(request, request.rest_path[0..3]) + # Just use the /users/USER/keys endpoint + request.rest_path = request.rest_path[2..-1] + ActorKeyEndpoint.new(server).get(request) + end + end + end +end diff --git a/lib/chef_zero/endpoints/organization_user_keys_endpoint.rb b/lib/chef_zero/endpoints/organization_user_keys_endpoint.rb new file mode 100644 index 0000000..96a84fe --- /dev/null +++ b/lib/chef_zero/endpoints/organization_user_keys_endpoint.rb @@ -0,0 +1,17 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # GET /organizations/ORG/users/USER/keys + class OrganizationUserKeysEndpoint < RestBase + def get(request) + # 404 if it doesn't exist + get_data(request, request.rest_path[0..3]) + # Just use the /users/USER/keys/key endpoint + original_path = request.rest_path + request.rest_path = request.rest_path[2..-1] + ActorKeysEndpoint.new(server).get(request, original_path) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policies_endpoint.rb b/lib/chef_zero/endpoints/policies_endpoint.rb index ddb2e9b..37493da 100644 --- a/lib/chef_zero/endpoints/policies_endpoint.rb +++ b/lib/chef_zero/endpoints/policies_endpoint.rb @@ -1,151 +1,26 @@ -require 'ffi_yajl' - -require 'chef_zero/endpoints/rest_object_endpoint' require 'chef_zero/chef_data/data_normalizer' module ChefZero module Endpoints - # /policies/:group/:name - class PoliciesEndpoint < RestObjectEndpoint - def initialize(server) - super(server, 'id') - end - + # /organizations/ORG/policies + class PoliciesEndpoint < RestBase + # GET /organizations/ORG/policies def get(request) - already_json_response(200, get_data(request)) - end - - # Right now we're allowing PUT to create. - def put(request) - error = validate(request) - return error if error - - code = - if data_store.exists?(request.rest_path) - set_data(request, request.rest_path, request.body, :data_store_exceptions) - 200 - else - name = request.rest_path[4] - data_store.create(request.rest_path[0..3], name, request.body, :create_dir) - 201 - end - already_json_response(code, request.body) - end - - def delete(request) - result = get_data(request, request.rest_path) - delete_data(request, request.rest_path, :data_store_exceptions) - already_json_response(200, result) - end - - private - - def validate(request) - req_object = validate_json(request.body) - validate_revision_id(request, req_object) || - validate_name(request, req_object) || - validate_run_list(req_object) || - validate_each_run_list_item(req_object) || - validate_cookbook_locks_collection(req_object) || - validate_each_cookbook_locks_item(req_object) - end - - def validate_json(request_body) - FFI_Yajl::Parser.parse(request_body) - # TODO: rescue parse error, return 400 - # error(400, "Must specify #{identity_keys.map { |k| k.inspect }.join(' or ')} in JSON") - end - - def validate_revision_id(request, req_object) - if !req_object.key?("revision_id") - error(400, "Field 'revision_id' missing") - elsif req_object["revision_id"].empty? - error(400, "Field 'revision_id' invalid") - elsif req_object["revision_id"].size > 255 - error(400, "Field 'revision_id' invalid") - elsif req_object["revision_id"] !~ /^[\-[:alnum:]_\.\:]+$/ - error(400, "Field 'revision_id' invalid") - end - end - - def validate_name(request, req_object) - if !req_object.key?("name") - error(400, "Field 'name' missing") - elsif req_object["name"] != (uri_policy_name = URI.decode(request.rest_path[4])) - error(400, "Field 'name' invalid : #{uri_policy_name} does not match #{req_object["name"]}") - elsif req_object["name"].size > 255 - error(400, "Field 'name' invalid") - elsif req_object["name"] !~ /^[\-[:alnum:]_\.\:]+$/ - error(400, "Field 'name' invalid") - end - end - - def validate_run_list(req_object) - if !req_object.key?("run_list") - error(400, "Field 'run_list' missing") - elsif !req_object["run_list"].kind_of?(Array) - error(400, "Field 'run_list' is not a valid run list") - end - end - - def validate_each_run_list_item(req_object) - req_object["run_list"].each do |run_list_item| - if res_400 = validate_run_list_item(run_list_item) - return res_400 - end - end - nil - end + response_data = {} + policy_names = list_data(request) + policy_names.each do |policy_name| + policy_path = request.rest_path + [policy_name] + policy_uri = build_uri(request.base_uri, policy_path) + revisions = list_data(request, policy_path + ["revisions"]) - def validate_run_list_item(run_list_item) - if !run_list_item.kind_of?(String) - error(400, "Field 'run_list' is not a valid run list") - elsif run_list_item !~ /\Arecipe\[[^\s]+::[^\s]+\]\Z/ - error(400, "Field 'run_list' is not a valid run list") + response_data[policy_name] = { + uri: policy_uri, + revisions: hashify_list(revisions) + } end - end - def validate_cookbook_locks_collection(req_object) - if !req_object.key?("cookbook_locks") - error(400, "Field 'cookbook_locks' missing") - elsif !req_object["cookbook_locks"].kind_of?(Hash) - error(400, "Field 'cookbook_locks' invalid") - end + return json_response(200, response_data) end - - def validate_each_cookbook_locks_item(req_object) - req_object["cookbook_locks"].each do |cookbook_name, lock| - if res_400 = validate_cookbook_locks_item(cookbook_name, lock) - return res_400 - end - end - nil - end - - def validate_cookbook_locks_item(cookbook_name, lock) - if !lock.kind_of?(Hash) - error(400, "cookbook_lock entries must be a JSON object") - elsif !lock.key?("identifier") - error(400, "Field 'identifier' missing") - elsif lock["identifier"].size > 255 - error(400, "Field 'identifier' invalid") - elsif !lock.key?("version") - error(400, "Field 'version' missing") - elsif lock.key?("dotted_decimal_identifier") - unless valid_version?(lock["dotted_decimal_identifier"]) - error(400, "Field 'dotted_decimal_identifier' is not a valid version") - end - end - end - - def valid_version?(version_string) - Gem::Version.new(version_string) - true - rescue ArgumentError - false - end - end end end - diff --git a/lib/chef_zero/endpoints/policy_endpoint.rb b/lib/chef_zero/endpoints/policy_endpoint.rb new file mode 100644 index 0000000..d8c1bc8 --- /dev/null +++ b/lib/chef_zero/endpoints/policy_endpoint.rb @@ -0,0 +1,24 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policies/NAME + class PolicyEndpoint < RestBase + # GET /organizations/ORG/policies/NAME + def get(request) + revisions = list_data(request, request.rest_path + ["revisions"]) + data = { revisions: hashify_list(revisions) } + return json_response(200, data) + end + + # DELETE /organizations/ORG/policies/NAME + def delete(request) + revisions = list_data(request, request.rest_path + ["revisions"]) + data = { revisions: hashify_list(revisions) } + + delete_data_dir(request, nil, :recursive) + return json_response(200, data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policy_group_endpoint.rb b/lib/chef_zero/endpoints/policy_group_endpoint.rb new file mode 100644 index 0000000..54732c8 --- /dev/null +++ b/lib/chef_zero/endpoints/policy_group_endpoint.rb @@ -0,0 +1,46 @@ +require 'ffi_yajl' +require 'chef_zero/rest_base' +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policy_groups/NAME + class PolicyGroupEndpoint < RestBase + + # GET /organizations/ORG/policy_groups/NAME + def get(request) + data = { + uri: build_uri(request.base_uri, request.rest_path), + policies: get_policy_group_policies(request) + } + json_response(200, data) + end + + # build a hash of {"some_policy_name"=>{"revision_id"=>"909c26701e291510eacdc6c06d626b9fa5350d25"}} + def get_policy_group_policies(request) + policies_revisions = {} + + policies_path = request.rest_path + ["policies"] + policy_names = list_data(request, policies_path) + policy_names.each do |policy_name| + revision = parse_json(get_data(request, policies_path + [policy_name])) + policies_revisions[policy_name] = { revision_id: revision} + end + + policies_revisions + end + + # DELETE /organizations/ORG/policy_groups/NAME + def delete(request) + policy_group_policies = get_policy_group_policies(request) + delete_data_dir(request, nil, :recursive) + + data = { + uri: build_uri(request.base_uri, request.rest_path), + policies: policy_group_policies + } + json_response(200, data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policy_group_policy_endpoint.rb b/lib/chef_zero/endpoints/policy_group_policy_endpoint.rb new file mode 100644 index 0000000..d227905 --- /dev/null +++ b/lib/chef_zero/endpoints/policy_group_policy_endpoint.rb @@ -0,0 +1,84 @@ +require 'ffi_yajl' +require 'chef_zero/rest_base' +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policy_groups/GROUP/policies/NAME + # + # in the data store, this REST path actually stores the revision ID of ${policy_name} that's currently + # associated with ${policy_group}. + class PolicyGroupPolicyEndpoint < RestBase + + # GET /organizations/ORG/policy_groups/GROUP/policies/NAME + def get(request) + policy_name = request.rest_path[5] + + # fetch /organizations/{organization}/policies/{policy_name}/revisions/{revision_id} + revision_id = parse_json(get_data(request)) + result = get_data(request, request.rest_path[0..1] + + ["policies", policy_name, "revisions", revision_id]) + result = ChefData::DataNormalizer.normalize_policy(parse_json(result), policy_name, revision_id) + json_response(200, result) + end + + # Create or update the policy document for the given policy group and policy name. If no policy group + # with the given name exists, it will be created. If no policy with the given revision_id exists, it + # will be created from the document in the request body. If a policy with that revision_id exists, the + # Chef Server simply associates that revision id with the given policy group. When successful, the + # document that was created or updated is returned. + + ## MANDATORY FIELDS AND FORMATS + # * `revision_id`: String; Must be < 255 chars, matches /^[\-[:alnum:]_\.\:]+$/ + # * `name`: String; Must match name in URI; Must be <= 255 chars, matches /^[\-[:alnum:]_\.\:]+$/ + # * `run_list`: Array + # * `run_list[i]`: Fully Qualified Recipe Run List Item + # * `cookbook_locks`: JSON Object + # * `cookbook_locks(key)`: CookbookName + # * `cookbook_locks[item]`: JSON Object, mandatory keys: "identifier", "dotted_decimal_identifier" + # * `cookbook_locks[item]["identifier"]`: varchar(255) ? + # * `cookbook_locks[item]["dotted_decimal_identifier"]` ChefCompatibleVersionNumber + + # PUT /organizations/ORG/policy_groups/GROUP/policies/NAME + def put(request) + policyfile_data = parse_json(request.body) + policy_name = request.rest_path[5] + revision_id = policyfile_data["revision_id"] + + # If the policy revision being submitted does not exist, create it. + # Storage: /organizations/ORG/policies/POLICY/revisions/REVISION + policyfile_path = request.rest_path[0..1] + ["policies", policy_name, "revisions", revision_id] + if !exists_data?(request, policyfile_path) + create_data(request, policyfile_path[0..-2], revision_id, request.body, :create_dir) + end + + # if named policy exists and the given revision ID exists, associate the revision ID with the policy + # group. + # Storage: /organizations/ORG/policies/POLICY/revisions/REVISION + response_code = exists_data?(request) ? 200 : 201 + set_data(request, nil, to_json(revision_id), :create, :create_dir) + + already_json_response(response_code, request.body) + end + + # DELETE /organizations/ORG/policy_groups/GROUP/policies/NAME + def delete(request) + # Save the existing association. + current_revision_id = parse_json(get_data(request)) + + # delete the association. + delete_data(request) + + # return the full policy document at the no-longer-associated revision. + policy_name = request.rest_path[5] + policy_path = request.rest_path[0..1] + ["policies", policy_name, + "revisions", current_revision_id] + + + full_policy_doc = parse_json(get_data(request, policy_path)) + full_policy_doc = ChefData::DataNormalizer.normalize_policy(full_policy_doc, policy_name, current_revision_id) + return json_response(200, full_policy_doc) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policy_groups_endpoint.rb b/lib/chef_zero/endpoints/policy_groups_endpoint.rb new file mode 100644 index 0000000..f17db8d --- /dev/null +++ b/lib/chef_zero/endpoints/policy_groups_endpoint.rb @@ -0,0 +1,38 @@ +require 'ffi_yajl' +require 'chef_zero/rest_base' +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policy_groups + # + # in the data store, this REST path actually stores the revision ID of ${policy_name} that's currently + # associated with ${policy_group}. + class PolicyGroupsEndpoint < RestBase + # GET /organizations/ORG/policy_groups + def get(request) + # each policy group has policies and associated revisions under + # /policy_groups/{group name}/policies/{policy name}. + response_data = {} + list_data(request).each do |group_name| + group_path = request.rest_path + [group_name] + policy_list = list_data(request, group_path + ["policies"]) + + # build the list of policies with their revision ID associated with this policy group. + policies = {} + policy_list.each do |policy_name| + revision_id = parse_json(get_data(request, group_path + ["policies", policy_name])) + policies[policy_name] = { revision_id: revision_id } + end + + response_data[group_name] = { + uri: build_uri(request.base_uri, group_path) + } + response_data[group_name][:policies] = policies unless policies.empty? + end + + json_response(200, response_data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policy_revision_endpoint.rb b/lib/chef_zero/endpoints/policy_revision_endpoint.rb new file mode 100644 index 0000000..6a77d26 --- /dev/null +++ b/lib/chef_zero/endpoints/policy_revision_endpoint.rb @@ -0,0 +1,23 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policies/NAME/revisions/REVISION + class PolicyRevisionEndpoint < RestBase + # GET /organizations/ORG/policies/NAME/revisions/REVISION + def get(request) + data = parse_json(get_data(request)) + data = ChefData::DataNormalizer.normalize_policy(data, request.rest_path[3], request.rest_path[5]) + return json_response(200, data) + end + + # DELETE /organizations/ORG/policies/NAME/revisions/REVISION + def delete(request) + policyfile_data = parse_json(get_data(request)) + policyfile_data = ChefData::DataNormalizer.normalize_policy(policyfile_data, request.rest_path[3], request.rest_path[5]) + delete_data(request) + return json_response(200, policyfile_data) + end + end + end +end diff --git a/lib/chef_zero/endpoints/policy_revisions_endpoint.rb b/lib/chef_zero/endpoints/policy_revisions_endpoint.rb new file mode 100644 index 0000000..7c20a24 --- /dev/null +++ b/lib/chef_zero/endpoints/policy_revisions_endpoint.rb @@ -0,0 +1,15 @@ +require 'chef_zero/chef_data/data_normalizer' + +module ChefZero + module Endpoints + # /organizations/ORG/policies/NAME/revisions + class PolicyRevisionsEndpoint < RestBase + # POST /organizations/ORG/policies/NAME/revisions + def post(request) + policyfile_data = parse_json(request.body) + create_data(request, request.rest_path, policyfile_data["revision_id"], request.body, :create_dir) + return already_json_response(201, request.body) + end + end + end +end diff --git a/lib/chef_zero/endpoints/principal_endpoint.rb b/lib/chef_zero/endpoints/principal_endpoint.rb index 8cad07c..64c6986 100644 --- a/lib/chef_zero/endpoints/principal_endpoint.rb +++ b/lib/chef_zero/endpoints/principal_endpoint.rb @@ -8,16 +8,20 @@ module ChefZero class PrincipalEndpoint < RestBase def get(request) name = request.rest_path[-1] + # If /organizations/ORG/users/NAME exists, use this user (only org members have precedence over clients). hey are an org member. json = get_data(request, request.rest_path[0..1] + [ 'users', name ], :nil) if json type = 'user' org_member = true else + # If /organizations/ORG/clients/NAME exists, use the client. json = get_data(request, request.rest_path[0..1] + [ 'clients', name ], :nil) if json type = 'client' org_member = true else + # If there is no client with that name, check for a user (/users/NAME) and return that with + # org_member = false. json = get_data(request, [ 'users', name ], :nil) if json type = 'user' @@ -26,13 +30,22 @@ module ChefZero end end if json - json_response(200, { + principal_data = { 'name' => name, 'type' => type, 'public_key' => FFI_Yajl::Parser.parse(json)['public_key'] || PUBLIC_KEY, 'authz_id' => '0'*32, 'org_member' => org_member - }) + } + + response_data = + if request.api_v0? + principal_data + else + { "principals" => [ principal_data ] } + end + + json_response(200, response_data) else error(404, 'Principal not found') end diff --git a/lib/chef_zero/endpoints/rest_object_endpoint.rb b/lib/chef_zero/endpoints/rest_object_endpoint.rb index 9e978b4..7e839c0 100644 --- a/lib/chef_zero/endpoints/rest_object_endpoint.rb +++ b/lib/chef_zero/endpoints/rest_object_endpoint.rb @@ -21,12 +21,11 @@ module ChefZero def put(request) # We grab the old body to trigger a 404 if it doesn't exist old_body = get_data(request) - request_json = FFI_Yajl::Parser.parse(request.body, :create_additions => false) - key = identity_keys.map { |k| request_json[k] }.select { |v| v }.first - key ||= request.rest_path[-1] + # If it's a rename, check for conflict and delete the old value - rename = key != request.rest_path[-1] - if rename + if is_rename?(request) + key = identity_key_value(request) + begin create_data(request, request.rest_path[0..-2], key, request.body, :data_store_exceptions) rescue DataStore::DataAlreadyExistsError @@ -56,8 +55,23 @@ module ChefZero return FFI_Yajl::Encoder.encode(merged_json, :pretty => true) end end + request.body end + + private + + # Get the value of the (first existing) identity key from the request body or nil + def identity_key_value(request) + request_json = parse_json(request.body) + identity_keys.map { |k| request_json[k] }.compact.first + end + + # Does this request change the value of the identity key? + def is_rename?(request) + return false unless key = identity_key_value(request) + key != request.rest_path[-1] + end end end end diff --git a/lib/chef_zero/endpoints/server_api_version_endpoint.rb b/lib/chef_zero/endpoints/server_api_version_endpoint.rb index 631f105..8ddeaba 100644 --- a/lib/chef_zero/endpoints/server_api_version_endpoint.rb +++ b/lib/chef_zero/endpoints/server_api_version_endpoint.rb @@ -7,7 +7,7 @@ module ChefZero API_VERSION = 1 def get(request) json_response(200, {"min_api_version"=>MIN_API_VERSION, "max_api_version"=>MAX_API_VERSION}, - request.api_version, API_VERSION) + request_version: request.api_version, response_version: API_VERSION) end end end diff --git a/lib/chef_zero/rest_base.rb b/lib/chef_zero/rest_base.rb index 715d705..71f6f15 100644 --- a/lib/chef_zero/rest_base.rb +++ b/lib/chef_zero/rest_base.rb @@ -5,6 +5,9 @@ require 'chef_zero/chef_data/acl_path' module ChefZero class RestBase + DEFAULT_REQUEST_VERSION = 0 + DEFAULT_RESPONSE_VERSION = 0 + def initialize(server) @server = server end @@ -16,21 +19,28 @@ module ChefZero end def check_api_version(request) - version = request.api_version - return nil if version.nil? # Not present in headers + return if request.api_version.nil? # Not present in headers + version = request.api_version.to_i + + unless version.to_s == request.api_version.to_s # Version is not an Integer + return json_response(406, + { "username" => request.requestor }, + request_version: -1, response_version: -1 + ) + end - if version.to_i.to_s != version.to_s # Version is not an Integer - return json_response(406, { "username" => request.requestor }, -1, -1) - elsif version.to_i > MAX_API_VERSION or version.to_i < MIN_API_VERSION + if version > MAX_API_VERSION || version < MIN_API_VERSION response = { "error" => "invalid-x-ops-server-api-version", "message" => "Specified version #{version} not supported", "min_api_version" => MIN_API_VERSION, "max_api_version" => MAX_API_VERSION } - return json_response(406, response, version, -1) - else - return nil + + return json_response(406, + response, + request_version: version, response_version: -1 + ) end end @@ -51,7 +61,7 @@ module ChefZero begin self.send(method, request) rescue RestErrorResponse => e - ChefZero::Log.debug("#{e.inspect}\n#{e.backtrace.join("\n")}") + ChefZero::Log.info("#{e.inspect}\n#{e.backtrace.join("\n")}") error(e.response_code, e.error) end end @@ -104,7 +114,7 @@ module ChefZero if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, rest_path)}") end end @@ -123,7 +133,7 @@ module ChefZero if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, rest_path)}") end end @@ -142,7 +152,7 @@ module ChefZero if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, rest_path)}") end end end @@ -155,13 +165,13 @@ module ChefZero if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(404, "Parent not found: #{build_uri(request.base_uri, request.rest_path)}") + raise RestErrorResponse.new(404, "Parent not found: #{build_uri(request.base_uri, rest_path)}") end rescue DataStore::DataAlreadyExistsError if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(409, "Object already exists: #{build_uri(request.base_uri, request.rest_path + [name])}") + raise RestErrorResponse.new(409, "Object already exists: #{build_uri(request.base_uri, rest_path + [name])}") end end end @@ -174,13 +184,13 @@ module ChefZero if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(404, "Parent not found: #{build_uri(request.base_uri, request.rest_path)}") + raise RestErrorResponse.new(404, "Parent not found: #{build_uri(request.base_uri, rest_path)}") end rescue DataStore::DataAlreadyExistsError if options.include?(:data_store_exceptions) raise else - raise RestErrorResponse.new(409, "Object already exists: #{build_uri(request.base_uri, request.rest_path + [name])}") + raise RestErrorResponse.new(409, "Object already exists: #{build_uri(request.base_uri, rest_path + [name])}") end end end @@ -195,26 +205,60 @@ module ChefZero data_store.exists_dir?(rest_path) end - def error(response_code, error) - json_response(response_code, {"error" => [error]}) + def error(response_code, error, opts={}) + json_response(response_code, { "error" => [ error ] }, opts) end - def json_response(response_code, json, request_version=0, response_version=0) - already_json_response(response_code, FFI_Yajl::Encoder.encode(json, :pretty => true), request_version, response_version) + # Serializes `data` to JSON and returns an Array with the + # response code, HTTP headers and JSON body. + # + # @param [Fixnum] response_code HTTP response code + # @param [Hash] data The data for the response body as a Hash + # @param [Hash] options + # @option options [Hash] :headers (see #already_json_response) + # @option options [Boolean] :pretty (true) Pretty-format the JSON + # @option options [Fixnum] :request_version (see #already_json_response) + # @option options [Fixnum] :response_version (see #already_json_response) + # + # @return (see #already_json_response) + # + def json_response(response_code, data, options={}) + options = { pretty: true }.merge(options) + do_pretty_json = !!options.delete(:pretty) # make sure we have a proper Boolean. + json = FFI_Yajl::Encoder.encode(data, pretty: do_pretty_json) + already_json_response(response_code, json, options) end def text_response(response_code, text) [response_code, {"Content-Type" => "text/plain"}, text] end - def already_json_response(response_code, json_text, request_version=0, response_version=0) - header = { "min_version" => MIN_API_VERSION.to_s, "max_version" => MAX_API_VERSION.to_s, - "request_version" => request_version.to_s, - "response_version" => response_version.to_s } - [ response_code, - { "Content-Type" => "application/json", - "X-Ops-Server-API-Version" => FFI_Yajl::Encoder.encode(header) }, - json_text ] + # Returns an Array with the response code, HTTP headers, and JSON body. + # + # @param [Fixnum] response_code The HTTP response code + # @param [String] json_text The JSON body for the response + # @param [Hash] options + # @option options [Hash] :headers ({}) HTTP headers (may override default headers) + # @option options [Fixnum] :request_version (0) Request API version + # @option options [Fixnum] :response_version (0) Response API version + # + # @return [Array(Fixnum, Hash{String => String}, String)] + # + def already_json_response(response_code, json_text, options={}) + version_header = FFI_Yajl::Encoder.encode( + "min_version" => MIN_API_VERSION.to_s, + "max_version" => MAX_API_VERSION.to_s, + "request_version" => options[:request_version] || DEFAULT_REQUEST_VERSION.to_s, + "response_version" => options[:response_version] || DEFAULT_RESPONSE_VERSION.to_s + ) + + headers = { + "Content-Type" => "application/json", + "X-Ops-Server-API-Version" => version_header + } + headers.merge!(options[:headers]) if options[:headers] + + [ response_code, headers, json_text ] end # To be called from inside rest endpoints @@ -223,12 +267,12 @@ module ChefZero # Strip off /organizations/chef if we are in single org mode if rest_path[0..1] != [ 'organizations', server.options[:single_org] ] raise "Unexpected URL #{rest_path[0..1]} passed to build_uri in single org mode" - else - "#{base_uri}/#{rest_path[2..-1].join('/')}" end - else - "#{base_uri}/#{rest_path.join('/')}" + + return self.class.build_uri(base_uri, rest_path[2..-1]) end + + self.class.build_uri(base_uri, rest_path) end def self.build_uri(base_uri, rest_path) @@ -238,5 +282,39 @@ module ChefZero def populate_defaults(request, response) response end + + def parse_json(json) + FFI_Yajl::Parser.parse(json, create_additions: false) + end + + def to_json(data) + FFI_Yajl::Encoder.encode(data, :pretty => true) + end + + def get_data_or_else(request, path, or_else_value) + if exists_data?(request, path) + parse_json(get_data(request, path)) + else + or_else_value + end + end + + def list_data_or_else(request, path, or_else_value) + if exists_data_dir?(request, path) + list_data(request, path) + else + or_else_value + end + end + + def hashify_list(list) + list.reduce({}) { |acc, obj| acc.merge( obj => {} ) } + end + + def policy_name_invalid?(name) + !name.is_a?(String) || + name.size > 255 || + name =~ /[+ !]/ + end end end diff --git a/lib/chef_zero/rest_error_response.rb b/lib/chef_zero/rest_error_response.rb index e75d427..8859650 100644 --- a/lib/chef_zero/rest_error_response.rb +++ b/lib/chef_zero/rest_error_response.rb @@ -1,11 +1,11 @@ module ChefZero class RestErrorResponse < StandardError + attr_reader :response_code, :error + def initialize(response_code, error) @response_code = response_code @error = error + super "#{response_code}: #{error}" end - - attr_reader :response_code - attr_reader :error end end diff --git a/lib/chef_zero/rest_request.rb b/lib/chef_zero/rest_request.rb index 6b37c18..c12ea31 100644 --- a/lib/chef_zero/rest_request.rb +++ b/lib/chef_zero/rest_request.rb @@ -2,6 +2,9 @@ require 'rack/request' module ChefZero class RestRequest + + ZERO = "0".freeze + def initialize(env, rest_base_prefix = []) @env = env @rest_base_prefix = rest_base_prefix @@ -11,7 +14,13 @@ module ChefZero attr_accessor :rest_base_prefix def base_uri - @base_uri ||= "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}#{env['SCRIPT_NAME']}" + # Load balancer awareness + if env['HTTP_X_FORWARDED_PROTO'] + scheme = env['HTTP_X_FORWARDED_PROTO'] + else + scheme = env['rack.url_scheme'] + end + @base_uri ||= "#{scheme}://#{env['HTTP_HOST']}#{env['SCRIPT_NAME']}" end def base_uri=(value) @@ -19,7 +28,11 @@ module ChefZero end def api_version - @env['HTTP_X_OPS_SERVER_API_VERSION'] || 0 + @env['HTTP_X_OPS_SERVER_API_VERSION'] || ZERO + end + + def api_v0? + api_version == ZERO end def requestor @@ -34,6 +47,10 @@ module ChefZero @rest_path ||= rest_base_prefix + env['PATH_INFO'].split('/').select { |part| part != "" } end + def rest_path=(rest_path) + @rest_path = rest_path + end + def body=(body) @body = body end diff --git a/lib/chef_zero/rest_router.rb b/lib/chef_zero/rest_router.rb index f2770d3..a93af8b 100644 --- a/lib/chef_zero/rest_router.rb +++ b/lib/chef_zero/rest_router.rb @@ -1,3 +1,5 @@ +require 'pp' + module ChefZero class RestRouter def initialize(routes) @@ -15,24 +17,18 @@ module ChefZero attr_accessor :not_found def call(request) - begin - ChefZero::Log.debug(request) - ChefZero::Log.debug(request.body) if request.body - - clean_path = "/" + request.rest_path.join("/") - - response = find_endpoint(clean_path).call(request) - ChefZero::Log.debug([ - "", - "--- RESPONSE (#{response[0]}) ---", - response[2], - "--- END RESPONSE ---", - ].join("\n")) - return response - rescue - ChefZero::Log.error("#{$!.inspect}\n#{$!.backtrace.join("\n")}") - [500, {"Content-Type" => "text/plain"}, "Exception raised! #{$!.inspect}\n#{$!.backtrace.join("\n")}"] + log_request(request) + + clean_path = "/" + request.rest_path.join("/") + + find_endpoint(clean_path).call(request).tap do |response| + log_response(response) end + rescue => ex + exception = "#{ex.inspect}\n#{ex.backtrace.join("\n")}" + + ChefZero::Log.error(exception) + [ 500, { "Content-Type" => "text/plain" }, "Exception raised! #{exception}" ] end private @@ -41,5 +37,36 @@ module ChefZero _, endpoint = routes.find { |route, endpoint| route.match(clean_path) } endpoint || not_found end + + def log_request(request) + ChefZero::Log.debug do + "#{request.method} /#{request.rest_path.join("/")}".tap do |msg| + next unless request.method =~ /^(POST|PUT)$/ + + if request.body.nil? || request.body.empty? + msg << " (no body)" + else + msg << [ + "", + "--- #{request.method} BODY ---", + request.body.chomp, + "--- END #{request.method} BODY ---" + ].join("\n") + end + end + end + + ChefZero::Log.debug { request.pretty_inspect } + end + + def log_response(response) + ChefZero::Log.debug { + [ "", + "--- RESPONSE (#{response[0]}) ---", + response[2].chomp, + "--- END RESPONSE ---", + ].join("\n") + } + end end end diff --git a/lib/chef_zero/rspec.rb b/lib/chef_zero/rspec.rb index 2dd9a3a..8867f37 100644 --- a/lib/chef_zero/rspec.rb +++ b/lib/chef_zero/rspec.rb @@ -67,7 +67,7 @@ module ChefZero if chef_server_options[:server_scope] != self.class.chef_server_options[:server_scope] raise "server_scope: #{chef_server_options[:server_scope]} will not be honored: it can only be set on when_the_chef_server!" end - Log.debug("Starting Chef server with options #{chef_server_options}") + Log.info("Starting Chef server with options #{chef_server_options}") ChefZero::RSpec.set_server_options(chef_server_options) @@ -125,6 +125,10 @@ module ChefZero end end + def cookbook_artifact(name, identifier, data = {}, &block) + before(chef_server_options[:server_scope]) { cookbook_artifact(name, identifier, data, &block) } + end + def data_bag(name, data, &block) before(chef_server_options[:server_scope]) { data_bag(name, data, &block) } end @@ -149,6 +153,14 @@ module ChefZero before(chef_server_options[:server_scope]) { org_member(*usernames) } end + def policy(name, data, &block) + before(chef_server_options[:server_scope]) { policy(name, data, &block) } + end + + def policy_group(name, data, &block) + before(chef_server_options[:server_scope]) { policy_group(name, data, &block) } + end + def role(name, data, &block) before(chef_server_options[:server_scope]) { role(name, data, &block) } end @@ -203,6 +215,8 @@ module ChefZero def cookbook(name, version, data = {}, options = {}, &block) with_object_path("cookbooks/#{name}") do + # If you didn't specify metadata.rb, we generate it for you. If you + # explicitly set it to nil, that means you don't want it at all. if data.has_key?('metadata.rb') if data['metadata.rb'].nil? data.delete('metadata.rb') @@ -215,6 +229,22 @@ module ChefZero end end + def cookbook_artifact(name, identifier, data = {}, &block) + with_object_path("cookbook_artifacts/#{name}") do + # If you didn't specify metadata.rb, we generate it for you. If you + # explicitly set it to nil, that means you don't want it at all. + if data.has_key?('metadata.rb') + if data['metadata.rb'].nil? + data.delete('metadata.rb') + end + else + data['metadata.rb'] = "name #{name.inspect}" + end + ChefZero::RSpec.server.load_data({ 'cookbook_artifacts' => { "#{name}-#{identifier}" => data } }, current_org) + instance_eval(&block) if block_given? + end + end + def data_bag(name, data, &block) with_object_path("data/#{name}") do ChefZero::RSpec.server.load_data({ 'data' => { name => data }}, current_org) @@ -251,6 +281,20 @@ module ChefZero ChefZero::RSpec.server.load_data({ 'members' => usernames }, current_org) end + def policy(name, version, data, &block) + with_object_path("policies/#{name}") do + ChefZero::RSpec.server.load_data({ 'policies' => { name => { version => data } } }, current_org) + instance_eval(&block) if block_given? + end + end + + def policy_group(name, data, &block) + with_object_path("policy_groups/#{name}") do + ChefZero::RSpec.server.load_data({ 'policy_groups' => { name => data } }, current_org) + instance_eval(&block) if block_given? + end + end + def role(name, data, &block) with_object_path("roles/#{name}") do ChefZero::RSpec.server.load_data({ 'roles' => { name => data } }, current_org) diff --git a/lib/chef_zero/server.rb b/lib/chef_zero/server.rb index 016f299..19744b1 100644 --- a/lib/chef_zero/server.rb +++ b/lib/chef_zero/server.rb @@ -40,13 +40,24 @@ require 'chef_zero/endpoints/rest_list_endpoint' require 'chef_zero/endpoints/authenticate_user_endpoint' require 'chef_zero/endpoints/acls_endpoint' require 'chef_zero/endpoints/acl_endpoint' -require 'chef_zero/endpoints/actors_endpoint' require 'chef_zero/endpoints/actor_endpoint' +require 'chef_zero/endpoints/actors_endpoint' +require 'chef_zero/endpoints/actor_key_endpoint' +require 'chef_zero/endpoints/organization_user_key_endpoint' +require 'chef_zero/endpoints/organization_user_default_key_endpoint' +require 'chef_zero/endpoints/organization_user_keys_endpoint' +require 'chef_zero/endpoints/actor_default_key_endpoint' +require 'chef_zero/endpoints/actor_keys_endpoint' require 'chef_zero/endpoints/cookbooks_endpoint' require 'chef_zero/endpoints/cookbook_endpoint' require 'chef_zero/endpoints/cookbook_version_endpoint' +require 'chef_zero/endpoints/cookbook_artifacts_endpoint' +require 'chef_zero/endpoints/cookbook_artifact_endpoint' +require 'chef_zero/endpoints/cookbook_artifact_identifier_endpoint' require 'chef_zero/endpoints/containers_endpoint' require 'chef_zero/endpoints/container_endpoint' +require 'chef_zero/endpoints/controls_endpoint' +require 'chef_zero/endpoints/dummy_endpoint' require 'chef_zero/endpoints/data_bags_endpoint' require 'chef_zero/endpoints/data_bag_endpoint' require 'chef_zero/endpoints/data_bag_item_endpoint' @@ -61,6 +72,7 @@ require 'chef_zero/endpoints/environment_recipes_endpoint' require 'chef_zero/endpoints/environment_role_endpoint' require 'chef_zero/endpoints/license_endpoint' require 'chef_zero/endpoints/node_endpoint' +require 'chef_zero/endpoints/nodes_endpoint' require 'chef_zero/endpoints/node_identifiers_endpoint' require 'chef_zero/endpoints/organizations_endpoint' require 'chef_zero/endpoints/organization_endpoint' @@ -70,8 +82,14 @@ require 'chef_zero/endpoints/organization_authenticate_user_endpoint' require 'chef_zero/endpoints/organization_users_endpoint' require 'chef_zero/endpoints/organization_user_endpoint' require 'chef_zero/endpoints/organization_validator_key_endpoint' -require 'chef_zero/endpoints/principal_endpoint' require 'chef_zero/endpoints/policies_endpoint' +require 'chef_zero/endpoints/policy_endpoint' +require 'chef_zero/endpoints/policy_revisions_endpoint' +require 'chef_zero/endpoints/policy_revision_endpoint' +require 'chef_zero/endpoints/policy_groups_endpoint' +require 'chef_zero/endpoints/policy_group_endpoint' +require 'chef_zero/endpoints/policy_group_policy_endpoint' +require 'chef_zero/endpoints/principal_endpoint' require 'chef_zero/endpoints/role_endpoint' require 'chef_zero/endpoints/role_environments_endpoint' require 'chef_zero/endpoints/sandboxes_endpoint' @@ -89,12 +107,13 @@ require 'chef_zero/endpoints/version_endpoint' require 'chef_zero/endpoints/server_api_version_endpoint' module ChefZero + class Server DEFAULT_OPTIONS = { - :host => '127.0.0.1', + :host => ['127.0.0.1'], :port => 8889, - :log_level => :info, + :log_level => :warn, :generate_real_keys => true, :single_org => 'chef', :ssl => false @@ -123,6 +142,7 @@ module ChefZero def port if @port @port + # If options[:port] is not an Array or an Enumerable, it is just an Integer. elsif !options[:port].respond_to?(:each) options[:port] else @@ -145,10 +165,11 @@ module ChefZero # def url sch = @options[:ssl] ? 'https' : 'http' - @url ||= if @options[:host].include?(':') - URI("#{sch}://[#{@options[:host]}]:#{port}").to_s + hosts = Array(@options[:host]) + @url ||= if hosts.first.include?(':') + URI("#{sch}://[#{hosts.first}]:#{port}").to_s else - URI("#{sch}://#{@options[:host]}:#{port}").to_s + URI("#{sch}://#{hosts.first}:#{port}").to_s end end @@ -246,12 +267,26 @@ module ChefZero # @return [Thread] # the thread the background process is running in # + def listen(hosts, port) + hosts.each do |host| + @server.listen(host, port) + end + true + rescue Errno::EADDRINUSE + ChefZero::Log.warn("Port #{port} not available") + @server.listeners.each { |l| l.close } + @server.listeners.clear + false + end + def start_background(wait = 5) @server = WEBrick::HTTPServer.new( :DoNotListen => true, :AccessLog => [], :Logger => WEBrick::Log.new(StringIO.new, 7), + :RequestTimeout => 300, :SSLEnable => options[:ssl], + :SSLOptions => ssl_opts, :SSLCertName => [ [ 'CN', WEBrick::Utils::getservername ] ], :StartCallback => proc { @running = true @@ -261,22 +296,17 @@ module ChefZero @server.mount('/', Rack::Handler::WEBrick, app) # Pick a port - if options[:port].respond_to?(:each) - options[:port].each do |port| - begin - @server.listen(options[:host], port) - @port = port - break - rescue Errno::EADDRINUSE - ChefZero::Log.info("Port #{port} in use: #{$!}") - end + # If options[:port] can be an Enumerator, an Array, or an Integer, + # we need something that can respond to .each (Enum and Array can already). + Array(options[:port]).each do |port| + if listen(Array(options[:host]), port) + @port = port + break end - if !@port - raise Errno::EADDRINUSE, "No port in :port range #{options[:port]} is available" - end - else - @server.listen(options[:host], options[:port]) - @port = options[:port] + end + if !@port + raise Errno::EADDRINUSE, + "No port in :port range #{options[:port]} is available" end # Start the server in the background @@ -443,20 +473,43 @@ module ChefZero end end - if contents['cookbooks'] - contents['cookbooks'].each_pair do |name_version, cookbook| - if name_version =~ /(.+)-(\d+\.\d+\.\d+)$/ - cookbook_data = ChefData::CookbookData.to_hash(cookbook, $1, $2) - else - cookbook_data = ChefData::CookbookData.to_hash(cookbook, name_version) + if contents['policies'] + contents['policies'].each_pair do |policy_name, policy_struct| + # data_store.create_dir(['organizations', org_name, 'policies', policy_name], "revisions", :recursive) + dejsonize_children(policy_struct).each do |revision, policy_data| + data_store.set(['organizations', org_name, 'policies', policy_name, + "revisions", revision], policy_data, :create, :create_dir) + end + end + end + + if contents['policy_groups'] + contents['policy_groups'].each_pair do |group_name, group| + group['policies'].each do |policy_name, policy_revision| + data_store.set(['organizations', org_name, 'policy_groups', group_name, 'policies', policy_name], FFI_Yajl::Encoder.encode(policy_revision['revision_id'], :pretty => true), :create, :create_dir) end - raise "No version specified" if !cookbook_data[:version] - data_store.create_dir(['organizations', org_name, 'cookbooks'], cookbook_data[:cookbook_name], :recursive) - data_store.set(['organizations', org_name, 'cookbooks', cookbook_data[:cookbook_name], cookbook_data[:version]], FFI_Yajl::Encoder.encode(cookbook_data, :pretty => true), :create) - cookbook_data.values.each do |files| - next unless files.is_a? Array - files.each do |file| - data_store.set(['organizations', org_name, 'file_store', 'checksums', file[:checksum]], get_file(cookbook, file[:path]), :create) + end + end + + %w(cookbooks cookbook_artifacts).each do |cookbook_type| + if contents[cookbook_type] + contents[cookbook_type].each_pair do |name_version, cookbook| + if cookbook_type == 'cookbook_artifacts' + name, dash, identifier = name_version.rpartition('-') + cookbook_data = ChefData::CookbookData.to_hash(cookbook, name, identifier) + elsif name_version =~ /(.+)-(\d+\.\d+\.\d+)$/ + cookbook_data = ChefData::CookbookData.to_hash(cookbook, $1, $2) + else + cookbook_data = ChefData::CookbookData.to_hash(cookbook, name_version) + end + raise "No version specified" if !cookbook_data[:version] + data_store.create_dir(['organizations', org_name, cookbook_type], cookbook_data[:cookbook_name], :recursive) + data_store.set(['organizations', org_name, cookbook_type, cookbook_data[:cookbook_name], cookbook_data[:version]], FFI_Yajl::Encoder.encode(cookbook_data, :pretty => true), :create) + cookbook_data.values.each do |files| + next unless files.is_a? Array + files.each do |file| + data_store.set(['organizations', org_name, 'file_store', 'checksums', file[:checksum]], get_file(cookbook, file[:path]), :create) + end end end end @@ -481,13 +534,13 @@ module ChefZero private - def open_source_endpoints + def endpoints result = if options[:osc_compat] # OSC-only [ [ "/organizations/*/users", ActorsEndpoint.new(self) ], [ "/organizations/*/users/*", ActorEndpoint.new(self) ], - [ "/organizations/*/authenticate_user", OrganizationAuthenticateUserEndpoint.new(self) ], + [ "/organizations/*/authenticate_user", OrganizationAuthenticateUserEndpoint.new(self) ] ] else # EC-only @@ -501,11 +554,13 @@ module ChefZero [ "/users/*/association_requests", UserAssociationRequestsEndpoint.new(self) ], [ "/users/*/association_requests/count", UserAssociationRequestsCountEndpoint.new(self) ], [ "/users/*/association_requests/*", UserAssociationRequestEndpoint.new(self) ], + [ "/users/*/keys", ActorKeysEndpoint.new(self) ], + [ "/users/*/keys/default", ActorDefaultKeyEndpoint.new(self) ], + [ "/users/*/keys/*", ActorKeyEndpoint.new(self) ], [ "/users/*/organizations", UserOrganizationsEndpoint.new(self) ], [ "/authenticate_user", AuthenticateUserEndpoint.new(self) ], [ "/system_recovery", SystemRecoveryEndpoint.new(self) ], [ "/license", LicenseEndpoint.new(self) ], - [ "/organizations", OrganizationsEndpoint.new(self) ], [ "/organizations/*", OrganizationEndpoint.new(self) ], [ "/organizations/*/_validator_key", OrganizationValidatorKeyEndpoint.new(self) ], @@ -525,11 +580,22 @@ module ChefZero end result + [ # Both + [ "/dummy", DummyEndpoint.new(self) ], [ "/organizations/*/clients", ActorsEndpoint.new(self) ], [ "/organizations/*/clients/*", ActorEndpoint.new(self) ], + [ "/organizations/*/clients/*/keys", ActorKeysEndpoint.new(self) ], + [ "/organizations/*/clients/*/keys/default", ActorDefaultKeyEndpoint.new(self) ], + [ "/organizations/*/clients/*/keys/*", ActorKeyEndpoint.new(self) ], + [ "/organizations/*/users/*/keys", OrganizationUserKeysEndpoint.new(self) ], + [ "/organizations/*/users/*/keys/default", OrganizationUserDefaultKeyEndpoint.new(self) ], + [ "/organizations/*/users/*/keys/*", OrganizationUserKeyEndpoint.new(self) ], + [ "/organizations/*/controls", ControlsEndpoint.new(self) ], [ "/organizations/*/cookbooks", CookbooksEndpoint.new(self) ], [ "/organizations/*/cookbooks/*", CookbookEndpoint.new(self) ], [ "/organizations/*/cookbooks/*/*", CookbookVersionEndpoint.new(self) ], + [ "/organizations/*/cookbook_artifacts", CookbookArtifactsEndpoint.new(self) ], + [ "/organizations/*/cookbook_artifacts/*", CookbookArtifactEndpoint.new(self) ], + [ "/organizations/*/cookbook_artifacts/*/*", CookbookArtifactIdentifierEndpoint.new(self) ], [ "/organizations/*/data", DataBagsEndpoint.new(self) ], [ "/organizations/*/data/*", DataBagEndpoint.new(self) ], [ "/organizations/*/data/*/*", DataBagItemEndpoint.new(self) ], @@ -541,10 +607,16 @@ module ChefZero [ "/organizations/*/environments/*/nodes", EnvironmentNodesEndpoint.new(self) ], [ "/organizations/*/environments/*/recipes", EnvironmentRecipesEndpoint.new(self) ], [ "/organizations/*/environments/*/roles/*", EnvironmentRoleEndpoint.new(self) ], - [ "/organizations/*/nodes", RestListEndpoint.new(self) ], + [ "/organizations/*/nodes", NodesEndpoint.new(self) ], [ "/organizations/*/nodes/*", NodeEndpoint.new(self) ], [ "/organizations/*/nodes/*/_identifiers", NodeIdentifiersEndpoint.new(self) ], - [ "/organizations/*/policies/*/*", PoliciesEndpoint.new(self) ], + [ "/organizations/*/policies", PoliciesEndpoint.new(self) ], + [ "/organizations/*/policies/*", PolicyEndpoint.new(self) ], + [ "/organizations/*/policies/*/revisions", PolicyRevisionsEndpoint.new(self) ], + [ "/organizations/*/policies/*/revisions/*", PolicyRevisionEndpoint.new(self) ], + [ "/organizations/*/policy_groups", PolicyGroupsEndpoint.new(self) ], + [ "/organizations/*/policy_groups/*", PolicyGroupEndpoint.new(self) ], + [ "/organizations/*/policy_groups/*/policies/*", PolicyGroupPolicyEndpoint.new(self) ], [ "/organizations/*/principals/*", PrincipalEndpoint.new(self) ], [ "/organizations/*/roles", RestListEndpoint.new(self) ], [ "/organizations/*/roles/*", RoleEndpoint.new(self) ], @@ -570,7 +642,7 @@ module ChefZero def app return @app if @app - router = RestRouter.new(open_source_endpoints) + router = RestRouter.new(endpoints) router.not_found = NotFoundEndpoint.new if options[:single_org] @@ -638,5 +710,16 @@ module ChefZero end value end + + ## Disable unsecure ssl + ## Ref: https://www.ruby-lang.org/en/news/2014/10/27/changing-default-settings-of-ext-openssl/ + def ssl_opts + ssl_opts = OpenSSL::SSL::OP_ALL + ssl_opts &= ~OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS if defined?(OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS) + ssl_opts |= OpenSSL::SSL::OP_NO_COMPRESSION if defined?(OpenSSL::SSL::OP_NO_COMPRESSION) + ssl_opts |= OpenSSL::SSL::OP_NO_SSLv2 if defined?(OpenSSL::SSL::OP_NO_SSLv2) + ssl_opts |= OpenSSL::SSL::OP_NO_SSLv3 if defined?(OpenSSL::SSL::OP_NO_SSLv3) + ssl_opts + end end end diff --git a/lib/chef_zero/solr/query/regexpable_query.rb b/lib/chef_zero/solr/query/regexpable_query.rb index 241e675..cebc011 100644 --- a/lib/chef_zero/solr/query/regexpable_query.rb +++ b/lib/chef_zero/solr/query/regexpable_query.rb @@ -21,8 +21,8 @@ module ChefZero end DEFAULT_FIELD = "text" - WORD_CHARACTER = "[A-Za-z0-9@._':]" - NON_WORD_CHARACTER = "[^A-Za-z0-9@._':]" + WORD_CHARACTER = "[A-Za-z0-9@._':\-]" + NON_WORD_CHARACTER = "[^A-Za-z0-9@._':\-]" end end end diff --git a/lib/chef_zero/version.rb b/lib/chef_zero/version.rb index 963b82f..d39f82c 100644 --- a/lib/chef_zero/version.rb +++ b/lib/chef_zero/version.rb @@ -1,3 +1,3 @@ module ChefZero - VERSION = '4.3.2' + VERSION = '4.7.1' end diff --git a/spec/run_oc_pedant.rb b/spec/run_oc_pedant.rb index d2d1c84..d874fb8 100644 --- a/spec/run_oc_pedant.rb +++ b/spec/run_oc_pedant.rb @@ -5,55 +5,193 @@ require 'bundler/setup' require 'chef_zero/server' require 'rspec/core' -tmpdir = nil +# This file runs oc-chef-pedant specs and is invoked by `rake pedant` +# and other Rake tasks. Run `rake -T` to list tasks. +# +# Options for oc-chef-pedant and rspec can be specified via +# ENV['PEDANT_OPTS'] and ENV['RSPEC_OPTS'], respectively. +# +# The log level can be specified via ENV['LOG_LEVEL']. +# +# Example: +# +# $ PEDANT_OPTS="--focus-users --skip-keys" \ +# > RSPEC_OPTS="--fail-fast --profile 5" \ +# > LOG_LEVEL=debug \ +# > rake pedant +# -begin - if ENV['FILE_STORE'] - require 'tmpdir' - require 'chef_zero/data_store/raw_file_store' - tmpdir = Dir.mktmpdir - data_store = ChefZero::DataStore::RawFileStore.new(tmpdir, true) - data_store = ChefZero::DataStore::DefaultFacade.new(data_store, false, false) - server = ChefZero::Server.new(:port => 8889, :single_org => false, :data_store => data_store) - server.start_background +DEFAULT_SERVER_OPTIONS = { + port: 8889, + single_org: false, +}.freeze - else - server = ChefZero::Server.new(:port => 8889, :single_org => false)#, :log_level => :debug) - server.start_background +DEFAULT_LOG_LEVEL = :warn + +def log_level + return ENV['LOG_LEVEL'].downcase.to_sym if ENV['LOG_LEVEL'] + return :debug if ENV['DEBUG'] + DEFAULT_LOG_LEVEL +end + +def start_chef_server(opts={}) + opts = DEFAULT_SERVER_OPTIONS.merge(opts) + opts[:log_level] = log_level + + ChefZero::Server.new(opts).tap {|server| server.start_background } +end + +def start_cheffs_server(chef_repo_path) + require 'chef/version' + require 'chef/config' + require 'chef/chef_fs/config' + require 'chef/chef_fs/chef_fs_data_store' + require 'chef_zero/server' + + Dir.mkdir(chef_repo_path) if !File.exists?(chef_repo_path) + + # 11.6 and below had a bug where it couldn't create the repo children automatically + if Chef::VERSION.to_f < 11.8 + %w(clients cookbooks data_bags environments nodes roles users).each do |child| + Dir.mkdir("#{chef_repo_path}/#{child}") if !File.exists?("#{chef_repo_path}/#{child}") + end end + # Start the new server + Chef::Config.repo_mode = 'hosted_everything' + Chef::Config.chef_repo_path = chef_repo_path + Chef::Config.versioned_cookbooks = true + chef_fs_config = Chef::ChefFS::Config.new + + data_store = Chef::ChefFS::ChefFSDataStore.new(chef_fs_config.local_fs, chef_fs_config.chef_config) + data_store = ChefZero::DataStore::V1ToV2Adapter.new(data_store, 'pedant-testorg') + data_store = ChefZero::DataStore::DefaultFacade.new(data_store, 'pedant-testorg', false) + data_store.create(%w(organizations pedant-testorg users), 'pivotal', '{}') + data_store.set(%w(organizations pedant-testorg groups admins), '{ "users": [ "pivotal" ] }') + data_store.set(%w(organizations pedant-testorg groups users), '{ "users": [ "pivotal" ] }') + + start_chef_server(data_store: data_store) +end + +def pedant_args_from_env + args_from_env('PEDANT_OPTS') +end + +def rspec_args_from_env + args_from_env('RSPEC_OPTS') +end + +def args_from_env(key) + return [] unless ENV[key] + ENV[key].split +end + +begin + tmpdir = nil + server = + if ENV['FILE_STORE'] + require 'tmpdir' + require 'chef_zero/data_store/raw_file_store' + tmpdir = Dir.mktmpdir + data_store = ChefZero::DataStore::RawFileStore.new(tmpdir, true) + data_store = ChefZero::DataStore::DefaultFacade.new(data_store, false, false) + + start_chef_server(data_store: data_store) + + elsif ENV['CHEF_FS'] + require 'tmpdir' + tmpdir = Dir.mktmpdir + start_cheffs_server(tmpdir) + + else + start_chef_server + end + require 'rspec/core' require 'pedant' require 'pedant/organization' - #Pedant::Config.rerun = true + # Pedant::Config.rerun = true Pedant.config.suite = 'api' - Pedant.config.internal_server = 'http://localhost:8889' + Pedant.config[:config_file] = 'spec/support/oc_pedant.rb' - Pedant.config[:server_api_version] = 0 - Pedant.setup([ - '--skip-knife', - '--skip-keys', - '--skip-controls', - '--skip-acl', + + # Because ChefFS can only ever have one user (pivotal), we can't do most of the + # tests that involve multiple + chef_fs_skips = if ENV['CHEF_FS'] + [ '--skip-association', + '--skip-users', + '--skip-organizations', + '--skip-multiuser', + '--skip-user-keys', + + # chef-zero has some non-removable quirks, such as the fact that files + # with 255-character names cannot be stored in local mode. This is + # reserved only for quirks that are *irrevocable* and by design; and + # should barely be used at all. + '--skip-chef-zero-quirks', + ] + else + [] + end + # The latest released Chef doesn't do ACLs, Cookbook Artifacts or Policies yet + chef_fs_skips << '--skip-acl' + chef_fs_skips << '--skip-cookbook-artifacts' + chef_fs_skips << '--skip-policies' + + # Multi-keys don't work prior to 12.8 + unless Gem::Requirement.new(">= 12.8.0").satisfied_by?(Gem::Version.new(Chef::VERSION)) + chef_fs_skips << '--skip-keys' + end + + # These things aren't supported by Chef Zero in any mode of operation: + default_skips = [ + # "the goal is that only authorization, authentication and validation tests + # are turned off" - @jkeiser + # + # ...but we're not there yet + + # Chef Zero does not intend to support validation the way erchef does. '--skip-validation', + + # Chef Zero does not intend to support authentication the way erchef does. '--skip-authentication', + + # Chef Zero does not intend to support authorization the way erchef does. '--skip-authorization', + + # Omnibus tests depend on erchef features that are specific to erchef and + # bundled in the omnibus package. Currently the only test in this category + # is for the search reindexing script. '--skip-omnibus', + + # USAGs (user-specific association groups) are Authz groups that contain + # only one user and represent that user's association with an org. Though + # there are good reasons for them, they don't work well in practice and + # only the manage console really uses them. Since Chef Zero + Manage is a + # quite unusual configuration, we're ignoring them. '--skip-usags', - '--exclude-internal-orgs', - '--skip-headers', # Chef 12 features not yet 100% supported by Chef Zero - '--skip-policies', - '--skip-cookbook-artifacts', - '--skip-containers', - '--skip-api-v1' - ]) + # The universe endpoint is unlikely to ever make sense for Chef Zero + '--skip-universe', + ] + + # The knife tests are very slow and don't give us a lot of extra coverage, + # so we run them in a different entry in the travis test matrix. + pedant_args = + if ENV["PEDANT_KNIFE_TESTS"] + default_skips + %w{ --focus-knife } + else + default_skips + chef_fs_skips + %w{ --skip-knife } + end + + Pedant.setup(pedant_args + pedant_args_from_env) - result = RSpec::Core::Runner.run(Pedant.config.rspec_args) + rspec_args = Pedant.config.rspec_args + rspec_args_from_env + result = RSpec::Core::Runner.run(rspec_args) server.stop if server.running? ensure diff --git a/spec/server_spec.rb b/spec/server_spec.rb index d46084f..123a13e 100644 --- a/spec/server_spec.rb +++ b/spec/server_spec.rb @@ -27,6 +27,10 @@ describe ChefZero::Server do expect { ChefZero::Server.new(:port => 8889.upto(8889)).start_background }.to raise_error Errno::EADDRINUSE end + it 'has a very patient request timeout' do + expect(@server.server.config[:RequestTimeout]).to eq 300 + end + context 'accept headers' do def get_nodes(accepts) uri = URI(@server.url) diff --git a/spec/support/oc_pedant.rb b/spec/support/oc_pedant.rb index e3bcf27..6e39878 100644 --- a/spec/support/oc_pedant.rb +++ b/spec/support/oc_pedant.rb @@ -45,6 +45,16 @@ maximum_search_time 0 # # to be enabled for Pedant tests to work correctly explicit_port_url true +server_api_version 0 + +internal_server chef_server + +# see dummy_endpoint.rb for details. +search_server chef_server +search_commit_url "/dummy" +search_url_fmt "/dummy?fq=+X_CHEF_type_CHEF_X:%{type}&q=%{query}&wt=json" + + # We're starting to break tests up into groups based on different # criteria. The proper API tests (the results of which are viewable # to OPC customers) should be the only ones run by Pedant embedded in @@ -59,9 +69,12 @@ explicit_port_url true # value. include_internal false -# This is the bit that is different from pedant.rb -org({:name => "pedant-testorg", - :create_me => true}) +key = 'spec/support/stickywicket.pem' + +org(name: "pedant-testorg", + create_me: !ENV['CHEF_FS'], + validator_key: key) + internal_account_url chef_server delete_org true @@ -72,11 +85,16 @@ delete_org true # are using pre-existing users, you must supply a ':key_file' key, # which should be the fully-qualified path /on the machine Pedant is # running on/ to a private key for that user. -key = 'spec/support/stickywicket.pem' superuser_name 'pivotal' superuser_key key webui_key key +def cheffs_or_else_user(value) + ENV['CHEF_FS'] ? "pivotal" : value +end + +keyfile_maybe = ENV['CHEF_FS'] ? { key_file: key } : { key_file: nil } + requestors({ :clients => { # The the admin user, for the purposes of getting things rolling @@ -102,24 +120,26 @@ requestors({ :users => { # An administrator in the testing organization :admin => { - :name => "pedant_admin_user", - :create_me => true, + :name => cheffs_or_else_user("pedant_admin_user"), + :create_me => !ENV['CHEF_FS'], + :associate => !ENV['CHEF_FS'], :create_knife => true - }, + }.merge(keyfile_maybe), :non_admin => { - :name => "pedant_user", - :create_me => true, + :name => cheffs_or_else_user("pedant_user"), + :create_me => !ENV['CHEF_FS'], + :associate => !ENV['CHEF_FS'], :create_knife => true - }, + }.merge(keyfile_maybe), # A user that is not a member of the testing organization :bad => { - :name => "pedant-nobody", - :create_me => true, + :name => cheffs_or_else_user("pedant-nobody"), + :create_me => !ENV['CHEF_FS'], :create_knife => true, :associate => false - }, + }.merge(keyfile_maybe), } }) |