diff options
author | jkeiser <jkeiser@opscode.com> | 2012-12-19 23:22:54 -0800 |
---|---|---|
committer | jkeiser <jkeiser@opscode.com> | 2012-12-19 23:23:21 -0800 |
commit | 666374b272a8851a2c57530a71a6183d4d06a648 (patch) | |
tree | b53efd8134aa36ab0d459f3e4e87e1a6bbd1fe21 /lib | |
download | chef-zero-666374b272a8851a2c57530a71a6183d4d06a648.tar.gz |
Initial commit (moved/split up from jk/tiny-chef-server branch of jkeiser/chef)
Diffstat (limited to 'lib')
46 files changed, 2093 insertions, 0 deletions
diff --git a/lib/chef_zero.rb b/lib/chef_zero.rb new file mode 100644 index 0000000..2e91115 --- /dev/null +++ b/lib/chef_zero.rb @@ -0,0 +1,5 @@ +module ChefZero + CERTIFICATE = "-----BEGIN CERTIFICATE-----\nMIIDMzCCApygAwIBAgIBATANBgkqhkiG9w0BAQUFADCBnjELMAkGA1UEBhMCVVMx\nEzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxFjAUBgNVBAoM\nDU9wc2NvZGUsIEluYy4xHDAaBgNVBAsME0NlcnRpZmljYXRlIFNlcnZpY2UxMjAw\nBgNVBAMMKW9wc2NvZGUuY29tL2VtYWlsQWRkcmVzcz1hdXRoQG9wc2NvZGUuY29t\nMB4XDTEyMTEyMTAwMzQyMVoXDTIyMTExOTAwMzQyMVowgZsxEDAOBgNVBAcTB1Nl\nYXR0bGUxEzARBgNVBAgTCldhc2hpbmd0b24xCzAJBgNVBAYTAlVTMRwwGgYDVQQL\nExNDZXJ0aWZpY2F0ZSBTZXJ2aWNlMRYwFAYDVQQKEw1PcHNjb2RlLCBJbmMuMS8w\nLQYDVQQDFCZVUkk6aHR0cDovL29wc2NvZGUuY29tL0dVSURTL3VzZXJfZ3VpZDCC\nASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANLDmPbR71bS2esZlZh/HfC6\n0azXFjl2677wq2ovk9xrUb0Ui4ZLC66TqQ9C/RBzOjXU4TRf3hgPTqvlCgHusl0d\nIcLCrsSl6kPEhJpYWWfRoroIAwf82A9yLQekhqXZEXu5EKkwoUMqyF6m0ZCasaE1\ny8niQxdLAsk3ady/CGQlFqHTPKFfU5UASR2LRtYC1MCIvJHDFRKAp9kPJbQo9P37\nZ8IU7cDudkZFgNLmDixlWsh7C0ghX8fgAlj1P6FgsFufygam973k79GhIP54dELB\nc0S6E8ekkRSOXU9jX/IoiXuFglBvFihAdhvED58bMXzj2AwXUyeAlxItnvs+NVUC\nAwEAATANBgkqhkiG9w0BAQUFAAOBgQBkFZRbMoywK3hb0/X7MXmPYa7nlfnd5UXq\nr2n32ettzZNmEPaI2d1j+//nL5qqhOlrWPS88eKEPnBOX/jZpUWOuAAddnrvFzgw\nrp/C2H7oMT+29F+5ezeViLKbzoFYb4yECHBoi66IFXNae13yj7taMboBeUmE664G\nTB/MZpRr8g==\n-----END CERTIFICATE-----\n" + PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0sOY9tHvVtLZ6xmVmH8d\n8LrRrNcWOXbrvvCrai+T3GtRvRSLhksLrpOpD0L9EHM6NdThNF/eGA9Oq+UKAe6y\nXR0hwsKuxKXqQ8SEmlhZZ9GiuggDB/zYD3ItB6SGpdkRe7kQqTChQyrIXqbRkJqx\noTXLyeJDF0sCyTdp3L8IZCUWodM8oV9TlQBJHYtG1gLUwIi8kcMVEoCn2Q8ltCj0\n/ftnwhTtwO52RkWA0uYOLGVayHsLSCFfx+ACWPU/oWCwW5/KBqb3veTv0aEg/nh0\nQsFzRLoTx6SRFI5dT2Nf8iiJe4WCUG8WKEB2G8QPnxsxfOPYDBdTJ4CXEi2e+z41\nVQIDAQAB\n-----END PUBLIC KEY-----\n" + PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0sOY9tHvVtLZ6xmVmH8d8LrRrNcWOXbrvvCrai+T3GtRvRSL\nhksLrpOpD0L9EHM6NdThNF/eGA9Oq+UKAe6yXR0hwsKuxKXqQ8SEmlhZZ9GiuggD\nB/zYD3ItB6SGpdkRe7kQqTChQyrIXqbRkJqxoTXLyeJDF0sCyTdp3L8IZCUWodM8\noV9TlQBJHYtG1gLUwIi8kcMVEoCn2Q8ltCj0/ftnwhTtwO52RkWA0uYOLGVayHsL\nSCFfx+ACWPU/oWCwW5/KBqb3veTv0aEg/nh0QsFzRLoTx6SRFI5dT2Nf8iiJe4WC\nUG8WKEB2G8QPnxsxfOPYDBdTJ4CXEi2e+z41VQIDAQABAoIBAALhqbW2KQ+G0nPk\nZacwFbi01SkHx8YBWjfCEpXhEKRy0ytCnKW5YO+CFU2gHNWcva7+uhV9OgwaKXkw\nKHLeUJH1VADVqI4Htqw2g5mYm6BPvWnNsjzpuAp+BR+VoEGkNhj67r9hatMAQr0I\nitTvSH5rvd2EumYXIHKfz1K1SegUk1u1EL1RcMzRmZe4gDb6eNBs9Sg4im4ybTG6\npPIytA8vBQVWhjuAR2Tm+wZHiy0Az6Vu7c2mS07FSX6FO4E8SxWf8idaK9ijMGSq\nFvIS04mrY6XCPUPUC4qm1qNnhDPpOr7CpI2OO98SqGanStS5NFlSFXeXPpM280/u\nfZUA0AECgYEA+x7QUnffDrt7LK2cX6wbvn4mRnFxet7bJjrfWIHf+Rm0URikaNma\nh0/wNKpKBwIH+eHK/LslgzcplrqPytGGHLOG97Gyo5tGAzyLHUWBmsNkRksY2sPL\nuHq6pYWJNkqhnWGnIbmqCr0EWih82x/y4qxbJYpYqXMrit0wVf7yAgkCgYEA1twI\ngFaXqesetTPoEHSQSgC8S4D5/NkdriUXCYb06REcvo9IpFMuiOkVUYNN5d3MDNTP\nIdBicfmvfNELvBtXDomEUD8ls1UuoTIXRNGZ0VsZXu7OErXCK0JKNNyqRmOwcvYL\nJRqLfnlei5Ndo1lu286yL74c5rdTLs/nI2p4e+0CgYB079ZmcLeILrmfBoFI8+Y/\ngJLmPrFvXBOE6+lRV7kqUFPtZ6I3yQzyccETZTDvrnx0WjaiFavUPH27WMjY01S2\nTMtO0Iq1MPsbSrglO1as8MvjB9ldFcvp7gy4Q0Sv6XT0yqJ/S+vo8Df0m+H4UBpU\nf5o6EwBSd/UQxwtZIE0lsQKBgQCswfjX8Eg8KL/lJNpIOOE3j4XXE9ptksmJl2sB\njxDnQYoiMqVO808saHVquC/vTrpd6tKtNpehWwjeTFuqITWLi8jmmQ+gNTKsC9Gn\n1Pxf2Gb67PqnEpwQGln+TRtgQ5HBrdHiQIi+5am+gnw89pDrjjO5rZwhanAo6KPJ\n1zcPNQKBgQDxFu8v4frDmRNCVaZS4f1B6wTrcMrnibIDlnzrK9GG6Hz1U7dDv8s8\nNf4UmeMzDXjlPWZVOvS5+9HKJPdPj7/onv8B2m18+lcgTTDJBkza7R1mjL1Cje/Z\nKcVGsryKN6cjE7yCDasnA7R2rVBV/7NWeJV77bmzT5O//rW4yIfUIg==\n-----END RSA PRIVATE KEY-----\n" +end diff --git a/lib/chef_zero/data_normalizer.rb b/lib/chef_zero/data_normalizer.rb new file mode 100644 index 0000000..1dfa618 --- /dev/null +++ b/lib/chef_zero/data_normalizer.rb @@ -0,0 +1,129 @@ +require 'chef_zero' +require 'chef_zero/rest_base' + +module ChefZero + class DataNormalizer + def self.normalize_client(client, name) + client['name'] ||= name + client['admin'] ||= false + client['public_key'] ||= PUBLIC_KEY + client['validator'] ||= false + client['json_class'] ||= "Chef::ApiClient" + client['chef_type'] ||= "client" + client + end + + def self.normalize_user(user, name) + user['name'] ||= name + user['admin'] ||= false + user['public_key'] ||= PUBLIC_KEY + user + end + + def self.normalize_data_bag_item(data_bag_item, data_bag_name, id, method) + if method == 'DELETE' + # TODO SERIOUSLY, WHO DOES THIS MANY EXCEPTIONS IN THEIR INTERFACE + if !(data_bag_item['json_class'] == 'Chef::DataBagItem' && data_bag_item['raw_data']) + data_bag_item['id'] ||= id + data_bag_item = { 'raw_data' => data_bag_item } + data_bag_item['chef_type'] ||= 'data_bag_item' + data_bag_item['json_class'] ||= 'Chef::DataBagItem' + data_bag_item['data_bag'] ||= data_bag_name + data_bag_item['name'] ||= "data_bag_item_#{data_bag_name}_#{id}" + end + else + # If it's not already wrapped with raw_data, wrap it. + if data_bag_item['json_class'] == 'Chef::DataBagItem' && data_bag_item['raw_data'] + data_bag_item = data_bag_item['raw_data'] + end + # Argh. We don't do this on GET, but we do on PUT and POST???? + if %w(PUT POST).include?(method) + data_bag_item['chef_type'] ||= 'data_bag_item' + data_bag_item['data_bag'] ||= data_bag_name + end + data_bag_item['id'] ||= id + end + data_bag_item + end + + def self.normalize_environment(environment, name) + environment['name'] ||= name + environment['description'] ||= '' + environment['cookbook_versions'] ||= {} + environment['json_class'] ||= "Chef::Environment" + environment['chef_type'] ||= "environment" + environment['default_attributes'] ||= {} + environment['override_attributes'] ||= {} + environment + end + + def self.normalize_cookbook(cookbook, name, version, base_uri, method) + # TODO I feel dirty + if method != 'PUT' + cookbook.each_pair do |key, value| + if value.is_a?(Array) + value.each do |file| + if file.is_a?(Hash) && file.has_key?('checksum') + file['url'] ||= RestBase::build_uri(base_uri, ['file_store', file['checksum']]) + end + end + end + 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 + cookbook['json_class'] ||= 'Chef::CookbookVersion' + cookbook['chef_type'] ||= 'cookbook_version' + cookbook['frozen?'] ||= false + cookbook['metadata'] ||= {} + cookbook['metadata']['version'] ||= version + cookbook['metadata']['name'] ||= name + cookbook + end + + def self.normalize_node(node, name) + node['name'] ||= name + node['json_class'] ||= 'Chef::Node' + node['chef_type'] ||= 'node' + node['chef_environment'] ||= '_default' + node['override'] ||= {} + node['normal'] ||= {} + node['default'] ||= {} + node['automatic'] ||= {} + node['run_list'] ||= [] + node['run_list'] = normalize_run_list(node['run_list']) + node + end + + def self.normalize_role(role, name) + role['name'] ||= name + role['description'] ||= '' + role['json_class'] ||= 'Chef::Role' + role['chef_type'] ||= 'role' + role['default_attributes'] ||= {} + role['override_attributes'] ||= {} + role['run_list'] ||= [] + role['run_list'] = normalize_run_list(role['run_list']) + role['env_run_lists'] ||= {} + role['env_run_lists'].each_pair do |env, run_list| + role['env_run_lists'][env] = normalize_run_list(run_list) + end + role + end + + def self.normalize_run_list(run_list) + run_list.map{|item| + case item + when /^recipe\[.*\]$/ + item # explicit recipe + when /^role\[.*\]$/ + item # explicit role + else + "recipe[#{item}]" + end + }.uniq + end + end +end diff --git a/lib/chef_zero/endpoints/actor_endpoint.rb b/lib/chef_zero/endpoints/actor_endpoint.rb new file mode 100644 index 0000000..467422b --- /dev/null +++ b/lib/chef_zero/endpoints/actor_endpoint.rb @@ -0,0 +1,68 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /clients/* and /users/* + class ActorEndpoint < RestObjectEndpoint + def put(request) + # Find out if we're updating the public key. + request_body = JSON.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 + request_body.delete('public_key') + else + updating_public_key = true + end + + # Generate private_key if requested. + if request_body.has_key?('private_key') + body_modified = true + if request_body['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 = JSON.pretty_generate(request_body) if body_modified + + # PUT /clients is patchy + request.body = patch_request_body(request) + + result = super(request) + + # Inject private_key into response, delete public_key/password if applicable + if result[0] == 200 + response = JSON.parse(result[2], :create_additions => false) + response['private_key'] = private_key if private_key + response.delete('public_key') if !updating_public_key && request.rest_path[0] == 'users' + response.delete('password') + # For PUT /clients, a rename returns 201. + if request_body['name'] && request.rest_path[1] != request_body['name'] + json_response(201, response) + else + json_response(200, response) + end + else + result + end + end + + def populate_defaults(request, response_json) + response = JSON.parse(response_json, :create_additions => false) + if request.rest_path[0] == 'clients' + response = DataNormalizer.normalize_client(response, request.rest_path[1]) + else + response = DataNormalizer.normalize_user(response, request.rest_path[1]) + end + JSON.pretty_generate(response) + end + end + end +end + diff --git a/lib/chef_zero/endpoints/actors_endpoint.rb b/lib/chef_zero/endpoints/actors_endpoint.rb new file mode 100644 index 0000000..52908d2 --- /dev/null +++ b/lib/chef_zero/endpoints/actors_endpoint.rb @@ -0,0 +1,32 @@ +require 'json' +require 'chef_zero/endpoints/rest_list_endpoint' + +module ChefZero + module Endpoints + # /clients or /users + class ActorsEndpoint < RestListEndpoint + def post(request) + # First, find out if the user actually posted a public key. If not, make + # one. + request_body = JSON.parse(request.body, :create_additions => false) + public_key = request_body['public_key'] + if !public_key + private_key, public_key = server.gen_key_pair + request_body['public_key'] = public_key + request.body = JSON.pretty_generate(request_body) + end + + result = super(request) + if result[0] == 201 + # If we generated a key, stuff it in the response. + response = JSON.parse(result[2], :create_additions => false) + response['private_key'] = private_key if private_key + response['public_key'] = public_key + json_response(201, response) + else + result + end + end + end + end +end diff --git a/lib/chef_zero/endpoints/authenticate_user_endpoint.rb b/lib/chef_zero/endpoints/authenticate_user_endpoint.rb new file mode 100644 index 0000000..ce044c7 --- /dev/null +++ b/lib/chef_zero/endpoints/authenticate_user_endpoint.rb @@ -0,0 +1,21 @@ +require 'json' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /authenticate_user + class AuthenticateUserEndpoint < RestBase + def post(request) + request_json = JSON.parse(request.body, :create_additions => false) + name = request_json['name'] + password = request_json['password'] + user = data['users'][name] + verified = user && JSON.parse(user, :create_additions => false)['password'] == password + json_response(200, { + 'name' => name, + 'verified' => !!verified + }) + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_endpoint.rb b/lib/chef_zero/endpoints/cookbook_endpoint.rb new file mode 100644 index 0000000..9334af7 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbook_endpoint.rb @@ -0,0 +1,39 @@ +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /cookbooks/NAME + class CookbookEndpoint < CookbooksBase + def get(request) + filter = request.rest_path[1] + case filter + when '_latest' + result = {} + filter_cookbooks(data['cookbooks'], {}, 1) do |name, versions| + if versions.size > 0 + result[name] = build_uri(request.base_uri, ['cookbooks', name, versions[0]]) + end + end + json_response(200, result) + when '_recipes' + result = [] + filter_cookbooks(data['cookbooks'], {}, 1) do |name, versions| + if versions.size > 0 + cookbook = JSON.parse(data['cookbooks'][name][versions[0]], :create_additions => false) + result += recipe_names(name, cookbook) + end + end + json_response(200, result.sort) + else + cookbook_list = { filter => get_data(request, request.rest_path) } + json_response(200, format_cookbooks_list(request, cookbook_list)) + end + end + + def latest_version(versions) + sorted = versions.sort_by { |version| Chef::Version.new(version) } + sorted[-1] + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbook_version_endpoint.rb b/lib/chef_zero/endpoints/cookbook_version_endpoint.rb new file mode 100644 index 0000000..1ff74e8 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbook_version_endpoint.rb @@ -0,0 +1,102 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/rest_error_response' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /cookbooks/NAME/VERSION + class CookbookVersionEndpoint < RestObjectEndpoint + def get(request) + if request.rest_path[2] == "_latest" + request.rest_path[2] = latest_version(get_data(request, request.rest_path[0..1]).keys) + end + super(request) + end + + def put(request) + name = request.rest_path[1] + version = request.rest_path[2] + data['cookbooks'][name] = {} if !data['cookbooks'][name] + existing_cookbook = data['cookbooks'][name][version] + + # Honor frozen + if existing_cookbook + existing_cookbook_json = JSON.parse(existing_cookbook, :create_additions => false) + if existing_cookbook_json['frozen?'] + if request.query_params['force'] != "true" + raise RestErrorResponse.new(409, "The cookbook #{name} at version #{version} is frozen. Use the 'force' option to override.") + end + # For some reason, you are forever unable to modify "frozen?" on a frozen cookbook. + request_body = JSON.parse(request.body, :create_additions => false) + if !request_body['frozen?'] + request_body['frozen?'] = true + request.body = JSON.pretty_generate(request_body) + end + end + end + + # Set the cookbook + data['cookbooks'][name][version] = request.body + + # If the cookbook was updated, check for deleted files and clean them up + if existing_cookbook + missing_checksums = get_checksums(existing_cookbook) - get_checksums(request.body) + if missing_checksums.size > 0 + hoover_unused_checksums(missing_checksums) + end + end + + already_json_response(existing_cookbook ? 200 : 201, populate_defaults(request, data['cookbooks'][name][version])) + end + + def delete(request) + deleted_cookbook = get_data(request, request.rest_path) + response = super(request) + cookbook_name = request.rest_path[1] + data['cookbooks'].delete(cookbook_name) if data['cookbooks'][cookbook_name].size == 0 + + # Hoover deleted files, if they exist + hoover_unused_checksums(get_checksums(deleted_cookbook)) + response + end + + def get_checksums(cookbook) + result = [] + JSON.parse(cookbook, :create_additions => false).each_pair do |key, value| + if value.is_a?(Array) + value.each do |file| + if file.is_a?(Hash) && file.has_key?('checksum') + result << file['checksum'] + end + end + end + end + result + end + + def hoover_unused_checksums(deleted_checksums) + data['cookbooks'].each_pair do |cookbook_name, versions| + versions.each_pair do |cookbook_version, cookbook| + deleted_checksums = deleted_checksums - get_checksums(cookbook) + end + end + deleted_checksums.each do |checksum| + data['file_store'].delete(checksum) + end + end + + def populate_defaults(request, response_json) + # Inject URIs into each cookbook file + cookbook = JSON.parse(response_json, :create_additions => false) + cookbook = DataNormalizer.normalize_cookbook(cookbook, request.rest_path[1], request.rest_path[2], request.base_uri, request.method) + JSON.pretty_generate(cookbook) + end + + def latest_version(versions) + sorted = versions.sort_by { |version| Chef::Version.new(version) } + sorted[-1] + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbooks_base.rb b/lib/chef_zero/endpoints/cookbooks_base.rb new file mode 100644 index 0000000..53f7945 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbooks_base.rb @@ -0,0 +1,59 @@ +require 'json' +require 'chef/exceptions' # Needed so Chef::Version/VersionConstraint load +require 'chef/version_class' +require 'chef/version_constraint' +require 'chef_zero/rest_base' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # Common code for endpoints that return cookbook lists + class CookbooksBase < RestBase + def format_cookbooks_list(request, cookbooks_list, constraints = {}, num_versions = nil) + results = {} + filter_cookbooks(cookbooks_list, constraints, num_versions) do |name, versions| + versions_list = versions.map do |version| + { + 'url' => build_uri(request.base_uri, ['cookbooks', name, version]), + 'version' => version + } + end + results[name] = { + 'url' => build_uri(request.base_uri, ['cookbooks', name]), + 'versions' => versions_list + } + end + results + end + + def filter_cookbooks(cookbooks_list, constraints = {}, num_versions = nil) + cookbooks_list.keys.sort.each do |name| + constraint = Chef::VersionConstraint.new(constraints[name]) + versions = [] + cookbooks_list[name].keys.sort_by { |version| Chef::Version.new(version) }.reverse.each do |version| + break if num_versions && versions.size >= num_versions + if constraint.include?(version) + versions << version + end + end + yield [name, versions] + end + end + + def recipe_names(cookbook_name, cookbook) + result = [] + if cookbook['recipes'] + cookbook['recipes'].each do |recipe| + if recipe['path'] == "recipes/#{recipe['name']}" && recipe['name'][-3..-1] == '.rb' + if recipe['name'] == 'default.rb' + result << cookbook_name + end + result << "#{cookbook_name}::#{recipe['name'][0..-4]}" + end + end + end + result + end + end + end +end diff --git a/lib/chef_zero/endpoints/cookbooks_endpoint.rb b/lib/chef_zero/endpoints/cookbooks_endpoint.rb new file mode 100644 index 0000000..a595718 --- /dev/null +++ b/lib/chef_zero/endpoints/cookbooks_endpoint.rb @@ -0,0 +1,12 @@ +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /cookbooks + class CookbooksEndpoint < CookbooksBase + def get(request) + json_response(200, format_cookbooks_list(request, data['cookbooks'])) + end + end + end +end diff --git a/lib/chef_zero/endpoints/data_bag_endpoint.rb b/lib/chef_zero/endpoints/data_bag_endpoint.rb new file mode 100644 index 0000000..6f3d204 --- /dev/null +++ b/lib/chef_zero/endpoints/data_bag_endpoint.rb @@ -0,0 +1,50 @@ +require 'json' +require 'chef_zero/endpoints/rest_list_endpoint' +require 'chef_zero/endpoints/data_bag_item_endpoint' +require 'chef_zero/rest_error_response' + +module ChefZero + module Endpoints + # /data/NAME + class DataBagEndpoint < RestListEndpoint + def initialize(server) + super(server, 'id') + end + + def post(request) + key = JSON.parse(request.body, :create_additions => false)[identity_key] + response = super(request) + if response[0] == 201 + already_json_response(201, DataBagItemEndpoint::populate_defaults(request, request.body, request.rest_path[1], key)) + else + response + end + end + + def get_key(contents) + data_bag_item = JSON.parse(contents, :create_additions => false) + if data_bag_item['json_class'] == 'Chef::DataBagItem' && data_bag_item['raw_data'] + data_bag_item['raw_data']['id'] + else + data_bag_item['id'] + end + end + + def delete(request) + key = request.rest_path[1] + container = data['data'] + if !container.has_key?(key) + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + end + result = container[key] + container.delete(key) + json_response(200, { + 'chef_type' => 'data_bag', + 'json_class' => 'Chef::DataBag', + 'name' => key + }) + end + end + end +end + diff --git a/lib/chef_zero/endpoints/data_bag_item_endpoint.rb b/lib/chef_zero/endpoints/data_bag_item_endpoint.rb new file mode 100644 index 0000000..9c084a3 --- /dev/null +++ b/lib/chef_zero/endpoints/data_bag_item_endpoint.rb @@ -0,0 +1,25 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/endpoints/data_bag_item_endpoint' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /data/NAME/NAME + class DataBagItemEndpoint < RestObjectEndpoint + def initialize(server) + super(server, 'id') + end + + def populate_defaults(request, response_json) + DataBagItemEndpoint::populate_defaults(request, response_json, request.rest_path[1], request.rest_path[2]) + end + + def self.populate_defaults(request, response_json, data_bag, data_bag_item) + response = JSON.parse(response_json, :create_additions => false) + response = DataNormalizer.normalize_data_bag_item(response, data_bag, data_bag_item, request.method) + JSON.pretty_generate(response) + end + end + end +end diff --git a/lib/chef_zero/endpoints/data_bags_endpoint.rb b/lib/chef_zero/endpoints/data_bags_endpoint.rb new file mode 100644 index 0000000..8cf015b --- /dev/null +++ b/lib/chef_zero/endpoints/data_bags_endpoint.rb @@ -0,0 +1,21 @@ +require 'json' +require 'chef_zero/endpoints/rest_list_endpoint' + +module ChefZero + module Endpoints + # /data + class DataBagsEndpoint < RestListEndpoint + def post(request) + container = get_data(request) + contents = request.body + name = JSON.parse(contents, :create_additions => false)[identity_key] + if container[name] + error(409, "Object already exists") + else + container[name] = {} + json_response(201, {"uri" => "#{build_uri(request.base_uri, request.rest_path + [name])}"}) + end + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_cookbook_endpoint.rb b/lib/chef_zero/endpoints/environment_cookbook_endpoint.rb new file mode 100644 index 0000000..3360cd5 --- /dev/null +++ b/lib/chef_zero/endpoints/environment_cookbook_endpoint.rb @@ -0,0 +1,24 @@ +require 'json' +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /environments/NAME/cookbooks/NAME + class EnvironmentCookbookEndpoint < CookbooksBase + def get(request) + cookbook_name = request.rest_path[3] + environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false) + constraints = environment['cookbook_versions'] || {} + cookbook = get_data(request, request.rest_path[2..3]) + if request.query_params['num_versions'] == 'all' + num_versions = nil + elsif request.query_params['num_versions'] + num_versions = request.query_params['num_versions'].to_i + else + num_versions = nil + end + json_response(200, format_cookbooks_list(request, { cookbook_name => cookbook }, constraints, num_versions)) + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_cookbook_versions_endpoint.rb b/lib/chef_zero/endpoints/environment_cookbook_versions_endpoint.rb new file mode 100644 index 0000000..0d601ce --- /dev/null +++ b/lib/chef_zero/endpoints/environment_cookbook_versions_endpoint.rb @@ -0,0 +1,114 @@ +require 'json' +require 'chef/exceptions' # Needed so Chef::Version/VersionConstraint load +require 'chef/version_class' +require 'chef/version_constraint' +require 'chef_zero/rest_base' +require 'chef_zero/rest_error_response' + +module ChefZero + module Endpoints + # /environments/NAME/cookbook_versions + class EnvironmentCookbookVersionsEndpoint < RestBase + def cookbooks + data['cookbooks'] + end + + def environments + data['environments'] + end + + def post(request) + # Get the list of cookbooks and versions desired by the runlist + desired_versions = {} + run_list = JSON.parse(request.body, :create_additions => false)['run_list'] + run_list.each do |run_list_entry| + if run_list_entry =~ /(.+)\@(.+)/ + raise RestErrorResponse.new(412, "No such cookbook: #{$1}") if !cookbooks[$1] + raise RestErrorResponse.new(412, "No such cookbook version for cookbook #{$1}: #{$2}") if !cookbooks[$1][$2] + desired_versions[$1] = [ $2 ] + else + raise RestErrorResponse.new(412, "No such cookbook: #{run_list_entry}") if !cookbooks[run_list_entry] + desired_versions[run_list_entry] = cookbooks[run_list_entry].keys + end + end + + # Filter by environment constraints + environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false) + environment_constraints = environment['cookbook_versions'] + + desired_versions.each_key do |name| + desired_versions = filter_by_constraint(desired_versions, name, environment_constraints[name]) + end + + # Depsolve! + solved = depsolve(desired_versions.keys, desired_versions, environment_constraints) + if !solved + return raise RestErrorResponse.new(412, "Unsolvable versions!") + end + + result = {} + solved.each_pair do |name, versions| + result[name] = JSON.parse(data['cookbooks'][name][versions[0]], :create_additions => false) + end + json_response(200, result) + end + + def depsolve(unsolved, desired_versions, environment_constraints) + return nil if desired_versions.values.any? { |versions| versions.empty? } + + # If everything is already + solve_for = unsolved[0] + return desired_versions if !solve_for + + # Go through each desired version of this cookbook, starting with the latest, + # until we find one we can solve successfully with + sort_versions(desired_versions[solve_for]).each do |desired_version| + new_desired_versions = desired_versions.clone + new_desired_versions[solve_for] = [ desired_version ] + new_unsolved = unsolved[1..-1] + + # Pick this cookbook, and add dependencies + cookbook_obj = JSON.parse(cookbooks[solve_for][desired_version], :create_additions => false) + dep_not_found = false + cookbook_obj['metadata']['dependencies'].each_pair do |dep_name, dep_constraint| + # If the dep is not already in the list, add it to the list to solve + # and bring in all environment-allowed cookbook versions to desired_versions + if !new_desired_versions.has_key?(dep_name) + new_unsolved = new_unsolved + [dep_name] + # If the dep is missing, we will try other versions of the cookbook that might not have the bad dep. + if !cookbooks[dep_name] + dep_not_found = true + break + end + new_desired_versions[dep_name] = cookbooks[dep_name].keys + new_desired_versions = filter_by_constraint(new_desired_versions, dep_name, environment_constraints[dep_name]) + end + new_desired_versions = filter_by_constraint(new_desired_versions, dep_name, dep_constraint) + end + + next if dep_not_found + + # Depsolve children with this desired version! First solution wins. + result = depsolve(new_unsolved, new_desired_versions, environment_constraints) + return result if result + end + return nil + end + + def sort_versions(versions) + result = versions.sort_by { |version| Chef::Version.new(version) } + result.reverse + end + + def filter_by_constraint(versions, cookbook_name, constraint) + return versions if !constraint + constraint = Chef::VersionConstraint.new(constraint) + new_versions = versions[cookbook_name] + new_versions = new_versions.select { |version| constraint.include?(version) } + result = versions.clone + result[cookbook_name] = new_versions + result + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_cookbooks_endpoint.rb b/lib/chef_zero/endpoints/environment_cookbooks_endpoint.rb new file mode 100644 index 0000000..591f43f --- /dev/null +++ b/lib/chef_zero/endpoints/environment_cookbooks_endpoint.rb @@ -0,0 +1,22 @@ +require 'json' +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /environments/NAME/cookbooks + class EnvironmentCookbooksEndpoint < CookbooksBase + def get(request) + environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false) + constraints = environment['cookbook_versions'] || {} + if request.query_params['num_versions'] == 'all' + num_versions = nil + elsif request.query_params['num_versions'] + num_versions = request.query_params['num_versions'].to_i + else + num_versions = 1 + end + json_response(200, format_cookbooks_list(request, data['cookbooks'], constraints, num_versions)) + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_endpoint.rb b/lib/chef_zero/endpoints/environment_endpoint.rb new file mode 100644 index 0000000..a418e78 --- /dev/null +++ b/lib/chef_zero/endpoints/environment_endpoint.rb @@ -0,0 +1,33 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /environments/NAME + class EnvironmentEndpoint < RestObjectEndpoint + def delete(request) + if request.rest_path[1] == "_default" + # 405, really? + error(405, "The '_default' environment cannot be modified.") + else + super(request) + end + end + + def put(request) + if request.rest_path[1] == "_default" + error(405, "The '_default' environment cannot be modified.") + else + super(request) + end + end + + def populate_defaults(request, response_json) + response = JSON.parse(response_json, :create_additions => false) + response = DataNormalizer.normalize_environment(response, request.rest_path[1]) + JSON.pretty_generate(response) + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_nodes_endpoint.rb b/lib/chef_zero/endpoints/environment_nodes_endpoint.rb new file mode 100644 index 0000000..31a4044 --- /dev/null +++ b/lib/chef_zero/endpoints/environment_nodes_endpoint.rb @@ -0,0 +1,23 @@ +require 'json' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /environment/NAME/nodes + class EnvironmentNodesEndpoint < RestBase + def get(request) + # 404 if environment does not exist + get_data(request, request.rest_path[0..1]) + + result = {} + data['nodes'].each_pair do |name, node| + node_json = JSON.parse(node, :create_additions => false) + if node['chef_environment'] == request.rest_path[1] + result[name] = build_uri(request.base_uri, 'nodes', name) + end + end + json_response(200, result) + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_recipes_endpoint.rb b/lib/chef_zero/endpoints/environment_recipes_endpoint.rb new file mode 100644 index 0000000..0bbaa8b --- /dev/null +++ b/lib/chef_zero/endpoints/environment_recipes_endpoint.rb @@ -0,0 +1,22 @@ +require 'json' +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /environment/NAME/recipes + class EnvironmentRecipesEndpoint < CookbooksBase + def get(request) + environment = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false) + constraints = environment['cookbook_versions'] || {} + result = [] + filter_cookbooks(data['cookbooks'], constraints, 1) do |name, versions| + if versions.size > 0 + cookbook = JSON.parse(data['cookbooks'][name][versions[0]], :create_additions => false) + result += recipe_names(name, cookbook) + end + end + json_response(200, result.sort) + end + end + end +end diff --git a/lib/chef_zero/endpoints/environment_role_endpoint.rb b/lib/chef_zero/endpoints/environment_role_endpoint.rb new file mode 100644 index 0000000..94be3ab --- /dev/null +++ b/lib/chef_zero/endpoints/environment_role_endpoint.rb @@ -0,0 +1,35 @@ +require 'json' +require 'chef_zero/endpoints/cookbooks_base' + +module ChefZero + module Endpoints + # /environments/NAME/roles/NAME + # /roles/NAME/environments/NAME + class EnvironmentRoleEndpoint < CookbooksBase + def get(request) + # 404 if environment does not exist + if request.rest_path[0] == 'environments' + environment_path = request.rest_path[0..1] + role_path = request.rest_path[2..3] + else + environment_path = request.rest_path[2..3] + role_path = request.rest_path[0..1] + end + get_data(request, environment_path) + + role = JSON.parse(get_data(request, role_path), :create_additions => false) + environment_name = environment_path[1] + if environment_name == '_default' + run_list = role['run_list'] + else + if role['env_run_lists'] + run_list = role['env_run_lists'][environment_name] + else + run_list = nil + end + end + json_response(200, { 'run_list' => run_list }) + end + end + end +end diff --git a/lib/chef_zero/endpoints/file_store_file_endpoint.rb b/lib/chef_zero/endpoints/file_store_file_endpoint.rb new file mode 100644 index 0000000..98cea4d --- /dev/null +++ b/lib/chef_zero/endpoints/file_store_file_endpoint.rb @@ -0,0 +1,22 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # The minimum amount of S3 necessary to support cookbook upload/download + # /file_store/FILE + class FileStoreFileEndpoint < RestBase + def json_only + false + end + + def get(request) + [200, {"Content-Type" => 'application/x-binary'}, get_data(request) ] + end + + def put(request) + data['file_store'][request.rest_path[1]] = request.body + json_response(200, {}) + end + end + end +end diff --git a/lib/chef_zero/endpoints/node_endpoint.rb b/lib/chef_zero/endpoints/node_endpoint.rb new file mode 100644 index 0000000..980008f --- /dev/null +++ b/lib/chef_zero/endpoints/node_endpoint.rb @@ -0,0 +1,17 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /nodes/ID + class NodeEndpoint < RestObjectEndpoint + def populate_defaults(request, response_json) + node = JSON.parse(response_json, :create_additions => false) + node = DataNormalizer.normalize_node(node, request.rest_path[1]) + JSON.pretty_generate(node) + end + end + end +end + diff --git a/lib/chef_zero/endpoints/not_found_endpoint.rb b/lib/chef_zero/endpoints/not_found_endpoint.rb new file mode 100644 index 0000000..5625c63 --- /dev/null +++ b/lib/chef_zero/endpoints/not_found_endpoint.rb @@ -0,0 +1,9 @@ +module ChefZero + module Endpoints + class NotFoundEndpoint + def call(env) + return [404, {"Content-Type" => "application/json"}, "Object not found: #{env['REQUEST_PATH']}"] + end + end + end +end diff --git a/lib/chef_zero/endpoints/principal_endpoint.rb b/lib/chef_zero/endpoints/principal_endpoint.rb new file mode 100644 index 0000000..1833592 --- /dev/null +++ b/lib/chef_zero/endpoints/principal_endpoint.rb @@ -0,0 +1,30 @@ +require 'json' +require 'chef_zero' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /principals/NAME + class PrincipalEndpoint < RestBase + def get(request) + name = request.rest_path[-1] + json = data['users'][name] + if json + type = 'user' + else + json = data['clients'][name] + type = 'client' + end + if json + json_response(200, { + 'name' => name, + 'type' => type, + 'public_key' => JSON.parse(json)['public_key'] || PUBLIC_KEY + }) + else + error(404, 'Principal not found') + end + end + end + end +end diff --git a/lib/chef_zero/endpoints/rest_list_endpoint.rb b/lib/chef_zero/endpoints/rest_list_endpoint.rb new file mode 100644 index 0000000..46f8f88 --- /dev/null +++ b/lib/chef_zero/endpoints/rest_list_endpoint.rb @@ -0,0 +1,41 @@ +require 'json' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # Typical REST list endpoint (/roles or /data/BAG) + class RestListEndpoint < RestBase + def initialize(server, identity_key = 'name') + super(server) + @identity_key = identity_key + end + + attr_reader :identity_key + + def get(request) + # Get the result + result_hash = {} + get_data(request).keys.sort.each do |name| + result_hash[name] = "#{build_uri(request.base_uri, request.rest_path + [name])}" + end + json_response(200, result_hash) + end + + def post(request) + container = get_data(request) + contents = request.body + key = get_key(contents) + if container[key] + error(409, 'Object already exists') + else + container[key] = contents + json_response(201, {'uri' => "#{build_uri(request.base_uri, request.rest_path + [key])}"}) + end + end + + def get_key(contents) + JSON.parse(contents, :create_additions => false)[identity_key] + end + end + end +end diff --git a/lib/chef_zero/endpoints/rest_object_endpoint.rb b/lib/chef_zero/endpoints/rest_object_endpoint.rb new file mode 100644 index 0000000..bd45afe --- /dev/null +++ b/lib/chef_zero/endpoints/rest_object_endpoint.rb @@ -0,0 +1,65 @@ +require 'json' +require 'chef_zero/rest_base' +require 'chef_zero/rest_error_response' + +module ChefZero + module Endpoints + # Typical REST leaf endpoint (/roles/NAME or /data/BAG/NAME) + class RestObjectEndpoint < RestBase + def initialize(server, identity_key = 'name') + super(server) + @identity_key = identity_key + end + + attr_reader :identity_key + + def get(request) + already_json_response(200, populate_defaults(request, get_data(request))) + end + + def put(request) + # We grab the old body to trigger a 404 if it doesn't exist + old_body = get_data(request) + request_json = JSON.parse(request.body, :create_additions => false) + key = request_json[identity_key] || request.rest_path[-1] + container = get_data(request, request.rest_path[0..-2]) + # If it's a rename, check for conflict and delete the old value + rename = key != request.rest_path[-1] + if rename + if container.has_key?(key) + return error(409, "Cannot rename '#{request.rest_path[-1]}' to '#{key}': '#{key}' already exists") + end + container.delete(request.rest_path[-1]) + end + container[key] = request.body + already_json_response(200, populate_defaults(request, request.body)) + end + + def delete(request) + key = request.rest_path[-1] + container = get_data(request, request.rest_path[0..-2]) + if !container.has_key?(key) + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + end + result = container[key] + container.delete(key) + already_json_response(200, populate_defaults(request, result)) + end + + def patch_request_body(request) + container = get_data(request, request.rest_path[0..-2]) + existing_value = container[request.rest_path[-1]] + if existing_value + request_json = JSON.parse(request.body, :create_additions => false) + existing_json = JSON.parse(existing_value, :create_additions => false) + merged_json = existing_json.merge(request_json) + if merged_json.size > request_json.size + return JSON.pretty_generate(merged_json) + end + end + request.body + end + end + end +end + diff --git a/lib/chef_zero/endpoints/role_endpoint.rb b/lib/chef_zero/endpoints/role_endpoint.rb new file mode 100644 index 0000000..6a4cfd4 --- /dev/null +++ b/lib/chef_zero/endpoints/role_endpoint.rb @@ -0,0 +1,16 @@ +require 'json' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/data_normalizer' + +module ChefZero + module Endpoints + # /roles/NAME + class RoleEndpoint < RestObjectEndpoint + def populate_defaults(request, response_json) + role = JSON.parse(response_json, :create_additions => false) + role = DataNormalizer.normalize_role(role, request.rest_path[1]) + JSON.pretty_generate(role) + end + end + end +end diff --git a/lib/chef_zero/endpoints/role_environments_endpoint.rb b/lib/chef_zero/endpoints/role_environments_endpoint.rb new file mode 100644 index 0000000..327602e --- /dev/null +++ b/lib/chef_zero/endpoints/role_environments_endpoint.rb @@ -0,0 +1,14 @@ +require 'json' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /roles/NAME/environments + class RoleEnvironmentsEndpoint < RestBase + def get(request) + role = JSON.parse(get_data(request, request.rest_path[0..1]), :create_additions => false) + json_response(200, [ '_default' ] + (role['env_run_lists'].keys || [])) + end + end + end +end diff --git a/lib/chef_zero/endpoints/sandbox_endpoint.rb b/lib/chef_zero/endpoints/sandbox_endpoint.rb new file mode 100644 index 0000000..09cb180 --- /dev/null +++ b/lib/chef_zero/endpoints/sandbox_endpoint.rb @@ -0,0 +1,22 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /sandboxes/ID + class SandboxEndpoint < RestBase + def put(request) + existing_sandbox = get_data(request, request.rest_path) + data['sandboxes'].delete(request.rest_path[1]) + time_str = existing_sandbox[:create_time].strftime('%Y-%m-%dT%H:%M:%S%z') + time_str = "#{time_str[0..21]}:#{time_str[22..23]}" + json_response(200, { + :guid => request.rest_path[1], + :name => request.rest_path[1], + :checksums => existing_sandbox[:checksums], + :create_time => time_str, + :is_completed => true + }) + end + end + end +end diff --git a/lib/chef_zero/endpoints/sandboxes_endpoint.rb b/lib/chef_zero/endpoints/sandboxes_endpoint.rb new file mode 100644 index 0000000..698564f --- /dev/null +++ b/lib/chef_zero/endpoints/sandboxes_endpoint.rb @@ -0,0 +1,44 @@ +require 'json' +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /sandboxes + class SandboxesEndpoint < RestBase + def initialize(server) + super(server) + @next_id = 1 + end + + def post(request) + sandbox_checksums = [] + + needed_checksums = JSON.parse(request.body, :create_additions => false)['checksums'] + result_checksums = {} + needed_checksums.keys.each do |needed_checksum| + if data['file_store'].has_key?(needed_checksum) + result_checksums[needed_checksum] = { :needs_upload => false } + else + result_checksums[needed_checksum] = { + :needs_upload => true, + :url => build_uri(request.base_uri, ['file_store', needed_checksum]) + } + sandbox_checksums << needed_checksum + end + end + + id = @next_id.to_s + @next_id+=1 + + data['sandboxes'][id] = { :create_time => Time.now.utc, :checksums => sandbox_checksums } + + json_response(201, { + :uri => build_uri(request.base_uri, request.rest_path + [id.to_s]), + :checksums => result_checksums, + :sandbox_id => id + }) + end + end + end +end + diff --git a/lib/chef_zero/endpoints/search_endpoint.rb b/lib/chef_zero/endpoints/search_endpoint.rb new file mode 100644 index 0000000..8a66d9a --- /dev/null +++ b/lib/chef_zero/endpoints/search_endpoint.rb @@ -0,0 +1,139 @@ +require 'json' +require 'chef/mixin/deep_merge' +require 'chef_zero/endpoints/rest_object_endpoint' +require 'chef_zero/data_normalizer' +require 'chef_zero/rest_error_response' +require 'chef_zero/solr/solr_parser' +require 'chef_zero/solr/solr_doc' + +module ChefZero + module Endpoints + # /search/INDEX + class SearchEndpoint < RestBase + def get(request) + results = search(request) + results['rows'] = results['rows'].map { |name,uri,value,search_value| value } + json_response(200, results) + end + + def post(request) + full_results = search(request) + keys = JSON.parse(request.body, :create_additions => false) + partial_results = full_results['rows'].map do |name, uri, doc, search_value| + data = {} + keys.each_pair do |key, path| + if path.size > 0 + value = search_value + path.each do |path_part| + value = value[path_part] if !value.nil? + end + data[key] = value + else + data[key] = nil + end + end + { + 'url' => uri, + 'data' => data + } + end + json_response(200, { + 'rows' => partial_results, + 'start' => full_results['start'], + 'total' => full_results['total'] + }) + end + + private + + def search_container(request, index) + case index + when 'client' + [ data['clients'], Proc.new { |client, name| DataNormalizer.normalize_client(client, name) }, build_uri(request.base_uri, [ 'clients' ]) ] + when 'node' + [ data['nodes'], Proc.new { |node, name| DataNormalizer.normalize_node(node, name) }, build_uri(request.base_uri, [ 'nodes' ]) ] + when 'environment' + [ data['environments'], Proc.new { |environment, name| DataNormalizer.normalize_environment(environment, name) }, build_uri(request.base_uri, [ 'environments' ]) ] + when 'role' + [ data['roles'], Proc.new { |role, name| DataNormalizer.normalize_role(role, name) }, build_uri(request.base_uri, [ 'roles' ]) ] + else + [ data['data'][index], Proc.new { |data_bag_item, id| DataNormalizer.normalize_data_bag_item(data_bag_item, index, id, 'DELETE') }, build_uri(request.base_uri, [ 'data', index ]) ] + end + end + + def expand_for_indexing(value, index, id) + if index == 'node' + result = {} + Chef::Mixin::DeepMerge.deep_merge!(value['default'] || {}, result) + Chef::Mixin::DeepMerge.deep_merge!(value['normal'] || {}, result) + Chef::Mixin::DeepMerge.deep_merge!(value['override'] || {}, result) + Chef::Mixin::DeepMerge.deep_merge!(value['automatic'] || {}, result) + result['recipe'] = [] + result['role'] = [] + if value['run_list'] + value['run_list'].each do |run_list_entry| + if run_list_entry =~ /^(recipe|role)\[(.*)\]/ + result[$1] << $2 + end + end + end + value.each_pair do |key, value| + result[key] = value unless %w(default normal override automatic).include?(key) + end + result + + elsif !%w(client environment role).include?(index) + DataNormalizer.normalize_data_bag_item(value, index, id, 'GET') + else + value + end + end + + def search(request) + # Extract parameters + index = request.rest_path[1] + query_string = request.query_params['q'] || '*:*' + solr_query = ChefZero::Solr::SolrParser.new(query_string).parse + sort_string = request.query_params['sort'] + start = request.query_params['start'] + start = start.to_i if start + rows = request.query_params['rows'] + rows = rows.to_i if rows + + # Get the search container + container, expander, base_uri = search_container(request, index) + if container.nil? + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, request.rest_path)}") + end + + # Search! + result = [] + container.each_pair do |name,value| + expanded = expander.call(JSON.parse(value, :create_additions => false), name) + result << [ name, build_uri(base_uri, [name]), expanded, expand_for_indexing(expanded, index, name) ] + end + result = result.select do |name, uri, value, search_value| + solr_query.matches_doc?(ChefZero::Solr::SolrDoc.new(search_value, name)) + end + total = result.size + + # Sort + if sort_string + sort_key, sort_order = sort_string.split(/\s+/, 2) + result = result.sort_by { |name,uri,value,search_value| ChefZero::Solr::SolrDoc.new(search_value, name)[sort_key] } + result = result.reverse if sort_order == "DESC" + end + + # Paginate + if start + result = result[start..start+(rows||-1)] + end + { + 'rows' => result, + 'start' => start || 0, + 'total' => total + } + end + end + end +end diff --git a/lib/chef_zero/endpoints/searches_endpoint.rb b/lib/chef_zero/endpoints/searches_endpoint.rb new file mode 100644 index 0000000..d7ab451 --- /dev/null +++ b/lib/chef_zero/endpoints/searches_endpoint.rb @@ -0,0 +1,18 @@ +require 'chef_zero/rest_base' + +module ChefZero + module Endpoints + # /search + class SearchesEndpoint < RestBase + def get(request) + # Get the result + result_hash = {} + indices = (%w(client environment node role) + data['data'].keys).sort + indices.each do |index| + result_hash[index] = build_uri(request.base_uri, request.rest_path + [index]) + end + json_response(200, result_hash) + end + end + end +end diff --git a/lib/chef_zero/rest_base.rb b/lib/chef_zero/rest_base.rb new file mode 100644 index 0000000..068a55d --- /dev/null +++ b/lib/chef_zero/rest_base.rb @@ -0,0 +1,82 @@ +require 'chef_zero/rest_request' +require 'chef_zero/rest_error_response' + +module ChefZero + class RestBase + def initialize(server) + @server = server + end + + attr_reader :server + + def data + server.data + end + + def call(env) + begin + rest_path = env['PATH_INFO'].split('/').select { |part| part != "" } + method = env['REQUEST_METHOD'].downcase.to_sym + if !self.respond_to?(method) + accept_methods = [:get, :put, :post, :delete].select { |m| self.respond_to?(m) } + accept_methods_str = accept_methods.map { |m| m.to_s.upcase }.join(', ') + return [405, {"Content-Type" => "text/plain", "Allow" => accept_methods_str}, "Bad request method for '#{env['REQUEST_PATH']}': #{env['REQUEST_METHOD']}"] + end + if json_only && !env['HTTP_ACCEPT'].split(';').include?('application/json') + return [406, {"Content-Type" => "text/plain"}, "Must accept application/json"] + end + # Dispatch to get()/post()/put()/delete() + begin + self.send(method, RestRequest.new(env)) + rescue RestErrorResponse => e + error(e.response_code, e.error) + end + rescue + puts $!.inspect + puts $!.backtrace + raise + end + end + + def json_only + true + end + + def get_data(request, rest_path=nil) + rest_path ||= request.rest_path + # Grab the value we're looking for + value = data + rest_path.each do |path_part| + if !value.has_key?(path_part) + raise RestErrorResponse.new(404, "Object not found: #{build_uri(request.base_uri, rest_path)}") + end + value = value[path_part] + end + value + end + + def error(response_code, error) + json_response(response_code, {"error" => [error]}) + end + + def json_response(response_code, json) + already_json_response(response_code, JSON.pretty_generate(json)) + end + + def already_json_response(response_code, json_text) + [response_code, {"Content-Type" => "application/json"}, json_text] + end + + def build_uri(base_uri, rest_path) + RestBase::build_uri(base_uri, rest_path) + end + + def self.build_uri(base_uri, rest_path) + "#{base_uri}/#{rest_path.join('/')}" + end + + def populate_defaults(request, response) + response + end + end +end diff --git a/lib/chef_zero/rest_error_response.rb b/lib/chef_zero/rest_error_response.rb new file mode 100644 index 0000000..2edca25 --- /dev/null +++ b/lib/chef_zero/rest_error_response.rb @@ -0,0 +1,11 @@ +module ChefZero + class RestErrorResponse < Exception + def initialize(response_code, error) + @response_code = response_code + @error = 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 new file mode 100644 index 0000000..5df3d0f --- /dev/null +++ b/lib/chef_zero/rest_request.rb @@ -0,0 +1,42 @@ +require 'rack/request' + +module ChefZero + class RestRequest + def initialize(env) + @env = env + end + + attr_reader :env + + def base_uri + @base_uri ||= "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}#{env['SCRIPT_NAME']}" + end + + def method + @env['REQUEST_METHOD'] + end + + def rest_path + @rest_path ||= env['PATH_INFO'].split('/').select { |part| part != "" } + end + + def body=(body) + @body = body + end + + def body + @body ||= env['rack.input'].read + end + + def query_params + @query_params ||= begin + params = Rack::Request.new(env).GET + params.keys.each do |key| + params[key] = URI.unescape(params[key]) + end + params + end + end + end +end + diff --git a/lib/chef_zero/router.rb b/lib/chef_zero/router.rb new file mode 100644 index 0000000..0389c8a --- /dev/null +++ b/lib/chef_zero/router.rb @@ -0,0 +1,24 @@ +module ChefZero + class Router + def initialize(routes) + @routes = routes.map do |route, endpoint| + pattern = Regexp.new("^#{route.gsub('*', '[^/]*')}$") + [ pattern, endpoint ] + end + end + + attr_reader :routes + attr_accessor :not_found + + def call(env) + puts "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}#{env['QUERY_STRING'] != '' ? "?" + env['QUERY_STRING'] : ''}" + clean_path = "/" + env['PATH_INFO'].split('/').select { |part| part != "" }.join("/") + routes.each do |route, endpoint| + if route.match(clean_path) + return endpoint.call(env) + end + end + not_found.call(env) + end + end +end diff --git a/lib/chef_zero/server.rb b/lib/chef_zero/server.rb new file mode 100644 index 0000000..7f0b780 --- /dev/null +++ b/lib/chef_zero/server.rb @@ -0,0 +1,140 @@ +# +# Author:: John Keiser (<jkeiser@opscode.com>) +# Copyright:: Copyright (c) 2012 Opscode, Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'rubygems' +require 'webrick' +require 'rack' +require 'openssl' +require 'chef_zero' +require 'chef_zero/router' + +require 'chef_zero/endpoints/authenticate_user_endpoint' +require 'chef_zero/endpoints/actors_endpoint' +require 'chef_zero/endpoints/actor_endpoint' +require 'chef_zero/endpoints/cookbooks_endpoint' +require 'chef_zero/endpoints/cookbook_endpoint' +require 'chef_zero/endpoints/cookbook_version_endpoint' +require 'chef_zero/endpoints/data_bags_endpoint' +require 'chef_zero/endpoints/data_bag_endpoint' +require 'chef_zero/endpoints/data_bag_item_endpoint' +require 'chef_zero/endpoints/rest_list_endpoint' +require 'chef_zero/endpoints/environment_endpoint' +require 'chef_zero/endpoints/environment_cookbooks_endpoint' +require 'chef_zero/endpoints/environment_cookbook_endpoint' +require 'chef_zero/endpoints/environment_cookbook_versions_endpoint' +require 'chef_zero/endpoints/environment_nodes_endpoint' +require 'chef_zero/endpoints/environment_recipes_endpoint' +require 'chef_zero/endpoints/environment_role_endpoint' +require 'chef_zero/endpoints/node_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' +require 'chef_zero/endpoints/sandbox_endpoint' +require 'chef_zero/endpoints/searches_endpoint' +require 'chef_zero/endpoints/search_endpoint' +require 'chef_zero/endpoints/file_store_file_endpoint' +require 'chef_zero/endpoints/not_found_endpoint' + +module ChefZero + class Server < Rack::Server + def initialize(options) + options[:host] ||= "localhost" # TODO 0.0.0.0? + options[:port] ||= 80 + options[:generate_real_keys] = true if !options.has_key?(:generate_real_keys) + super(options) + @generate_real_keys = options[:generate_real_keys] + @data = { + 'clients' => { + 'chef-validator' => '{ "validator": true }', + 'chef-webui' => '{ "admin": true }' + }, + 'cookbooks' => {}, + 'data' => {}, + 'environments' => { + '_default' => '{ "description": "The default Chef environment" }' + }, + 'file_store' => {}, + 'nodes' => {}, + 'roles' => {}, + 'sandboxes' => {}, + 'users' => { + 'admin' => '{ "admin": true }' + } + } + end + + attr_reader :data + attr_reader :generate_real_keys + + include ChefZero::Endpoints + + def app + @app ||= begin + router = Router.new([ + [ '/authenticate_user', AuthenticateUserEndpoint.new(self) ], + [ '/clients', ActorsEndpoint.new(self) ], + [ '/clients/*', ActorEndpoint.new(self) ], + [ '/cookbooks', CookbooksEndpoint.new(self) ], + [ '/cookbooks/*', CookbookEndpoint.new(self) ], + [ '/cookbooks/*/*', CookbookVersionEndpoint.new(self) ], + [ '/data', DataBagsEndpoint.new(self) ], + [ '/data/*', DataBagEndpoint.new(self) ], + [ '/data/*/*', DataBagItemEndpoint.new(self) ], + [ '/environments', RestListEndpoint.new(self) ], + [ '/environments/*', EnvironmentEndpoint.new(self) ], + [ '/environments/*/cookbooks', EnvironmentCookbooksEndpoint.new(self) ], + [ '/environments/*/cookbooks/*', EnvironmentCookbookEndpoint.new(self) ], + [ '/environments/*/cookbook_versions', EnvironmentCookbookVersionsEndpoint.new(self) ], + [ '/environments/*/nodes', EnvironmentNodesEndpoint.new(self) ], + [ '/environments/*/recipes', EnvironmentRecipesEndpoint.new(self) ], + [ '/environments/*/roles/*', EnvironmentRoleEndpoint.new(self) ], + [ '/nodes', RestListEndpoint.new(self) ], + [ '/nodes/*', NodeEndpoint.new(self) ], + [ '/principals/*', PrincipalEndpoint.new(self) ], + [ '/roles', RestListEndpoint.new(self) ], + [ '/roles/*', RoleEndpoint.new(self) ], + [ '/roles/*/environments', RoleEnvironmentsEndpoint.new(self) ], + [ '/roles/*/environments/*', EnvironmentRoleEndpoint.new(self) ], + [ '/sandboxes', SandboxesEndpoint.new(self) ], + [ '/sandboxes/*', SandboxEndpoint.new(self) ], + [ '/search', SearchesEndpoint.new(self) ], + [ '/search/*', SearchEndpoint.new(self) ], + [ '/users', ActorsEndpoint.new(self) ], + [ '/users/*', ActorEndpoint.new(self) ], + + [ '/file_store/*', FileStoreFileEndpoint.new(self) ], + ]) + router.not_found = NotFoundEndpoint.new + router + end + end + + def gen_key_pair + if generate_real_keys + private_key = OpenSSL::PKey::RSA.new(2048) + public_key = private_key.public_key.to_s + public_key.sub!(/^-----BEGIN RSA PUBLIC KEY-----/, '-----BEGIN PUBLIC KEY-----') + public_key.sub!(/-----END RSA PUBLIC KEY-----(\s+)$/, '-----END PUBLIC KEY-----\1') + [private_key.to_s, public_key] + else + [PRIVATE_KEY, PUBLIC_KEY] + end + end + end +end diff --git a/lib/chef_zero/solr/query/binary_operator.rb b/lib/chef_zero/solr/query/binary_operator.rb new file mode 100644 index 0000000..bd8fa4c --- /dev/null +++ b/lib/chef_zero/solr/query/binary_operator.rb @@ -0,0 +1,53 @@ +module ChefZero + module Solr + module Query + class BinaryOperator + def initialize(left, operator, right) + @left = left + @operator = operator + @right = right + end + + def to_s + "(#{left} #{operator} #{right})" + end + + attr_reader :left + attr_reader :operator + attr_reader :right + + def matches_doc?(doc) + case @operator + when 'AND' + left.matches_doc?(doc) && right.matches_doc?(doc) + when 'OR' + left.matches_doc?(doc) || right.matches_doc?(doc) + when '^' + left.matches_doc?(doc) + when ':' + if left.respond_to?(:literal_string) && left.literal_string + value = doc[left.literal_string] + right.matches_values?([value]) + else + values = doc.matching_values { |key| left.matches_values?([key]) } + right.matches_values?(values) + end + end + end + + def matches_values?(values) + case @operator + when 'AND' + left.matches_values?(values) && right.matches_values?(values) + when 'OR' + left.matches_values?(values) || right.matches_values?(values) + when '^' + left.matches_values?(values) + when ':' + raise ": does not work inside a : or term" + end + end + end + end + end +end diff --git a/lib/chef_zero/solr/query/phrase.rb b/lib/chef_zero/solr/query/phrase.rb new file mode 100644 index 0000000..f229da9 --- /dev/null +++ b/lib/chef_zero/solr/query/phrase.rb @@ -0,0 +1,23 @@ +require 'chef_zero/solr/query/regexpable_query' + +module ChefZero + module Solr + module Query + class Phrase < RegexpableQuery + def initialize(terms) + # Phrase is terms separated by whitespace + if terms.size == 0 && terms[0].literal_string + literal_string = terms[0].literal_string + else + literal_string = nil + end + super(terms.map { |term| term.regexp_string }.join("#{NON_WORD_CHARACTER}+"), literal_string) + end + + def to_s + "Phrase(\"#{@regexp_string}\")" + end + end + end + end +end diff --git a/lib/chef_zero/solr/query/range_query.rb b/lib/chef_zero/solr/query/range_query.rb new file mode 100644 index 0000000..db92548 --- /dev/null +++ b/lib/chef_zero/solr/query/range_query.rb @@ -0,0 +1,34 @@ +module ChefZero + module Solr + module Query + class RangeQuery + def initialize(from, to, from_inclusive, to_inclusive) + @from = from + @to = to + @from_inclusive = from_inclusive + @to_inclusive = to_inclusive + end + + def to_s + "#{@from_inclusive ? '[' : '{'}#{@from} TO #{@to}#{@to_inclusive ? '[' : '{'}" + end + + def matches?(key, value) + case @from <=> value + when -1 + return false + when 0 + return false if !@from_inclusive + end + case @to <=> value + when 1 + return false + when 0 + return false if !@to_inclusive + end + return true + end + end + end + end +end diff --git a/lib/chef_zero/solr/query/regexpable_query.rb b/lib/chef_zero/solr/query/regexpable_query.rb new file mode 100644 index 0000000..5166309 --- /dev/null +++ b/lib/chef_zero/solr/query/regexpable_query.rb @@ -0,0 +1,29 @@ +module ChefZero + module Solr + module Query + class RegexpableQuery + def initialize(regexp_string, literal_string) + @regexp_string = regexp_string + # Surround the regexp with word boundaries + @regexp = Regexp.new("(^|#{NON_WORD_CHARACTER})#{regexp_string}($|#{NON_WORD_CHARACTER})", true) + @literal_string = literal_string + end + + attr_reader :literal_string + attr_reader :regexp_string + attr_reader :regexp + + def matches_doc?(doc) + value = doc[DEFAULT_FIELD] + return value ? matches_values?([value]) : false + end + def matches_values?(values) + values.any? { |value| !@regexp.match(value).nil? } + end + + WORD_CHARACTER = "[A-Za-z0-9@._':]" + NON_WORD_CHARACTER = "[^A-Za-z0-9@._':]" + end + end + end +end diff --git a/lib/chef_zero/solr/query/subquery.rb b/lib/chef_zero/solr/query/subquery.rb new file mode 100644 index 0000000..3727a20 --- /dev/null +++ b/lib/chef_zero/solr/query/subquery.rb @@ -0,0 +1,35 @@ +module ChefZero + module Solr + module Query + class Subquery + def initialize(subquery) + @subquery = subquery + end + + def to_s + "(#{@subquery})" + end + + def literal_string + subquery.literal_string + end + + def regexp + subquery.regexp + end + + def regexp_string + subquery.regexp_string + end + + def matches_doc?(doc) + subquery.matches_doc?(doc) + end + + def matches_values?(values) + subquery.matches_values?(values) + end + end + end + end +end diff --git a/lib/chef_zero/solr/query/term.rb b/lib/chef_zero/solr/query/term.rb new file mode 100644 index 0000000..23f4a72 --- /dev/null +++ b/lib/chef_zero/solr/query/term.rb @@ -0,0 +1,45 @@ +require 'chef_zero/solr/query/regexpable_query' + +module ChefZero + module Solr + module Query + class Term < RegexpableQuery + def initialize(term) + # Get rid of escape characters, turn * and ? into .* and . for regex, and + # escape everything that needs escaping + literal_string = "" + regexp_string = "" + index = 0 + while index < term.length + if term[index] == '*' + regexp_string << "#{WORD_CHARACTER}*" + literal_string = nil + index += 1 + elsif term[index] == '?' + regexp_string << WORD_CHARACTER + literal_string = nil + index += 1 + elsif term[index] == '~' + raise "~ unsupported" + else + if term[index] == '\\' + index = index+1 + if index >= term.length + raise "Backslash at end of string '#{term}'" + end + end + literal_string << term[index] if literal_string + regexp_string << Regexp.escape(term[index]) + index += 1 + end + end + super(regexp_string, literal_string) + end + + def to_s + "Term(#{regexp_string})" + end + end + end + end +end diff --git a/lib/chef_zero/solr/query/unary_operator.rb b/lib/chef_zero/solr/query/unary_operator.rb new file mode 100644 index 0000000..fc46c0d --- /dev/null +++ b/lib/chef_zero/solr/query/unary_operator.rb @@ -0,0 +1,43 @@ +module ChefZero + module Solr + module Query + class UnaryOperator + def initialize(operator, operand) + @operator = operator + @operand = operand + end + + def to_s + "#{operator} #{operand}" + end + + attr_reader :operator + attr_reader :operand + + def matches_doc?(doc) + case @operator + when '-' + when 'NOT' + !operand.matches_doc?(doc) + when '+' + # TODO This operator uses relevance to eliminate other, unrelated + # expressions. +a OR b means "if it has b but not a, don't return it" + raise "+ not supported yet, because it is hard." + end + end + + def matches_values?(values) + case @operator + when '-' + when 'NOT' + !operand.matches_values?(values) + when '+' + # TODO This operator uses relevance to eliminate other, unrelated + # expressions. +a OR b means "if it has b but not a, don't return it" + raise "+ not supported yet, because it is hard." + end + end + end + end + end +end diff --git a/lib/chef_zero/solr/solr_doc.rb b/lib/chef_zero/solr/solr_doc.rb new file mode 100644 index 0000000..d61a7d5 --- /dev/null +++ b/lib/chef_zero/solr/solr_doc.rb @@ -0,0 +1,62 @@ +module ChefZero + module Solr + # This does what expander does, flattening the json doc into keys and values + # so that solr can search them. + class SolrDoc + def initialize(json, id) + @json = json + @id = id + end + + def [](key) + values = matching_values { |match_key| match_key == key } + values[0] + end + + def matching_values(&block) + result = {} + key_values(nil, @json) do |key, value| + if block.call(key) + if result.has_key?(key) + result[key] << value.to_s + else + result[key] = value.to_s.clone + end + end + end + # Handle manufactured value(s) + if block.call('X_CHEF_id_CHEF_X') + if result.has_key?('X_CHEF_id_CHEF_X') + result['X_CHEF_id_CHEF_X'] << @id.to_s + else + result['X_CHEF_id_CHEF_X'] = @id.to_s.clone + end + end + + result.values + end + + private + + def key_values(key_so_far, value, &block) + if value.is_a?(Hash) + value.each_pair do |child_key, child_value| + block.call(child_key, child_value.to_s) + if key_so_far + new_key = "#{key_so_far}_#{child_key}" + key_values(new_key, child_value, &block) + else + key_values(child_key, child_value, &block) if child_value.is_a?(Hash) || child_value.is_a?(Array) + end + end + elsif value.is_a?(Array) + value.each do |child_value| + key_values(key_so_far, child_value, &block) + end + else + block.call(key_so_far || 'text', value.to_s) + end + end + end + end +end diff --git a/lib/chef_zero/solr/solr_parser.rb b/lib/chef_zero/solr/solr_parser.rb new file mode 100644 index 0000000..589b78f --- /dev/null +++ b/lib/chef_zero/solr/solr_parser.rb @@ -0,0 +1,194 @@ +require 'chef_zero/solr/query/binary_operator' +require 'chef_zero/solr/query/unary_operator' +require 'chef_zero/solr/query/term' +require 'chef_zero/solr/query/phrase' +require 'chef_zero/solr/query/range_query' +require 'chef_zero/solr/query/subquery' + +module ChefZero + module Solr + class SolrParser + def initialize(query_string) + @query_string = query_string + @index = 0 + end + + def parse + read_expression + end + + # + # Tokenization + # + def peek_token + @next_token ||= parse_token + end + + def next_token + result = peek_token + @next_token = nil + result + end + + def parse_token + # Skip whitespace + skip_whitespace + return nil if eof? + + # Operators + operator = peek_operator_token + if operator + @index+=operator.length + operator + else + # Everything that isn't whitespace or an operator, is part of a term + # (characters plus backslashed escaped characters) + start_index = @index + begin + if @query_string[@index] == '\\' + @index+=1 + end + @index+=1 if !eof? + end until eof? || @query_string[@index] =~ /\s/ || peek_operator_token + @query_string[start_index..@index-1] + end + end + + def skip_whitespace + if @query_string[@index] =~ /\s/ + whitespace = /\s+/.match(@query_string, @index) + @index += whitespace[0].length + end + end + + def peek_operator_token + if ['"', '+', '-', '!', '(', ')', '{', '}', '[', ']', '^', ':'].include?(@query_string[@index]) + return @query_string[@index] + else + result = @query_string[@index..@index+1] + if ['&&', '||'].include?(result) + return result + end + end + nil + end + + def eof? + !@next_token && @index >= @query_string.length + end + + # Parse tree creation + def read_expression + result = read_single_expression + # Expression is over when we hit a close paren or eof + # (peek_token has the side effect of skipping whitespace for us, so we + # really know if we're at eof or not) + until peek_token == ')' || eof? + operator = peek_token + if binary_operator?(operator) + next_token + else + # If 2 terms are next to each other, the default operator is OR + operator = 'OR' + end + next_expression = read_single_expression + + # Build the operator, taking precedence into account + if result.is_a?(Query::BinaryOperator) && + binary_operator_precedence(operator) > binary_operator_precedence(result.operator) + # a+b*c -> a+(b*c) + new_right = Query::BinaryOperator.new(result.right, operator, next_expression) + result = Query::BinaryOperator.new(result.left, result.operator, new_right) + else + # a*b+c -> (a*b)+c + result = Query::BinaryOperator.new(result, operator, next_expression) + end + end + result + end + + def parse_error(token, str) + error = "Error on token '#{token}' at #{@index} of '#{@query_string}': #{str}" + puts error + raise error + end + + def read_single_expression + token = next_token + # If EOF, we have a problem Houston + if !token + parse_error(nil, "Expected expression!") + + # If it's an unary operand, build that + elsif unary_operator?(token) + operand = read_single_expression + # TODO We rely on all unary operators having higher precedence than all + # binary operators. Check if this is the case. + Query::UnaryOperator.new(token, operand) + + # If it's the start of a phrase, read the terms in the phrase + elsif token == '"' + # Read terms until close " + phrase_terms = [] + until (term = next_token) == '"' + phrase_terms << Query::Term.new(term) + end + Query::Phrase.new(phrase_terms) + + # If it's the start of a range query, build that + elsif token == '{' || token == '[' + left = next_token + parse_error(left, "Expected left term in range query") if !left + to = next_token + parse_error(left, "Expected TO in range query") if to != "TO" + right = next_token + parse_error(right, "Expected left term in range query") if !right + end_range = next_token + parse_error(right, "Expected end range '#{expected_end_range}") if !['{', '['].include?(end_range) + Query::RangeQuery.new(left, right, token == '[', end_range == ']') + + elsif token == '(' + subquery = read_expression + close_paren = next_token + parse_error(close_paren, "Expected ')'") if close_paren != ')' + Query::Subquery.new(subquery) + + # If it's the end of a closure, raise an exception + elsif ['}',']',')'].include?(token) + parse_error(token, "Unexpected end paren") + + # If it's a binary operator, raise an exception + elsif binary_operator?(token) + parse_error(token, "Unexpected binary operator") + + # Otherwise it's a term. + else + Query::Term.new(token) + end + end + + def unary_operator?(token) + [ 'NOT', '+', '-' ].include?(token) + end + + def binary_operator?(token) + [ 'AND', 'OR', '^', ':'].include?(token) + end + + def binary_operator_precedence(token) + case token + when '^' + 4 + when ':' + 3 + when 'AND' + 2 + when 'OR' + 1 + end + end + + DEFAULT_FIELD = 'text' + end + end +end diff --git a/lib/chef_zero/version.rb b/lib/chef_zero/version.rb new file mode 100644 index 0000000..9b7d21a --- /dev/null +++ b/lib/chef_zero/version.rb @@ -0,0 +1,3 @@ +module ChefZero + VERSION = '0.9' +end |