├── .expeditor ├── config.yml ├── run_linux_tests.sh ├── update_version.sh └── verify.pipeline.yml ├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── BUG_TEMPLATE.md │ ├── DESIGN_PROPOSAL.md │ ├── ENHANCEMENT_REQUEST_TEMPLATE.md │ └── SUPPORT_QUESTION.md ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── VERSION ├── chef-api.gemspec ├── chef-infra-api.gemspec ├── lib ├── chef-api.rb └── chef-api │ ├── aclable.rb │ ├── authentication.rb │ ├── boolean.rb │ ├── configurable.rb │ ├── connection.rb │ ├── defaults.rb │ ├── error_collection.rb │ ├── errors.rb │ ├── log.rb │ ├── multipart.rb │ ├── resource.rb │ ├── resources │ ├── base.rb │ ├── client.rb │ ├── collection_proxy.rb │ ├── cookbook.rb │ ├── cookbook_version.rb │ ├── data_bag.rb │ ├── data_bag_item.rb │ ├── environment.rb │ ├── group.rb │ ├── node.rb │ ├── organization.rb │ ├── partial_search.rb │ ├── principal.rb │ ├── role.rb │ ├── search.rb │ └── user.rb │ ├── schema.rb │ ├── util.rb │ ├── validator.rb │ ├── validators │ ├── base.rb │ ├── required.rb │ └── type.rb │ └── version.rb ├── spec ├── integration │ └── resources │ │ ├── client_spec.rb │ │ ├── environment_spec.rb │ │ ├── node_spec.rb │ │ ├── partial_search_spec.rb │ │ ├── role_spec.rb │ │ ├── search_spec.rb │ │ └── user_spec.rb ├── spec_helper.rb ├── support │ ├── chef_server.rb │ ├── cookbook.tar.gz │ ├── shared │ │ └── chef_api_resource.rb │ └── user.pem └── unit │ ├── authentication_spec.rb │ ├── defaults_spec.rb │ ├── errors_spec.rb │ └── resources │ ├── base_spec.rb │ ├── client_spec.rb │ └── connection_spec.rb └── templates └── errors ├── abstract_method.erb ├── cannot_regenerate_key.erb ├── chef_api_error.erb ├── file_not_found.erb ├── http_bad_request.erb ├── http_forbidden_request.erb ├── http_gateway_timeout.erb ├── http_method_not_allowed.erb ├── http_not_acceptable.erb ├── http_not_found.erb ├── http_server_unavailable.erb ├── http_unauthorized_request.erb ├── insufficient_file_permissions.erb ├── invalid_resource.erb ├── invalid_validator.erb ├── missing_url_parameter.erb ├── not_a_directory.erb ├── resource_already_exists.erb ├── resource_not_found.erb ├── resource_not_mutable.erb └── unknown_attribute.erb /.expeditor/config.yml: -------------------------------------------------------------------------------- 1 | # Documentation available at https://expeditor.chef.io/docs/getting-started/ 2 | --- 3 | 4 | # Slack channel in Chef Software slack to send notifications about build failures, etc 5 | slack: 6 | notify_channel: chef-found-notify 7 | 8 | # This publish is triggered by the `built_in:publish_rubygems` artifact_action. 9 | rubygems: 10 | - chef-api 11 | - chef-infra-api 12 | 13 | github: 14 | # This deletes the GitHub PR branch after successfully merged into the release branch 15 | delete_branch_on_merge: true 16 | # The tag format to use (e.g. v1.0.0) 17 | version_tag_format: "v{{version}}" 18 | # allow bumping the minor release via label 19 | minor_bump_labels: 20 | - "Expeditor: Bump Version Minor" 21 | # allow bumping the major release via label 22 | major_bump_labels: 23 | - "Expeditor: Bump Version Major" 24 | 25 | changelog: 26 | rollup_header: Changes not yet released to rubygems.org 27 | 28 | # These actions are taken, in order they are specified, anytime a Pull Request is merged. 29 | merge_actions: 30 | - built_in:bump_version: 31 | ignore_labels: 32 | - "Expeditor: Skip Version Bump" 33 | - "Expeditor: Skip All" 34 | - bash:.expeditor/update_version.sh: 35 | only_if: built_in:bump_version 36 | - built_in:update_changelog: 37 | ignore_labels: 38 | - "Expeditor: Skip Changelog" 39 | - "Expeditor: Skip All" 40 | - built_in:build_gem: 41 | only_if: built_in:bump_version 42 | 43 | promote: 44 | actions: 45 | - built_in:rollover_changelog 46 | - built_in:publish_rubygems 47 | 48 | pipelines: 49 | - verify: 50 | description: Pull Request validation tests 51 | public: true 52 | -------------------------------------------------------------------------------- /.expeditor/run_linux_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script runs a passed in command, but first setups up the bundler caching on the repo 4 | 5 | set -ue 6 | 7 | export USER="root" 8 | 9 | echo "--- dependencies" 10 | export LANG=C.UTF-8 LANGUAGE=C.UTF-8 11 | S3_URL="s3://public-cd-buildkite-cache/${BUILDKITE_PIPELINE_SLUG}/${BUILDKITE_LABEL}" 12 | 13 | pull_s3_file() { 14 | aws s3 cp "${S3_URL}/$1" "$1" || echo "Could not pull $1 from S3" 15 | } 16 | 17 | push_s3_file() { 18 | if [ -f "$1" ]; then 19 | aws s3 cp "$1" "${S3_URL}/$1" || echo "Could not push $1 to S3 for caching." 20 | fi 21 | } 22 | 23 | apt-get update -y 24 | apt-get install awscli -y 25 | 26 | echo "--- bundle install" 27 | pull_s3_file "bundle.tar.gz" 28 | pull_s3_file "bundle.sha256" 29 | 30 | if [ -f bundle.tar.gz ]; then 31 | tar -xzf bundle.tar.gz 32 | fi 33 | 34 | if [ -n "${RESET_BUNDLE_CACHE:-}" ]; then 35 | rm bundle.sha256 36 | fi 37 | 38 | bundle config --local path vendor/bundle 39 | bundle install --jobs=7 --retry=3 40 | 41 | echo "--- bundle cache" 42 | if test -f bundle.sha256 && shasum --check bundle.sha256 --status; then 43 | echo "Bundled gems have not changed. Skipping upload to s3" 44 | else 45 | echo "Bundled gems have changed. Uploading to s3" 46 | shasum -a 256 Gemfile.lock > bundle.sha256 47 | tar -czf bundle.tar.gz vendor/ 48 | push_s3_file bundle.tar.gz 49 | push_s3_file bundle.sha256 50 | fi 51 | 52 | echo "+++ bundle exec task" 53 | bundle exec $1 54 | -------------------------------------------------------------------------------- /.expeditor/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # After a PR merge, Chef Expeditor will bump the PATCH version in the VERSION file. 4 | # It then executes this file to update any other files/components with that new version. 5 | # 6 | 7 | set -evx 8 | 9 | sed -i -r "s/^(\s*)VERSION = \".+\"/\1VERSION = \"$(cat VERSION)\"/" lib/chef-api/version.rb 10 | 11 | # Once Expeditor finishes executing this script, it will commit the changes and push 12 | # the commit as a new tag corresponding to the value in the VERSION file. 13 | -------------------------------------------------------------------------------- /.expeditor/verify.pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | expeditor: 3 | defaults: 4 | buildkite: 5 | timeout_in_minutes: 30 6 | 7 | steps: 8 | 9 | - label: run-lint-and-specs-ruby-2.4 10 | command: 11 | - .expeditor/run_linux_tests.sh rake 12 | expeditor: 13 | executor: 14 | docker: 15 | image: ruby:2.4-buster 16 | 17 | - label: run-lint-and-specs-ruby-2.5 18 | command: 19 | - .expeditor/run_linux_tests.sh rake 20 | expeditor: 21 | executor: 22 | docker: 23 | image: ruby:2.5-buster 24 | 25 | - label: run-lint-and-specs-ruby-2.6 26 | command: 27 | - .expeditor/run_linux_tests.sh rake 28 | expeditor: 29 | executor: 30 | docker: 31 | image: ruby:2.6-buster 32 | 33 | - label: run-lint-and-specs-ruby-2.7 34 | command: 35 | - .expeditor/run_linux_tests.sh rake 36 | expeditor: 37 | executor: 38 | docker: 39 | image: ruby:2.7-buster 40 | 41 | - label: run-specs-windows 42 | command: 43 | - bundle install --jobs=7 --retry=3 --without docs debug 44 | - bundle exec rake 45 | expeditor: 46 | executor: 47 | docker: 48 | host_os: windows 49 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | 3 | * @chef/chef-foundation-owners @chef/chef-foundation-approvers @chef/chef-foundation-reviewers 4 | .expeditor/ @chef/jex-team 5 | *.md @chef/docs-team 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: � Bug Report 3 | about: If something isn't working as expected �. 4 | labels: "Status: Untriaged, Type: Bug" 5 | --- 6 | 7 | # Description 8 | 9 | Briefly describe the issue 10 | 11 | # ChefDK Version 12 | 13 | Tell us which version of the ChefDK you are running. Run `chef --version` to display the version. 14 | 15 | # Platform Version 16 | 17 | Tell us which operating system distribution and version ChefDK is running on. 18 | 19 | # Replication Case 20 | 21 | Tell us what steps to take to replicate your problem. See [How to create a Minimal, Complete, and Verifiable example](https://stackoverflow.com/help/mcve) for information on how to create a good replication case. 22 | 23 | # Stacktrace 24 | 25 | Please include the stacktrace.out output or link to a gist of it, if there is one. 26 | 27 | ## NOTE: CHEFDK BUGS ONLY 28 | 29 | This issue tracker is for the code contained within this repo -- `chefdk`. 30 | 31 | - [Server issues](https://github.com/chef/chef-server/issues/new) 32 | - [Chef-client issues](https://github.com/chef/chef/issues/new) 33 | - Cookbook Issues (see the repos or search [Supermarket](https://supermarket.chef.io) or GitHub/Google) 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DESIGN_PROPOSAL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Proposal 3 | about: I have a significant change I would like to propose and discuss before starting 4 | labels: "Status: Untriaged, Type: Design Proposal" 5 | --- 6 | 7 | ### When a Change Needs a Design Proposal 8 | 9 | A design proposal should be opened any time a change meets one of the following qualifications: 10 | 11 | - Significantly changes the user experience of a project in a way that impacts users. 12 | - Significantly changes the underlying architecture of the project in a way that impacts other developers. 13 | - Changes the development or testing process of the project such as a change of CI systems or test frameworks. 14 | 15 | ### Why We Use This Process 16 | 17 | - Allows all interested parties (including any community member) to discuss large impact changes to a project. 18 | - Serves as a durable paper trail for discussions regarding project architecture. 19 | - Forces design discussions to occur before PRs are created. 20 | - Reduces PR refactoring and rejected PRs. 21 | 22 | --- 23 | 24 | 25 | 26 | ## Motivation 27 | 28 | 33 | 34 | ## Specification 35 | 36 | 37 | 38 | ## Downstream Impact 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Enhancement Request 3 | about: I have a suggestion (and may want to implement it 🙂)! 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | ### Describe the Enhancement 8 | 9 | 10 | ### Describe the Need 11 | 12 | 13 | ### Current Alternative 14 | 15 | 16 | ### Can We Help You Implement This? 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: � Support Question 3 | about: If you have a question �, please check out our Slack! 4 | --- 5 | 6 | We use GitHub issues to track bugs and feature requests. If you need help please post to our Mailing List or join the Chef Community Slack. 7 | 8 | * Chef Community Slack at 9 | * Chef Mailing List 10 | 11 | Support issues opened here will be closed and redirected to Slack or Discourse. 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | bin 19 | .rspec 20 | .ruby-version 21 | vendor/ 22 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config --no-offense-counts` 3 | # on 2019-10-18 13:52:41 -0700 using RuboCop version 0.72.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Cop supports --auto-correct. 10 | # Configuration parameters: EnforcedStyleAlignWith, AutoCorrect, Severity. 11 | # SupportedStylesAlignWith: keyword, variable, start_of_line 12 | Layout/EndAlignment: 13 | Exclude: 14 | - 'lib/chef-api/connection.rb' 15 | - 'lib/chef-api/resources/search.rb' 16 | 17 | Lint/AmbiguousBlockAssociation: 18 | Exclude: 19 | - 'spec/unit/errors_spec.rb' 20 | 21 | # Configuration parameters: AllowSafeAssignment. 22 | Lint/AssignmentInCondition: 23 | Exclude: 24 | - 'lib/chef-api/authentication.rb' 25 | - 'lib/chef-api/connection.rb' 26 | - 'lib/chef-api/defaults.rb' 27 | - 'lib/chef-api/multipart.rb' 28 | - 'lib/chef-api/resources/client.rb' 29 | - 'lib/chef-api/resources/search.rb' 30 | - 'lib/chef-api/schema.rb' 31 | 32 | Lint/ShadowingOuterLocalVariable: 33 | Exclude: 34 | - 'lib/chef-api/resources/base.rb' 35 | - 'lib/chef-api/schema.rb' 36 | 37 | Lint/UselessAssignment: 38 | Exclude: 39 | - 'lib/chef-api/resources/data_bag.rb' 40 | - 'lib/chef-api/schema.rb' 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChefAPI Changelog 2 | 3 | 4 | 5 | ### Changes not yet released to rubygems.org 6 | 7 | 8 | 9 | ## [v0.10.10](https://github.com/chef/chef-api/tree/v0.10.10) (2020-08-21) 10 | 11 | 12 | ## [v0.10.10](https://github.com/chef/chef-api/tree/v0.10.10) (2020-08-20) 13 | 14 | #### Merged Pull Requests 15 | - Avoid Ruby 2.7 deprecation warnings by switching to CGI [#91](https://github.com/chef/chef-api/pull/91) ([tas50](https://github.com/tas50)) 16 | - Fix chefstyle violations. [#94](https://github.com/chef/chef-api/pull/94) ([phiggins](https://github.com/phiggins)) 17 | - Optimize our requires [#93](https://github.com/chef/chef-api/pull/93) ([tas50](https://github.com/tas50)) 18 | 19 | ## [v0.10.7](https://github.com/chef/chef-api/tree/v0.10.7) (2020-06-12) 20 | 21 | #### Merged Pull Requests 22 | - Fix an undefined variable error in Validator::Type [#89](https://github.com/chef/chef-api/pull/89) ([yuta1024](https://github.com/yuta1024)) 23 | - Pin pry-stack-explorer and fix indentation [#90](https://github.com/chef/chef-api/pull/90) ([tas50](https://github.com/tas50)) 24 | 25 | ## [v0.10.5](https://github.com/chef/chef-api/tree/v0.10.5) (2020-01-29) 26 | 27 | #### Merged Pull Requests 28 | - Switch logging to mixlib-log instead of logify [#85](https://github.com/chef/chef-api/pull/85) ([tecracer-theinen](https://github.com/tecracer-theinen)) 29 | - Loosen the mixlib-log dep to allow for older ruby releases [#86](https://github.com/chef/chef-api/pull/86) ([tas50](https://github.com/tas50)) 30 | - Test on Ruby 2.7 and test on Windows [#87](https://github.com/chef/chef-api/pull/87) ([tas50](https://github.com/tas50)) 31 | 32 | ## [v0.10.2](https://github.com/chef/chef-api/tree/v0.10.2) (2019-12-21) 33 | 34 | #### Merged Pull Requests 35 | - Apply chefstyle to this repo [#79](https://github.com/chef/chef-api/pull/79) ([tas50](https://github.com/tas50)) 36 | - Substitute require for require_relative [#84](https://github.com/chef/chef-api/pull/84) ([tas50](https://github.com/tas50)) 37 | 38 | ## [v0.10.0](https://github.com/chef/chef-api/tree/v0.10.0) (2019-10-18) 39 | 40 | #### Merged Pull Requests 41 | - Wire up Expeditor to release chef-api and chef-infra-api [#77](https://github.com/chef/chef-api/pull/77) ([tas50](https://github.com/tas50)) 42 | - Require Ruby 2.3+ and remove travis config [#78](https://github.com/chef/chef-api/pull/78) ([tas50](https://github.com/tas50)) 43 | 44 | ## v0.9.0 (2018-12-03) 45 | 46 | - Removed support for the EOL Ruby 2.1 release 47 | - Removed the note about heavy development in the readme 48 | - Updated the gemspec to use a SPDX compliant license string 49 | - Slimmed the gem down to only ship the necessary files for execution vs. full development 50 | 51 | ## v0.8.0 (2018-03-02) 52 | 53 | - support `filter_result` in chef queries 54 | - support a configurable read_timeout 55 | 56 | ## v0.7.1 (2017-08-06) 57 | 58 | - Don't set nil `JSON.create_id` as it's unnecessary in recent versions 59 | of the JSON library 60 | - Avoid ArgumentError when no HOME environment variable is set 61 | - Add Resource::Organization proxy 62 | - Update all comments to point to the correct Docs site URLs 63 | 64 | ## v0.6.0 (2016-05-05) 65 | 66 | - Remove support for Ruby 1.9 67 | - Add the ability to disable signing on a request 68 | - Always send JSON when authenticating a user 69 | - Fix to_json method contract 70 | - Add config file support. See the readme for an example 71 | - Add required fields to role schema 72 | - Do not symbolize keys in the config 73 | - Fix boolean logic in determining if SSL should be verified 74 | 75 | ## v0.5.0 (2014-07-10) 76 | 77 | - Relax the dependency on mime-types 78 | - When searching for the file object of a multipart filepart, find the first IO that not a StringIO 79 | - Rewind IO objects after digesting them 80 | 81 | ## v0.4.1 (2014-07-07) 82 | 83 | - Remove dependency on mixlib-authentication 84 | - Fix a bug where Content-Type headers were not sent properly 85 | - Switch to rake for test running 86 | - Improve test coverage with fixtures 87 | 88 | ## v0.4.0 (2014-07-05) 89 | 90 | - Support multipart POST 91 | 92 | ## v0.3.0 (2014-06-18) 93 | 94 | - Add search functionality 95 | - Add partial search 96 | - Update testing harness 97 | 98 | ## v0.2.1 (2014-04-17) 99 | 100 | - Fix a series of typographical errors 101 | - Improved documentation for loading resources from disk 102 | - Improved spec coverage 103 | - Switch to Logify for logging 104 | - Add HEC endpoint for authenticating users 105 | - Change the default options for Hosted Chef 106 | - Implement HTTPGatewayTimeout (504) 107 | - Do not automatically inflate JSON objects 108 | - Improved logging awesomeness 109 | - Add "flavors" when defining the schema (OSC is different than HEC) 110 | - Remove i18n in favor of ERB 111 | - Fix an issue when providing a key at an unexpanded path -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to the Chef Community Code of Conduct at https://www.chef.io/code-of-conduct/ 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to CONTRIBUTING.md for the `chef` project: https://github.com/chef/chef/blob/master/CONTRIBUTING.md". 2 | 3 | For information about building and testing, see [BUILDING.md](BUILDING.md) in this repository. 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec name: "chef-api" 3 | 4 | group :development do 5 | gem "chefstyle" 6 | gem "rake", ">= 10.1.0" 7 | gem "rspec", "~> 3.0" 8 | gem "chef-zero", "~> 2.0.0" 9 | 10 | end 11 | 12 | group :docs do 13 | gem "yard" 14 | gem "redcarpet" 15 | gem "github-markup" 16 | end 17 | 18 | group :debug do 19 | gem "pry" 20 | gem "pry-byebug" 21 | gem "pry-stack_explorer", "~> 0.4.0" # supports Ruby < 2.6 22 | gem "rb-readline" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2013 Seth Vargo 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChefAPI Client 2 | 3 | [![Gem Version](http://img.shields.io/gem/v/chef-api.svg)][gem] 4 | [![Build status](https://badge.buildkite.com/621970e904ad272767b8b79d37b6e31fd4307f500967286b9b.svg?branch=master)](https://buildkite.com/chef-oss/chef-chef-api-master-verify) 5 | 6 | ChefAPI is a dependency-minimal Ruby client for interacting with a Chef Server. It adopts many patterns and principles from Rails 7 | 8 | ## DEPRECATED 9 | 10 | The chef-api gem is no longer maintained. Please use the supported `Chef::ServerAPI` library from the [Chef](https://github.com/chef/chef) gem. Documentation is provided at https://docs.chef.io/server/api_chef_server/. 11 | 12 | ## Quick start 13 | 14 | Install via Rubygems: 15 | 16 | ``` 17 | $ gem install chef-api 18 | ``` 19 | 20 | or add it to your Gemfile if you are using Bundler: 21 | 22 | ```ruby 23 | gem 'chef-api', '~> 0.1' 24 | ``` 25 | 26 | In your library or project, you will likely want to include the `ChefAPI::Resource` namespace: 27 | 28 | ```ruby 29 | include ChefAPI::Resource 30 | ``` 31 | 32 | This will give you "Rails-like" access to the top-level Chef resources like: 33 | 34 | ```ruby 35 | Client.all 36 | Node.all 37 | ``` 38 | 39 | If you choose not to include the module, you will need to specify the full module path to access resources: 40 | 41 | ```ruby 42 | ChefAPI::Resource::Client.all 43 | ChefAPI::Resource::Node.all 44 | ``` 45 | 46 | ### Create a connection 47 | 48 | Before you can make a request, you must give the ChefAPI your connection information and credentials. 49 | 50 | ```ruby 51 | ChefAPI.configure do |config| 52 | # The endpoint for the Chef Server. This can be an Open Source Chef Server, 53 | # Hosted Chef Server, or Enterprise Chef Server. 54 | config.endpoint = 'https://api.opscode.com/organizations/meats' 55 | 56 | # ChefAPI will try to determine if you are running on an Enterprise Chef 57 | # Server or Open Source Chef depending on the URL you provide for the 58 | # +endpoint+ attribute. However, it may be incorrect. If is seems like the 59 | # generated schema does not match the response from the server, it is 60 | # possible this value was calculated incorrectly. Thus, you should set it 61 | # manually. Possible values are +:enterprise+ and +:open_source+. 62 | config.flavor = :enterprise 63 | 64 | # The client and key must also be specified (unless you are running Chef Zero 65 | # in no-authentication mode). The +key+ attribute may be the raw private key, 66 | # the path to the private key on disk, or an +OpenSSLL::PKey+ object. 67 | config.client = 'bacon' 68 | config.key = '~/.chef/bacon.pem' 69 | 70 | # If you are running your own Chef Server with a custom SSL certificate, you 71 | # will need to specify the path to a pem file with your custom certificates 72 | # and ChefAPI will wire everything up correctly. (NOTE: it must be a valid 73 | # PEM file). 74 | config.ssl_pem_file = '/path/to/my.pem' 75 | 76 | # If you would like to be vulnerable to MITM attacks, you can also turn off 77 | # SSL verification. Despite what Internet blog posts may suggest, you should 78 | # exhaust other methods before disabling SSL verification. ChefAPI will emit 79 | # a warning message for every request issued with SSL verification disabled. 80 | config.ssl_verify = false 81 | 82 | # If you are behind a proxy, Chef API can run requests through the proxy as 83 | # well. Just set the following configuration parameters as needed. 84 | config.proxy_username = 'user' 85 | config.proxy_password = 'password' 86 | config.proxy_address = 'my.proxy.server' # or 10.0.0.50 87 | config.proxy_port = '8080' 88 | 89 | # If you want to make queries that return a very large result chef, you might 90 | # need to adjust the timeout limits for the network request. (NOTE: time is 91 | # given in seconds). 92 | config.read_timeout = 120 93 | end 94 | ``` 95 | 96 | All of these configuration options are available via the top-level `ChefAPI` object. 97 | 98 | ```ruby 99 | ChefAPI.endpoint = '...' 100 | ``` 101 | 102 | You can also configure everything via environment variables (great solution for Docker-based usage). All the environment variables are of the format `CHEF_API_{key}`, where `key` is the uppercase equivalent of the item in the configuration object. 103 | 104 | ```bash 105 | # ChefAPI will use these values 106 | export CHEF_API_ENDPOINT=https://api.opscode.com/organizations/meats 107 | export CHEF_API_CLIENT=bacon 108 | export CHEF_API_KEY=~/.chef/bacon.pem 109 | ``` 110 | 111 | In addition, you can configure the environment variables in a JSON-formatted config file either placed in ~/.chef-api or placed in a location configured via the environment variable `CHEF_API_CONFIG`. For example: 112 | 113 | ```json 114 | { 115 | "CHEF_API_ENDPOINT": "https://api.opscode.com/organizations/meats", 116 | "CHEF_API_CLIENT": "bacon", 117 | "CHEF_API_KEY": "~/.chef/bacon.pem" 118 | } 119 | ``` 120 | 121 | If you prefer a more object-oriented approach (or if you want to support multiple simultaneous connections), you can create a raw `ChefAPI::Connection` object. All of the options that are available on the `ChefAPI` object are also available on a raw connection: 122 | 123 | ```ruby 124 | connection = ChefAPI::Connection.new( 125 | endpoint: 'https://api.opscode.com/organizations/meats', 126 | client: 'bacon', 127 | key: '~/.chef/bacon.pem' 128 | ) 129 | 130 | connection.clients.fetch('chef-webui') 131 | connection.environments.delete('production') 132 | ``` 133 | 134 | If you do not want to manage a `ChefAPI::Connection` object, or if you just prefer an alternative syntax, you can use the block-form: 135 | 136 | ```ruby 137 | ChefAPI::Connection.new do |connection| 138 | connection.endpoint = 'https://api.opscode.com/organizations/meats' 139 | connection.client = 'bacon' 140 | connection.key = '~/.chef/bacon.pem' 141 | 142 | # The connection object is now setup, so you can use it directly: 143 | connection.clients.fetch('chef-webui') 144 | connection.environments.delete('production') 145 | end 146 | ``` 147 | 148 | ### Making Requests 149 | 150 | The ChefAPI gem attempts to wrap the Chef Server API in an object-oriented and Rubyesque way. All of the methods and API calls are heavily documented inline using YARD. For a full list of every possible option, please see the inline documentation. 151 | 152 | Most resources can be listed, retrieved, created, updated, and destroyed. In programming, this is commonly referred to as "CRUD". 153 | 154 | #### Create 155 | 156 | There are multiple ways to create a new Chef resource on the remote Chef Server. You can use the native `create` method. It accepts a list of parameters as a hash: 157 | 158 | ```ruby 159 | Client.create(name: 'new-client') #=> # 160 | ``` 161 | 162 | Or you can create an instance of the object, setting parameters as you go. 163 | 164 | ```ruby 165 | client = Client.new 166 | client.name = 'new-client' 167 | client.save #=> # 168 | ``` 169 | 170 | You can also mix-and-match the hash and object initialization: 171 | 172 | ```ruby 173 | client = Client.new(name: 'new-client') 174 | client.validator = true 175 | client.admin = true 176 | client.save #=> # 177 | ``` 178 | 179 | #### Read 180 | 181 | Most resources have the following "read" functions: 182 | 183 | - `.list`, `.all`, and `.each` for listing Chef resources 184 | - `.fetch` for getting a single Chef resource with the given identifier 185 | 186 | ##### Listing 187 | 188 | You can get a list of all the identifiers for a given type of resource using the `.list` method. This is especially useful when you only want to list items by their identifier, since it only issues a single API request. For example, to get the names of all of the Client resources: 189 | 190 | ```ruby 191 | Client.list #=> ["chef-webui", "validator"] 192 | ``` 193 | 194 | You can also get the full collection of Chef resources using the `.all` method: 195 | 196 | ```ruby 197 | Client.all #=> [#, 198 | #] 199 | ``` 200 | 201 | However, this is incredibly inefficient. Because of the way the Chef Server serves requests, this will make N+1 queries to the Chef Server. Unless you absolutely need every resource in the collection, you are much better using the lazy enumerable: 202 | 203 | ```ruby 204 | Client.each do |client| 205 | puts client.name 206 | end 207 | ``` 208 | 209 | Because the resources include Ruby's custom Enumerable, you can treat the top-level resources as if they were native Ruby enumerable objects. Here are just a few examples: 210 | 211 | ```ruby 212 | Client.first #=> # 213 | Client.first(3) #=> [#, ...] 214 | Client.map(&:public_key) #=> ["-----BEGIN PUBLIC KEY-----\nMIGfMA...", "-----BEGIN PUBLIC KEY-----\nMIIBI..."] 215 | ``` 216 | 217 | ##### Fetching 218 | 219 | You can also fetch a single resource from the Chef Server using the given identifier. Each Chef resource has a unique identifier; internally this is called the "primary key". For most resources, this attribute is "name". You can fetch a resource by it's primary key using the `.fetch` method: 220 | 221 | ```ruby 222 | Client.fetch('chef-webui') #=> # 223 | ``` 224 | 225 | If a resource with the given identifier does not exist, it will return `nil`: 226 | 227 | ```ruby 228 | Client.fetch('not-a-real-client') #=> nil 229 | ``` 230 | 231 | #### Update 232 | 233 | You can update a resource using it's unique identifier and a list of hash attributes: 234 | 235 | ```ruby 236 | Client.update('chef-webui', admin: true) 237 | ``` 238 | 239 | Or you can get an instance of the object and update the attributes manually: 240 | 241 | ```ruby 242 | client = Client.fetch('chef-webui') 243 | client.admin = true 244 | client.save 245 | ``` 246 | 247 | #### Delete 248 | 249 | You can destroy a resource using it's unique identifier: 250 | 251 | ```ruby 252 | Client.destroy('chef-webui') #=> true 253 | ``` 254 | 255 | Or you can get an instance of the object and delete it manually: 256 | 257 | ```ruby 258 | client = Client.fetch('chef-webui') 259 | client.destroy #=> true 260 | ``` 261 | 262 | ### Validations 263 | 264 | Each resource includes its own validations. If these validations fail, they exhibit custom errors messages that are added to the resource. For example, Chef clients **must** have a name attribute. This is validated on the client side: 265 | 266 | ```ruby 267 | client = Client.new 268 | client.save #=> false 269 | ``` 270 | 271 | Notice that the `client.save` call returned `false`? This is an indication that the resource did not commit back to the server because of a failed validation. You can get the error(s) that prevented the object from saving using the `.errors` method on an instance: 272 | 273 | ```ruby 274 | client.errors #=> { :name => ["must be present"] } 275 | ``` 276 | 277 | Just like Rails, you can also get the human-readable list of these errors by calling `#full_messages` on the errors hash. This is useful if you are using ChefAPI as a library and want to give developers a semantic error: 278 | 279 | ```ruby 280 | client.errors.full_messages #=> ["`name' must be present"] 281 | ``` 282 | 283 | You can also force ChefAPI to raise an exception if the validations fail, using the "bang" version of save - `save!`: 284 | 285 | ```ruby 286 | client.save! #=> InvalidResource: There were errors saving your resource: `name' must be present 287 | ``` 288 | 289 | ### Objects on Disk 290 | 291 | ChefAPI also has the ability to read and manipulate objects on disk. This varies from resource-to-resource, but the `.from_file` method accepts a path to a resource on disk and loads as much information about the object on disk as it can. The attributes are then merged with the remote resource, if one exists. For example, you can read a Client resource from disk: 292 | 293 | ```ruby 294 | client = Client.from_file('~/.chef/bacon.pem') #=> # 295 | ``` 296 | 297 | ## Searching 298 | 299 | ChefAPI employs both search and partial search functionality. 300 | 301 | ```ruby 302 | # Using regular search 303 | results = Search.query(:node, '*:*', start: 1) 304 | results.total #=> 5_000 305 | results.rows.each do |result| 306 | puts result 307 | end 308 | 309 | # Using partial search 310 | results = PartialSearch.query(:node, { data: ['fqdn'] }, start: 1) 311 | results.total #=> 2 312 | results.rows.each do |result| 313 | puts result 314 | end 315 | ``` 316 | 317 | ## FAQ 318 | 319 | **Q: How is this different than [Ridley](https://github.com/RiotGames/ridley)?**
320 | A: Ridley is optimized for highly concurrent connections with support for multiple Chef Servers. ChefAPI is designed for the "average user" who does not need the advanced use cases that Ridley provides. For this reason, the ChefAPI is incredibly opinionated about the features it will include. If you need complex features, take a look at [Ridley](https://github.com/RiotGames/ridley). 321 | 322 | ## Development 323 | 324 | 1. Clone the project on GitHub 325 | 2. Create a feature branch 326 | 3. Submit a Pull Request 327 | 328 | Important Notes: 329 | 330 | - **All new features must include test coverage.** At a bare minimum, Unit tests are required. It is preferred if you include acceptance tests as well. 331 | - **The tests must be be idempotent.** The HTTP calls made during a test should be able to be run over and over. 332 | - **Tests are order independent.** The default RSpec configuration randomizes the test order, so this should not be a problem. 333 | 334 | ## License & Authors 335 | 336 | - Author: Seth Vargo [sethvargo@gmail.com](mailto:sethvargo@gmail.com) 337 | 338 | ```text 339 | Copyright 2013-2014 Seth Vargo 340 | 341 | Licensed under the Apache License, Version 2.0 (the "License"); 342 | you may not use this file except in compliance with the License. 343 | You may obtain a copy of the License at 344 | 345 | http://www.apache.org/licenses/LICENSE-2.0 346 | 347 | Unless required by applicable law or agreed to in writing, software 348 | distributed under the License is distributed on an "AS IS" BASIS, 349 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 350 | See the License for the specific language governing permissions and 351 | limitations under the License. 352 | ``` 353 | 354 | [gem]: https://rubygems.org/gems/chef-api 355 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | require "bundler/gem_helper" 3 | 4 | Bundler::GemHelper.install_tasks name: "chef-api" 5 | 6 | require "rspec/core/rake_task" 7 | RSpec::Core::RakeTask.new do |t| 8 | t.rspec_opts = [ 9 | "--color", 10 | "--format progress", 11 | ].join(" ") 12 | end 13 | 14 | begin 15 | require "chefstyle" 16 | require "rubocop/rake_task" 17 | desc "Run Chefstyle tests" 18 | RuboCop::RakeTask.new(:style) do |task| 19 | task.options += ["--display-cop-names", "--no-color"] 20 | end 21 | rescue LoadError 22 | puts "chefstyle gem is not installed. bundle install first to make sure all dependencies are installed." 23 | end 24 | 25 | begin 26 | require "yard" 27 | YARD::Rake::YardocTask.new(:docs) 28 | rescue LoadError 29 | puts "yard is not available. bundle install first to make sure all dependencies are installed." 30 | end 31 | 32 | task default: %i{style spec} 33 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.10.10 -------------------------------------------------------------------------------- /chef-api.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "chef-api/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "chef-api" 7 | spec.version = ChefAPI::VERSION 8 | spec.authors = ["Seth Vargo", "Tim Smith"] 9 | spec.email = ["sethvargo@gmail.com", "tsmith84@gmail.com"] 10 | spec.description = "A tiny Chef Infra API client with minimal dependencies" 11 | spec.summary = "A Chef Infra API client in Ruby" 12 | spec.homepage = "https://github.com/chef/chef-api" 13 | spec.license = "Apache-2.0" 14 | 15 | spec.required_ruby_version = ">= 2.3" 16 | 17 | spec.files = %w{LICENSE} + Dir.glob("{lib,templates}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "mixlib-log", ">= 1", "< 4" # we need to validate 4.x before we loosen this 22 | spec.add_dependency "mime-types" 23 | end 24 | -------------------------------------------------------------------------------- /chef-infra-api.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "chef-api/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "chef-infra-api" 7 | spec.version = ChefAPI::VERSION 8 | spec.authors = ["Seth Vargo", "Tim Smith"] 9 | spec.email = ["sethvargo@gmail.com", "tsmith84@gmail.com"] 10 | spec.description = "A tiny Chef Infra API client with minimal dependencies" 11 | spec.summary = "A Chef Infra API client in Ruby" 12 | spec.homepage = "https://github.com/chef/chef-api" 13 | spec.license = "Apache-2.0" 14 | 15 | spec.required_ruby_version = ">= 2.3" 16 | 17 | spec.files = %w{LICENSE} + Dir.glob("{lib,templates}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "mixlib-log", ">= 1", "< 4" # we need to validate 4.x before we loosen this 22 | spec.add_dependency "mime-types" 23 | end 24 | -------------------------------------------------------------------------------- /lib/chef-api.rb: -------------------------------------------------------------------------------- 1 | require "json" unless defined?(JSON) 2 | require "pathname" unless defined?(Pathname) 3 | require_relative "chef-api/log" 4 | require_relative "chef-api/version" 5 | 6 | module ChefAPI 7 | autoload :Authentication, "chef-api/authentication" 8 | autoload :Boolean, "chef-api/boolean" 9 | autoload :Configurable, "chef-api/configurable" 10 | autoload :Connection, "chef-api/connection" 11 | autoload :Defaults, "chef-api/defaults" 12 | autoload :Error, "chef-api/errors" 13 | autoload :ErrorCollection, "chef-api/error_collection" 14 | autoload :Multipart, "chef-api/multipart" 15 | autoload :Resource, "chef-api/resource" 16 | autoload :Schema, "chef-api/schema" 17 | autoload :Util, "chef-api/util" 18 | autoload :Validator, "chef-api/validator" 19 | 20 | # 21 | # @todo Document this and why it's important 22 | # 23 | UNSET = Object.new 24 | 25 | class << self 26 | include ChefAPI::Configurable 27 | 28 | # 29 | # Set the log level. 30 | # 31 | # @example Set the log level to :info 32 | # ChefAPI.log_level = :info 33 | # 34 | # @param [Symbol] level 35 | # the log level to set 36 | # 37 | def log_level=(level) 38 | ChefAPI::Log.level = level 39 | end 40 | 41 | # 42 | # Get the current log level. 43 | # 44 | # @return [Symbol] 45 | # 46 | def log_level 47 | ChefAPI::Log.level 48 | end 49 | 50 | # 51 | # The source root of the ChefAPI gem. This is useful when requiring files 52 | # that are relative to the root of the project. 53 | # 54 | # @return [Pathname] 55 | # 56 | def root 57 | @root ||= Pathname.new(File.expand_path("../../", __FILE__)) 58 | end 59 | 60 | # 61 | # API connection object based off the configured options in {Configurable}. 62 | # 63 | # @return [ChefAPI::Connection] 64 | # 65 | def connection 66 | unless @connection && @connection.same_options?(options) 67 | @connection = ChefAPI::Connection.new(options) 68 | end 69 | 70 | @connection 71 | end 72 | 73 | # 74 | # Delegate all methods to the connection object, essentially making the 75 | # module object behave like a {Connection}. 76 | # 77 | def method_missing(m, *args, &block) 78 | if connection.respond_to?(m) 79 | connection.send(m, *args, &block) 80 | else 81 | super 82 | end 83 | end 84 | 85 | # 86 | # Delegating +respond_to+ to the {Connection}. 87 | # 88 | def respond_to_missing?(m, include_private = false) 89 | connection.respond_to?(m) || super 90 | end 91 | end 92 | end 93 | 94 | # Load the initial default values 95 | ChefAPI.setup 96 | -------------------------------------------------------------------------------- /lib/chef-api/aclable.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | module AclAble 3 | def acl_path 4 | resource_path + "/_acl" 5 | end 6 | 7 | def load_acl 8 | data = self.class.connection.get(acl_path) 9 | # make deep copy 10 | @orig_acl_data = Marshal.load(Marshal.dump(data)) 11 | data.freeze 12 | @acl = data 13 | end 14 | 15 | def acl 16 | unless @acl 17 | load_acl 18 | end 19 | @acl 20 | end 21 | 22 | def save! 23 | super 24 | if @acl != @orig_acl_data 25 | %w{create update grant read delete}.each { |action| 26 | if @acl[action] != @orig_acl_data[action] 27 | url = "#{acl_path}/#{action}" 28 | self.class.connection.put(url, { action => @acl[action] }.to_json) 29 | end 30 | } 31 | end 32 | end 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/chef-api/authentication.rb: -------------------------------------------------------------------------------- 1 | require "base64" unless defined?(Base64) 2 | require "digest" unless defined?(Digest) 3 | require "openssl" unless defined?(OpenSSL) 4 | require "time" unless defined?(Time) 5 | 6 | # 7 | # DEBUG steps: 8 | # 9 | # check .chomp 10 | # 11 | 12 | module ChefAPI 13 | class Authentication 14 | 15 | # @todo: Enable this in the future when Mixlib::Authentication supports 16 | # signing the full request body instead of just the uploaded file parameter. 17 | SIGN_FULL_BODY = false 18 | 19 | SIGNATURE = "algorithm=sha1;version=1.0;".freeze 20 | 21 | # Headers 22 | X_OPS_SIGN = "X-Ops-Sign".freeze 23 | X_OPS_USERID = "X-Ops-Userid".freeze 24 | X_OPS_TIMESTAMP = "X-Ops-Timestamp".freeze 25 | X_OPS_CONTENT_HASH = "X-Ops-Content-Hash".freeze 26 | X_OPS_AUTHORIZATION = "X-Ops-Authorization".freeze 27 | 28 | class << self 29 | # 30 | # Create a new signing object from the given options. All options are 31 | # required. 32 | # 33 | # @see (#initialize) 34 | # 35 | # @option options [String] :user 36 | # @option options [String, OpenSSL::PKey::RSA] :key 37 | # @option options [String, Symbol] verb 38 | # @option options [String] :path 39 | # @option options [String, IO] :body 40 | # 41 | def from_options(options = {}) 42 | user = options.fetch(:user) 43 | key = options.fetch(:key) 44 | verb = options.fetch(:verb) 45 | path = options.fetch(:path) 46 | body = options.fetch(:body) 47 | 48 | new(user, key, verb, path, body) 49 | end 50 | end 51 | 52 | # 53 | # Create a new Authentication object for signing. Creating an instance will 54 | # not run any validations or perform any operations (this is on purpose). 55 | # 56 | # @param [String] user 57 | # the username/client/user of the user to sign the request. In Hosted 58 | # Chef land, this is your "client". In Supermarket land, this is your 59 | # "username". 60 | # @param [String, OpenSSL::PKey::RSA] key 61 | # the path to a private key on disk, the raw private key (as a String), 62 | # or the raw private key (as an OpenSSL::PKey::RSA instance) 63 | # @param [Symbol, String] verb 64 | # the verb for the request (e.g. +:get+) 65 | # @param [String] path 66 | # the "path" part of the URI (e.g. +/path/to/resource+) 67 | # @param [String, IO] body 68 | # the body to sign for the request, as a raw string or an IO object to be 69 | # read in chunks 70 | # 71 | def initialize(user, key, verb, path, body) 72 | @user = user 73 | @key = key 74 | @verb = verb 75 | @path = path 76 | @body = body 77 | end 78 | 79 | # 80 | # The fully-qualified headers for this authentication object of the form: 81 | # 82 | # { 83 | # 'X-Ops-Sign' => 'algorithm=sha1;version=1.1', 84 | # 'X-Ops-Userid' => 'sethvargo', 85 | # 'X-Ops-Timestamp' => '2014-07-07T02:17:15Z', 86 | # 'X-Ops-Content-Hash' => '...', 87 | # 'x-Ops-Authorization-1' => '...' 88 | # 'x-Ops-Authorization-2' => '...' 89 | # 'x-Ops-Authorization-3' => '...' 90 | # # ... 91 | # } 92 | # 93 | # @return [Hash] 94 | # the signing headers 95 | # 96 | def headers 97 | { 98 | X_OPS_SIGN => SIGNATURE, 99 | X_OPS_USERID => @user, 100 | X_OPS_TIMESTAMP => canonical_timestamp, 101 | X_OPS_CONTENT_HASH => content_hash, 102 | }.merge(signature_lines) 103 | end 104 | 105 | # 106 | # The canonical body. This could be an IO object (such as +#body_stream+), 107 | # an actual string (such as +#body+), or just the empty string if the 108 | # request's body and stream was nil. 109 | # 110 | # @return [String, IO] 111 | # 112 | def content_hash 113 | return @content_hash if @content_hash 114 | 115 | if SIGN_FULL_BODY 116 | @content_hash = hash(@body || "").chomp 117 | else 118 | if @body.is_a?(Multipart::MultiIO) 119 | filepart = @body.ios.find { |io| io.is_a?(Multipart::MultiIO) } 120 | file = filepart.ios.find { |io| !io.is_a?(StringIO) } 121 | 122 | @content_hash = hash(file).chomp 123 | else 124 | @content_hash = hash(@body || "").chomp 125 | end 126 | end 127 | 128 | @content_hash 129 | end 130 | 131 | # 132 | # Parse the given private key. Users can specify the private key as: 133 | # 134 | # - the path to the key on disk 135 | # - the raw string key 136 | # - an +OpenSSL::PKey::RSA object+ 137 | # 138 | # Any other implementations are not supported and will likely explode. 139 | # 140 | # @todo 141 | # Handle errors when the file cannot be read due to insufficient 142 | # permissions 143 | # 144 | # @return [OpenSSL::PKey::RSA] 145 | # the RSA private key as an OpenSSL object 146 | # 147 | def canonical_key 148 | return @canonical_key if @canonical_key 149 | 150 | ChefAPI::Log.info "Parsing private key..." 151 | 152 | if @key.nil? 153 | ChefAPI::Log.warn "No private key given!" 154 | raise "No private key given!" 155 | end 156 | 157 | if @key.is_a?(OpenSSL::PKey::RSA) 158 | ChefAPI::Log.debug "Detected private key is an OpenSSL Ruby object" 159 | @canonical_key = @key 160 | elsif @key =~ /(.+)\.pem$/ || File.exist?(File.expand_path(@key)) 161 | ChefAPI::Log.debug "Detected private key is the path to a file" 162 | contents = File.read(File.expand_path(@key)) 163 | @canonical_key = OpenSSL::PKey::RSA.new(contents) 164 | else 165 | ChefAPI::Log.debug "Detected private key was the literal string key" 166 | @canonical_key = OpenSSL::PKey::RSA.new(@key) 167 | end 168 | 169 | @canonical_key 170 | end 171 | 172 | # 173 | # The canonical path, with duplicate and trailing slashes removed. This 174 | # value is then hashed. 175 | # 176 | # @example 177 | # "/zip//zap/foo" #=> "/zip/zap/foo" 178 | # 179 | # @return [String] 180 | # 181 | def canonical_path 182 | @canonical_path ||= hash(@path.squeeze("/").gsub(%r{(/)+$}, "")).chomp 183 | end 184 | 185 | # 186 | # The iso8601 timestamp for this request. This value must be cached so it 187 | # is persisted throughout this entire request. 188 | # 189 | # @return [String] 190 | # 191 | def canonical_timestamp 192 | @canonical_timestamp ||= Time.now.utc.iso8601 193 | end 194 | 195 | # 196 | # The uppercase verb. 197 | # 198 | # @example 199 | # :get #=> "GET" 200 | # 201 | # @return [String] 202 | # 203 | def canonical_method 204 | @canonical_method ||= @verb.to_s.upcase 205 | end 206 | 207 | # 208 | # The canonical request, from the path, body, user, and current timestamp. 209 | # 210 | # @return [String] 211 | # 212 | def canonical_request 213 | [ 214 | "Method:#{canonical_method}", 215 | "Hashed Path:#{canonical_path}", 216 | "X-Ops-Content-Hash:#{content_hash}", 217 | "X-Ops-Timestamp:#{canonical_timestamp}", 218 | "X-Ops-UserId:#{@user}", 219 | ].join("\n") 220 | end 221 | 222 | # 223 | # The canonical request, encrypted by the given private key. 224 | # 225 | # @return [String] 226 | # 227 | def encrypted_request 228 | canonical_key.private_encrypt(canonical_request) 229 | end 230 | 231 | # 232 | # The +X-Ops-Authorization-N+ headers. This method takes the encrypted 233 | # request, splits on a newline, and creates a signed header authentication 234 | # request. N begins at 1, not 0 because the original author of 235 | # Mixlib::Authentication did not believe in computer science. 236 | # 237 | # @return [Hash] 238 | # 239 | def signature_lines 240 | signature = Base64.encode64(encrypted_request) 241 | signature.split(/\n/).each_with_index.inject({}) do |hash, (line, index)| 242 | hash["#{X_OPS_AUTHORIZATION}-#{index + 1}"] = line 243 | hash 244 | end 245 | end 246 | 247 | private 248 | 249 | # 250 | # Hash the given object. 251 | # 252 | # @param [String, IO] object 253 | # a string or IO object to hash 254 | # 255 | # @return [String] 256 | # the hashed value 257 | # 258 | def hash(object) 259 | if object.respond_to?(:read) 260 | digest_io(object) 261 | else 262 | digest_string(object) 263 | end 264 | end 265 | 266 | # 267 | # Digest the given IO, reading in 1024 bytes at one time. 268 | # 269 | # @param [IO] io 270 | # the IO (or File object) 271 | # 272 | # @return [String] 273 | # 274 | def digest_io(io) 275 | digester = Digest::SHA1.new 276 | 277 | while buffer = io.read(1024) 278 | digester.update(buffer) 279 | end 280 | 281 | io.rewind 282 | 283 | Base64.encode64(digester.digest) 284 | end 285 | 286 | # 287 | # Digest a string. 288 | # 289 | # @param [String] string 290 | # the string to digest 291 | # 292 | # @return [String] 293 | # 294 | def digest_string(string) 295 | Base64.encode64(Digest::SHA1.digest(string)) 296 | end 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /lib/chef-api/boolean.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | module Boolean; end 3 | 4 | TrueClass.send(:include, Boolean) 5 | FalseClass.send(:include, Boolean) 6 | end 7 | -------------------------------------------------------------------------------- /lib/chef-api/configurable.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | # 3 | # A re-usable class containing configuration information for the {Connection}. 4 | # See {Defaults} for a list of default values. 5 | # 6 | module Configurable 7 | class << self 8 | # 9 | # The list of configurable keys. 10 | # 11 | # @return [Array] 12 | # 13 | def keys 14 | @keys ||= %i{ 15 | endpoint 16 | flavor 17 | client 18 | key 19 | proxy_address 20 | proxy_password 21 | proxy_port 22 | proxy_username 23 | ssl_pem_file 24 | ssl_verify 25 | user_agent 26 | read_timeout 27 | } 28 | end 29 | end 30 | 31 | # 32 | # Create one attribute getter and setter for each key. 33 | # 34 | ChefAPI::Configurable.keys.each do |key| 35 | attr_accessor key 36 | end 37 | 38 | # 39 | # Set the configuration for this config, using a block. 40 | # 41 | # @example Configure the API endpoint 42 | # ChefAPI.configure do |config| 43 | # config.endpoint = "http://www.my-ChefAPI-server.com/ChefAPI" 44 | # end 45 | # 46 | def configure 47 | yield self 48 | end 49 | 50 | # 51 | # Reset all configuration options to their default values. 52 | # 53 | # @example Reset all settings 54 | # ChefAPI.reset! 55 | # 56 | # @return [self] 57 | # 58 | def reset! 59 | ChefAPI::Configurable.keys.each do |key| 60 | instance_variable_set(:"@#{key}", Defaults.options[key]) 61 | end 62 | self 63 | end 64 | alias_method :setup, :reset! 65 | 66 | private 67 | 68 | # 69 | # The list of configurable keys, as an options hash. 70 | # 71 | # @return [Hash] 72 | # 73 | def options 74 | map = ChefAPI::Configurable.keys.map do |key| 75 | [key, instance_variable_get(:"@#{key}")] 76 | end 77 | Hash[map] 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/chef-api/connection.rb: -------------------------------------------------------------------------------- 1 | require "net/http" unless defined?(Net::HTTP) 2 | require "openssl" unless defined?(OpenSSL) 3 | require "uri" unless defined?(URI) 4 | require "cgi" unless defined?(CGI) 5 | 6 | module ChefAPI 7 | # 8 | # Connection object for the ChefAPI API. 9 | # 10 | # @see https://docs.chef.io/api_chef_server.html 11 | # 12 | class Connection 13 | class << self 14 | # 15 | # @private 16 | # 17 | # @macro proxy 18 | # @method $1 19 | # Get the list of $1 for this {Connection}. This method is threadsafe. 20 | # 21 | # @example Get the $1 from this {Connection} object 22 | # connection = ChefAPI::Connection.new('...') 23 | # connection.$1 #=> $2(attribute1, attribute2, ...) 24 | # 25 | # @return [Class<$2>] 26 | # 27 | def proxy(name, klass) 28 | class_eval <<-EOH, __FILE__, __LINE__ + 1 29 | def #{name} 30 | Thread.current['chefapi.connection'] = self 31 | #{klass} 32 | end 33 | EOH 34 | end 35 | end 36 | 37 | include ChefAPI::Configurable 38 | 39 | proxy :clients, "Resource::Client" 40 | proxy :cookbooks, "Resource::Cookbook" 41 | proxy :data_bags, "Resource::DataBag" 42 | proxy :data_bag_item, "Resource::DataBagItem" 43 | proxy :environments, "Resource::Environment" 44 | proxy :groups, "Resource::Group" 45 | proxy :nodes, "Resource::Node" 46 | proxy :partial_search, "Resource::PartialSearch" 47 | proxy :principals, "Resource::Principal" 48 | proxy :roles, "Resource::Role" 49 | proxy :search, "Resource::Search" 50 | proxy :users, "Resource::User" 51 | proxy :organizations, "Resource::Organization" 52 | 53 | # 54 | # Create a new ChefAPI Connection with the given options. Any options 55 | # given take precedence over the default options. 56 | # 57 | # @example Create a connection object from a list of options 58 | # ChefAPI::Connection.new( 59 | # endpoint: 'https://...', 60 | # client: 'bacon', 61 | # key: '~/.chef/bacon.pem', 62 | # ) 63 | # 64 | # @example Create a connection object using a block 65 | # ChefAPI::Connection.new do |connection| 66 | # connection.endpoint = 'https://...' 67 | # connection.client = 'bacon' 68 | # connection.key = '~/.chef/bacon.pem' 69 | # end 70 | # 71 | # @return [ChefAPI::Connection] 72 | # 73 | def initialize(options = {}) 74 | # Use any options given, but fall back to the defaults set on the module 75 | ChefAPI::Configurable.keys.each do |key| 76 | value = if options[key].nil? 77 | ChefAPI.instance_variable_get(:"@#{key}") 78 | else 79 | options[key] 80 | end 81 | 82 | instance_variable_set(:"@#{key}", value) 83 | end 84 | 85 | yield self if block_given? 86 | end 87 | 88 | # 89 | # Determine if the given options are the same as ours. 90 | # 91 | # @return [Boolean] 92 | # 93 | def same_options?(opts) 94 | opts.hash == options.hash 95 | end 96 | 97 | # 98 | # Make a HTTP GET request 99 | # 100 | # @param path (see Connection#request) 101 | # @param [Hash] params 102 | # the list of query params 103 | # @param request_options (see Connection#request) 104 | # 105 | # @raise (see Connection#request) 106 | # @return (see Connection#request) 107 | # 108 | def get(path, params = {}, request_options = {}) 109 | request(:get, path, params) 110 | end 111 | 112 | # 113 | # Make a HTTP POST request 114 | # 115 | # @param path (see Connection#request) 116 | # @param [String, #read] data 117 | # the body to use for the request 118 | # @param [Hash] params 119 | # the list of query params 120 | # @param request_options (see Connection#request) 121 | # 122 | # @raise (see Connection#request) 123 | # @return (see Connection#request) 124 | # 125 | def post(path, data, params = {}, request_options = {}) 126 | request(:post, path, data, params) 127 | end 128 | 129 | # 130 | # Make a HTTP PUT request 131 | # 132 | # @param path (see Connection#request) 133 | # @param data (see Connection#post) 134 | # @param params (see Connection#post) 135 | # @param request_options (see Connection#request) 136 | # 137 | # @raise (see Connection#request) 138 | # @return (see Connection#request) 139 | # 140 | def put(path, data, params = {}, request_options = {}) 141 | request(:put, path, data, params) 142 | end 143 | 144 | # 145 | # Make a HTTP PATCH request 146 | # 147 | # @param path (see Connection#request) 148 | # @param data (see Connection#post) 149 | # @param params (see Connection#post) 150 | # @param request_options (see Connection#request) 151 | # 152 | # @raise (see Connection#request) 153 | # @return (see Connection#request) 154 | # 155 | def patch(path, data, params = {}, request_options = {}) 156 | request(:patch, path, data, params) 157 | end 158 | 159 | # 160 | # Make a HTTP DELETE request 161 | # 162 | # @param path (see Connection#request) 163 | # @param params (see Connection#get) 164 | # @param request_options (see Connection#request) 165 | # 166 | # @raise (see Connection#request) 167 | # @return (see Connection#request) 168 | # 169 | def delete(path, params = {}, request_options = {}) 170 | request(:delete, path, params) 171 | end 172 | 173 | # 174 | # Make an HTTP request with the given verb, data, params, and headers. If 175 | # the response has a return type of JSON, the JSON is automatically parsed 176 | # and returned as a hash; otherwise it is returned as a string. 177 | # 178 | # @raise [Error::HTTPError] 179 | # if the request is not an HTTP 200 OK 180 | # 181 | # @param [Symbol] verb 182 | # the lowercase symbol of the HTTP verb (e.g. :get, :delete) 183 | # @param [String] path 184 | # the absolute or relative path from {Defaults.endpoint} to make the 185 | # request against 186 | # @param [#read, Hash, nil] data 187 | # the data to use (varies based on the +verb+) 188 | # @param [Hash] params 189 | # the params to use for :patch, :post, :put 190 | # @param [Hash] request_options 191 | # the list of options/configurables for the actual request 192 | # 193 | # @option request_options [true, false] :sign (default: +true+) 194 | # whether to sign the request using mixlib authentication headers 195 | # 196 | # @return [String, Hash] 197 | # the response body 198 | # 199 | def request(verb, path, data = {}, params = {}, request_options = {}) 200 | ChefAPI::Log.info "#{verb.to_s.upcase} #{path}..." 201 | ChefAPI::Log.debug "Chef flavor: #{flavor.inspect}" 202 | 203 | # Build the URI and request object from the given information 204 | if %i{delete get}.include?(verb) 205 | uri = build_uri(verb, path, data) 206 | else 207 | uri = build_uri(verb, path, params) 208 | end 209 | request = class_for_request(verb).new(uri.request_uri) 210 | 211 | # Add request headers 212 | add_request_headers(request) 213 | 214 | # Setup PATCH/POST/PUT 215 | if %i{patch post put}.include?(verb) 216 | if data.respond_to?(:read) 217 | ChefAPI::Log.info "Detected file/io presence" 218 | request.body_stream = data 219 | elsif data.is_a?(Hash) 220 | # If any of the values in the hash are File-like, assume this is a 221 | # multi-part post 222 | if data.values.any? { |value| value.respond_to?(:read) } 223 | ChefAPI::Log.info "Detected multipart body" 224 | 225 | multipart = Multipart::Body.new(data) 226 | 227 | ChefAPI::Log.debug "Content-Type: #{multipart.content_type}" 228 | ChefAPI::Log.debug "Content-Length: #{multipart.content_length}" 229 | 230 | request.content_length = multipart.content_length 231 | request.content_type = multipart.content_type 232 | 233 | request.body_stream = multipart.stream 234 | else 235 | ChefAPI::Log.info "Detected form data" 236 | request.form_data = data 237 | end 238 | else 239 | ChefAPI::Log.info "Detected regular body" 240 | request.body = data 241 | end 242 | end 243 | 244 | # Sign the request 245 | if request_options[:sign] == false 246 | ChefAPI::Log.info "Skipping signed header authentication (user requested)..." 247 | else 248 | add_signing_headers(verb, uri.path, request) 249 | end 250 | 251 | # Create the HTTP connection object - since the proxy information defaults 252 | # to +nil+, we can just pass it to the initializer method instead of doing 253 | # crazy strange conditionals. 254 | connection = Net::HTTP.new(uri.host, uri.port, 255 | proxy_address, proxy_port, proxy_username, proxy_password) 256 | 257 | # Large or filtered result sets can take a long time to return, so allow 258 | # setting a longer read_timeout for our client if we want to make an 259 | # expensive request. 260 | connection.read_timeout = read_timeout if read_timeout 261 | 262 | # Apply SSL, if applicable 263 | if uri.scheme == "https" 264 | # Turn on SSL 265 | connection.use_ssl = true 266 | 267 | # Custom pem files, no problem! 268 | if ssl_pem_file 269 | pem = File.read(ssl_pem_file) 270 | connection.cert = OpenSSL::X509::Certificate.new(pem) 271 | connection.key = OpenSSL::PKey::RSA.new(pem) 272 | connection.verify_mode = OpenSSL::SSL::VERIFY_PEER 273 | end 274 | 275 | # Naughty, naughty, naughty! Don't blame when when someone hops in 276 | # and executes a MITM attack! 277 | unless ssl_verify 278 | ChefAPI::Log.warn "Disabling SSL verification..." 279 | ChefAPI::Log.warn "Neither ChefAPI nor the maintainers are responsible for " \ 280 | "damages incurred as a result of disabling SSL verification. " \ 281 | "Please use this with extreme caution, or consider specifying " \ 282 | "a custom certificate using `config.ssl_pem_file'." 283 | connection.verify_mode = OpenSSL::SSL::VERIFY_NONE 284 | end 285 | end 286 | 287 | # Create a connection using the block form, which will ensure the socket 288 | # is properly closed in the event of an error. 289 | connection.start do |http| 290 | response = http.request(request) 291 | 292 | ChefAPI::Log.debug "Raw response:" 293 | ChefAPI::Log.debug response.body 294 | 295 | case response 296 | when Net::HTTPRedirection 297 | redirect = URI.parse(response["location"]).to_s 298 | ChefAPI::Log.debug "Performing HTTP redirect to #{redirect}" 299 | request(verb, redirect, data) 300 | when Net::HTTPSuccess 301 | success(response) 302 | else 303 | error(response) 304 | end 305 | end 306 | rescue SocketError, Errno::ECONNREFUSED, EOFError 307 | ChefAPI::Log.warn "Unable to reach the Chef Server" 308 | raise Error::HTTPServerUnavailable.new 309 | end 310 | 311 | # 312 | # Construct a URL from the given verb and path. If the request is a GET or 313 | # DELETE request, the params are assumed to be query params are are 314 | # converted as such using {Connection#to_query_string}. 315 | # 316 | # If the path is relative, it is merged with the {Defaults.endpoint} 317 | # attribute. If the path is absolute, it is converted to a URI object and 318 | # returned. 319 | # 320 | # @param [Symbol] verb 321 | # the lowercase HTTP verb (e.g. :+get+) 322 | # @param [String] path 323 | # the absolute or relative HTTP path (url) to get 324 | # @param [Hash] params 325 | # the list of params to build the URI with (for GET and DELETE requests) 326 | # 327 | # @return [URI] 328 | # 329 | def build_uri(verb, path, params = {}) 330 | ChefAPI::Log.info "Building URI..." 331 | 332 | # Add any query string parameters 333 | if querystring = to_query_string(params) 334 | ChefAPI::Log.debug "Detected verb deserves a querystring" 335 | ChefAPI::Log.debug "Building querystring using #{params.inspect}" 336 | ChefAPI::Log.debug "Compiled querystring is #{querystring.inspect}" 337 | path = [path, querystring].compact.join("?") 338 | end 339 | 340 | # Parse the URI 341 | uri = URI.parse(path) 342 | 343 | # Don't merge absolute URLs 344 | unless uri.absolute? 345 | ChefAPI::Log.debug "Detected URI is relative" 346 | ChefAPI::Log.debug "Appending #{path} to #{endpoint}" 347 | uri = URI.parse(File.join(endpoint, path)) 348 | end 349 | 350 | # Return the URI object 351 | uri 352 | end 353 | 354 | # 355 | # Helper method to get the corresponding +Net::HTTP+ class from the given 356 | # HTTP verb. 357 | # 358 | # @param [#to_s] verb 359 | # the HTTP verb to create a class from 360 | # 361 | # @return [Class] 362 | # 363 | def class_for_request(verb) 364 | Net::HTTP.const_get(verb.to_s.capitalize) 365 | end 366 | 367 | # 368 | # Convert the given hash to a list of query string parameters. Each key and 369 | # value in the hash is URI-escaped for safety. 370 | # 371 | # @param [Hash] hash 372 | # the hash to create the query string from 373 | # 374 | # @return [String, nil] 375 | # the query string as a string, or +nil+ if there are no params 376 | # 377 | def to_query_string(hash) 378 | hash.map do |key, value| 379 | "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}" 380 | end.join("&")[/.+/] 381 | end 382 | 383 | private 384 | 385 | # 386 | # Parse the response object and manipulate the result based on the given 387 | # +Content-Type+ header. For now, this method only parses JSON, but it 388 | # could be expanded in the future to accept other content types. 389 | # 390 | # @param [HTTP::Message] response 391 | # the response object from the request 392 | # 393 | # @return [String, Hash] 394 | # the parsed response, as an object 395 | # 396 | def success(response) 397 | ChefAPI::Log.info "Parsing response as success..." 398 | 399 | case response["Content-Type"] 400 | when /json/ 401 | ChefAPI::Log.debug "Detected response as JSON" 402 | ChefAPI::Log.debug "Parsing response body as JSON" 403 | JSON.parse(response.body) 404 | else 405 | ChefAPI::Log.debug "Detected response as text/plain" 406 | response.body 407 | end 408 | end 409 | 410 | # 411 | # Raise a response error, extracting as much information from the server's 412 | # response as possible. 413 | # 414 | # @param [HTTP::Message] response 415 | # the response object from the request 416 | # 417 | def error(response) 418 | ChefAPI::Log.info "Parsing response as error..." 419 | 420 | case response["Content-Type"] 421 | when /json/ 422 | ChefAPI::Log.debug "Detected error response as JSON" 423 | ChefAPI::Log.debug "Parsing error response as JSON" 424 | message = JSON.parse(response.body) 425 | else 426 | ChefAPI::Log.debug "Detected response as text/plain" 427 | message = response.body 428 | end 429 | 430 | case response.code.to_i 431 | when 400 432 | raise Error::HTTPBadRequest.new(message: message) 433 | when 401 434 | raise Error::HTTPUnauthorizedRequest.new(message: message) 435 | when 403 436 | raise Error::HTTPForbiddenRequest.new(message: message) 437 | when 404 438 | raise Error::HTTPNotFound.new(message: message) 439 | when 405 440 | raise Error::HTTPMethodNotAllowed.new(message: message) 441 | when 406 442 | raise Error::HTTPNotAcceptable.new(message: message) 443 | when 504 444 | raise Error::HTTPGatewayTimeout.new(message: message) 445 | when 500..600 446 | raise Error::HTTPServerUnavailable.new 447 | else 448 | raise "I got an error #{response.code} that I don't know how to handle!" 449 | end 450 | end 451 | 452 | # 453 | # Adds the default headers to the request object. 454 | # 455 | # @param [Net::HTTP::Request] request 456 | # 457 | def add_request_headers(request) 458 | ChefAPI::Log.info "Adding request headers..." 459 | 460 | headers = { 461 | "Accept" => "application/json", 462 | "Content-Type" => "application/json", 463 | "Connection" => "keep-alive", 464 | "Keep-Alive" => "30", 465 | "User-Agent" => user_agent, 466 | "X-Chef-Version" => "11.4.0", 467 | } 468 | 469 | headers.each do |key, value| 470 | ChefAPI::Log.debug "#{key}: #{value}" 471 | request[key] = value 472 | end 473 | end 474 | 475 | # 476 | # Create a signed header authentication that can be consumed by 477 | # +Mixlib::Authentication+. 478 | # 479 | # @param [Symbol] verb 480 | # the HTTP verb (e.g. +:get+) 481 | # @param [String] path 482 | # the requested URI path (e.g. +/resources/foo+) 483 | # @param [Net::HTTP::Request] request 484 | # 485 | def add_signing_headers(verb, path, request) 486 | ChefAPI::Log.info "Adding signed header authentication..." 487 | 488 | authentication = Authentication.from_options( 489 | user: client, 490 | key: key, 491 | verb: verb, 492 | path: path, 493 | body: request.body || request.body_stream 494 | ) 495 | 496 | authentication.headers.each do |key, value| 497 | ChefAPI::Log.debug "#{key}: #{value}" 498 | request[key] = value 499 | end 500 | 501 | if request.body_stream 502 | request.body_stream.rewind 503 | end 504 | end 505 | end 506 | end 507 | -------------------------------------------------------------------------------- /lib/chef-api/defaults.rb: -------------------------------------------------------------------------------- 1 | require_relative "version" 2 | require "pathname" unless defined?(Pathname) 3 | require "json" unless defined?(JSON) 4 | 5 | module ChefAPI 6 | module Defaults 7 | # Default API endpoint 8 | ENDPOINT = "https://api.opscode.com/".freeze 9 | 10 | # Default User Agent header string 11 | USER_AGENT = "ChefAPI Ruby Gem #{ChefAPI::VERSION}".freeze 12 | 13 | class << self 14 | # 15 | # The list of calculated default options for the configuration. 16 | # 17 | # @return [Hash] 18 | # 19 | def options 20 | Hash[Configurable.keys.map { |key| [key, send(key)] }] 21 | end 22 | 23 | # 24 | # The Chef API configuration 25 | # 26 | # @return [Hash] 27 | def config 28 | path = config_path 29 | @config ||= path.exist? ? JSON.parse(path.read) : {} 30 | end 31 | 32 | # 33 | # Pathname to configuration file, or a blank Pathname. 34 | # 35 | # @return [Pathname] an expanded Pathname or a non-existent Pathname 36 | def config_path 37 | if result = chef_api_config_path 38 | Pathname(result).expand_path 39 | else 40 | Pathname("") 41 | end 42 | end 43 | 44 | # 45 | # String representation of path to configuration file 46 | # 47 | # @return [String, nil] Path to config file, or nil 48 | def chef_api_config_path 49 | ENV["CHEF_API_CONFIG"] || if ENV.key?("HOME") 50 | "~/.chef-api" 51 | else 52 | nil 53 | end 54 | end 55 | 56 | # 57 | # The endpoint where the Chef Server lives. This is equivalent to the 58 | # +chef_server_url+ in Chef terminology. If you are using Enterprise 59 | # Hosted Chef or Enterprise Chef on premise, this endpoint should include 60 | # your organization name. For example: 61 | # 62 | # https://api.opscode.com/organizations/bacon 63 | # 64 | # If you are running Open Source Chef Server or Chef Zero, this is the 65 | # full URL to your Chef Server instance, including the server port and 66 | # FQDN. 67 | # 68 | # https://chef.server.local:4567/ 69 | # 70 | # @return [String] (default: +https://api.opscode.com/+) 71 | # 72 | def endpoint 73 | ENV["CHEF_API_ENDPOINT"] || config["CHEF_API_ENDPOINT"] || ENDPOINT 74 | end 75 | 76 | # 77 | # The flavor of the target Chef Server. There are two possible values: 78 | # 79 | # - enterprise 80 | # - open_source 81 | # 82 | # "Enterprise" covers both Hosted Chef and Enterprise Chef. "Open Source" 83 | # covers both Chef Zero and Open Source Chef Server. 84 | # 85 | # @return [true, false] 86 | # 87 | def flavor 88 | if ENV["CHEF_API_FLAVOR"] 89 | ENV["CHEF_API_FLAVOR"].to_sym 90 | elsif config["CHEF_API_FLAVOR"] 91 | config["CHEF_API_FLAVOR"] 92 | else 93 | endpoint.include?("/organizations") ? :enterprise : :open_source 94 | end 95 | end 96 | 97 | # 98 | # The User Agent header to send along. 99 | # 100 | # @return [String] 101 | # 102 | def user_agent 103 | ENV["CHEF_API_USER_AGENT"] || config["CHEF_API_USER_AGENT"] || USER_AGENT 104 | end 105 | 106 | # 107 | # The name of the Chef API client. This is the equivalent of 108 | # +client_name+ in Chef terminology. In most cases, this is your Chef 109 | # username. 110 | # 111 | # @return [String, nil] 112 | # 113 | def client 114 | ENV["CHEF_API_CLIENT"] || config["CHEF_API_CLIENT"] 115 | end 116 | 117 | # 118 | # The private key to authentication against the Chef Server. This is 119 | # equivalent to the +client_key+ in Chef terminology. This value can 120 | # be the client key in plain text or a path to the key on disk. 121 | # 122 | # @return [String, nil] 123 | # 124 | def key 125 | ENV["CHEF_API_KEY"] || config["CHEF_API_KEY"] 126 | end 127 | 128 | # 129 | # The HTTP Proxy server address as a string 130 | # 131 | # @return [String, nil] 132 | # 133 | def proxy_address 134 | ENV["CHEF_API_PROXY_ADDRESS"] || config["CHEF_API_PROXY_ADDRESS"] 135 | end 136 | 137 | # 138 | # The HTTP Proxy user password as a string 139 | # 140 | # @return [String, nil] 141 | # 142 | def proxy_password 143 | ENV["CHEF_API_PROXY_PASSWORD"] || config["CHEF_API_PROXY_PASSWORD"] 144 | end 145 | 146 | # 147 | # The HTTP Proxy server port as a string 148 | # 149 | # @return [String, nil] 150 | # 151 | def proxy_port 152 | ENV["CHEF_API_PROXY_PORT"] || config["CHEF_API_PROXY_PORT"] 153 | end 154 | 155 | # 156 | # The HTTP Proxy server username as a string 157 | # 158 | # @return [String, nil] 159 | # 160 | def proxy_username 161 | ENV["CHEF_API_PROXY_USERNAME"] || config["CHEF_API_PROXY_USERNAME"] 162 | end 163 | 164 | # 165 | # The path to a pem file on disk for use with a custom SSL verification 166 | # 167 | # @return [String, nil] 168 | # 169 | def ssl_pem_file 170 | ENV["CHEF_API_SSL_PEM_FILE"] || config["CHEF_API_SSL_PEM_FILE"] 171 | end 172 | 173 | # 174 | # Verify SSL requests (default: true) 175 | # 176 | # @return [true, false] 177 | # 178 | def ssl_verify 179 | if ENV["CHEF_API_SSL_VERIFY"].nil? && config["CHEF_API_SSL_VERIFY"].nil? 180 | true 181 | else 182 | %w{t y}.include?(ENV["CHEF_API_SSL_VERIFY"].downcase[0]) || config["CHEF_API_SSL_VERIFY"] 183 | end 184 | end 185 | 186 | # 187 | # Network request read timeout in seconds (default: 60) 188 | # 189 | # @return [Integer, nil] 190 | # 191 | def read_timeout 192 | timeout_from_env = ENV["CHEF_API_READ_TIMEOUT"] || config["CHEF_API_READ_TIMEOUT"] 193 | 194 | Integer(timeout_from_env) unless timeout_from_env.nil? 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /lib/chef-api/error_collection.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | # 3 | # Private internal class for managing the error collection. 4 | # 5 | class ErrorCollection < Hash 6 | # 7 | # The default proc for the hash needs to be an empty Array. 8 | # 9 | # @return [Proc] 10 | # 11 | def initialize 12 | super { |h, k| h[k] = [] } 13 | end 14 | 15 | # 16 | # Add a new error to the hash. 17 | # 18 | # @param [Symbol] key 19 | # the attribute key 20 | # @param [String] error 21 | # the error message to push 22 | # 23 | # @return [self] 24 | # 25 | def add(key, error) 26 | self[key].push(error) 27 | self 28 | end 29 | 30 | # 31 | # Output the full messages for each error. This is useful for displaying 32 | # information about validation to the user when something goes wrong. 33 | # 34 | # @return [Array] 35 | # 36 | def full_messages 37 | map do |key, errors| 38 | errors.map do |error| 39 | "`#{key}' #{error}" 40 | end 41 | end.flatten 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/chef-api/errors.rb: -------------------------------------------------------------------------------- 1 | require "erb" unless defined?(Erb) 2 | 3 | module ChefAPI 4 | module Error 5 | class ErrorBinding 6 | def initialize(options = {}) 7 | options.each do |key, value| 8 | instance_variable_set(:"@#{key}", value) 9 | end 10 | end 11 | 12 | def get_binding 13 | binding 14 | end 15 | end 16 | 17 | class ChefAPIError < StandardError 18 | def initialize(options = {}) 19 | @options = options 20 | @filename = options.delete(:_template) 21 | 22 | super() 23 | end 24 | 25 | def message 26 | erb = ERB.new(File.read(template)) 27 | erb.result(ErrorBinding.new(@options).get_binding) 28 | end 29 | alias_method :to_s, :message 30 | 31 | private 32 | 33 | def template 34 | class_name = self.class.to_s.split("::").last 35 | filename = @filename || Util.underscore(class_name) 36 | ChefAPI.root.join("templates", "errors", "#{filename}.erb") 37 | end 38 | end 39 | 40 | class AbstractMethod < ChefAPIError; end 41 | class CannotRegenerateKey < ChefAPIError; end 42 | class FileNotFound < ChefAPIError; end 43 | 44 | class HTTPError < ChefAPIError; end 45 | class HTTPBadRequest < HTTPError; end 46 | class HTTPForbiddenRequest < HTTPError; end 47 | class HTTPGatewayTimeout < HTTPError; end 48 | class HTTPNotAcceptable < HTTPError; end 49 | class HTTPNotFound < HTTPError; end 50 | class HTTPMethodNotAllowed < HTTPError; end 51 | class HTTPServerUnavailable < HTTPError; end 52 | 53 | class HTTPUnauthorizedRequest < ChefAPIError; end 54 | class InsufficientFilePermissions < ChefAPIError; end 55 | class InvalidResource < ChefAPIError; end 56 | class InvalidValidator < ChefAPIError; end 57 | class MissingURLParameter < ChefAPIError; end 58 | class NotADirectory < ChefAPIError; end 59 | class ResourceAlreadyExists < ChefAPIError; end 60 | class ResourceNotFound < ChefAPIError; end 61 | class ResourceNotMutable < ChefAPIError; end 62 | class UnknownAttribute < ChefAPIError; end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/chef-api/log.rb: -------------------------------------------------------------------------------- 1 | require "mixlib/log" 2 | 3 | module ChefAPI 4 | class Log 5 | extend Mixlib::Log 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/chef-api/multipart.rb: -------------------------------------------------------------------------------- 1 | require "cgi" unless defined?(CGI) 2 | require "mime/types" unless defined?(MIME::Types) 3 | 4 | module ChefAPI 5 | module Multipart 6 | BOUNDARY = "------ChefAPIMultipartBoundary".freeze 7 | 8 | class Body 9 | def initialize(params = {}) 10 | params.each do |key, value| 11 | if value.respond_to?(:read) 12 | parts << FilePart.new(key, value) 13 | else 14 | parts << ParamPart.new(key, value) 15 | end 16 | end 17 | 18 | parts << EndingPart.new 19 | end 20 | 21 | def stream 22 | MultiIO.new(*parts.map(&:io)) 23 | end 24 | 25 | def content_type 26 | "multipart/form-data; boundary=#{BOUNDARY}" 27 | end 28 | 29 | def content_length 30 | parts.map(&:size).inject(:+) 31 | end 32 | 33 | private 34 | 35 | def parts 36 | @parts ||= [] 37 | end 38 | end 39 | 40 | class MultiIO 41 | attr_reader :ios 42 | 43 | def initialize(*ios) 44 | @ios = ios 45 | @index = 0 46 | end 47 | 48 | # Read from IOs in order until `length` bytes have been received. 49 | def read(length = nil, outbuf = nil) 50 | got_result = false 51 | outbuf = outbuf ? outbuf.replace("") : "" 52 | 53 | while io = current_io 54 | if result = io.read(length) 55 | got_result ||= !result.nil? 56 | result.force_encoding("BINARY") if result.respond_to?(:force_encoding) 57 | outbuf << result 58 | length -= result.length if length 59 | break if length == 0 60 | end 61 | advance_io 62 | end 63 | 64 | (!got_result && length) ? nil : outbuf 65 | end 66 | 67 | def rewind 68 | @ios.each(&:rewind) 69 | @index = 0 70 | end 71 | 72 | private 73 | 74 | def current_io 75 | @ios[@index] 76 | end 77 | 78 | def advance_io 79 | @index += 1 80 | end 81 | end 82 | 83 | # 84 | # A generic key => value part. 85 | # 86 | class ParamPart 87 | def initialize(name, value) 88 | @part = build(name, value) 89 | end 90 | 91 | def io 92 | @io ||= StringIO.new(@part) 93 | end 94 | 95 | def size 96 | @part.bytesize 97 | end 98 | 99 | private 100 | 101 | def build(name, value) 102 | part = %{--#{BOUNDARY}\r\n} 103 | part << %{Content-Disposition: form-data; name="#{CGI.escape(name)}"\r\n\r\n} 104 | part << %{#{value}\r\n} 105 | part 106 | end 107 | end 108 | 109 | # 110 | # A File part 111 | # 112 | class FilePart 113 | def initialize(name, file) 114 | @file = file 115 | @head = build(name, file) 116 | @foot = "\r\n" 117 | end 118 | 119 | def io 120 | @io ||= MultiIO.new( 121 | StringIO.new(@head), 122 | @file, 123 | StringIO.new(@foot) 124 | ) 125 | end 126 | 127 | def size 128 | @head.bytesize + @file.size + @foot.bytesize 129 | end 130 | 131 | private 132 | 133 | def build(name, file) 134 | filename = File.basename(file.path) 135 | mime_type = MIME::Types.type_for(filename)[0] || MIME::Types["application/octet-stream"][0] 136 | 137 | part = %{--#{BOUNDARY}\r\n} 138 | part << %{Content-Disposition: form-data; name="#{CGI.escape(name)}"; filename="#{filename}"\r\n} 139 | part << %{Content-Length: #{file.size}\r\n} 140 | part << %{Content-Type: #{mime_type.simplified}\r\n} 141 | part << %{Content-Transfer-Encoding: binary\r\n} 142 | part << %{\r\n} 143 | part 144 | end 145 | end 146 | 147 | # 148 | # The end of the entire request 149 | # 150 | class EndingPart 151 | def initialize 152 | @part = "--#{BOUNDARY}--\r\n\r\n" 153 | end 154 | 155 | def io 156 | @io ||= StringIO.new(@part) 157 | end 158 | 159 | def size 160 | @part.bytesize 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/chef-api/resource.rb: -------------------------------------------------------------------------------- 1 | require_relative "aclable" 2 | module ChefAPI 3 | module Resource 4 | autoload :Base, "chef-api/resources/base" 5 | autoload :Client, "chef-api/resources/client" 6 | autoload :CollectionProxy, "chef-api/resources/collection_proxy" 7 | autoload :Cookbook, "chef-api/resources/cookbook" 8 | autoload :CookbookVersion, "chef-api/resources/cookbook_version" 9 | autoload :DataBag, "chef-api/resources/data_bag" 10 | autoload :DataBagItem, "chef-api/resources/data_bag_item" 11 | autoload :Environment, "chef-api/resources/environment" 12 | autoload :Group, "chef-api/resources/group" 13 | autoload :Node, "chef-api/resources/node" 14 | autoload :Organization, "chef-api/resources/organization" 15 | autoload :PartialSearch, "chef-api/resources/partial_search" 16 | autoload :Principal, "chef-api/resources/principal" 17 | autoload :Role, "chef-api/resources/role" 18 | autoload :Search, "chef-api/resources/search" 19 | autoload :User, "chef-api/resources/user" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/chef-api/resources/base.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Base 3 | class << self 4 | require "cgi" unless defined?(CGI) 5 | 6 | # Including the Enumberable module gives us magic 7 | include Enumerable 8 | 9 | # 10 | # Load the given resource from it's on-disk equivalent. This action only 11 | # makes sense for some resources, and must be defined on a per-resource 12 | # basis, since the logic varies between resources. 13 | # 14 | # @param [String] path 15 | # the path to the file on disk 16 | # 17 | def from_file(path) 18 | raise Error::AbstractMethod.new(method: "Resource::Base#from_file") 19 | end 20 | 21 | # 22 | # @todo doc 23 | # 24 | def from_url(url, prefix = {}) 25 | from_json(connection.get(url), prefix) 26 | end 27 | 28 | # 29 | # Get or set the schema for the remote resource. You probably only want 30 | # to call schema once with a block, because it will overwrite the 31 | # existing schema (meaning entries are not merged). If a block is given, 32 | # a new schema object is created, otherwise the current one is returned. 33 | # 34 | # @example 35 | # schema do 36 | # attribute :id, primary: true 37 | # attribute :name, type: String, default: 'bacon' 38 | # attribute :admin, type: Boolean, required: true 39 | # end 40 | # 41 | # @return [Schema] 42 | # the schema object for this resource 43 | # 44 | def schema(&block) 45 | if block 46 | @schema = Schema.new(&block) 47 | else 48 | @schema 49 | end 50 | end 51 | 52 | # 53 | # Protect one or more resources from being altered by the user. This is 54 | # useful if there's an admin client or magical cookbook that you don't 55 | # want users to modify. 56 | # 57 | # @example 58 | # protect 'chef-webui', 'my-magical-validator' 59 | # 60 | # @example 61 | # protect ->(resource) { resource.name =~ /internal_(.+)/ } 62 | # 63 | # @param [Array] ids 64 | # the list of "things" to protect 65 | # 66 | def protect(*ids) 67 | ids.each { |id| protected_resources << id } 68 | end 69 | 70 | # 71 | # Create a nested relationship collection. The associated collection 72 | # is cached on the class, reducing API requests. 73 | # 74 | # @example Create an association to environments 75 | # 76 | # has_many :environments 77 | # 78 | # @example Create an association with custom configuration 79 | # 80 | # has_many :environments, class_name: 'Environment' 81 | # 82 | def has_many(method, options = {}) 83 | class_name = options[:class_name] || "Resource::#{Util.camelize(method).sub(/s$/, "")}" 84 | rest_endpoint = options[:rest_endpoint] || method 85 | 86 | class_eval <<-EOH, __FILE__, __LINE__ + 1 87 | def #{method} 88 | associations[:#{method}] ||= 89 | Resource::CollectionProxy.new(self, #{class_name}, '#{rest_endpoint}') 90 | end 91 | EOH 92 | end 93 | 94 | # 95 | # @todo doc 96 | # 97 | def protected_resources 98 | @protected_resources ||= [] 99 | end 100 | 101 | # 102 | # Get or set the name of the remote resource collection. This is most 103 | # likely the remote API endpoint (such as +/clients+), without the 104 | # leading slash. 105 | # 106 | # @example Setting a base collection path 107 | # collection_path '/clients' 108 | # 109 | # @example Setting a collection path with nesting 110 | # collection_path '/data/:name' 111 | # 112 | # @param [Symbol] value 113 | # the value to use for the collection name. 114 | # 115 | # @return [Symbol, String] 116 | # the name of the collection 117 | # 118 | def collection_path(value = UNSET) 119 | if value != UNSET 120 | @collection_path = value.to_s 121 | else 122 | @collection_path || 123 | raise(ArgumentError, "collection_path not set for #{self.class}") 124 | end 125 | end 126 | 127 | # 128 | # Make an authenticated HTTP POST request using the connection object. 129 | # This method returns a new object representing the response from the 130 | # server, which should be merged with an existing object's attributes to 131 | # reflect the newest state of the resource. 132 | # 133 | # @param [Hash] body 134 | # the request body to create the resource with (probably JSON) 135 | # @param [Hash] prefix 136 | # the list of prefix options (for nested resources) 137 | # 138 | # @return [String] 139 | # the JSON response from the server 140 | # 141 | def post(body, prefix = {}) 142 | path = expanded_collection_path(prefix) 143 | connection.post(path, body) 144 | end 145 | 146 | # 147 | # Perform a PUT request to the Chef Server against the given resource or 148 | # resource identifier. The resource will be partially updated (this 149 | # method doubles as PATCH) with the given parameters. 150 | # 151 | # @param [String, Resource::Base] id 152 | # a resource object or a string representing the unique identifier of 153 | # the resource object to update 154 | # @param [Hash] body 155 | # the request body to create the resource with (probably JSON) 156 | # @param [Hash] prefix 157 | # the list of prefix options (for nested resources) 158 | # 159 | # @return [String] 160 | # the JSON response from the server 161 | # 162 | def put(id, body, prefix = {}) 163 | path = resource_path(id, prefix) 164 | connection.put(path, body) 165 | end 166 | 167 | # 168 | # Delete the remote resource from the Chef Sserver. 169 | # 170 | # @param [String, Fixnum] id 171 | # the id of the resource to delete 172 | # @param [Hash] prefix 173 | # the list of prefix options (for nested resources) 174 | # @return [true] 175 | # 176 | def delete(id, prefix = {}) 177 | path = resource_path(id, prefix) 178 | connection.delete(path) 179 | true 180 | rescue Error::HTTPNotFound 181 | true 182 | end 183 | 184 | # 185 | # Get the "list" of items in this resource. This list contains the 186 | # primary keys of all of the resources in this collection. This method 187 | # is useful in CLI applications, because it only makes a single API 188 | # request to gather this data. 189 | # 190 | # @param [Hash] prefix 191 | # the listof prefix options (for nested resources) 192 | # 193 | # @example Get the list of all clients 194 | # Client.list #=> ['validator', 'chef-webui'] 195 | # 196 | # @return [Array] 197 | # 198 | def list(prefix = {}) 199 | path = expanded_collection_path(prefix) 200 | connection.get(path).keys.sort 201 | end 202 | 203 | # 204 | # Destroy a record with the given id. 205 | # 206 | # @param [String, Fixnum] id 207 | # the id of the resource to delete 208 | # @param [Hash] prefix 209 | # the list of prefix options (for nested resources) 210 | # 211 | # @return [Base, nil] 212 | # the destroyed resource, or nil if the resource does not exist on the 213 | # remote Chef Server 214 | # 215 | def destroy(id, prefix = {}) 216 | resource = fetch(id, prefix) 217 | return nil if resource.nil? 218 | 219 | resource.destroy 220 | resource 221 | end 222 | 223 | # 224 | # Delete all remote resources of the given type from the Chef Server 225 | # 226 | # @param [Hash] prefix 227 | # the list of prefix options (for nested resources) 228 | # @return [Array] 229 | # an array containing the list of resources that were deleted 230 | # 231 | def destroy_all(prefix = {}) 232 | map(&:destroy) 233 | end 234 | 235 | # 236 | # Fetch a single resource in the remote collection. 237 | # 238 | # @example fetch a single client 239 | # Client.fetch('chef-webui') #=> # 240 | # 241 | # @param [String, Fixnum] id 242 | # the id of the resource to fetch 243 | # @param [Hash] prefix 244 | # the list of prefix options (for nested resources) 245 | # 246 | # @return [Resource::Base, nil] 247 | # an instance of the resource, or nil if that given resource does not 248 | # exist 249 | # 250 | def fetch(id, prefix = {}) 251 | return nil if id.nil? 252 | 253 | path = resource_path(id, prefix) 254 | response = connection.get(path) 255 | from_json(response, prefix) 256 | rescue Error::HTTPNotFound 257 | nil 258 | end 259 | 260 | # 261 | # Build a new resource from the given attributes. 262 | # 263 | # @see ChefAPI::Resource::Base#initialize for more information 264 | # 265 | # @example build an empty resource 266 | # Bacon.build #=> # 267 | # 268 | # @example build a resource with attributes 269 | # Bacon.build(foo: 'bar') #=> # 270 | # 271 | # @param [Hash] attributes 272 | # the list of attributes for the new resource - any attributes that 273 | # are not defined in the schema are silently ignored 274 | # 275 | def build(attributes = {}, prefix = {}) 276 | new(attributes, prefix) 277 | end 278 | 279 | # 280 | # Create a new resource and save it to the Chef Server, raising any 281 | # exceptions that might occur. This method will save the resource back to 282 | # the Chef Server, raising any validation errors that occur. 283 | # 284 | # @raise [Error::ResourceAlreadyExists] 285 | # if the resource with the primary key already exists on the Chef Server 286 | # @raise [Error::InvalidResource] 287 | # if any of the resource's validations fail 288 | # 289 | # @param [Hash] attributes 290 | # the list of attributes to set on the new resource 291 | # 292 | # @return [Resource::Base] 293 | # an instance of the created resource 294 | # 295 | def create(attributes = {}, prefix = {}) 296 | resource = build(attributes, prefix) 297 | 298 | unless resource.new_resource? 299 | raise Error::ResourceAlreadyExists.new 300 | end 301 | 302 | resource.save! 303 | resource 304 | end 305 | 306 | # 307 | # Check if the given resource exists on the Chef Server. 308 | # 309 | # @param [String, Fixnum] id 310 | # the id of the resource to fetch 311 | # @param [Hash] prefix 312 | # the list of prefix options (for nested resources) 313 | # 314 | # @return [Boolean] 315 | # 316 | def exists?(id, prefix = {}) 317 | !fetch(id, prefix).nil? 318 | end 319 | 320 | # 321 | # Perform a PUT request to the Chef Server for the current resource, 322 | # updating the given parameters. The parameters may be a full or 323 | # partial resource update, as supported by the Chef Server. 324 | # 325 | # @raise [Error::ResourceNotFound] 326 | # if the given resource does not exist on the Chef Server 327 | # 328 | # @param [String, Fixnum] id 329 | # the id of the resource to update 330 | # @param [Hash] attributes 331 | # the list of attributes to set on the new resource 332 | # @param [Hash] prefix 333 | # the list of prefix options (for nested resources) 334 | # 335 | # @return [Resource::Base] 336 | # the new resource 337 | # 338 | def update(id, attributes = {}, prefix = {}) 339 | resource = fetch(id, prefix) 340 | 341 | unless resource 342 | raise Error::ResourceNotFound.new(type: type, id: id) 343 | end 344 | 345 | resource.update(attributes).save 346 | resource 347 | end 348 | 349 | # 350 | # (Lazy) iterate over each item in the collection, yielding the fully- 351 | # built resource object. This method, coupled with the Enumerable 352 | # module, provides us with other methods like +first+ and +map+. 353 | # 354 | # @example get the first resource 355 | # Bacon.first #=> # 356 | # 357 | # @example get the first 3 resources 358 | # Bacon.first(3) #=> [#, ...] 359 | # 360 | # @example iterate over each resource 361 | # Bacon.each { |bacon| puts bacon.name } 362 | # 363 | # @example get all the resource's names 364 | # Bacon.map(&:name) #=> ["ham", "sausage", "turkey"] 365 | # 366 | def each(prefix = {}, &block) 367 | collection(prefix).each do |resource, path| 368 | response = connection.get(path) 369 | result = from_json(response, prefix) 370 | 371 | block.call(result) if block 372 | end 373 | end 374 | 375 | # 376 | # The total number of reosurces in the collection. 377 | # 378 | # @return [Fixnum] 379 | # 380 | def count(prefix = {}) 381 | collection(prefix).size 382 | end 383 | alias_method :size, :count 384 | 385 | # 386 | # Return an array of all resources in the collection. 387 | # 388 | # @note Unless you need the _entire_ collection, please consider using the 389 | # {size} and {each} methods instead as they are much more perforant. 390 | # 391 | # @return [Array] 392 | # 393 | def all 394 | entries 395 | end 396 | 397 | # 398 | # Construct the object from a JSON response. This method actually just 399 | # delegates to the +new+ method, but it removes some marshall data and 400 | # whatnot from the response first. 401 | # 402 | # @param [String] response 403 | # the JSON response from the Chef Server 404 | # 405 | # @return [Resource::Base] 406 | # an instance of the resource represented by this JSON 407 | # 408 | def from_json(response, prefix = {}) 409 | response.delete("json_class") 410 | response.delete("chef_type") 411 | 412 | new(response, prefix) 413 | end 414 | 415 | # 416 | # The string representation of this class. 417 | # 418 | # @example for the Bacon class 419 | # Bacon.to_s #=> "Resource::Bacon" 420 | # 421 | # @return [String] 422 | # 423 | def to_s 424 | classname 425 | end 426 | 427 | # 428 | # The detailed string representation of this class, including the full 429 | # schema definition. 430 | # 431 | # @example for the Bacon class 432 | # Bacon.inspect #=> "Resource::Bacon(id, description, ...)" 433 | # 434 | # @return [String] 435 | # 436 | def inspect 437 | "#{classname}(#{schema.attributes.keys.join(", ")})" 438 | end 439 | 440 | # 441 | # The name for this resource, minus the parent module. 442 | # 443 | # @example 444 | # classname #=> Resource::Bacon 445 | # 446 | # @return [String] 447 | # 448 | def classname 449 | name.split("::")[1..-1].join("::") 450 | end 451 | 452 | # 453 | # The type of this resource. 454 | # 455 | # @example 456 | # bacon 457 | # 458 | # @return [String] 459 | # 460 | def type 461 | Util.underscore(name.split("::").last).gsub("_", " ") 462 | end 463 | 464 | # 465 | # The full collection list. 466 | # 467 | # @param [Hash] prefix 468 | # any prefix options to use 469 | # 470 | # @return [Array] 471 | # a list of resources in the collection 472 | # 473 | def collection(prefix = {}) 474 | connection.get(expanded_collection_path(prefix)) 475 | end 476 | 477 | # 478 | # The path to an individual resource. 479 | # 480 | # @param [Hash] prefix 481 | # the list of prefix options 482 | # 483 | # @return [String] 484 | # the path to the resource 485 | # 486 | def resource_path(id, prefix = {}) 487 | [expanded_collection_path(prefix), id].join("/") 488 | end 489 | 490 | # 491 | # Expand the collection path, "interpolating" any parameters. This syntax 492 | # is heavily borrowed from Rails and it will make more sense by looking 493 | # at an example. 494 | # 495 | # @example 496 | # /bacon, {} #=> "foo" 497 | # /bacon/:type, { type: 'crispy' } #=> "bacon/crispy" 498 | # 499 | # @raise [Error::MissingURLParameter] 500 | # if a required parameter is not given 501 | # 502 | # @param [Hash] prefix 503 | # the list of prefix options 504 | # 505 | # @return [String] 506 | # the "interpolated" URL string 507 | # 508 | def expanded_collection_path(prefix = {}) 509 | collection_path.gsub(/:\w+/) do |param| 510 | key = param.delete(":") 511 | value = prefix[key] || prefix[key.to_sym] 512 | 513 | if value.nil? 514 | raise Error::MissingURLParameter.new(param: key) 515 | end 516 | 517 | CGI.escape(value) 518 | end.sub(%r{^/}, "") # Remove leading slash 519 | end 520 | 521 | # 522 | # The current connection object. 523 | # 524 | # @return [ChefAPI::Connection] 525 | # 526 | def connection 527 | Thread.current["chefapi.connection"] || ChefAPI.connection 528 | end 529 | end 530 | 531 | # 532 | # The list of associations. 533 | # 534 | # @return [Hash] 535 | # 536 | attr_reader :associations 537 | 538 | # 539 | # Initialize a new resource with the given attributes. These attributes 540 | # are merged with the default values from the schema. Any attributes 541 | # that aren't defined in the schema are silently ignored for security 542 | # purposes. 543 | # 544 | # @example create a resource using attributes 545 | # Bacon.new(foo: 'bar', zip: 'zap') #=> # 546 | # 547 | # @example using a block 548 | # Bacon.new do |bacon| 549 | # bacon.foo = 'bar' 550 | # bacon.zip = 'zap' 551 | # end 552 | # 553 | # @param [Hash] attributes 554 | # the list of initial attributes to set on the model 555 | # @param [Hash] prefix 556 | # the list of prefix options (for nested resources) 557 | # 558 | def initialize(attributes = {}, prefix = {}) 559 | @schema = self.class.schema.dup 560 | @schema.load_flavor(self.class.connection.flavor) 561 | 562 | @associations = {} 563 | @_prefix = prefix 564 | 565 | # Define a getter and setter method for each attribute in the schema 566 | _attributes.each do |key, value| 567 | define_singleton_method(key) { _attributes[key] } 568 | define_singleton_method("#{key}=") { |value| update_attribute(key, value) } 569 | end 570 | 571 | attributes.each do |key, value| 572 | unless ignore_attribute?(key) 573 | update_attribute(key, value) 574 | end 575 | end 576 | 577 | yield self if block_given? 578 | end 579 | 580 | # 581 | # The primary key for the resource. 582 | # 583 | # @return [Symbol] 584 | # the primary key for this resource 585 | # 586 | def primary_key 587 | @schema.primary_key 588 | end 589 | 590 | # 591 | # The unique id for this resource. 592 | # 593 | # @return [Object] 594 | # 595 | def id 596 | _attributes[primary_key] 597 | end 598 | 599 | # 600 | # @todo doc 601 | # 602 | def _prefix 603 | @_prefix 604 | end 605 | 606 | # 607 | # The list of attributes on this resource. 608 | # 609 | # @return [Hash] 610 | # 611 | def _attributes 612 | @_attributes ||= {}.merge(@schema.attributes) 613 | end 614 | 615 | # 616 | # Determine if this resource has the given attribute. 617 | # 618 | # @param [Symbol, String] key 619 | # the attribute key to find 620 | # 621 | # @return [Boolean] 622 | # true if the attribute exists, false otherwise 623 | # 624 | def attribute?(key) 625 | _attributes.key?(key.to_sym) 626 | end 627 | 628 | # 629 | # Determine if this current resource is protected. Resources may be 630 | # protected by name or by a Proc. A protected resource is one that should 631 | # not be modified (i.e. created/updated/deleted) by the user. An example of 632 | # a protected resource is the pivotal key or the chef-webui client. 633 | # 634 | # @return [Boolean] 635 | # 636 | def protected? 637 | @protected ||= self.class.protected_resources.any? do |thing| 638 | if thing.is_a?(Proc) 639 | thing.call(self) 640 | else 641 | id == thing 642 | end 643 | end 644 | end 645 | 646 | # 647 | # Reload (or reset) this object using the values currently stored on the 648 | # remote server. This method will also clear any cached collection proxies 649 | # so they will be reloaded the next time they are requested. If the remote 650 | # record does not exist, no attributes are modified. 651 | # 652 | # @note This will remove any custom values you have set on the resource! 653 | # 654 | # @return [self] 655 | # the instance of the reloaded record 656 | # 657 | def reload! 658 | associations.clear 659 | 660 | remote = self.class.fetch(id, _prefix) 661 | return self if remote.nil? 662 | 663 | remote._attributes.each do |key, value| 664 | update_attribute(key, value) 665 | end 666 | 667 | self 668 | end 669 | 670 | # 671 | # Commit the resource and any changes to the remote Chef Server. Any errors 672 | # will raise an exception in the main thread and the resource will not be 673 | # committed back to the Chef Server. 674 | # 675 | # Any response errors (such as server-side responses) that ChefAPI failed 676 | # to account for in validations will also raise an exception. 677 | # 678 | # @return [Boolean] 679 | # true if the resource was saved 680 | # 681 | def save! 682 | validate! 683 | 684 | response = if new_resource? 685 | self.class.post(to_json, _prefix) 686 | else 687 | self.class.put(id, to_json, _prefix) 688 | end 689 | 690 | # Update our local copy with any partial information that was returned 691 | # from the server, ignoring an "bad" attributes that aren't defined in 692 | # our schema. 693 | response.each do |key, value| 694 | update_attribute(key, value) if attribute?(key) 695 | end 696 | 697 | true 698 | end 699 | 700 | # 701 | # Commit the resource and any changes to the remote Chef Server. Any errors 702 | # are gracefully handled and added to the resource's error collection for 703 | # handling. 704 | # 705 | # @return [Boolean] 706 | # true if the save was successfuly, false otherwise 707 | # 708 | def save 709 | save! 710 | rescue 711 | false 712 | end 713 | 714 | # 715 | # Remove the resource from the Chef Server. 716 | # 717 | # @return [self] 718 | # the current instance of this object 719 | # 720 | def destroy 721 | self.class.delete(id, _prefix) 722 | self 723 | end 724 | 725 | # 726 | # Update a subset of attributes on the current resource. This is a handy 727 | # way to update multiple attributes at once. 728 | # 729 | # @param [Hash] attributes 730 | # the list of attributes to update 731 | # 732 | # @return [self] 733 | # 734 | def update(attributes = {}) 735 | attributes.each do |key, value| 736 | update_attribute(key, value) 737 | end 738 | 739 | self 740 | end 741 | 742 | # 743 | # Update a single attribute in the attributes hash. 744 | # 745 | # @raise 746 | # 747 | def update_attribute(key, value) 748 | unless attribute?(key.to_sym) 749 | raise Error::UnknownAttribute.new(attribute: key) 750 | end 751 | 752 | _attributes[key.to_sym] = value 753 | end 754 | 755 | # 756 | # The list of validators for this resource. This is primarily set and 757 | # managed by the underlying schema clean room. 758 | # 759 | # @return [Array<~Validator::Base>] 760 | # the list of validators for this resource 761 | # 762 | def validators 763 | @validators ||= @schema.validators 764 | end 765 | 766 | # 767 | # Run all of this resource's validations, raising an exception if any 768 | # validations fail. 769 | # 770 | # @raise [Error::InvalidResource] 771 | # if any of the validations fail 772 | # 773 | # @return [Boolean] 774 | # true if the validation was successful - this method will never return 775 | # anything other than true because an exception is raised if validations 776 | # fail 777 | # 778 | def validate! 779 | unless valid? 780 | sentence = errors.full_messages.join(", ") 781 | raise Error::InvalidResource.new(errors: sentence) 782 | end 783 | 784 | true 785 | end 786 | 787 | # 788 | # Determine if the current resource is valid. This relies on the 789 | # validations defined in the schema at initialization. 790 | # 791 | # @return [Boolean] 792 | # true if the resource is valid, false otherwise 793 | # 794 | def valid? 795 | errors.clear 796 | 797 | validators.each do |validator| 798 | validator.validate(self) 799 | end 800 | 801 | errors.empty? 802 | end 803 | 804 | # 805 | # Check if this resource exists on the remote Chef Server. This is useful 806 | # when determining if a resource should be saved or updated, since a 807 | # resource must exist before it can be saved. 808 | # 809 | # @example when the resource does not exist on the remote Chef Server 810 | # bacon = Bacon.new 811 | # bacon.new_resource? #=> true 812 | # 813 | # @example when the resource exists on the remote Chef Server 814 | # bacon = Bacon.first 815 | # bacon.new_resource? #=> false 816 | # 817 | # @return [Boolean] 818 | # true if the resource exists on the remote Chef Server, false otherwise 819 | # 820 | def new_resource? 821 | !self.class.exists?(id, _prefix) 822 | end 823 | 824 | # 825 | # Check if the local resource is in sync with the remote Chef Server. When 826 | # a remote resource is updated, ChefAPI has no way of knowing it's cached 827 | # resources are dirty unless additional requests are made against the 828 | # remote Chef Server and diffs are compared. 829 | # 830 | # @example when the resource is out of sync with the remote Chef Server 831 | # bacon = Bacon.first 832 | # bacon.description = "I'm different, yeah, I'm different!" 833 | # bacon.dirty? #=> true 834 | # 835 | # @example when the resource is in sync with the remote Chef Server 836 | # bacon = Bacon.first 837 | # bacon.dirty? #=> false 838 | # 839 | # @return [Boolean] 840 | # true if the local resource has differing attributes from the same 841 | # resource on the remote Chef Server, false otherwise 842 | # 843 | def dirty? 844 | new_resource? || !diff.empty? 845 | end 846 | 847 | # 848 | # Calculate a differential of the attributes on the local resource with 849 | # it's remote Chef Server counterpart. 850 | # 851 | # @example when the local resource is in sync with the remote resource 852 | # bacon = Bacon.first 853 | # bacon.diff #=> {} 854 | # 855 | # @example when the local resource differs from the remote resource 856 | # bacon = Bacon.first 857 | # bacon.description = "My new description" 858 | # bacon.diff #=> { :description => { :local => "My new description", :remote => "Old description" } } 859 | # 860 | # @note This is a VERY expensive operation - use it sparringly! 861 | # 862 | # @return [Hash] 863 | # 864 | def diff 865 | diff = {} 866 | 867 | remote = self.class.fetch(id, _prefix) || self.class.new({}, _prefix) 868 | remote._attributes.each do |key, value| 869 | unless _attributes[key] == value 870 | diff[key] = { local: _attributes[key], remote: value } 871 | end 872 | end 873 | 874 | diff 875 | end 876 | 877 | # 878 | # The URL for this resource on the Chef Server. 879 | # 880 | # @example Get the resource path for a resource 881 | # bacon = Bacon.first 882 | # bacon.resource_path #=> /bacons/crispy 883 | # 884 | # @return [String] 885 | # the partial URL path segment 886 | # 887 | def resource_path 888 | self.class.resource_path(id, _prefix) 889 | end 890 | 891 | # 892 | # Determine if a given attribute should be ignored. Ignored attributes 893 | # are defined at the schema level and are frozen. 894 | # 895 | # @param [Symbol] key 896 | # the attribute to check ignorance 897 | # 898 | # @return [Boolean] 899 | # 900 | def ignore_attribute?(key) 901 | @schema.ignored_attributes.key?(key.to_sym) 902 | end 903 | 904 | # 905 | # The collection of errors on the resource. 906 | # 907 | # @return [ErrorCollection] 908 | # 909 | def errors 910 | @errors ||= ErrorCollection.new 911 | end 912 | 913 | # 914 | # The hash representation of this resource. All attributes are serialized 915 | # and any values that respond to +to_hash+ are also serialized. 916 | # 917 | # @return [Hash] 918 | # 919 | def to_hash 920 | {}.tap do |hash| 921 | _attributes.each do |key, value| 922 | hash[key] = value.respond_to?(:to_hash) ? value.to_hash : value 923 | end 924 | end 925 | end 926 | 927 | # 928 | # The JSON serialization of this resource. 929 | # 930 | # @return [String] 931 | # 932 | def to_json(*) 933 | JSON.fast_generate(to_hash) 934 | end 935 | 936 | # 937 | # Custom to_s method for easier readability. 938 | # 939 | # @return [String] 940 | # 941 | def to_s 942 | "#<#{self.class.classname} #{primary_key}: #{id.inspect}>" 943 | end 944 | 945 | # 946 | # Custom inspect method for easier readability. 947 | # 948 | # @return [String] 949 | # 950 | def inspect 951 | attrs = (_prefix).merge(_attributes).map do |key, value| 952 | if value.is_a?(String) 953 | "#{key}: #{Util.truncate(value, length: 50).inspect}" 954 | else 955 | "#{key}: #{value.inspect}" 956 | end 957 | end 958 | 959 | "#<#{self.class.classname} #{attrs.join(", ")}>" 960 | end 961 | end 962 | end 963 | -------------------------------------------------------------------------------- /lib/chef-api/resources/client.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Client < Resource::Base 3 | include ChefAPI::AclAble 4 | collection_path "/clients" 5 | 6 | schema do 7 | attribute :name, type: String, primary: true, required: true 8 | attribute :admin, type: Boolean, default: false 9 | attribute :public_key, type: String 10 | attribute :private_key, type: [String, Boolean], default: false 11 | attribute :validator, type: Boolean, default: false 12 | 13 | ignore :certificate, :clientname, :orgname 14 | end 15 | 16 | # @todo implement 17 | protect "chef-webui", "chef-validator" 18 | 19 | class << self 20 | # 21 | # Load the client from a .pem file on disk. Lots of assumptions are made 22 | # here. 23 | # 24 | # @param [String] path 25 | # the path to the client on disk 26 | # 27 | # @return [Resource::Client] 28 | # 29 | def from_file(path) 30 | name, key = Util.safe_read(path) 31 | 32 | if client = fetch(name) 33 | client.private_key = key 34 | client 35 | else 36 | new(name: name, private_key: key) 37 | end 38 | end 39 | end 40 | 41 | # 42 | # Override the loading of the client. Since HEC and EC both return 43 | # +certificate+, but OPC and CZ both use +public_key+. In order to 44 | # normalize this discrepancy, the intializer converts the response from 45 | # the server OPC format. HEC and EC both handle putting a public key to 46 | # the server instead of a certificate. 47 | # 48 | # @see Resource::Base#initialize 49 | # 50 | def initialize(attributes = {}, prefix = {}) 51 | if certificate = attributes.delete(:certificate) || 52 | attributes.delete("certificate") 53 | x509 = OpenSSL::X509::Certificate.new(certificate) 54 | attributes[:public_key] = x509.public_key.to_pem 55 | end 56 | 57 | super 58 | end 59 | 60 | # 61 | # Generate a new RSA private key for this API client. 62 | # 63 | # @example Regenerate the private key 64 | # key = client.regenerate_key 65 | # key #=> "-----BEGIN PRIVATE KEY-----\nMIGfMA0GCS..." 66 | # 67 | # @note For security reasons, you should perform this operation sparingly! 68 | # The resulting private key is committed to this object, meaning it is 69 | # saved to memory somewhere. You should set this resource's +private_key+ 70 | # to +nil+ after you have committed it to disk and perform a manual GC to 71 | # be ultra-secure. 72 | # 73 | # @note Regenerating the private key also regenerates the public key! 74 | # 75 | # @return [self] 76 | # the current resource with the new public and private key attributes 77 | # 78 | def regenerate_keys 79 | raise Error::CannotRegenerateKey if new_resource? 80 | 81 | update(private_key: true).save! 82 | self 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/chef-api/resources/collection_proxy.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::CollectionProxy 3 | include Enumerable 4 | 5 | # 6 | # Create a new collection proxy from the given parent class and collection 7 | # information. The collection proxy aims to make working with nested 8 | # resource collections a bit easier. The proxy waits until non-existing 9 | # data is requested before making another HTTP request. In this way, it 10 | # helps reduce bandwidth and API requets. 11 | # 12 | # Additionally, the collection proxy caches the results of an object request 13 | # in memory by +id+, so additional requests for the same object will hit the 14 | # cache, again reducing HTTP requests. 15 | # 16 | # @param [Resource::Base] parent 17 | # the parent resource that created the collection 18 | # @param [Class] klass 19 | # the class the resulting objects should be 20 | # @param [String] endpoint 21 | # the relative path for the RESTful endpoint 22 | # 23 | # @return [CollectionProxy] 24 | # 25 | def initialize(parent, klass, endpoint, prefix = {}) 26 | @parent = parent 27 | @klass = klass 28 | @endpoint = "#{parent.resource_path}/#{endpoint}" 29 | @prefix = prefix 30 | @collection = load_collection 31 | end 32 | 33 | # 34 | # Force a reload of this collection proxy and all its elements. This is 35 | # useful if you think additional items have been added to the remote 36 | # collection and need access to them locally. This will also clear any 37 | # existing cached responses, so use with caution. 38 | # 39 | # @return [self] 40 | # 41 | def reload! 42 | cache.clear 43 | @collection = load_collection 44 | 45 | self 46 | end 47 | 48 | # 49 | # Fetch a specific resource in the collection by id. 50 | # 51 | # @example Fetch a resource 52 | # Bacon.first.items.fetch('crispy') 53 | # 54 | # @param [String, Symbol] id 55 | # the id of the resource to fetch 56 | # 57 | # @return [Resource::Base, nil] 58 | # the fetched class, or nil if it does not exists 59 | # 60 | def fetch(id) 61 | return nil unless exists?(id) 62 | 63 | cached(id) { klass.from_url(get(id), prefix) } 64 | end 65 | 66 | # 67 | # Determine if the resource with the given id exists on the remote Chef 68 | # Server. This method does not actually query the Chef Server, but rather 69 | # delegates to the cached collection. To guarantee the most fresh set of 70 | # data, you should call +reload!+ before +exists?+ to ensure you have the 71 | # most up-to-date collection of resources. 72 | # 73 | # @param [String, Symbol] id 74 | # the unique id of the resource to find. 75 | # 76 | # @return [Boolean] 77 | # true if the resource exists, false otherwise 78 | # 79 | def exists?(id) 80 | collection.key?(id.to_s) 81 | end 82 | 83 | # 84 | # Get the full list of all entries in the collection. This method is 85 | # incredibly expensive and should only be used when you absolutely need 86 | # all resources. If you need a specific resource, you should use an iterator 87 | # such as +select+ or +find+ instead, since it will minimize HTTP requests. 88 | # Once all the objects are requested, they are cached, reducing the number 89 | # of HTTP requests. 90 | # 91 | # @return [Array] 92 | # 93 | def all 94 | entries 95 | end 96 | 97 | # 98 | # The custom iterator for looping over each object in this collection. For 99 | # more information, please see the +Enumerator+ module in Ruby core. 100 | # 101 | def each(&block) 102 | collection.each do |id, url| 103 | object = cached(id) { klass.from_url(url, prefix) } 104 | block.call(object) if block 105 | end 106 | end 107 | 108 | # 109 | # The total number of items in this collection. This method does not make 110 | # an API request, but rather counts the number of keys in the given 111 | # collection. 112 | # 113 | def count 114 | collection.length 115 | end 116 | alias_method :size, :count 117 | 118 | # 119 | # The string representation of this collection proxy. 120 | # 121 | # @return [String] 122 | # 123 | def to_s 124 | "#<#{self.class.name}>" 125 | end 126 | 127 | # 128 | # The detailed string representation of this collection proxy. 129 | # 130 | # @return [String] 131 | # 132 | def inspect 133 | objects = collection 134 | .map { |id, _| cached(id) || klass.new(klass.schema.primary_key => id) } 135 | .map(&:to_s) 136 | 137 | "#<#{self.class.name} [#{objects.join(", ")}]>" 138 | end 139 | 140 | private 141 | 142 | attr_reader :collection 143 | attr_reader :endpoint 144 | attr_reader :klass 145 | attr_reader :parent 146 | attr_reader :prefix 147 | 148 | # 149 | # Fetch the object collection from the Chef Server. Since the Chef Server's 150 | # API is completely insane and all over the place, it might return a Hash 151 | # where the key is the id of the resource and the value is the url for that 152 | # item on the Chef Server: 153 | # 154 | # { "key" => "url" } 155 | # 156 | # Or if the Chef Server's fancy is tickled, it might just return an array 157 | # of the list of items: 158 | # 159 | # ["item_1", "item_2"] 160 | # 161 | # Or if the Chef Server is feeling especially magical, it might return the 162 | # actual objects, but prefixed with the JSON id: 163 | # 164 | # [{"organization" => {"_id" => "..."}}, {"organization" => {...}}] 165 | # 166 | # So, this method attempts to intelligent handle these use cases. That being 167 | # said, I can almost guarantee that someone is going to do some crazy 168 | # strange edge case with this library and hit a bug here, so it will likely 169 | # be changed in the future. For now, it "works on my machine". 170 | # 171 | # @return [Hash] 172 | # 173 | def load_collection 174 | case response = Resource::Base.connection.get(endpoint) 175 | when Array 176 | if response.first.is_a?(Hash) 177 | key = klass.schema.primary_key.to_s 178 | 179 | {}.tap do |hash| 180 | response.each do |results| 181 | results.each do |_, info| 182 | hash[key] = klass.resource_path(info[key]) 183 | end 184 | end 185 | end 186 | else 187 | Hash[*response.map { |item| [item, klass.resource_path(item)] }.flatten] 188 | end 189 | when Hash 190 | response 191 | end 192 | end 193 | 194 | # 195 | # Retrieve a cached value. This method helps significantly reduce the 196 | # number of HTTP requests made against the remote server. 197 | # 198 | # @param [String, Symbol] key 199 | # the cache key (typically the +name+ of the resource) 200 | # @param [Proc] block 201 | # the block to evaluate to set the value if it doesn't exist 202 | # 203 | # @return [Object] 204 | # the value at the cache 205 | # 206 | def cached(key, &block) 207 | cache[key.to_sym] ||= block ? block.call : nil 208 | end 209 | 210 | # 211 | # The cache... 212 | # 213 | # @return [Hash] 214 | # 215 | def cache 216 | @cache ||= {} 217 | end 218 | 219 | # 220 | # Retrieve a specific item in the collection. Note, this will always return 221 | # the original raw record (with the key => URL pairing), not a cached 222 | # resource. 223 | # 224 | # @param [String, Symbol] id 225 | # the id of the resource to fetch 226 | # 227 | # @return [String, nil] 228 | # the URL to retrieve the item in the collection, or nil if it does not 229 | # exist 230 | # 231 | def get(id) 232 | collection[id.to_s] 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /lib/chef-api/resources/cookbook.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | # 3 | # In the real world, a "cookbook" is a single entity with multiple versions. 4 | # In Chef land, a "cookbook" is actually just a wrapper around a collection 5 | # of +cookbook_version+ objects that fully detail the layout of a cookbook. 6 | # 7 | class Resource::Cookbook < Resource::Base 8 | collection_path "/cookbooks" 9 | 10 | schema do 11 | attribute :name, type: String, primary: true, required: true 12 | end 13 | 14 | has_many :versions, 15 | class_name: CookbookVersion, 16 | rest_endpoint: "/?num_versions=all" 17 | 18 | class << self 19 | def from_json(response, prefix = {}) 20 | new(name: response.keys.first) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/chef-api/resources/cookbook_version.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::CookbookVersion < Resource::Base 3 | collection_path "/cookbooks/:cookbook" 4 | 5 | schema do 6 | attribute :name, type: String, primary: true, required: true 7 | attribute :cookbook_name, type: String, required: true 8 | attribute :metadata, type: Hash, required: true 9 | attribute :version, type: String, required: true 10 | attribute :frozen?, type: Boolean, default: false 11 | 12 | attribute :attributes, type: Array, default: [] 13 | attribute :definitions, type: Array, default: [] 14 | attribute :files, type: Array, default: [] 15 | attribute :libraries, type: Array, default: [] 16 | attribute :providers, type: Array, default: [] 17 | attribute :recipes, type: Array, default: [] 18 | attribute :resources, type: Array, default: [] 19 | attribute :root_files, type: Array, default: [] 20 | attribute :templates, type: Array, default: [] 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/chef-api/resources/data_bag.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::DataBag < Resource::Base 3 | collection_path "/data" 4 | 5 | schema do 6 | attribute :name, type: String, primary: true, required: true 7 | end 8 | 9 | class << self 10 | # 11 | # Load the data bag from a collection of JSON files on disk. Just like 12 | # +knife+, the basename of the folder is assumed to be the name of the 13 | # data bag and all containing items a proper JSON data bag. 14 | # 15 | # This will load **all** items in the data bag, returning an array of 16 | # those items. To load an individual data bag item, see 17 | # {DataBagItem.from_file}. 18 | # 19 | # **This method does NOT return an instance of a {DataBag}!** 20 | # 21 | # @param [String] path 22 | # the path to the data bag **folder** on disk 23 | # @param [String] name 24 | # the name of the data bag 25 | # 26 | # @return [Array] 27 | # 28 | def from_file(path, name = File.basename(path)) 29 | path = File.expand_path(path) 30 | 31 | raise Error::FileNotFound.new(path: path) unless File.exist?(path) 32 | raise Error::NotADirectory.new(path: path) unless File.directory?(path) 33 | 34 | raise ArgumentError unless File.directory?(path) 35 | 36 | bag = new(name: name) 37 | 38 | Util.fast_collect(Dir["#{path}/*.json"]) do |item| 39 | Resource::DataBagItem.from_file(item, bag) 40 | end 41 | end 42 | 43 | # 44 | # 45 | # 46 | def fetch(id, prefix = {}) 47 | return nil if id.nil? 48 | 49 | path = resource_path(id, prefix) 50 | response = connection.get(path) 51 | new(name: id) 52 | rescue Error::HTTPNotFound 53 | nil 54 | end 55 | 56 | # 57 | # 58 | # 59 | def each(&block) 60 | collection.each do |name, path| 61 | result = new(name: name) 62 | block.call(result) if block 63 | end 64 | end 65 | end 66 | 67 | # 68 | # This is the same as +has_many :items+, but creates a special collection 69 | # for data bag items, which is mutable and handles some special edge cases 70 | # that only data bags encounter. 71 | # 72 | # @see Base.has_many 73 | # 74 | def items 75 | associations[:items] ||= Resource::DataBagItemCollectionProxy.new(self) 76 | end 77 | end 78 | end 79 | 80 | module ChefAPI 81 | # 82 | # The mutable collection is a special kind of collection proxy that permits 83 | # Rails-like attribtue creation, like: 84 | # 85 | # DataBag.first.items.create(id: 'me', thing: 'bar', zip: 'zap') 86 | # 87 | class Resource::DataBagItemCollectionProxy < Resource::CollectionProxy 88 | def initialize(bag) 89 | # Delegate to the superclass 90 | super(bag, Resource::DataBagItem, nil, bag: bag.name) 91 | end 92 | 93 | # @see klass.new 94 | def new(data = {}) 95 | klass.new(data, prefix, parent) 96 | end 97 | 98 | # @see klass.destroy 99 | def destroy(id) 100 | klass.destroy(id, prefix) 101 | ensure 102 | reload! 103 | end 104 | 105 | # @see klass.destroy_all 106 | def destroy_all 107 | klass.destroy_all(prefix) 108 | ensure 109 | reload! 110 | end 111 | 112 | # @see klass.build 113 | def build(data = {}) 114 | klass.build(data, prefix) 115 | end 116 | 117 | # @see klass.create 118 | def create(data = {}) 119 | klass.create(data, prefix) 120 | ensure 121 | reload! 122 | end 123 | 124 | # @see klass.create! 125 | def create!(data = {}) 126 | klass.create!(data, prefix) 127 | ensure 128 | reload! 129 | end 130 | 131 | # @see klass.update 132 | def update(id, data = {}) 133 | klass.update(id, data, prefix) 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/chef-api/resources/data_bag_item.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::DataBagItem < Resource::Base 3 | collection_path "/data/:bag" 4 | 5 | schema do 6 | attribute :id, type: String, primary: true, required: true 7 | attribute :data, type: Hash, default: {} 8 | end 9 | 10 | class << self 11 | def from_file(path, bag = File.basename(File.dirname(path))) 12 | id, contents = Util.safe_read(path) 13 | data = JSON.parse(contents) 14 | data[:id] = id 15 | 16 | bag = bag.is_a?(Resource::DataBag) ? bag : Resource::DataBag.new(name: bag) 17 | 18 | new(data, { bag: bag.name }, bag) 19 | end 20 | end 21 | 22 | attr_reader :bag 23 | 24 | # 25 | # Override the initialize method to move any attributes into the +data+ 26 | # hash. 27 | # 28 | def initialize(attributes = {}, prefix = {}, bag = nil) 29 | @bag = bag || Resource::DataBag.fetch(prefix[:bag]) 30 | 31 | id = attributes.delete(:id) || attributes.delete("id") 32 | super({ id: id, data: attributes }, prefix) 33 | end 34 | 35 | # 36 | # Override the to_hash method to move data to the upper scope. 37 | # 38 | # @see (Resource::Base#to_hash) 39 | # 40 | def to_hash 41 | {}.tap do |hash| 42 | _attributes.each do |key, value| 43 | if key == :data 44 | hash.merge!(value) 45 | else 46 | hash[key] = value.respond_to?(:to_hash) ? value.to_hash : value 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/chef-api/resources/environment.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Environment < Resource::Base 3 | collection_path "/environments" 4 | 5 | schema do 6 | attribute :name, type: String, primary: true, required: true 7 | attribute :description, type: String 8 | attribute :default_attributes, type: Hash, default: {} 9 | attribute :override_attributes, type: Hash, default: {} 10 | attribute :cookbook_versions, type: Hash, default: {} 11 | end 12 | 13 | has_many :cookbooks 14 | has_many :nodes 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/chef-api/resources/group.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Group < Resource::Base 3 | collection_path "/groups" 4 | 5 | schema do 6 | attribute :groupname, type: String, primary: true, required: true 7 | attribute :name, type: String 8 | attribute :orgname, type: String 9 | attribute :actors, type: Array, default: [] 10 | attribute :users, type: Array, default: [] 11 | attribute :clients, type: Array, default: [] 12 | attribute :groups, type: Array, default: [] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/chef-api/resources/node.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Node < Resource::Base 3 | include ChefAPI::AclAble 4 | collection_path "/nodes" 5 | 6 | schema do 7 | attribute :name, type: String, primary: true, required: true 8 | attribute :automatic, type: Hash, default: {} 9 | attribute :default, type: Hash, default: {} 10 | attribute :normal, type: Hash, default: {} 11 | attribute :override, type: Hash, default: {} 12 | attribute :run_list, type: Array, default: [] 13 | attribute :policy_name, type: String 14 | attribute :policy_group, type: String 15 | 16 | # Enterprise Chef attributes 17 | attribute :chef_environment, type: String, default: "_default" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/chef-api/resources/organization.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Organization < Resource::Base 3 | collection_path "/organizations" 4 | 5 | schema do 6 | attribute :name, type: String, primary: true, required: true 7 | attribute :org_type, type: String 8 | attribute :full_name, type: String 9 | attribute :clientname, type: String 10 | attribute :guid, type: String 11 | 12 | ignore :_id 13 | ignore :_rev 14 | ignore :chargify_subscription_id 15 | ignore :chargify_customer_id 16 | ignore :billing_plan 17 | ignore :requester_id 18 | ignore :assigned_at 19 | ignore "couchrest-type" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/chef-api/resources/partial_search.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::PartialSearch < Resource::Base 3 | collection_path "/search/:index" 4 | 5 | schema do 6 | attribute :total, type: Integer 7 | attribute :start, type: Integer 8 | attribute :rows, type: Array 9 | end 10 | 11 | class << self 12 | # 13 | # About search : https://docs.chef.io/chef_search.html 14 | # 15 | # @param [String] index 16 | # the name of the index to search 17 | # @param [Hash] keys 18 | # key paths for the attributes to be returned 19 | # @param [String] query 20 | # the query string 21 | # @param [Hash] options 22 | # the query string 23 | # 24 | # @return [self] 25 | # the current resource 26 | # 27 | def query(index, keys, query = "*:*", options = {}) 28 | return nil if index.nil? 29 | 30 | params = {}.tap do |o| 31 | o[:q] = query 32 | o[:rows] = options[:rows] || 1000 33 | o[:sort] = options[:sort] || "X_CHEF_id_CHEF_X" 34 | o[:start] = options[:start] || 0 35 | end 36 | 37 | path = expanded_collection_path(index: index.to_s) 38 | response = connection.post(path, keys.to_json, params) 39 | response["rows"].map! { |row| row["data"] } 40 | from_json(response, index: index.to_s) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/chef-api/resources/principal.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Principal < Resource::Base 3 | collection_path "/principals" 4 | 5 | schema do 6 | attribute :name, type: String, primary: true, required: true 7 | attribute :type, type: String 8 | attribute :public_key, type: String 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/chef-api/resources/role.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Role < Resource::Base 3 | include ChefAPI::AclAble 4 | collection_path "/roles" 5 | 6 | schema do 7 | attribute :name, type: String, primary: true, required: true 8 | attribute :json_class, type: String, default: "Chef::Role" 9 | attribute :description, type: String 10 | attribute :default_attributes, type: Hash, default: {} 11 | attribute :override_attributes, type: Hash, default: {} 12 | attribute :run_list, type: Array, default: [] 13 | attribute :env_run_lists, type: Hash, default: {} 14 | end 15 | 16 | has_many :environments 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/chef-api/resources/search.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::Search < Resource::Base 3 | collection_path "/search/:index" 4 | 5 | schema do 6 | attribute :total, type: Integer 7 | attribute :start, type: Integer 8 | attribute :rows, type: Array 9 | end 10 | 11 | class << self 12 | # 13 | # About search : https://docs.chef.io/chef_search.html 14 | # 15 | # @param [String] index 16 | # the name of the index to search 17 | # @param [String] query 18 | # the query string 19 | # @param [Hash] options 20 | # the query string 21 | # 22 | # @return [self] 23 | # the current resource 24 | # 25 | def query(index, query = "*:*", options = {}) 26 | return nil if index.nil? 27 | 28 | params = {}.tap do |o| 29 | o[:q] = query 30 | o[:rows] = options[:rows] || 1000 31 | o[:sort] = options[:sort] || "X_CHEF_id_CHEF_X" 32 | o[:start] = options[:start] || 0 33 | end 34 | 35 | path = expanded_collection_path(index: index.to_s) 36 | 37 | response = if filter_result = options[:filter_result] 38 | connection.post(path, filter_result.to_json, params) 39 | else 40 | connection.get(path, params) 41 | end 42 | 43 | from_json(response, index: index.to_s) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/chef-api/resources/user.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Resource::User < Resource::Base 3 | require "cgi" unless defined?(CGI) 4 | 5 | collection_path "/users" 6 | 7 | schema do 8 | flavor :enterprise do 9 | attribute :username, type: String, primary: true, required: true 10 | 11 | # "Vanity" attributes 12 | attribute :first_name, type: String 13 | attribute :middle_name, type: String 14 | attribute :last_name, type: String 15 | attribute :display_name, type: String 16 | attribute :email, type: String 17 | attribute :city, type: String 18 | attribute :country, type: String 19 | attribute :twitter_account, type: String 20 | end 21 | 22 | flavor :open_source do 23 | attribute :name, type: String, primary: true, required: true 24 | end 25 | 26 | attribute :admin, type: Boolean, default: false 27 | attribute :public_key, type: String 28 | attribute :private_key, type: [String, Boolean], default: false 29 | end 30 | 31 | has_many :organizations 32 | 33 | class << self 34 | # 35 | # @see Base.each 36 | # 37 | def each(prefix = {}, &block) 38 | users = collection(prefix) 39 | 40 | # HEC/EC returns a slightly different response than OSC/CZ 41 | if users.is_a?(Array) 42 | users.each do |info| 43 | name = CGI.escape(info["user"]["username"]) 44 | response = connection.get("/users/#{name}") 45 | result = from_json(response, prefix) 46 | 47 | block.call(result) if block 48 | end 49 | else 50 | users.each do |_, path| 51 | response = connection.get(path) 52 | result = from_json(response, prefix) 53 | 54 | block.call(result) if block 55 | end 56 | end 57 | end 58 | 59 | # 60 | # Authenticate a user with the given +username+ and +password+. 61 | # 62 | # @note Requires Enterprise Chef 63 | # 64 | # @example Authenticate a user 65 | # User.authenticate(username: 'user', password: 'pass') 66 | # #=> { "status" => "linked", "user" => { ... } } 67 | # 68 | # @param [Hash] options 69 | # the list of options to authenticate with 70 | # 71 | # @option options [String] username 72 | # the username to authenticate with 73 | # @option options [String] password 74 | # the plain-text password to authenticate with 75 | # 76 | # @return [Hash] 77 | # the parsed JSON response from the server 78 | # 79 | def authenticate(options = {}) 80 | connection.post("/authenticate_user", options.to_json) 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/chef-api/schema.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | # 3 | # A wrapper class that describes a remote schema (such as the Chef Server 4 | # API layer), with validation and other magic spinkled on top. 5 | # 6 | class Schema 7 | 8 | # 9 | # The full list of attributes defined on this schema. 10 | # 11 | # @return [Hash] 12 | # 13 | attr_reader :attributes 14 | 15 | attr_reader :ignored_attributes 16 | 17 | # 18 | # The list of defined validators for this schema. 19 | # 20 | # @return [Array] 21 | # 22 | attr_reader :validators 23 | 24 | # 25 | # Create a new schema and evaulte the block contents in a clean room. 26 | # 27 | def initialize(&block) 28 | @attributes = {} 29 | @ignored_attributes = {} 30 | @flavor_attributes = {} 31 | @validators = [] 32 | 33 | unlock { instance_eval(&block) } if block 34 | end 35 | 36 | # 37 | # The defined primary key for this schema. If no primary key is given, it 38 | # is assumed to be the first item in the list. 39 | # 40 | # @return [Symbol] 41 | # 42 | def primary_key 43 | @primary_key ||= @attributes.first[0] 44 | end 45 | 46 | # 47 | # Create a lazy-loaded block for a given flavor. 48 | # 49 | # @example Create a block for Enterprise Chef 50 | # flavor :enterprise do 51 | # attribute :custom_value 52 | # end 53 | # 54 | # @param [Symbol] id 55 | # the id of the flavor to target 56 | # @param [Proc] block 57 | # the block to capture 58 | # 59 | # @return [Proc] 60 | # the given block 61 | # 62 | def flavor(id, &block) 63 | @flavor_attributes[id] = block 64 | block 65 | end 66 | 67 | # 68 | # Load the flavor block for the given id. 69 | # 70 | # @param [Symbol] id 71 | # the id of the flavor to target 72 | # 73 | # @return [true, false] 74 | # true if the flavor existed and was evaluted, false otherwise 75 | # 76 | def load_flavor(id) 77 | if block = @flavor_attributes[id] 78 | unlock { instance_eval(&block) } 79 | true 80 | else 81 | false 82 | end 83 | end 84 | 85 | # 86 | # DSL method for defining an attribute. 87 | # 88 | # @param [Symbol] key 89 | # the key to use 90 | # @param [Hash] options 91 | # a list of options to create the attribute with 92 | # 93 | # @return [Symbol] 94 | # the attribute 95 | # 96 | def attribute(key, options = {}) 97 | if primary_key = options.delete(:primary) 98 | @primary_key = key.to_sym 99 | end 100 | 101 | @attributes[key] = options.delete(:default) 102 | 103 | # All remaining options are assumed to be validations 104 | options.each do |validation, options| 105 | if options 106 | @validators << Validator.find(validation).new(key, options) 107 | end 108 | end 109 | 110 | key 111 | end 112 | 113 | # 114 | # Ignore an attribute. This is handy if you know there's an attribute that 115 | # the remote server will return, but you don't want that information 116 | # exposed to the user (or the data is sensitive). 117 | # 118 | # @param [Array] keys 119 | # the list of attributes to ignore 120 | # 121 | def ignore(*keys) 122 | keys.each do |key| 123 | @ignored_attributes[key.to_sym] = true 124 | end 125 | end 126 | 127 | private 128 | 129 | # 130 | # @private 131 | # 132 | # Helper method to duplicate and unfreeze all the attributes in the schema, 133 | # yield control to the user for modification in the current context, and 134 | # then re-freeze the variables for modification. 135 | # 136 | def unlock 137 | @attributes = @attributes.dup 138 | @ignored_attributes = @ignored_attributes.dup 139 | @flavor_attributes = @flavor_attributes.dup 140 | @validators = @validators.dup 141 | 142 | yield 143 | 144 | @attributes.freeze 145 | @ignored_attributes.freeze 146 | @flavor_attributes.freeze 147 | @validators.freeze 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/chef-api/util.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | module Util 3 | extend self 4 | 5 | # 6 | # Covert the given CaMelCaSeD string to under_score. Graciously borrowed 7 | # from http://stackoverflow.com/questions/1509915. 8 | # 9 | # @param [String] string 10 | # the string to use for transformation 11 | # 12 | # @return [String] 13 | # 14 | def underscore(string) 15 | string 16 | .to_s 17 | .gsub(/::/, "/") 18 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 19 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') 20 | .tr("-", "_") 21 | .downcase 22 | end 23 | 24 | # 25 | # Convert an underscored string to it's camelcase equivalent constant. 26 | # 27 | # @param [String] string 28 | # the string to convert 29 | # 30 | # @return [String] 31 | # 32 | def camelize(string) 33 | string 34 | .to_s 35 | .split("_") 36 | .map(&:capitalize) 37 | .join 38 | end 39 | 40 | # 41 | # Truncate the given string to a certain number of characters. 42 | # 43 | # @param [String] string 44 | # the string to truncate 45 | # @param [Hash] options 46 | # the list of options (such as +length+) 47 | # 48 | def truncate(string, options = {}) 49 | length = options[:length] || 30 50 | 51 | if string.length > length 52 | string[0..length - 3] + "..." 53 | else 54 | string 55 | end 56 | end 57 | 58 | # 59 | # "Safely" read the contents of a file on disk, catching any permission 60 | # errors or not found errors and raising a nicer exception. 61 | # 62 | # @example Reading a file that does not exist 63 | # safe_read('/non-existent/file') #=> Error::FileNotFound 64 | # 65 | # @example Reading a file with improper permissions 66 | # safe_read('/bad-permissions') #=> Error::InsufficientFilePermissions 67 | # 68 | # @example Reading a regular file 69 | # safe_read('my-file.txt') #=> ["my-file", "..."] 70 | # 71 | # @param [String] path 72 | # the path to the file on disk 73 | # 74 | # @return [Array] 75 | # A array where the first value is the basename of the file and the 76 | # second value is the literal contents from +File.read+. 77 | # 78 | def safe_read(path) 79 | path = File.expand_path(path) 80 | name = File.basename(path, ".*") 81 | contents = File.read(path) 82 | 83 | [name, contents] 84 | rescue Errno::EACCES 85 | raise Error::InsufficientFilePermissions.new(path: path) 86 | rescue Errno::ENOENT 87 | raise Error::FileNotFound.new(path: path) 88 | end 89 | 90 | # 91 | # Quickly iterate over a collection using native Ruby threads, preserving 92 | # the original order of elements and being all thread-safe and stuff. 93 | # 94 | # @example Parse a collection of JSON files 95 | # 96 | # fast_collect(Dir['**/*.json']) do |item| 97 | # JSON.parse(File.read(item)) 98 | # end 99 | # 100 | # @param [#each] collection 101 | # the collection to iterate 102 | # @param [Proc] block 103 | # the block to evaluate (typically an expensive operation) 104 | # 105 | # @return [Array] 106 | # the result of the iteration 107 | # 108 | def fast_collect(collection, &block) 109 | collection.map do |item| 110 | Thread.new do 111 | Thread.current[:result] = block.call(item) 112 | end 113 | end.collect do |thread| 114 | thread.join 115 | thread[:result] 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/chef-api/validator.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | module Validator 3 | autoload :Base, "chef-api/validators/base" 4 | autoload :Required, "chef-api/validators/required" 5 | autoload :Type, "chef-api/validators/type" 6 | 7 | # 8 | # Find a validator by the given key. 9 | # 10 | def self.find(key) 11 | const_get(Util.camelize(key)) 12 | rescue NameError 13 | raise Error::InvalidValidator.new(key: key) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/chef-api/validators/base.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Validator::Base 3 | # 4 | # @return [Symbol] 5 | # the attribute to apply this validation on 6 | # 7 | attr_reader :attribute 8 | 9 | # 10 | # @return [Hash] 11 | # the hash of additional arguments passed in 12 | # 13 | attr_reader :options 14 | 15 | # 16 | # Create anew validator. 17 | # 18 | # @param [Symbol] attribute 19 | # the attribute to apply this validation on 20 | # @param [Hash] options 21 | # the list of options passed in 22 | # 23 | def initialize(attribute, options = {}) 24 | @attribute = attribute 25 | @options = options.is_a?(Hash) ? options : {} 26 | end 27 | 28 | # 29 | # Just in case someone forgets to define a key, this will return the 30 | # class's underscored name without "validator" as a symbol. 31 | # 32 | # @example 33 | # FooValidator.new.key #=> :foo 34 | # 35 | # @return [Symbol] 36 | # 37 | def key 38 | name = self.class.name.split("::").last 39 | Util.underscore(name).to_sym 40 | end 41 | 42 | # 43 | # Execute the validations. This is an abstract class and must be 44 | # overridden in custom validators. 45 | # 46 | # @param [Resource::Base::Base] resource 47 | # the parent resource to validate against 48 | # 49 | def validate(resource) 50 | raise Error::AbstractMethod.new(method: "Validators::Base#validate") 51 | end 52 | 53 | # 54 | # The string representation of this validation. 55 | # 56 | # @return [String] 57 | # 58 | def to_s 59 | "#<#{classname}>" 60 | end 61 | 62 | # 63 | # The string representation of this validation. 64 | # 65 | # @return [String] 66 | # 67 | def inspect 68 | "#<#{classname} :#{attribute}>" 69 | end 70 | 71 | private 72 | 73 | # 74 | # The class name for this validator. 75 | # 76 | # @return [String] 77 | # 78 | def classname 79 | @classname ||= self.class.name.split("::")[1..-1].join("::") 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/chef-api/validators/required.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Validator::Required < Validator::Base 3 | def validate(resource) 4 | value = resource._attributes[attribute] 5 | 6 | if value.to_s.strip.empty? 7 | resource.errors.add(attribute, "must be present") 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/chef-api/validators/type.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | class Validator::Type < Validator::Base 3 | attr_reader :types 4 | 5 | # 6 | # Overload the super method to capture the type attribute in the options 7 | # hash. 8 | # 9 | def initialize(attribute, type) 10 | super 11 | @types = Array(type) 12 | end 13 | 14 | def validate(resource) 15 | value = resource._attributes[attribute] 16 | 17 | if value && !types.any? { |type| value.is_a?(type) } 18 | short_name = types.to_s.split("::").last 19 | resource.errors.add(attribute, "must be a kind of #{short_name}") 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/chef-api/version.rb: -------------------------------------------------------------------------------- 1 | module ChefAPI 2 | VERSION = "0.10.10".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/integration/resources/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Client do 5 | it_behaves_like "a Chef API resource", :client, 6 | update: { validator: true } 7 | 8 | describe ".from_file" do 9 | let(:private_key) do 10 | <<-EOH.strip.gsub(/^ {10}/, "") 11 | -----BEGIN RSA PRIVATE KEY----- 12 | MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzI 13 | w+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoP 14 | kcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2 15 | hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NO 16 | Td0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcW 17 | yLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQIBIwKCAQEA4iqWPJXtzZA68mKd 18 | ELs4jJsdyky+ewdZeNds5tjcnHU5zUYE25K+ffJED9qUWICcLZDc81TGWjHyAqD1 19 | Bw7XpgUwFgeUJwUlzQurAv+/ySnxiwuaGJfhFM1CaQHzfXphgVml+fZUvnJUTvzf 20 | TK2Lg6EdbUE9TarUlBf/xPfuEhMSlIE5keb/Zz3/LUlRg8yDqz5w+QWVJ4utnKnK 21 | iqwZN0mwpwU7YSyJhlT4YV1F3n4YjLswM5wJs2oqm0jssQu/BT0tyEXNDYBLEF4A 22 | sClaWuSJ2kjq7KhrrYXzagqhnSei9ODYFShJu8UWVec3Ihb5ZXlzO6vdNQ1J9Xsf 23 | 4m+2ywKBgQD6qFxx/Rv9CNN96l/4rb14HKirC2o/orApiHmHDsURs5rUKDx0f9iP 24 | cXN7S1uePXuJRK/5hsubaOCx3Owd2u9gD6Oq0CsMkE4CUSiJcYrMANtx54cGH7Rk 25 | EjFZxK8xAv1ldELEyxrFqkbE4BKd8QOt414qjvTGyAK+OLD3M2QdCQKBgQDtx8pN 26 | CAxR7yhHbIWT1AH66+XWN8bXq7l3RO/ukeaci98JfkbkxURZhtxV/HHuvUhnPLdX 27 | 3TwygPBYZFNo4pzVEhzWoTtnEtrFueKxyc3+LjZpuo+mBlQ6ORtfgkr9gBVphXZG 28 | YEzkCD3lVdl8L4cw9BVpKrJCs1c5taGjDgdInQKBgHm/fVvv96bJxc9x1tffXAcj 29 | 3OVdUN0UgXNCSaf/3A/phbeBQe9xS+3mpc4r6qvx+iy69mNBeNZ0xOitIjpjBo2+ 30 | dBEjSBwLk5q5tJqHmy/jKMJL4n9ROlx93XS+njxgibTvU6Fp9w+NOFD/HvxB3Tcz 31 | 6+jJF85D5BNAG3DBMKBjAoGBAOAxZvgsKN+JuENXsST7F89Tck2iTcQIT8g5rwWC 32 | P9Vt74yboe2kDT531w8+egz7nAmRBKNM751U/95P9t88EDacDI/Z2OwnuFQHCPDF 33 | llYOUI+SpLJ6/vURRbHSnnn8a/XG+nzedGH5JGqEJNQsz+xT2axM0/W/CRknmGaJ 34 | kda/AoGANWrLCz708y7VYgAtW2Uf1DPOIYMdvo6fxIB5i9ZfISgcJ/bbCUkFrhoH 35 | +vq/5CIWxCPp0f85R4qxxQ5ihxJ0YDQT9Jpx4TMss4PSavPaBH3RXow5Ohe+bYoQ 36 | NE5OgEXk2wVfZczCZpigBKbKZHNYcelXtTt/nP3rsCuGcM4h53s= 37 | -----END RSA PRIVATE KEY----- 38 | EOH 39 | end 40 | 41 | let(:client) { described_class.from_file("/path/to/bacon.pem") } 42 | 43 | before do 44 | allow(File).to receive(:read).and_return(private_key) 45 | end 46 | 47 | it "loads the client from the server" do 48 | chef_server.create_client("bacon", validator: true) 49 | 50 | expect(client.name).to eq("bacon") 51 | expect(client.private_key).to eq(private_key) 52 | expect(client.validator).to be_truthy 53 | end 54 | 55 | it "creates a new instance when the client does not exist" do 56 | expect(client.name).to eq("bacon") 57 | expect(client.validator).to be_falsey 58 | expect(client.new_resource?).to be_truthy 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/integration/resources/environment_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Environment do 5 | it_behaves_like "a Chef API resource", :environment, 6 | update: { description: "This is the new description" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/resources/node_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Node do 5 | it_behaves_like "a Chef API resource", :node, 6 | update: { chef_environment: "my_environment" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/resources/partial_search_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::PartialSearch do 5 | describe ".query" do 6 | it "returns a partial search resource" do 7 | chef_server.send("create_client", "bacon") 8 | results = described_class.query(:client, { name: ["name"] }) 9 | expect(results).to be_a(described_class) 10 | end 11 | 12 | it "returns partial data" do 13 | chef_server.send("create_node", "bacon1", { foo: :bar }) 14 | chef_server.send("create_node", "bacon2", { foo: :baz, bar: :foo }) 15 | keys = { data: ["bar"] } 16 | results = described_class.query(:node, keys, "*:*", start: 1) 17 | expect(results.total).to be == 2 18 | expect(results.rows.size).to be == 1 19 | expect(results.rows.first).to be == { "data" => "foo" } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/integration/resources/role_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Role do 5 | it_behaves_like "a Chef API resource", :role, 6 | update: { description: "This is the new description" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/resources/search_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Search do 5 | describe ".query" do 6 | it "returns a search resource" do 7 | chef_server.send("create_client", "bacon") 8 | results = described_class.query(:client) 9 | expect(results).to be_a(described_class) 10 | end 11 | 12 | it "options are passed to the chef-server" do 13 | chef_server.send("create_node", "bacon1", { foo: :bar }) 14 | chef_server.send("create_node", "bacon2", { foo: :baz }) 15 | results = described_class.query(:node, "*:*", start: 1) 16 | expect(results.total).to be == 2 17 | expect(results.rows.size).to be == 1 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/integration/resources/user_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::User do 5 | it_behaves_like "a Chef API resource", :user, 6 | update: { admin: true } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "chef-api" 2 | 3 | RSpec.configure do |config| 4 | # Chef Server 5 | require "support/chef_server" 6 | config.include(RSpec::ChefServer::DSL) 7 | 8 | # Shared Examples 9 | Dir[ChefAPI.root.join("spec/support/shared/**/*.rb")].each { |file| require file } 10 | 11 | # Basic configuraiton 12 | config.run_all_when_everything_filtered = true 13 | config.filter_run(:focus) 14 | 15 | # 16 | config.before(:each) do 17 | ChefAPI::Log.level = :fatal 18 | end 19 | 20 | # Run specs in random order to surface order dependencies. If you find an 21 | # order dependency and want to debug it, you can fix the order by providing 22 | # the seed, which is printed after each run. 23 | # --seed 1234 24 | config.order = "random" 25 | end 26 | 27 | # 28 | # @return [String] 29 | # 30 | def rspec_support_file(*joins) 31 | File.join(File.expand_path("../support", __FILE__), *joins) 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/chef_server.rb: -------------------------------------------------------------------------------- 1 | require "chef_zero/server" 2 | 3 | module RSpec 4 | class ChefServer 5 | module DSL 6 | def chef_server 7 | RSpec::ChefServer 8 | end 9 | end 10 | 11 | class << self 12 | # 13 | # Delegate all methods to the singleton instance. 14 | # 15 | def method_missing(m, *args, &block) 16 | instance.send(m, *args, &block) 17 | end 18 | 19 | # 20 | # RSpec 3 checks +respond_to?+ 21 | # 22 | def respond_to_missing?(m, include_private = false) 23 | instance.respond_to?(m, include_private) || super 24 | end 25 | 26 | # 27 | # @macro entity 28 | # @method create_$1(name, data = {}) 29 | # Create a new $1 on the Chef Server 30 | # 31 | # @param [String] name 32 | # the name of the $1 33 | # @param [Hash] data 34 | # the list of data to load 35 | # 36 | # 37 | # @method $1(name) 38 | # Find a $1 at the given name 39 | # 40 | # @param [String] name 41 | # the name of the $1 42 | # 43 | # @return [$2, nil] 44 | # 45 | # 46 | # @method $3 47 | # The list of $1 on the Chef Server 48 | # 49 | # @return [Array] 50 | # all the $1 on the Chef Server 51 | # 52 | # 53 | # @method has_$1?(name) 54 | # Determine if the Chef Server has the given $1 55 | # 56 | # @param [String] name 57 | # the name of the $1 to find 58 | # 59 | # @return [Boolean] 60 | # 61 | def entity(method, key) 62 | class_eval <<-EOH, __FILE__, __LINE__ + 1 63 | def create_#{method}(name, data = {}) 64 | # Automatically set the "name" if no explicit one was given 65 | data[:name] ||= name 66 | 67 | # Convert it to JSON 68 | data = JSON.fast_generate(data) 69 | 70 | load_data(name, '#{key}', data) 71 | end 72 | 73 | def #{method}(name) 74 | data = get('#{key}', name) 75 | JSON.parse(data) 76 | rescue ChefZero::DataStore::DataNotFoundError 77 | nil 78 | end 79 | 80 | def #{key} 81 | get('#{key}') 82 | end 83 | 84 | def has_#{method}?(name) 85 | !get('#{key}', name).nil? 86 | rescue ChefZero::DataStore::DataNotFoundError 87 | false 88 | end 89 | EOH 90 | end 91 | end 92 | 93 | entity :client, :clients 94 | entity :data_bag, :data 95 | entity :environment, :environments 96 | entity :node, :nodes 97 | entity :role, :roles 98 | entity :user, :users 99 | 100 | require "singleton" unless defined?(Singleton) 101 | include Singleton 102 | 103 | # 104 | # 105 | # 106 | def initialize 107 | @server = ChefZero::Server.new({ 108 | # This uses a random port 109 | port: port, 110 | 111 | # Shut up 112 | log_level: :fatal, 113 | 114 | # Disable the "old" way - this is actually +multi_tenant: true+ 115 | single_org: false, 116 | 117 | # Don't generate real keys for faster test 118 | generate_real_keys: false, 119 | }) 120 | 121 | ChefAPI.endpoint = @server.url 122 | ChefAPI.key = ChefZero::PRIVATE_KEY 123 | end 124 | 125 | # 126 | # 127 | # 128 | def start 129 | @server.start_background(1) 130 | end 131 | 132 | # 133 | # Clear all the information in the server. This hook is run after each 134 | # example group, giving the appearance of an "empty" Chef Server for 135 | # each test. 136 | # 137 | def clear 138 | @server.clear_data 139 | end 140 | 141 | # 142 | # Stop the server (since it might not be running) 143 | # 144 | def stop 145 | @server.stop if @server.running? 146 | end 147 | 148 | # 149 | # Get the path to an item in the data store. 150 | # 151 | def get(*args) 152 | if args.size == 1 153 | @server.data_store.list(args) 154 | else 155 | @server.data_store.get(args) 156 | end 157 | end 158 | 159 | # 160 | # Shortcut method for loading data into Chef Zero. 161 | # 162 | # @param [String] name 163 | # the name or id of the item to load 164 | # @param [String, Symbol] key 165 | # the key to load 166 | # @param [Hash] data 167 | # the data for the object, which will be converted to JSON and uploaded 168 | # to the server 169 | # 170 | def load_data(name, key, data = {}) 171 | @server.load_data({ key => { name => data } }) 172 | end 173 | 174 | private 175 | 176 | # 177 | # A randomly assigned, open port for run the Chef Zero server. 178 | # 179 | # @return [Fixnum] 180 | # 181 | def port 182 | return @port if @port 183 | 184 | @server = TCPServer.new("127.0.0.1", 0) 185 | @port = @server.addr[1].to_i 186 | @server.close 187 | 188 | @port 189 | end 190 | end 191 | end 192 | 193 | RSpec.configure do |config| 194 | config.before(:suite) { RSpec::ChefServer.start } 195 | config.after(:each) { RSpec::ChefServer.clear } 196 | config.after(:suite) { RSpec::ChefServer.stop } 197 | end 198 | -------------------------------------------------------------------------------- /spec/support/cookbook.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chef-boneyard/chef-api/5fc0ee77056c7fe95cb6687ef0b9deb07642353f/spec/support/cookbook.tar.gz -------------------------------------------------------------------------------- /spec/support/shared/chef_api_resource.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a Chef API resource" do |type, options = {}| 2 | let(:resource_id) { "my_#{type}" } 3 | 4 | describe ".fetch" do 5 | it "returns nil when the resource does not exist" do 6 | expect(described_class.fetch("not_real")).to be_nil 7 | end 8 | 9 | it "returns the resource" do 10 | chef_server.send("create_#{type}", resource_id) 11 | expect(described_class.fetch(resource_id).name).to eq(resource_id) 12 | end 13 | end 14 | 15 | describe ".build" do 16 | it "builds a resource" do 17 | instance = described_class.build(name: resource_id) 18 | expect(instance).to be_a(described_class) 19 | end 20 | 21 | it "does not create a remote resource" do 22 | described_class.build(name: resource_id) 23 | expect(chef_server).to_not send("have_#{type}", resource_id) 24 | end 25 | end 26 | 27 | describe ".create" do 28 | it "creates a new remote resource" do 29 | described_class.create(name: resource_id) 30 | expect(chef_server).to send("have_#{type}", resource_id) 31 | end 32 | 33 | it "raises an exception when the resource already exists" do 34 | chef_server.send("create_#{type}", resource_id) 35 | expect { 36 | described_class.create(name: resource_id) 37 | }.to raise_error(ChefAPI::Error::ResourceAlreadyExists) 38 | end 39 | end 40 | 41 | describe ".exists?" do 42 | it "returns false when the resource does not exist" do 43 | expect(described_class.exists?(resource_id)).to be_falsey 44 | end 45 | 46 | it "returns true when the resource exists" do 47 | chef_server.send("create_#{type}", resource_id) 48 | expect(described_class.exists?(resource_id)).to be_truthy 49 | end 50 | end 51 | 52 | describe ".destroy" do 53 | it "destroys the #{type} with the given ID" do 54 | chef_server.send("create_#{type}", resource_id) 55 | described_class.delete(resource_id) 56 | 57 | expect(chef_server).to_not send("have_#{type}", resource_id) 58 | end 59 | 60 | it "does not raise an exception if the record does not exist" do 61 | expect { described_class.delete(resource_id) }.to_not raise_error 62 | end 63 | end 64 | 65 | describe ".update" do 66 | it "updates an existing resource" do 67 | chef_server.send("create_#{type}", resource_id) 68 | 69 | options[:update].each do |key, value| 70 | described_class.update(resource_id, key => value) 71 | expect(chef_server.send(type, resource_id)[key.to_s]).to eq(value) 72 | end 73 | end 74 | 75 | it "raises an exception when the resource does not exist" do 76 | expect { 77 | described_class.update(resource_id) 78 | }.to raise_error(ChefAPI::Error::ResourceNotFound) 79 | end 80 | end 81 | 82 | describe ".count" do 83 | it "returns the total number of resources" do 84 | 5.times do |i| 85 | chef_server.send("create_#{type}", "#{resource_id}_#{i}") 86 | end 87 | 88 | expect(described_class.count).to be >= 5 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/support/user.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA1xzQLQxDANx/Yu73NbqATU898uvHcVaMglg4FjMMOhqLTE3g 3 | MIDMUeBH05c167kRmF+6a3l3dNlDyt8cBtWo277Yfd3FPOzeaf/g7umKY0Ids9S4 4 | fZBP+wcG8mpk2ReNiAcIwJlTZHWSdUSoHIZeDXd8eE5tU1WfdWeXfd+yQFVTv5fj 5 | U0VwU/c4+UzMjySbHiv94mKIfels/M7hnlRoJyepJFLThTi5bE4bmyizhvBp6j8V 6 | HbaLdSfBis1FgDuaajSjTu6m41X7HshSuWkRp1w+2PWQnXwoCIBIogHcTlE+UGt6 7 | LNa23/jiMCzRGYQiS/gl5+6gTByhCcV7f2aXXwIDAQABAoIBADYZVvmdVdSHn7nf 8 | 42gtyUqoHSpUxcnpPFkjmqdqmy6ZsmK0SyennLsSrr22D6eC2bv6h0W0PKi0Y2pI 9 | BiJp5ZeuPYAaIBqcb6s04Pr03Qrte87YNrXNb2/wanzY6Rf35m5JZpgZd3GSaAz6 10 | AVV7LXgxjqoq/y+wHvRF40GS2p924BePIzSgwWld2X7s39YdgSrxk2vytuqU2B8m 11 | iH5fhJDghO7MQCX5aa+6YgLAvP28mKTPBOz8kCbiWPgDPxu9NpI4WGgzGgZqofjZ 12 | GIyqZcDQ15oILdi0awWaVAK6Ob/24QQm33QaHzKZTKaRzWUeIJpnqPcGndjRC4Yt 13 | FN/yVKECgYEA7ahqlovJN1AeZ1tDqP9UiGHSKyJmo3psnv3+u5DV+/cUUwDvbLF5 14 | atCaGWZbdd0oSejeNeNX+JKZZE5xxSrKmuFnQe9lljylrWEOZ0TBngi4MABVJvst 15 | vUG21vYVEZGiQzWZqZ92zxJQB3V0hOPNsyyAKVnyKctwhSlO3slbYhcCgYEA57bz 16 | ueFcwQsQuJFLK2fX6ZmMJD0bwMXMOZIb+1s/URAIEClFqJYnYJuT7GGthz8099FL 17 | 2HyrGScrTYL+ekrAFTHmx/hBLdPwYhCunyptUvPPQwU8+mEhKSsqVtjHKnFfyHRB 18 | T2c8AZNQNXhMMeJYANY3Gm1/4catWJ6RF/U1qfkCgYEApDqFrZLbcYXD/NhsYRRQ 19 | bg5rFbOoCcBH33bV2Pe1Z3DOcq1qxkm+BboxQuwgt8okVS6+n66C1Bs6NL6gkAeK 20 | Co1ItZ+hK7itJKq1MVeqFHMiFMmmDlH0wZvvpYxX8tQYtSkNDtJLX7zf4MehxVNG 21 | ilJuHiUx2v/iuaJaBkpPA/ECgYAxNXthOGkYXh848zJBj5Yc+Az5DTk9oUQT3eGv 22 | adtyfbMYq4stmGXYcHHju4K8vEGld39iBGfZuaXKmk0s738HgUd/pEtDTkU4rk5H 23 | Yx1AhqK3mv8uNT5zncUqGHODofwzd+z+ze/CbeSU1m1oEqeZ1eRx6ltEOYtKzLIH 24 | on25EQKBgEpxoKGGbp6EpY/wGCONeapjB/27gRAdLB5Nh/9HCAfVfoM/K31ECmL9 25 | ZWWiwM6U2Qlmh8jGrhN4su8hpsNGbjvZ+kpA0MqJnJQGr6Y7iiSKDtd+Xc1cLh1g 26 | YtL+yxlvdE9ue8oZut4Mfn0xQg2sns+OYi7mWQpssKeR/faPcGkK 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /spec/unit/authentication_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Authentication do 5 | let(:user) { "sethvargo" } 6 | let(:key) { rspec_support_file("user.pem") } 7 | let(:body) { nil } 8 | let(:verb) { :get } 9 | let(:path) { "/foo/bar" } 10 | let(:timestamp) { "1991-07-23T03:00:54Z" } 11 | 12 | let(:headers) { described_class.new(user, key, verb, path, body).headers } 13 | 14 | before do 15 | allow(Time).to receive_message_chain(:now, :utc, :iso8601) 16 | .and_return(timestamp) 17 | end 18 | 19 | context "when given a request with no body" do 20 | let(:body) { nil } 21 | 22 | it "returns the signed headers" do 23 | expect(headers["X-Ops-Sign"]).to eq("algorithm=sha1;version=1.0;") 24 | expect(headers["X-Ops-Userid"]).to eq("sethvargo") 25 | expect(headers["X-Ops-Timestamp"]).to eq("1991-07-23T03:00:54Z") 26 | expect(headers["X-Ops-Content-Hash"]).to eq("2jmj7l5rSw0yVb/vlWAYkK/YBwk=") 27 | expect(headers["X-Ops-Authorization-1"]).to eq("UuadIvkZTeZDcFW6oNilet0QzTcP/9JsRhSjIKCiZiqUeBG9jz4mU9w+TWsm") 28 | expect(headers["X-Ops-Authorization-2"]).to eq("2R3IiEKOW0S+UZpN19tPZ3nTdUluEvguidnsjuM/UpHymgY7M560pN4idXt5") 29 | expect(headers["X-Ops-Authorization-3"]).to eq("MQYAEHhFHTOfBX8ihOPkA5gkbLw6ehftjL10W/7H3bTrl1tiHHkv2Lmz4e+e") 30 | expect(headers["X-Ops-Authorization-4"]).to eq("9dJNeNDYVEaR1Efj7B7rnKjSD6SvRdqq0gbwiTfE7P2B88yjnq+a9eEoYgG3") 31 | expect(headers["X-Ops-Authorization-5"]).to eq("lmNnVT5pqJPHiE1YFj1OITywAi/5pMzJCzYzVyWxQT+4r+SIRtRESrRFi1Re") 32 | expect(headers["X-Ops-Authorization-6"]).to eq("OfHqhynKfmxMHAxVLJbfdjH3yX8Z8bq3tGPbdXxYAw==") 33 | end 34 | end 35 | 36 | context "when given a request with a string body" do 37 | let(:body) { '{ "some": { "json": true } }' } 38 | 39 | it "returns the signed headers" do 40 | expect(headers["X-Ops-Sign"]).to eq("algorithm=sha1;version=1.0;") 41 | expect(headers["X-Ops-Userid"]).to eq("sethvargo") 42 | expect(headers["X-Ops-Timestamp"]).to eq("1991-07-23T03:00:54Z") 43 | expect(headers["X-Ops-Content-Hash"]).to eq("D3+ox1HKmuzp3SLWiSU/5RdnbdY=") 44 | expect(headers["X-Ops-Authorization-1"]).to eq("fbV8dt51y832DJS0bfR1LJ+EF/HHiDEgqJawNZyKMkgMHZ0Bv78kQVtH73fS") 45 | expect(headers["X-Ops-Authorization-2"]).to eq("s3JQkMpZOwsNO8n2iduexmTthJe/JXG4sUgBKkS2qtKxpBy5snFSb6wD5ZuC") 46 | expect(headers["X-Ops-Authorization-3"]).to eq("VJuC1YpOF6bGM8CyUG0O0SZBZRFZVgyC5TFACJn8ymMIx0FznWSPLyvoSjsZ") 47 | expect(headers["X-Ops-Authorization-4"]).to eq("pdVOjhPV2+EQaj3c01dBFx5FSXgnBhWSmu2DCel/74TDt5RBraPcB4wczwpz") 48 | expect(headers["X-Ops-Authorization-5"]).to eq("VIeVqGMuQ71OE0tabej4OKyf1+BopOedxVH1+KF5ETisxqrNhmEtUY5WrmSS") 49 | expect(headers["X-Ops-Authorization-6"]).to eq("hjhiBXFdieV24Sojq6PKBhEEwpJqrPVP1lZNkRXdoA==") 50 | end 51 | end 52 | 53 | context "when given an IO object" do 54 | let(:body) { File.open(rspec_support_file("cookbook.tar.gz")) } 55 | 56 | it "returns the signed headers" do 57 | expect(headers["X-Ops-Sign"]).to eq("algorithm=sha1;version=1.0;") 58 | expect(headers["X-Ops-Userid"]).to eq("sethvargo") 59 | expect(headers["X-Ops-Timestamp"]).to eq("1991-07-23T03:00:54Z") 60 | expect(headers["X-Ops-Content-Hash"]).to eq("AWFSGfxiL2XltqdgSKCpdm84H9o=") 61 | expect(headers["X-Ops-Authorization-1"]).to eq("oRvANxtLQanzqdC28l0szONjTni9zLRBiybYNyxyxos7M1X3kSs5LknmMA/E") 62 | expect(headers["X-Ops-Authorization-2"]).to eq("i6Izk87dCcG3LLiGqRh0x/BoayS9SyoctdfMRR5ivrKRUzuQU9elHRpXnmjw") 63 | expect(headers["X-Ops-Authorization-3"]).to eq("7i/tlbLPrJQ/0+di9BU4m+BBD/vbh80KajmsaszxHx1wwNEBkNAymSLSDqXX") 64 | expect(headers["X-Ops-Authorization-4"]).to eq("gVAjNiaEzV9/EPQyGAYaU40SOdDwKzBthxgCpM9sfpfQsXj4Oj4SvSmO+4sy") 65 | expect(headers["X-Ops-Authorization-5"]).to eq("eJ0l7vpR0MyQqnhqbJHkQAGsG/HUhuhG0E9T7dClk08EB+sdsnDxr+5laei3") 66 | expect(headers["X-Ops-Authorization-6"]).to eq("YtCw2spOnumfdqx2hWvLmxR3y2eOuLBv77tZXUQ4Ug==") 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/unit/defaults_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Defaults do 5 | before(:each) do 6 | subject.instance_variable_set(:@config, nil) 7 | end 8 | 9 | context "without a config file" do 10 | before(:each) do 11 | allow(subject).to receive(:config).and_return({}) 12 | end 13 | 14 | it "returns the default endpoint" do 15 | expect(subject.endpoint).to eq subject::ENDPOINT 16 | end 17 | 18 | it "returns the default user agent" do 19 | expect(subject.user_agent).to eq subject::USER_AGENT 20 | end 21 | end 22 | 23 | context "without a config file and no ENV vars to find it" do 24 | around do |example| 25 | old_conf = ENV.delete("CHEF_API_CONFIG") 26 | old_home = ENV.delete("HOME") 27 | example.run 28 | ENV["CHEF_API_CONFIG"] = old_conf 29 | ENV["HOME"] = old_home 30 | end 31 | 32 | it "returns the default without errors" do 33 | expect { subject.config }.not_to raise_error 34 | end 35 | 36 | it "returns the default which is the empty hash" do 37 | expect(subject.config).to eq({}) 38 | end 39 | end 40 | 41 | context "with a config file" do 42 | before(:each) do 43 | config_content = "{\n"\ 44 | "\"CHEF_API_ENDPOINT\": \"test_endpoint\",\n" \ 45 | "\"CHEF_API_USER_AGENT\": \"test_user_agent\"\n" \ 46 | "}" 47 | path = instance_double(Pathname, read: config_content, exist?: true) 48 | allow(subject).to receive(:config_path).and_return(path) 49 | end 50 | 51 | it "returns the overridden value for endpoint" do 52 | expect(subject.endpoint).to eq "test_endpoint" 53 | end 54 | 55 | it "returns the overridden value for user agent" do 56 | expect(subject.user_agent).to eq "test_user_agent" 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/unit/errors_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI::Error 4 | describe ChefAPIError do 5 | let(:instance) { described_class.new } 6 | 7 | it "raises an exception with the correct message" do 8 | expect { raise instance }.to raise_error { |error| 9 | expect(error).to be_a(described_class) 10 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 11 | Oh no! Something really bad happened. I am not sure what actually happened because this is the catch-all error, but you should most definitely report an issue on GitHub at https://github.com/sethvargo/chef-api. 12 | EOH 13 | } 14 | end 15 | end 16 | 17 | describe AbstractMethod do 18 | let(:instance) { described_class.new(method: "Resource#load") } 19 | 20 | it "raises an exception with the correct message" do 21 | expect { raise instance }.to raise_error { |error| 22 | expect(error).to be_a(described_class) 23 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 24 | 'Resource#load' is an abstract method. You must override this method in your subclass with the proper implementation and logic. For more information, please see the inline documentation for Resource#load. If you are not a developer, this is most likely a bug in the ChefAPI gem. Please file a bug report at: 25 | 26 | https://github.com/sethvargo/chef-api/issues/new 27 | 28 | and include the command(s) or code you ran to arrive at this error. 29 | EOH 30 | } 31 | end 32 | end 33 | 34 | describe CannotRegenerateKey do 35 | let(:instance) { described_class.new } 36 | 37 | it "raises an exception with the correct message" do 38 | expect { raise instance }.to raise_error { |error| 39 | expect(error).to be_a(described_class) 40 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 41 | You attempted to regenerate the private key for a Client or User that does not yet exist on the remote Chef Server. You can only regenerate the key for an object that is persisted. Try saving this record this object before regenerating the key. 42 | EOH 43 | } 44 | end 45 | end 46 | 47 | describe FileNotFound do 48 | let(:instance) { described_class.new(path: "/path/to/file.rb") } 49 | 50 | it "raises an exception with the correct message" do 51 | expect { raise instance }.to raise_error { |error| 52 | expect(error).to be_a(described_class) 53 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 54 | I could not find a file at '/path/to/file.rb'. Please make sure you have typed the path correctly and that the resource exists at the given path. 55 | EOH 56 | } 57 | end 58 | end 59 | 60 | describe HTTPBadRequest do 61 | let(:instance) { described_class.new(message: "Something happened...") } 62 | 63 | it "raises an exception with the correct message" do 64 | expect { raise instance }.to raise_error { |error| 65 | expect(error).to be_a(described_class) 66 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 67 | The Chef Server did not understand the request because it was malformed. 68 | 69 | Something happened... 70 | EOH 71 | } 72 | end 73 | end 74 | 75 | describe HTTPForbiddenRequest do 76 | let(:instance) { described_class.new(message: "Something happened...") } 77 | 78 | it "raises an exception with the correct message" do 79 | expect { raise instance }.to raise_error { |error| 80 | expect(error).to be_a(described_class) 81 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 82 | The Chef Server actively refused to fulfill the request. 83 | 84 | Something happened... 85 | EOH 86 | } 87 | end 88 | end 89 | 90 | describe HTTPGatewayTimeout do 91 | let(:instance) { described_class.new(message: "Something happened...") } 92 | 93 | it "raises an exception with the correct message" do 94 | expect { raise instance }.to raise_error { |error| 95 | expect(error).to be_a(described_class) 96 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 97 | The Chef Server did not respond in an adequate amount of time. 98 | 99 | Something happened... 100 | EOH 101 | } 102 | end 103 | end 104 | 105 | describe HTTPMethodNotAllowed do 106 | let(:instance) { described_class.new(message: "Something happened...") } 107 | 108 | it "raises an exception with the correct message" do 109 | expect { raise instance }.to raise_error { |error| 110 | expect(error).to be_a(described_class) 111 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 112 | That HTTP method is not allowed on this URL. 113 | 114 | Something happened... 115 | EOH 116 | } 117 | end 118 | end 119 | 120 | describe HTTPNotAcceptable do 121 | let(:instance) { described_class.new(message: "Something happened...") } 122 | 123 | it "raises an exception with the correct message" do 124 | expect { raise instance }.to raise_error { |error| 125 | expect(error).to be_a(described_class) 126 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 127 | The Chef Server identified this request as unacceptable. This usually means you have not specified the correct Accept or Content-Type headers on the request. 128 | 129 | Something happened... 130 | EOH 131 | } 132 | end 133 | end 134 | 135 | describe HTTPNotFound do 136 | let(:instance) { described_class.new(message: "Something happened...") } 137 | 138 | it "raises an exception with the correct message" do 139 | expect { raise instance }.to raise_error { |error| 140 | expect(error).to be_a(described_class) 141 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 142 | The requested URL does not exist on the Chef Server. 143 | 144 | Something happened... 145 | EOH 146 | } 147 | end 148 | end 149 | 150 | describe HTTPServerUnavailable do 151 | let(:instance) { described_class.new } 152 | 153 | it "raises an exception with the correct message" do 154 | expect { raise instance }.to raise_error { |error| 155 | expect(error).to be_a(described_class) 156 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 157 | The Chef Server is currently unavailable or is not currently accepting client connections. Please ensure the server is accessible via ping or telnet on your local network. If this error persists, please contact your network administrator. 158 | EOH 159 | } 160 | end 161 | end 162 | 163 | describe HTTPUnauthorizedRequest do 164 | let(:instance) { described_class.new(message: "Something happened...") } 165 | 166 | it "raises an exception with the correct message" do 167 | expect { raise instance }.to raise_error { |error| 168 | expect(error).to be_a(described_class) 169 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 170 | The Chef Server requires authorization. Please ensure you have specified the correct client name and private key. If this error continues, please verify the given client has the proper permissions on the Chef Server. 171 | 172 | Something happened... 173 | EOH 174 | } 175 | end 176 | end 177 | 178 | describe InsufficientFilePermissions do 179 | let(:instance) { described_class.new(path: "/path/to/file.rb") } 180 | 181 | it "raises an exception with the correct message" do 182 | expect { raise instance }.to raise_error { |error| 183 | expect(error).to be_a(described_class) 184 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 185 | I cannot read the file at '/path/to/file.rb' because the permissions on the file do not permit it. Please ensure the file has the correct permissions and that this Ruby process is running as a user with access to that path. 186 | EOH 187 | } 188 | end 189 | end 190 | 191 | describe InvalidResource do 192 | let(:instance) { described_class.new(errors: "Missing a thing!") } 193 | 194 | it "raises an exception with the correct message" do 195 | expect { raise instance }.to raise_error { |error| 196 | expect(error).to be_a(described_class) 197 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 198 | There were errors saving your resource: Missing a thing! 199 | EOH 200 | } 201 | end 202 | end 203 | 204 | describe InvalidValidator do 205 | let(:instance) { described_class.new(key: "bacon") } 206 | 207 | it "raises an exception with the correct message" do 208 | expect { raise instance }.to raise_error { |error| 209 | expect(error).to be_a(described_class) 210 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 211 | 'bacon' is not a valid validator. Please make sure it is spelled correctly and that the constant is properly defined. If you are using a custom validator, please ensure the validator extends ChefAPI::Validator::Base and is a subclass of ChefAPI::Validator. 212 | EOH 213 | } 214 | end 215 | end 216 | 217 | describe MissingURLParameter do 218 | let(:instance) { described_class.new(param: "user") } 219 | 220 | it "raises an exception with the correct message" do 221 | expect { raise instance }.to raise_error { |error| 222 | expect(error).to be_a(described_class) 223 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 224 | The required URL parameter 'user' was not present. Please specify the parameter as an option, like Resource.new(id, user: 'value'). 225 | EOH 226 | } 227 | end 228 | end 229 | 230 | describe NotADirectory do 231 | let(:instance) { described_class.new(path: "/path/to/directory") } 232 | 233 | it "raises an exception with the correct message" do 234 | expect { raise instance }.to raise_error { |error| 235 | expect(error).to be_a(described_class) 236 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 237 | The given path '/path/to/directory' is not a directory. Please make sure you have passed the path to a directory on disk. 238 | EOH 239 | } 240 | end 241 | end 242 | 243 | describe ResourceAlreadyExists do 244 | let(:instance) { described_class.new(type: "client", id: "bacon") } 245 | 246 | it "raises an exception with the correct message" do 247 | expect { raise instance }.to raise_error { |error| 248 | expect(error).to be_a(described_class) 249 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 250 | The client 'bacon' already exists on the Chef Server. Each client must have a unique identifier and the Chef Server indicated this client already exists. If you are trying to update the client, consider using the 'update' method instead. 251 | EOH 252 | } 253 | end 254 | end 255 | 256 | describe ResourceNotFound do 257 | let(:instance) { described_class.new(type: "client", id: "bacon") } 258 | 259 | it "raises an exception with the correct message" do 260 | expect { raise instance }.to raise_error { |error| 261 | expect(error).to be_a(described_class) 262 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 263 | There is no client with an id of 'bacon' on the Chef Server. If you are updating the client, please make sure the client exists and has the correct Chef identifier (primary key). 264 | EOH 265 | } 266 | end 267 | end 268 | 269 | describe ResourceNotMutable do 270 | let(:instance) { described_class.new(type: "client", id: "bacon") } 271 | 272 | it "raises an exception with the correct message" do 273 | expect { raise instance }.to raise_error { |error| 274 | expect(error).to be_a(described_class) 275 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 276 | The client 'bacon' is not mutable. It may be locked by the remote Chef Server, or the Chef Server may not permit modifying the resource. 277 | EOH 278 | } 279 | end 280 | end 281 | 282 | describe UnknownAttribute do 283 | let(:instance) { described_class.new(attribute: "name") } 284 | 285 | it "raises an exception with the correct message" do 286 | expect { raise instance }.to raise_error { |error| 287 | expect(error).to be_a(described_class) 288 | expect(error.message).to eq <<-EOH.gsub(/^ {10}/, "") 289 | 'name' is not a valid attribute! 290 | EOH 291 | } 292 | end 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /spec/unit/resources/base_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Base do 5 | before do 6 | # Unset all instance variables (which are actually cached on the parent 7 | # class) to prevent caching. 8 | described_class.instance_variables.each do |instance_variable| 9 | described_class.send(:remove_instance_variable, instance_variable) 10 | end 11 | end 12 | 13 | describe ".schema" do 14 | it "sets the schema for the class" do 15 | block = Proc.new {} 16 | described_class.schema(&block) 17 | expect(described_class.schema).to be_a(Schema) 18 | end 19 | end 20 | 21 | describe ".collection_path" do 22 | it "raises an exception if the collection name is not set" do 23 | expect { 24 | described_class.collection_path 25 | }.to raise_error(ArgumentError, "collection_path not set for Class") 26 | end 27 | 28 | it "sets the collection name" do 29 | described_class.collection_path("bacons") 30 | expect(described_class.collection_path).to eq("bacons") 31 | end 32 | 33 | it "converts the symbol to a string" do 34 | described_class.collection_path(:bacons) 35 | expect(described_class.collection_path).to eq("bacons") 36 | end 37 | end 38 | 39 | describe ".build" do 40 | it "creates a new instance" do 41 | allow(described_class).to receive(:new) 42 | allow(described_class).to receive(:schema).and_return(double(attributes: {})) 43 | 44 | expect(described_class).to receive(:new).with({ foo: "bar" }, {}) 45 | described_class.build(foo: "bar") 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/unit/resources/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Resource::Client do 5 | describe ".initialize" do 6 | it "converts an x509 certificate to a public key" do 7 | certificate = <<-EOH.gsub(/^ {10}/, "") 8 | -----BEGIN CERTIFICATE----- 9 | MIIDOjCCAqOgAwIBAgIEkT9umDANBgkqhkiG9w0BAQUFADCBnjELMAkGA1UEBhMC 10 | VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxFjAUBgNV 11 | BAoMDU9wc2NvZGUsIEluYy4xHDAaBgNVBAsME0NlcnRpZmljYXRlIFNlcnZpY2Ux 12 | MjAwBgNVBAMMKW9wc2NvZGUuY29tL2VtYWlsQWRkcmVzcz1hdXRoQG9wc2NvZGUu 13 | Y29tMCAXDTEzMDYwNzE3NDcxNloYDzIxMDIwNzIyMTc0NzE2WjCBnTEQMA4GA1UE 14 | BxMHU2VhdHRsZTETMBEGA1UECBMKV2FzaGluZ3RvbjELMAkGA1UEBhMCVVMxHDAa 15 | BgNVBAsTE0NlcnRpZmljYXRlIFNlcnZpY2UxFjAUBgNVBAoTDU9wc2NvZGUsIElu 16 | Yy4xMTAvBgNVBAMUKFVSSTpodHRwOi8vb3BzY29kZS5jb20vR1VJRFMvY2xpZW50 17 | X2d1aWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCko42znqDzryvE 18 | aB1DBHLFpjLZ7aWrTJQJJUvQhMFTE/1nisa+1bw8MvOYnGDSp2j6V7XJsJgZFsAW 19 | 7w5TTBHrYRAz0Boi+uaQ3idqfGI5na/dRt2MqFnwJYqvm7z+LeeYbGlXFNnhUInt 20 | OjZD6AtrvuTGAEVdyIznsOMsLun/KWy9zG0+C+6vCnxGga+Z+xZ56JrBvWoWeIjG 21 | kO0J6E3uqyzAC8FwN6xnyaHNlvODE+40MuioVJ52oLikTwaVe3T+vSJQoCu1lz7c 22 | AbdszAhDW2p+GVvBBjAXLNi/w27heDQKBQOS+6tHJAX3WeFj0xgE5Bryae67E0q8 23 | hM4WPL6PAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAWlBQBu8kzhSA4TuHJNyngRAJ 24 | WXHus2brJZHaaZYMbzZMq+lklMbdw8NZBay+qVqN/latgQ7fjY9RSSdhCTeSITyw 25 | gn8s3zeFS7C6nwrzYNAQXTRJZoSgn32hgZoD1H0LjW5vcoqiYZOHvX3EOySboS09 26 | bAELUrq85D+uVns9C5A= 27 | -----END CERTIFICATE----- 28 | EOH 29 | 30 | instance = described_class.new(certificate: certificate) 31 | expect(instance.public_key).to eq <<-EOH.gsub(/^ {10}/, "") 32 | -----BEGIN PUBLIC KEY----- 33 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApKONs56g868rxGgdQwRy 34 | xaYy2e2lq0yUCSVL0ITBUxP9Z4rGvtW8PDLzmJxg0qdo+le1ybCYGRbAFu8OU0wR 35 | 62EQM9AaIvrmkN4nanxiOZ2v3UbdjKhZ8CWKr5u8/i3nmGxpVxTZ4VCJ7To2Q+gL 36 | a77kxgBFXciM57DjLC7p/ylsvcxtPgvurwp8RoGvmfsWeeiawb1qFniIxpDtCehN 37 | 7qsswAvBcDesZ8mhzZbzgxPuNDLoqFSedqC4pE8GlXt0/r0iUKArtZc+3AG3bMwI 38 | Q1tqfhlbwQYwFyzYv8Nu4Xg0CgUDkvurRyQF91nhY9MYBOQa8mnuuxNKvITOFjy+ 39 | jwIDAQAB 40 | -----END PUBLIC KEY----- 41 | EOH 42 | end 43 | end 44 | 45 | describe "#regenerate_keys" do 46 | it "raises an error if the client is not persisted to the server" do 47 | expect { 48 | described_class.new.regenerate_keys 49 | }.to raise_error(Error::CannotRegenerateKey) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/unit/resources/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module ChefAPI 4 | describe Connection do 5 | shared_examples "a proxy for" do |resource, klass| 6 | context "##{resource}" do 7 | it "sets Thread.current to self" do 8 | subject.send(resource) 9 | expect(Thread.current["chefapi.connection"]).to be(subject) 10 | end 11 | 12 | it "returns an instance of #{klass}" do 13 | # Fuck you Ruby 1.9.3 14 | expected = klass.split("::").inject(ChefAPI) { |c, i| c.const_get(i) } 15 | 16 | expect(subject.send(resource)).to be(expected) 17 | end 18 | end 19 | end 20 | 21 | it_behaves_like "a proxy for", :clients, "Resource::Client" 22 | it_behaves_like "a proxy for", :data_bags, "Resource::DataBag" 23 | it_behaves_like "a proxy for", :environments, "Resource::Environment" 24 | it_behaves_like "a proxy for", :nodes, "Resource::Node" 25 | it_behaves_like "a proxy for", :partial_search, "Resource::PartialSearch" 26 | it_behaves_like "a proxy for", :principals, "Resource::Principal" 27 | it_behaves_like "a proxy for", :roles, "Resource::Role" 28 | it_behaves_like "a proxy for", :search, "Resource::Search" 29 | it_behaves_like "a proxy for", :users, "Resource::User" 30 | 31 | context "#initialize" do 32 | context "when options are given" do 33 | let(:endpoint) { "http://endpoint.gov" } 34 | 35 | it "sets the option" do 36 | instance = described_class.new(endpoint: endpoint) 37 | expect(instance.endpoint).to eq(endpoint) 38 | end 39 | 40 | it "uses the default options" do 41 | instance = described_class.new 42 | expect(instance.endpoint).to eq(ChefAPI.endpoint) 43 | end 44 | end 45 | 46 | context "when a block is given" do 47 | it "yields self" do 48 | expect { |b| described_class.new(&b) }.to yield_with_args(described_class) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /templates/errors/abstract_method.erb: -------------------------------------------------------------------------------- 1 | '<%= @method %>' is an abstract method. You must override this method in your subclass with the proper implementation and logic. For more information, please see the inline documentation for <%= @method %>. If you are not a developer, this is most likely a bug in the ChefAPI gem. Please file a bug report at: 2 | 3 | https://github.com/sethvargo/chef-api/issues/new 4 | 5 | and include the command(s) or code you ran to arrive at this error. 6 | -------------------------------------------------------------------------------- /templates/errors/cannot_regenerate_key.erb: -------------------------------------------------------------------------------- 1 | You attempted to regenerate the private key for a Client or User that does not yet exist on the remote Chef Server. You can only regenerate the key for an object that is persisted. Try saving this record this object before regenerating the key. 2 | -------------------------------------------------------------------------------- /templates/errors/chef_api_error.erb: -------------------------------------------------------------------------------- 1 | Oh no! Something really bad happened. I am not sure what actually happened because this is the catch-all error, but you should most definitely report an issue on GitHub at https://github.com/sethvargo/chef-api. 2 | -------------------------------------------------------------------------------- /templates/errors/file_not_found.erb: -------------------------------------------------------------------------------- 1 | I could not find a file at '<%= @path %>'. Please make sure you have typed the path correctly and that the resource exists at the given path. 2 | -------------------------------------------------------------------------------- /templates/errors/http_bad_request.erb: -------------------------------------------------------------------------------- 1 | The Chef Server did not understand the request because it was malformed. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_forbidden_request.erb: -------------------------------------------------------------------------------- 1 | The Chef Server actively refused to fulfill the request. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_gateway_timeout.erb: -------------------------------------------------------------------------------- 1 | The Chef Server did not respond in an adequate amount of time. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_method_not_allowed.erb: -------------------------------------------------------------------------------- 1 | That HTTP method is not allowed on this URL. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_not_acceptable.erb: -------------------------------------------------------------------------------- 1 | The Chef Server identified this request as unacceptable. This usually means you have not specified the correct Accept or Content-Type headers on the request. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_not_found.erb: -------------------------------------------------------------------------------- 1 | The requested URL does not exist on the Chef Server. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/http_server_unavailable.erb: -------------------------------------------------------------------------------- 1 | The Chef Server is currently unavailable or is not currently accepting client connections. Please ensure the server is accessible via ping or telnet on your local network. If this error persists, please contact your network administrator. 2 | -------------------------------------------------------------------------------- /templates/errors/http_unauthorized_request.erb: -------------------------------------------------------------------------------- 1 | The Chef Server requires authorization. Please ensure you have specified the correct client name and private key. If this error continues, please verify the given client has the proper permissions on the Chef Server. 2 | 3 | <%= @message %> 4 | -------------------------------------------------------------------------------- /templates/errors/insufficient_file_permissions.erb: -------------------------------------------------------------------------------- 1 | I cannot read the file at '<%= @path %>' because the permissions on the file do not permit it. Please ensure the file has the correct permissions and that this Ruby process is running as a user with access to that path. 2 | -------------------------------------------------------------------------------- /templates/errors/invalid_resource.erb: -------------------------------------------------------------------------------- 1 | There were errors saving your resource: <%= @errors %> 2 | -------------------------------------------------------------------------------- /templates/errors/invalid_validator.erb: -------------------------------------------------------------------------------- 1 | '<%= @key %>' is not a valid validator. Please make sure it is spelled correctly and that the constant is properly defined. If you are using a custom validator, please ensure the validator extends ChefAPI::Validator::Base and is a subclass of ChefAPI::Validator. 2 | -------------------------------------------------------------------------------- /templates/errors/missing_url_parameter.erb: -------------------------------------------------------------------------------- 1 | The required URL parameter '<%= @param %>' was not present. Please specify the parameter as an option, like Resource.new(id, <%= @param %>: 'value'). 2 | -------------------------------------------------------------------------------- /templates/errors/not_a_directory.erb: -------------------------------------------------------------------------------- 1 | The given path '<%= @path %>' is not a directory. Please make sure you have passed the path to a directory on disk. 2 | -------------------------------------------------------------------------------- /templates/errors/resource_already_exists.erb: -------------------------------------------------------------------------------- 1 | The <%= @type %> '<%= @id %>' already exists on the Chef Server. Each <%= @type %> must have a unique identifier and the Chef Server indicated this <%= @type %> already exists. If you are trying to update the <%= @type %>, consider using the 'update' method instead. 2 | -------------------------------------------------------------------------------- /templates/errors/resource_not_found.erb: -------------------------------------------------------------------------------- 1 | There is no <%= @type %> with an id of '<%= @id %>' on the Chef Server. If you are updating the <%= @type %>, please make sure the <%= @type %> exists and has the correct Chef identifier (primary key). 2 | -------------------------------------------------------------------------------- /templates/errors/resource_not_mutable.erb: -------------------------------------------------------------------------------- 1 | The <%= @type %> '<%= @id %>' is not mutable. It may be locked by the remote Chef Server, or the Chef Server may not permit modifying the resource. 2 | -------------------------------------------------------------------------------- /templates/errors/unknown_attribute.erb: -------------------------------------------------------------------------------- 1 | '<%= @attribute %>' is not a valid attribute! 2 | --------------------------------------------------------------------------------