├── .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 ├── dependabot.yml └── workflows │ ├── lint.yml │ └── unit_specs.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── VERSION ├── bin └── resource ├── cheffish.gemspec ├── lib ├── chef │ └── resource │ │ ├── chef_acl.rb │ │ ├── chef_client.rb │ │ ├── chef_container.rb │ │ ├── chef_data_bag.rb │ │ ├── chef_data_bag_item.rb │ │ ├── chef_environment.rb │ │ ├── chef_group.rb │ │ ├── chef_mirror.rb │ │ ├── chef_node.rb │ │ ├── chef_organization.rb │ │ ├── chef_resolved_cookbooks.rb │ │ ├── chef_role.rb │ │ ├── chef_user.rb │ │ ├── private_key.rb │ │ └── public_key.rb ├── cheffish.rb └── cheffish │ ├── array_property.rb │ ├── base_properties.rb │ ├── base_resource.rb │ ├── basic_chef_client.rb │ ├── chef_actor_base.rb │ ├── chef_run.rb │ ├── chef_run_data.rb │ ├── chef_run_listener.rb │ ├── key_formatter.rb │ ├── merged_config.rb │ ├── node_properties.rb │ ├── recipe_dsl.rb │ ├── rspec.rb │ ├── rspec │ ├── chef_run_support.rb │ ├── matchers.rb │ ├── matchers │ │ ├── be_idempotent.rb │ │ ├── emit_no_warnings_or_errors.rb │ │ ├── have_updated.rb │ │ └── partially_match.rb │ ├── recipe_run_wrapper.rb │ └── repository_support.rb │ ├── server_api.rb │ ├── version.rb │ └── with_pattern.rb ├── metadata.rb └── spec ├── functional ├── fingerprint_spec.rb ├── merged_config_spec.rb └── server_api_spec.rb ├── integration ├── chef_acl_spec.rb ├── chef_client_spec.rb ├── chef_container_spec.rb ├── chef_data_bag_item_spec.rb ├── chef_group_spec.rb ├── chef_mirror_spec.rb ├── chef_node_spec.rb ├── chef_organization_spec.rb ├── chef_role_spec.rb ├── chef_user_spec.rb ├── private_key_spec.rb ├── recipe_dsl_spec.rb └── rspec │ └── converge_spec.rb ├── support ├── key_support.rb └── spec_support.rb └── unit ├── get_private_key_spec.rb └── recipe_run_wrapper_spec.rb /.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-infra-notify 7 | 8 | # This publish is triggered by the `built_in:publish_rubygems` artifact_action. 9 | rubygems: 10 | - cheffish 11 | 12 | github: 13 | # This deletes the GitHub PR branch after successfully merged into the release branch 14 | delete_branch_on_merge: true 15 | # The tag format to use (e.g. v1.0.0) 16 | version_tag_format: "v{{version}}" 17 | # allow bumping the minor release via label 18 | minor_bump_labels: 19 | - "Expeditor: Bump Version Minor" 20 | # allow bumping the major release via label 21 | major_bump_labels: 22 | - "Expeditor: Bump Version Major" 23 | 24 | changelog: 25 | rollup_header: Changes not yet released to rubygems.org 26 | 27 | subscriptions: 28 | # These actions are taken, in order they are specified, anytime a Pull Request is merged. 29 | - workload: pull_request_merged:{{github_repo}}:{{release_branch}}:* 30 | actions: 31 | - built_in:bump_version: 32 | ignore_labels: 33 | - "Expeditor: Skip Version Bump" 34 | - "Expeditor: Skip All" 35 | - bash:.expeditor/update_version.sh: 36 | only_if: built_in:bump_version 37 | - built_in:update_changelog: 38 | ignore_labels: 39 | - "Expeditor: Skip Changelog" 40 | - "Expeditor: Skip All" 41 | - built_in:build_gem: 42 | only_if: built_in:bump_version 43 | - workload: project_promoted:{{agent_id}}:* 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 -------------------------------------------------------------------------------- /.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 | export LANG=C.UTF-8 LANGUAGE=C.UTF-8 9 | 10 | echo "--- bundle install" 11 | 12 | bundle config --local path vendor/bundle 13 | bundle install --jobs=7 --retry=3 14 | 15 | echo "+++ bundle exec task" 16 | bundle exec "$@" 17 | -------------------------------------------------------------------------------- /.expeditor/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 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/cheffish/version.rb 10 | 11 | # Once Expeditor finshes 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: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chef/cheffish/9152b5ff275461db322ba6fddd2444d0038bdee6/.expeditor/verify.pipeline.yml -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | 3 | * @chef/chef-infra-reviewers @chef/chef-infra-approvers @chef/chef-infra-owners 4 | .expeditor/ @chef/infra-packages 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 | # Version: 8 | 9 | [Version of the project installed] 10 | 11 | # Environment: 12 | 13 | [Details about the environment such as the Operating System, cookbook details, etc...] 14 | 15 | # Scenario: 16 | 17 | [What you are trying to achieve and you can't?] 18 | 19 | # Steps to Reproduce: 20 | 21 | [If you are filing an issue what are the things we need to do in order to repro your problem?] 22 | 23 | # Expected Result: 24 | 25 | [What are you expecting to happen as the consequence of above reproduction steps?] 26 | 27 | # Actual Result: 28 | 29 | [What actually happens after the reproduction steps?] 30 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "06:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: lint 3 | 4 | "on": 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | chefstyle: 12 | runs-on: ubuntu-latest 13 | env: 14 | BUNDLE_WITHOUT: ruby_shadow:omnibus_package 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: '3.1' 20 | bundler-cache: true 21 | - uses: r7kamura/rubocop-problem-matchers-action@v1 # this shows the failures in the PR 22 | - run: bundle exec rake style 23 | -------------------------------------------------------------------------------- /.github/workflows/unit_specs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: unit_specs 3 | 4 | "on": 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | unit: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' 17 | ruby: ['3.0', '3.1'] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | - run: bundle exec rake spec 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | pkg 3 | binstubs/ 4 | chef-client-version.gemfile.lock 5 | .bundle 6 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -f documentation --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to the Chef Community Code of Conduct at https://www.chef.io/code-of-conduct/ 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "cookstyle", "~> 7.32.8" 7 | gem "rake" 8 | gem "rspec", "~> 3.0" 9 | end 10 | 11 | # Allow Travis to run tests with different dependency versions 12 | if ENV["GEMFILE_MOD"] 13 | puts ENV["GEMFILE_MOD"] 14 | instance_eval(ENV["GEMFILE_MOD"]) 15 | else 16 | group :development do 17 | # chef 17 is on 3.0 18 | # chef 18 is on 3.1 19 | case RUBY_VERSION 20 | when /^3\.0/ 21 | gem "chef", "~> 17.0" 22 | gem "ohai", "~> 17.0" 23 | when /^3\.1/ 24 | gem "chef", "~> 18.0" 25 | gem "ohai", "~> 18.0" 26 | else 27 | # go with the latest, unbounded 28 | gem "chef" 29 | gem "ohai" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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 | # Cheffish 2 | 3 | [![Gem Version](https://badge.fury.io/rb/cheffish.svg)](http://badge.fury.io/rb/cheffish) 4 | 5 | **Umbrella Project**: [Chef Infra](https://github.com/chef/chef-oss-practices/blob/master/projects/chef-infra.md) 6 | 7 | **Project State**: [Active](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md#active) 8 | 9 | **Issues [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 10 | 11 | **Pull Request [Response Time Maximum](https://github.com/chef/chef-oss-practices/blob/master/repo-management/repo-states.md)**: 14 days 12 | 13 | This library provides a variety of convergent resources for interacting with the Chef Server; along the way, it happens to provide some very useful and sophisticated ways of running Chef resources as recipes in RSpec examples. 14 | 15 | **This document may have errors, but it should have enough pointers to get you oriented.** 16 | 17 | There are essentially 3 collections here: 18 | 19 | ## Resource/Provider Pairs for Manipulating Chef Servers 20 | 21 | You'd use these in recipes/cookbooks. They are documented on the [main Chef docs site](https://docs.chef.io). 22 | 23 | - [chef_acl](https://docs.chef.io/resources/chef_acl) 24 | - [chef_client](https://docs.chef.io/resources/chef_client) 25 | - [chef_container](https://docs.chef.io/resources/chef_container) 26 | - [chef_data_bag](https://docs.chef.io/resources/chef_data_bag) 27 | - [chef_data_bag_item](https://docs.chef.io/resources/chef_data_bag_item) 28 | - [chef_environment](https://docs.chef.io/resources/chef_environment) 29 | - [chef_group](https://docs.chef.io/resources/chef_group) 30 | - [chef_mirror](https://docs.chef.io/resources/chef_mirror) 31 | - [chef_node](https://docs.chef.io/resources/chef_node) 32 | - [chef_organization](https://docs.chef.io/resources/chef_organization) 33 | - [chef_resolved_cookbooks](https://docs.chef.io/resources/chef_resolved_cookbooks) 34 | - [chef_role](https://docs.chef.io/resources/chef_role) 35 | - [chef_user](https://docs.chef.io/resources/chef_user) 36 | - private_key - DEPRECATED 37 | - public_key - DEPRECATED 38 | 39 | ## Base/Helper Classes 40 | 41 | To support the resource/provider pairs. 42 | 43 | ## RSpec Support 44 | 45 | Most of these RSpec...things were developed for testing the resource/provider pairs above; *however*, you can also `require cheffish/rspec/chef_run_support` for any RSpec `expect`s you'd like, as we do for `chef-provisioning` and its drivers (especially `chef-provisioning-aws`). 46 | 47 | The awesomeness here is that instead of instantiating a `run_context` and a `node` and a `resource` as Ruby objects, you can test your resources in an actual recipe: 48 | 49 | ```ruby 50 | when_the_chef_12_server "exists", organization: 'some-org', server_scope: :context, port: 8900..9000 do 51 | file "/tmp/something_important.json" do 52 | content "A resource in its native environment." 53 | end 54 | end 55 | ``` 56 | 57 | An enclosing context that spins up `chef-zero` (local mode) Chef servers as dictated by `server_scope`. `Chef::Config` will be set up with the appropriate server URLs (see the `with_*` operators below). 58 | 59 | `server_scope`: 60 | - `:context` 61 | - `:example` *[default?]* 62 | - ? 63 | 64 | `port`: 65 | - port number (8900 is the default) 66 | - port range (server will continue trying up this range until it finds a free port) 67 | 68 | ```ruby 69 | expect_recipe { 70 | # unquoted recipe DSL here. 71 | }.to be_truthy # or write your own matchers. 72 | ``` 73 | 74 | Converges the recipe using `expect()` (parentheses), which tests for a value and **cannot** be used with `raise_error`. 75 | 76 | ```ruby 77 | expect_converge { 78 | # unquoted recipe DSL here. 79 | }.to raise_error(ArgumentException) 80 | ``` 81 | 82 | Converges the recipe using `expect{ }` (curly brackets), which wraps the block in a `begin..rescue..end` to detect when the block raises an exception; hence, this is **only** for `raise_error`. 83 | 84 | The blocks for the following appear to be mostly optional: what they actually do is set the `Chef::Config` variable in the name to the given value, and if you provide a block, the change is scoped to that block. Probably this would be clearer if it were aliased to (and preferring) `using` rather than `with`. 85 | 86 | - with_chef_server(server_url, options = {}, &block) 87 | - with_chef_local_server(options, &block) 88 | - with_chef_environment(name, &block) 89 | - with_chef_data_bag_item_encryption(encryption_options, &block) 90 | - with_chef_data_bag(name) 91 | - Takes a block, though this is not noted in the method signature. 92 | 93 | 94 | 95 | get_private_key(name) 96 | 97 | 98 | ### RSpec matchers 99 | 100 | These are used with `expect_recipe` or `expect_converge`: 101 | 102 | ```ruby 103 | expect_recipe { 104 | file "/tmp/a_file.json" do 105 | content "Very important content." 106 | end 107 | }.to be_idempotent.and emit_no_warnings_or_errors 108 | ``` 109 | 110 | `be_idempotent` 111 | 112 | - Runs the provided recipe *again* (`expect_(recipe|converge)` ran it the first time) and asks the Chef run if it updated anything (using `updated?`, which appears to be defined on `Chef::Resource` instead of `Chef::Client`, so there's some clarification to be done there); the matcher is satisfied if the answer is "no." 113 | 114 | 115 | `emit_no_warnings_or_errors` 116 | 117 | - Greps the Chef client run's log output for WARN/ERROR lines; matcher is satisfied if there aren't any. 118 | 119 | `have_updated` 120 | 121 | - Sifts the recipe's event stream(!) to determine if any resources were updated; matcher is satisfied is the answer is "yes." 122 | - This is *not* the opposite of `be_idempotent`. 123 | 124 | `partially_match` 125 | 126 | - TBD 127 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | begin 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new do |t| 7 | t.pattern = "spec/**/*_spec.rb" 8 | end 9 | rescue LoadError 10 | desc "rspec is not installed, this task is disabled" 11 | task :spec do 12 | abort "rspec is not installed. bundle install first to make sure all dependencies are installed." 13 | end 14 | end 15 | 16 | begin 17 | require "cookstyle/chefstyle" 18 | require "rubocop/rake_task" 19 | desc "Run Chefstyle tests" 20 | RuboCop::RakeTask.new(:style) do |task| 21 | task.options += ["--display-cop-names", "--no-color"] 22 | end 23 | rescue LoadError 24 | puts "cookstyle gem is not installed. bundle install first to make sure all dependencies are installed." 25 | end 26 | 27 | begin 28 | require "yard" 29 | YARD::Rake::YardocTask.new(:docs) 30 | rescue LoadError 31 | puts "yard is not available. bundle install first to make sure all dependencies are installed." 32 | end 33 | 34 | task :console do 35 | require "irb" 36 | require "irb/completion" 37 | require "cheffish" 38 | ARGV.clear 39 | IRB.start 40 | end 41 | 42 | task default: %i{spec style} 43 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 17.1.8 -------------------------------------------------------------------------------- /bin/resource: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "cheffish/chef_run" 3 | 4 | def post(resource_type, name, properties) 5 | chef_run = Cheffish::ChefRun.new 6 | begin 7 | r = chef_run.client.build_resource(resource_type, name) do 8 | properties.each { |attr, value| public_send(attr, value) } 9 | end 10 | chef_run.client.add_resource(r) 11 | chef_run.converge 12 | puts "CODE: #{chef_run.updated? ? 201 : 200}" 13 | puts "STDOUT: #{chef_run.stdout}\nSTDERR: #{chef_run.stderr}\nLOGS: #{chef_run.logs}" 14 | rescue 15 | puts "CODE: 400" 16 | puts "ERROR: #{$!}\nBACKTRACE: #{$!.backtrace}\nSTDOUT: #{chef_run.stdout}\nSTDERR: #{chef_run.stderr}\nLOGS: #{chef_run.logs}" 17 | end 18 | end 19 | 20 | post(ARGV.shift, ARGV.shift, Hash[*ARGV]) 21 | -------------------------------------------------------------------------------- /cheffish.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift(File.dirname(__FILE__) + "/lib") 2 | require "cheffish/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "cheffish" 6 | s.version = Cheffish::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.license = "Apache-2.0" 9 | s.summary = "A set of Chef resources for configuring Chef Infra." 10 | s.description = s.summary 11 | s.author = "Chef Software Inc." 12 | s.email = "oss@chef.io" 13 | s.homepage = "https://github.com/chef/cheffish" 14 | 15 | s.required_ruby_version = ">= 3.0" 16 | 17 | s.add_dependency "chef-zero", ">= 14.0" 18 | s.add_dependency "chef-utils", ">= 17.0" 19 | s.add_dependency "logger", "< 1.6.0" 20 | s.add_dependency "net-ssh" 21 | 22 | s.require_path = "lib" 23 | s.files = %w{Gemfile Rakefile LICENSE} + Dir.glob("*.gemspec") + 24 | Dir.glob("{lib,spec}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) } 25 | end 26 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_client.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/chef_actor_base" 3 | 4 | class Chef 5 | class Resource 6 | class ChefClient < Cheffish::ChefActorBase 7 | provides :chef_client, target_mode: true 8 | 9 | # Client attributes 10 | property :chef_client_name, Cheffish::NAME_REGEX, name_property: true 11 | property :admin, [TrueClass, FalseClass] 12 | property :validator, [TrueClass, FalseClass] 13 | 14 | # Input key 15 | property :source_key # String or OpenSSL::PKey::* 16 | property :source_key_path, String 17 | property :source_key_pass_phrase 18 | 19 | # Output public key (if so desired) 20 | property :output_key_path, String 21 | property :output_key_format, Symbol, default: :openssh, equal_to: %i{pem der openssh} 22 | 23 | # Proc that runs just before the resource executes. Called with (resource) 24 | def before(&block) 25 | block ? @before = block : @before 26 | end 27 | 28 | # Proc that runs after the resource completes. Called with (resource, json, private_key, public_key) 29 | def after(&block) 30 | block ? @after = block : @after 31 | end 32 | 33 | action :create do 34 | create_actor 35 | end 36 | 37 | action :delete do 38 | delete_actor 39 | end 40 | 41 | action_class do 42 | def actor_type 43 | "client" 44 | end 45 | 46 | def actor_path 47 | "clients" 48 | end 49 | 50 | # 51 | # Helpers 52 | # 53 | 54 | def resource_class 55 | Chef::Resource::ChefClient 56 | end 57 | 58 | def data_handler 59 | Chef::ChefFS::DataHandler::ClientDataHandler.new 60 | end 61 | 62 | def keys 63 | { 64 | "name" => :chef_client_name, 65 | "admin" => :admin, 66 | "validator" => :validator, 67 | "public_key" => :source_key, 68 | } 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_container.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/chef_fs/data_handler/container_data_handler" 4 | 5 | class Chef 6 | class Resource 7 | class ChefContainer < Cheffish::BaseResource 8 | provides :chef_container, target_mode: true 9 | 10 | property :chef_container_name, Cheffish::NAME_REGEX, name_property: true 11 | 12 | action :create do 13 | unless @current_exists 14 | converge_by "create container #{new_resource.chef_container_name} at #{rest.url}" do 15 | rest.post("containers", normalize_for_post(new_json)) 16 | end 17 | end 18 | end 19 | 20 | action :delete do 21 | if @current_exists 22 | converge_by "delete container #{new_resource.chef_container_name} at #{rest.url}" do 23 | rest.delete("containers/#{new_resource.chef_container_name}") 24 | end 25 | end 26 | end 27 | 28 | action_class do 29 | def load_current_resource 30 | @current_exists = rest.get("containers/#{new_resource.chef_container_name}") 31 | rescue Net::HTTPClientException => e 32 | if e.response.code == "404" 33 | @current_exists = false 34 | else 35 | raise 36 | end 37 | end 38 | 39 | def new_json 40 | {} 41 | end 42 | 43 | def data_handler 44 | Chef::ChefFS::DataHandler::ContainerDataHandler.new 45 | end 46 | 47 | def keys 48 | { "containername" => :chef_container_name, "containerpath" => :chef_container_name } 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_data_bag.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | 4 | class Chef 5 | class Resource 6 | class ChefDataBag < Cheffish::BaseResource 7 | provides :chef_data_bag, target_mode: true 8 | 9 | property :data_bag_name, Cheffish::NAME_REGEX, name_property: true 10 | 11 | action :create do 12 | unless current_resource_exists? 13 | converge_by "create data bag #{new_resource.data_bag_name} at #{rest.url}" do 14 | rest.post("data", { "name" => new_resource.data_bag_name }) 15 | end 16 | end 17 | end 18 | 19 | action :delete do 20 | if current_resource_exists? 21 | converge_by "delete data bag #{new_resource.data_bag_name} at #{rest.url}" do 22 | rest.delete("data/#{new_resource.data_bag_name}") 23 | end 24 | end 25 | end 26 | 27 | action_class.class_eval do 28 | def load_current_resource 29 | @current_resource = json_to_resource(rest.get("data/#{new_resource.data_bag_name}")) 30 | rescue Net::HTTPClientException => e 31 | if e.response.code == "404" 32 | @current_resource = not_found_resource 33 | else 34 | raise 35 | end 36 | end 37 | 38 | # 39 | # Helpers 40 | # 41 | # Gives us new_json, current_json, not_found_json, etc. 42 | 43 | def resource_class 44 | Chef::Resource::ChefDataBag 45 | end 46 | 47 | def json_to_resource(json) 48 | Chef::Resource::ChefDataBag.new(json["name"], run_context) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_environment.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/environment" 4 | require "chef/chef_fs/data_handler/environment_data_handler" 5 | 6 | class Chef 7 | class Resource 8 | class ChefEnvironment < Cheffish::BaseResource 9 | provides :chef_environment, target_mode: true 10 | 11 | property :environment_name, Cheffish::NAME_REGEX, name_property: true 12 | property :description, String 13 | property :cookbook_versions, Hash, callbacks: { 14 | "should have valid cookbook versions" => lambda { |value| Chef::Environment.validate_cookbook_versions(value) }, 15 | } 16 | property :default_attributes, Hash 17 | property :override_attributes, Hash 18 | 19 | # default 'ip_address', '127.0.0.1' 20 | # default [ 'pushy', 'port' ], '9000' 21 | # default 'ip_addresses' do |existing_value| 22 | # (existing_value || []) + [ '127.0.0.1' ] 23 | # end 24 | # default 'ip_address', :delete 25 | attr_reader :default_attribute_modifiers 26 | def default(attribute_path, value = NOT_PASSED, &block) 27 | @default_attribute_modifiers ||= [] 28 | if value != NOT_PASSED 29 | @default_attribute_modifiers << [ attribute_path, value ] 30 | elsif block 31 | @default_attribute_modifiers << [ attribute_path, block ] 32 | else 33 | raise "default requires either a value or a block" 34 | end 35 | end 36 | 37 | # override 'ip_address', '127.0.0.1' 38 | # override [ 'pushy', 'port' ], '9000' 39 | # override 'ip_addresses' do |existing_value| 40 | # (existing_value || []) + [ '127.0.0.1' ] 41 | # end 42 | # override 'ip_address', :delete 43 | attr_reader :override_attribute_modifiers 44 | def override(attribute_path, value = NOT_PASSED, &block) 45 | @override_attribute_modifiers ||= [] 46 | if value != NOT_PASSED 47 | @override_attribute_modifiers << [ attribute_path, value ] 48 | elsif block 49 | @override_attribute_modifiers << [ attribute_path, block ] 50 | else 51 | raise "override requires either a value or a block" 52 | end 53 | end 54 | 55 | alias :attributes :default_attributes 56 | alias :attribute :default 57 | 58 | action :create do 59 | differences = json_differences(current_json, new_json) 60 | 61 | if current_resource_exists? 62 | if differences.size > 0 63 | description = [ "update environment #{new_resource.environment_name} at #{rest.url}" ] + differences 64 | converge_by description do 65 | rest.put("environments/#{new_resource.environment_name}", normalize_for_put(new_json)) 66 | end 67 | end 68 | else 69 | description = [ "create environment #{new_resource.environment_name} at #{rest.url}" ] + differences 70 | converge_by description do 71 | rest.post("environments", normalize_for_post(new_json)) 72 | end 73 | end 74 | end 75 | 76 | action :delete do 77 | if current_resource_exists? 78 | converge_by "delete environment #{new_resource.environment_name} at #{rest.url}" do 79 | rest.delete("environments/#{new_resource.environment_name}") 80 | end 81 | end 82 | end 83 | 84 | action_class.class_eval do 85 | def load_current_resource 86 | @current_resource = json_to_resource(rest.get("environments/#{new_resource.environment_name}")) 87 | rescue Net::HTTPClientException => e 88 | if e.response.code == "404" 89 | @current_resource = not_found_resource 90 | else 91 | raise 92 | end 93 | end 94 | 95 | def augment_new_json(json) 96 | # Apply modifiers 97 | json["default_attributes"] = apply_modifiers(new_resource.default_attribute_modifiers, json["default_attributes"]) 98 | json["override_attributes"] = apply_modifiers(new_resource.override_attribute_modifiers, json["override_attributes"]) 99 | json 100 | end 101 | 102 | # 103 | # Helpers 104 | # 105 | 106 | def resource_class 107 | Chef::Resource::ChefEnvironment 108 | end 109 | 110 | def data_handler 111 | Chef::ChefFS::DataHandler::EnvironmentDataHandler.new 112 | end 113 | 114 | def keys 115 | { 116 | "name" => :environment_name, 117 | "description" => :description, 118 | "cookbook_versions" => :cookbook_versions, 119 | "default_attributes" => :default_attributes, 120 | "override_attributes" => :override_attributes, 121 | } 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_group.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/run_list/run_list_item" 4 | require "chef/chef_fs/data_handler/group_data_handler" 5 | 6 | class Chef 7 | class Resource 8 | class ChefGroup < Cheffish::BaseResource 9 | provides :chef_group, target_mode: true 10 | 11 | property :group_name, Cheffish::NAME_REGEX, name_property: true 12 | property :users, ArrayType 13 | property :clients, ArrayType 14 | property :groups, ArrayType 15 | property :remove_users, ArrayType 16 | property :remove_clients, ArrayType 17 | property :remove_groups, ArrayType 18 | 19 | action :create do 20 | differences = json_differences(current_json, new_json) 21 | 22 | if current_resource_exists? 23 | if differences.size > 0 24 | description = [ "update group #{new_resource.group_name} at #{rest.url}" ] + differences 25 | converge_by description do 26 | rest.put("groups/#{new_resource.group_name}", normalize_for_put(new_json)) 27 | end 28 | end 29 | else 30 | description = [ "create group #{new_resource.group_name} at #{rest.url}" ] + differences 31 | converge_by description do 32 | rest.post("groups", normalize_for_post(new_json)) 33 | end 34 | end 35 | end 36 | 37 | action :delete do 38 | if current_resource_exists? 39 | converge_by "delete group #{new_resource.group_name} at #{rest.url}" do 40 | rest.delete("groups/#{new_resource.group_name}") 41 | end 42 | end 43 | end 44 | 45 | action_class.class_eval do 46 | def load_current_resource 47 | @current_resource = json_to_resource(rest.get("groups/#{new_resource.group_name}")) 48 | rescue Net::HTTPClientException => e 49 | if e.response.code == "404" 50 | @current_resource = not_found_resource 51 | else 52 | raise 53 | end 54 | end 55 | 56 | def augment_new_json(json) 57 | # Apply modifiers 58 | json["users"] |= new_resource.users 59 | json["clients"] |= new_resource.clients 60 | json["groups"] |= new_resource.groups 61 | json["users"] -= new_resource.remove_users 62 | json["clients"] -= new_resource.remove_clients 63 | json["groups"] -= new_resource.remove_groups 64 | json 65 | end 66 | 67 | # 68 | # Helpers 69 | # 70 | 71 | def resource_class 72 | Chef::Resource::ChefGroup 73 | end 74 | 75 | def data_handler 76 | Chef::ChefFS::DataHandler::GroupDataHandler.new 77 | end 78 | 79 | def keys 80 | { 81 | "name" => :group_name, 82 | "groupname" => :group_name, 83 | } 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_mirror.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/chef_fs/file_pattern" 4 | require "chef/chef_fs/file_system" 5 | require "chef/chef_fs/file_system/chef_server/chef_server_root_dir" 6 | require "chef/chef_fs/file_system/repository/chef_repository_file_system_root_dir" 7 | require "chef-utils/parallel_map" unless defined?(ChefUtils::ParallelMap) 8 | 9 | using ChefUtils::ParallelMap 10 | 11 | class Chef 12 | class Resource 13 | class ChefMirror < Cheffish::BaseResource 14 | provides :chef_mirror, target_mode: true 15 | 16 | # Path of the data to mirror, e.g. nodes, nodes/*, nodes/mynode, 17 | # */*, **, roles/base, data/secrets, cookbooks/apache2, etc. 18 | property :path, String, name_property: true 19 | 20 | # Local path. Can be a string (top level of repository) or hash 21 | # (:chef_repo_path, :node_path, etc.) 22 | # If neither chef_repo_path nor versioned_cookbooks are set, they default to their 23 | # Chef::Config values. If chef_repo_path is set but versioned_cookbooks is not, 24 | # versioned_cookbooks defaults to true. 25 | property :chef_repo_path, [ String, Hash ] 26 | 27 | # Whether the repo path should contain cookbooks with versioned names, 28 | # i.e. cookbooks/mysql-1.0.0, cookbooks/mysql-1.2.0, etc. 29 | # Defaults to true if chef_repo_path is specified, or to Chef::Config.versioned_cookbooks otherwise. 30 | property :versioned_cookbooks, [TrueClass, FalseClass] 31 | 32 | # Whether to purge deleted things: if we do not have cookbooks/x locally and we 33 | # *do* have cookbooks/x remotely, then :upload with purge will delete it. 34 | # Defaults to false. 35 | property :purge, [TrueClass, FalseClass] 36 | 37 | # Whether to freeze cookbooks on upload 38 | property :freeze_on_upload, [TrueClass, FalseClass] 39 | 40 | # `freeze` is an already-existing instance method on Object, so we can't use it or we'll throw 41 | # a deprecation warning. `freeze` has been renamed to `freeze_on_upload` and this method 42 | # is here to log a deprecation warning. 43 | def freeze(arg = nil) 44 | Chef::Log.warn("Property `freeze` on the `chef_mirror` resource has changed to `freeze_on_upload`." \ 45 | "Please use `freeze_on_upload` instead. This will raise an exception in a future version of the cheffish gem.") 46 | 47 | set_or_return( 48 | :freeze_on_upload, 49 | arg, 50 | kind_of: [TrueClass, FalseClass] 51 | ) 52 | end 53 | 54 | # If this is true, only new files will be copied. File contents will not be 55 | # diffed, so changed files will never be uploaded. 56 | property :no_diff, [TrueClass, FalseClass] 57 | 58 | # Number of parallel threads to list/upload/download with. Defaults to 10. 59 | property :concurrency, Integer, default: 10, desired_state: false 60 | 61 | action :upload do 62 | with_modified_config do 63 | copy_to(local_fs, remote_fs) 64 | end 65 | end 66 | 67 | action :download do 68 | with_modified_config do 69 | copy_to(remote_fs, local_fs) 70 | end 71 | end 72 | 73 | action_class.class_eval do 74 | 75 | def with_modified_config 76 | # pre-Chef-12 ChefFS reads versioned_cookbooks out of Chef::Config instead of 77 | # taking it as an input, so we need to modify it for the duration of copy_to 78 | @old_versioned_cookbooks = Chef::Config.versioned_cookbooks 79 | # If versioned_cookbooks is explicitly set, set it. 80 | if !new_resource.versioned_cookbooks.nil? 81 | Chef::Config.versioned_cookbooks = new_resource.versioned_cookbooks 82 | 83 | # If new_resource.chef_repo_path is set, versioned_cookbooks defaults to true. 84 | # Otherwise, it stays at its current Chef::Config value. 85 | elsif new_resource.chef_repo_path 86 | Chef::Config.versioned_cookbooks = true 87 | end 88 | 89 | begin 90 | yield 91 | ensure 92 | Chef::Config.versioned_cookbooks = @old_versioned_cookbooks 93 | end 94 | end 95 | 96 | def copy_to(src_root, dest_root) 97 | if new_resource.concurrency <= 0 98 | raise "chef_mirror.concurrency must be above 0! Was set to #{new_resource.concurrency}" 99 | end 100 | 101 | # Honor concurrency 102 | ChefUtils::DefaultThreadPool.instance.threads = new_resource.concurrency - 1 103 | 104 | # We don't let the user pass absolute paths; we want to reserve those for 105 | # multi-org support (/organizations/foo). 106 | if new_resource.path[0] == "/" 107 | raise "Absolute paths in chef_mirror not yet supported." 108 | end 109 | 110 | # Copy! 111 | path = Chef::ChefFS::FilePattern.new("/#{new_resource.path}") 112 | ui = CopyListener.new(self) 113 | error, _result = Chef::ChefFS::FileSystem.copy_to(path, src_root, dest_root, nil, options, ui, proc { |p| p.path }) 114 | 115 | if error 116 | raise "Errors while copying:#{ui.errors.map { |e| "#{e}\n" }.join("")}" 117 | end 118 | end 119 | 120 | def local_fs 121 | # If chef_repo_path is set to a string, put it in the form it usually is in 122 | # chef config (:chef_repo_path, :node_path, etc.) 123 | path_config = new_resource.chef_repo_path 124 | if path_config.is_a?(Hash) 125 | chef_repo_path = path_config.delete(:chef_repo_path) 126 | elsif path_config 127 | chef_repo_path = path_config 128 | path_config = {} 129 | else 130 | chef_repo_path = Chef::Config.chef_repo_path 131 | path_config = Chef::Config 132 | end 133 | chef_repo_path = Array(chef_repo_path).flatten 134 | 135 | # Go through the expected object paths and figure out the local paths for each. 136 | case repo_mode 137 | when "hosted_everything" 138 | object_names = %w{acls clients cookbooks containers data_bags environments groups nodes roles} 139 | else 140 | object_names = %w{clients cookbooks data_bags environments nodes roles users} 141 | end 142 | 143 | object_paths = {} 144 | object_names.each do |object_name| 145 | variable_name = "#{object_name[0..-2]}_path" # cookbooks -> cookbook_path 146 | if path_config[variable_name.to_sym] 147 | paths = Array(path_config[variable_name.to_sym]).flatten 148 | else 149 | paths = chef_repo_path.map { |path| ::File.join(path, object_name) } 150 | end 151 | object_paths[object_name] = paths.map { |path| ::File.expand_path(path) } 152 | end 153 | 154 | # Set up the root dir 155 | Chef::ChefFS::FileSystem::Repository::ChefRepositoryFileSystemRootDir.new(object_paths) 156 | end 157 | 158 | def remote_fs 159 | config = { 160 | chef_server_url: new_resource.chef_server[:chef_server_url], 161 | node_name: new_resource.chef_server[:options][:client_name], 162 | client_key: new_resource.chef_server[:options][:signing_key_filename], 163 | repo_mode: repo_mode, 164 | versioned_cookbooks: Chef::Config.versioned_cookbooks, 165 | } 166 | Chef::ChefFS::FileSystem::ChefServer::ChefServerRootDir.new("remote", config) 167 | end 168 | 169 | def repo_mode 170 | %r{/organizations/}.match?(new_resource.chef_server[:chef_server_url]) ? "hosted_everything" : "everything" 171 | end 172 | 173 | def options 174 | result = { 175 | purge: new_resource.purge, 176 | freeze: new_resource.freeze_on_upload, 177 | diff: new_resource.no_diff, 178 | dry_run: whyrun_mode?, 179 | } 180 | result[:diff] = !result[:diff] 181 | result[:repo_mode] = repo_mode 182 | result[:concurrency] = new_resource.concurrency if new_resource.concurrency 183 | result 184 | end 185 | 186 | def load_current_resource; end 187 | 188 | class CopyListener 189 | def initialize(mirror) 190 | @mirror = mirror 191 | @errors = [] 192 | end 193 | 194 | attr_reader :mirror 195 | attr_reader :errors 196 | 197 | # TODO output is not *always* indicative of a change. We may want to give 198 | # ChefFS the ability to tell us that info. For now though, assuming any output 199 | # means change is pretty damn close to the truth. 200 | def output(str) 201 | mirror.converge_by str do 202 | end 203 | end 204 | 205 | def warn(str) 206 | mirror.converge_by "WARNING: #{str}" do 207 | end 208 | end 209 | 210 | def error(str) 211 | mirror.converge_by "ERROR: #{str}" do 212 | end 213 | @errors << str 214 | end 215 | end 216 | end 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_node.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/chef_fs/data_handler/node_data_handler" 4 | require_relative "../../cheffish/node_properties" 5 | 6 | class Chef 7 | class Resource 8 | class ChefNode < Cheffish::BaseResource 9 | provides :chef_node, target_mode: true 10 | 11 | include Cheffish::NodeProperties 12 | 13 | action :create do 14 | differences = json_differences(current_json, new_json) 15 | 16 | if current_resource_exists? 17 | if differences.size > 0 18 | description = [ "update node #{new_resource.name} at #{rest.url}" ] + differences 19 | converge_by description do 20 | rest.put("nodes/#{new_resource.name}", normalize_for_put(new_json)) 21 | end 22 | end 23 | else 24 | description = [ "create node #{new_resource.name} at #{rest.url}" ] + differences 25 | converge_by description do 26 | rest.post("nodes", normalize_for_post(new_json)) 27 | end 28 | end 29 | end 30 | 31 | action :delete do 32 | if current_resource_exists? 33 | converge_by "delete node #{new_resource.name} at #{rest.url}" do 34 | rest.delete("nodes/#{new_resource.name}") 35 | end 36 | end 37 | end 38 | 39 | action_class.class_eval do 40 | def load_current_resource 41 | @current_resource = json_to_resource(rest.get("nodes/#{new_resource.name}")) 42 | rescue Net::HTTPClientException => e 43 | if e.response.code == "404" 44 | @current_resource = not_found_resource 45 | else 46 | raise 47 | end 48 | end 49 | 50 | def augment_new_json(json) 51 | # Preserve tags even if "attributes" was overwritten directly 52 | json["normal"]["tags"] = current_json["normal"]["tags"] unless json["normal"]["tags"] 53 | # Apply modifiers 54 | json["run_list"] = apply_run_list_modifiers(new_resource.run_list_modifiers, new_resource.run_list_removers, json["run_list"]) 55 | json["normal"] = apply_modifiers(new_resource.attribute_modifiers, json["normal"]) 56 | # Preserve default/override/automatic even when "complete true" 57 | json["default"] = current_json["default"] 58 | json["override"] = current_json["override"] 59 | json["automatic"] = current_json["automatic"] 60 | json 61 | end 62 | 63 | # 64 | # Helpers 65 | # 66 | 67 | def resource_class 68 | Chef::Resource::ChefNode 69 | end 70 | 71 | def data_handler 72 | Chef::ChefFS::DataHandler::NodeDataHandler.new 73 | end 74 | 75 | def keys 76 | { 77 | "name" => :name, 78 | "chef_environment" => :chef_environment, 79 | "run_list" => :run_list, 80 | "normal" => :attributes, 81 | } 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_organization.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/run_list/run_list_item" 4 | require "chef/chef_fs/data_handler/data_handler_base" 5 | 6 | class Chef 7 | class Resource 8 | class ChefOrganization < Cheffish::BaseResource 9 | provides :chef_organization, target_mode: true 10 | 11 | property :organization_name, Cheffish::NAME_REGEX, name_property: true 12 | property :full_name, String 13 | 14 | # A list of users who must at least be invited to the org (but may already be 15 | # members). Invites will be sent to users who are not already invited/in the org. 16 | property :invites, ArrayType 17 | 18 | # A list of users who must be members of the org. This will use the 19 | # new Chef 12 POST /organizations/ORG/users endpoint to add them 20 | # directly to the org. If you do not have permission to perform 21 | # this operation, and the users are not a part of the org, the 22 | # resource update will fail. 23 | property :members, ArrayType 24 | 25 | # A list of users who must not be members of the org. These users will be removed 26 | # from the org and invites will be revoked (if any). 27 | property :remove_members, ArrayType 28 | 29 | action :create do 30 | differences = json_differences(current_json, new_json) 31 | 32 | if current_resource_exists? 33 | if differences.size > 0 34 | description = [ "update organization #{new_resource.organization_name} at #{rest.url}" ] + differences 35 | converge_by description do 36 | rest.put("#{rest.root_url}/organizations/#{new_resource.organization_name}", normalize_for_put(new_json)) 37 | end 38 | end 39 | else 40 | description = [ "create organization #{new_resource.organization_name} at #{rest.url}" ] + differences 41 | converge_by description do 42 | rest.post("#{rest.root_url}/organizations", normalize_for_post(new_json)) 43 | end 44 | end 45 | 46 | # Revoke invites and memberships when asked 47 | invites_to_remove.each do |user| 48 | if outstanding_invites.key?(user) 49 | converge_by "revoke #{user}'s invitation to organization #{new_resource.organization_name}" do 50 | rest.delete("#{rest.root_url}/organizations/#{new_resource.organization_name}/association_requests/#{outstanding_invites[user]}") 51 | end 52 | end 53 | end 54 | members_to_remove.each do |user| 55 | if existing_members.include?(user) 56 | converge_by "remove #{user} from organization #{new_resource.organization_name}" do 57 | rest.delete("#{rest.root_url}/organizations/#{new_resource.organization_name}/users/#{user}") 58 | end 59 | end 60 | end 61 | 62 | # Invite and add members when asked 63 | new_resource.invites.each do |user| 64 | if !existing_members.include?(user) && !outstanding_invites.key?(user) 65 | converge_by "invite #{user} to organization #{new_resource.organization_name}" do 66 | rest.post("#{rest.root_url}/organizations/#{new_resource.organization_name}/association_requests", { "user" => user }) 67 | end 68 | end 69 | end 70 | new_resource.members.each do |user| 71 | unless existing_members.include?(user) 72 | converge_by "Add #{user} to organization #{new_resource.organization_name}" do 73 | rest.post("#{rest.root_url}/organizations/#{new_resource.organization_name}/users/", { "username" => user }) 74 | end 75 | end 76 | end 77 | end 78 | 79 | action_class.class_eval do 80 | def existing_members 81 | @existing_members ||= rest.get("#{rest.root_url}/organizations/#{new_resource.organization_name}/users").map { |u| u["user"]["username"] } 82 | end 83 | 84 | def outstanding_invites 85 | @outstanding_invites ||= begin 86 | invites = {} 87 | rest.get("#{rest.root_url}/organizations/#{new_resource.organization_name}/association_requests").each do |r| 88 | invites[r["username"]] = r["id"] 89 | end 90 | invites 91 | end 92 | end 93 | 94 | def invites_to_remove 95 | if new_resource.complete 96 | if new_resource.property_is_set?(:invites) || new_resource.property_is_set?(:members) 97 | result = outstanding_invites.keys 98 | result -= new_resource.invites if new_resource.property_is_set?(:invites) 99 | result -= new_resource.members if new_resource.property_is_set?(:members) 100 | result 101 | else 102 | [] 103 | end 104 | else 105 | new_resource.remove_members 106 | end 107 | end 108 | 109 | def members_to_remove 110 | if new_resource.complete 111 | if new_resource.property_is_set?(:members) 112 | existing_members - (new_resource.invites | new_resource.members) 113 | else 114 | [] 115 | end 116 | else 117 | new_resource.remove_members 118 | end 119 | end 120 | end 121 | 122 | action :delete do 123 | if current_resource_exists? 124 | converge_by "delete organization #{new_resource.organization_name} at #{rest.url}" do 125 | rest.delete("#{rest.root_url}/organizations/#{new_resource.organization_name}") 126 | end 127 | end 128 | end 129 | 130 | action_class.class_eval do 131 | def load_current_resource 132 | @current_resource = json_to_resource(rest.get("#{rest.root_url}/organizations/#{new_resource.organization_name}")) 133 | rescue Net::HTTPClientException => e 134 | if e.response.code == "404" 135 | @current_resource = not_found_resource 136 | else 137 | raise 138 | end 139 | end 140 | 141 | # 142 | # Helpers 143 | # 144 | 145 | def resource_class 146 | Chef::Resource::ChefOrganization 147 | end 148 | 149 | def data_handler 150 | OrganizationDataHandler.new 151 | end 152 | 153 | def keys 154 | { 155 | "name" => :organization_name, 156 | "full_name" => :full_name, 157 | } 158 | end 159 | 160 | class OrganizationDataHandler < Chef::ChefFS::DataHandler::DataHandlerBase 161 | def normalize(organization, entry) 162 | # Normalize the order of the keys for easier reading 163 | normalize_hash(organization, { 164 | "name" => remove_dot_json(entry.name), 165 | "full_name" => remove_dot_json(entry.name), 166 | "org_type" => "Business", 167 | "clientname" => "#{remove_dot_json(entry.name)}-validator", 168 | "billing_plan" => "platform-free", 169 | }) 170 | end 171 | end 172 | end 173 | 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_resolved_cookbooks.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish/base_resource" 2 | require "chef_zero" 3 | 4 | class Chef 5 | class Resource 6 | class ChefResolvedCookbooks < Cheffish::BaseResource 7 | provides :chef_resolved_cookbooks, target_mode: true 8 | 9 | def initialize(*args) 10 | super 11 | require "berkshelf" 12 | berksfile Berkshelf::Berksfile.new("/tmp/Berksfile") 13 | @cookbooks_from = [] 14 | end 15 | 16 | extend Forwardable 17 | 18 | def_delegators :@berksfile, :cookbook, :extension, :group, :metadata, :source 19 | 20 | def cookbooks_from(path = nil) 21 | if path 22 | @cookbooks_from << path 23 | else 24 | @cookbooks_from 25 | end 26 | end 27 | 28 | property :berksfile 29 | 30 | action :resolve do 31 | new_resource.cookbooks_from.each do |path| 32 | ::Dir.entries(path).each do |name| 33 | if ::File.directory?(::File.join(path, name)) && name != "." && name != ".." 34 | new_resource.berksfile.cookbook name, path: ::File.join(path, name) 35 | end 36 | end 37 | end 38 | 39 | new_resource.berksfile.install 40 | 41 | # Ridley really really wants a key :/ 42 | if new_resource.chef_server[:options][:signing_key_filename] 43 | new_resource.berksfile.upload( 44 | server_url: new_resource.chef_server[:chef_server_url], 45 | client_name: new_resource.chef_server[:options][:client_name], 46 | client_key: new_resource.chef_server[:options][:signing_key_filename] 47 | ) 48 | else 49 | file = Tempfile.new("privatekey") 50 | begin 51 | file.write(ChefZero::PRIVATE_KEY) 52 | file.close 53 | 54 | new_resource.berksfile.upload( 55 | server_url: new_resource.chef_server[:chef_server_url], 56 | client_name: new_resource.chef_server[:options][:client_name] || "me", 57 | client_key: file.path 58 | ) 59 | 60 | ensure 61 | file.close 62 | file.unlink 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_role.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/base_resource" 3 | require "chef/run_list/run_list_item" 4 | require "chef/chef_fs/data_handler/role_data_handler" 5 | 6 | class Chef 7 | class Resource 8 | class ChefRole < Cheffish::BaseResource 9 | provides :chef_role, target_mode: true 10 | 11 | property :role_name, Cheffish::NAME_REGEX, name_property: true 12 | property :description, String 13 | property :run_list, Array # We should let them specify it as a series of parameters too 14 | property :env_run_lists, Hash 15 | property :default_attributes, Hash 16 | property :override_attributes, Hash 17 | 18 | # default_attribute 'ip_address', '127.0.0.1' 19 | # default_attribute [ 'pushy', 'port' ], '9000' 20 | # default_attribute 'ip_addresses' do |existing_value| 21 | # (existing_value || []) + [ '127.0.0.1' ] 22 | # end 23 | # default_attribute 'ip_address', :delete 24 | attr_reader :default_attribute_modifiers 25 | def default_attribute(attribute_path, value = NOT_PASSED, &block) 26 | @default_attribute_modifiers ||= [] 27 | if value != NOT_PASSED 28 | @default_attribute_modifiers << [ attribute_path, value ] 29 | elsif block 30 | @default_attribute_modifiers << [ attribute_path, block ] 31 | else 32 | raise "default_attribute requires either a value or a block" 33 | end 34 | end 35 | 36 | # override_attribute 'ip_address', '127.0.0.1' 37 | # override_attribute [ 'pushy', 'port' ], '9000' 38 | # override_attribute 'ip_addresses' do |existing_value| 39 | # (existing_value || []) + [ '127.0.0.1' ] 40 | # end 41 | # override_attribute 'ip_address', :delete 42 | attr_reader :override_attribute_modifiers 43 | def override_attribute(attribute_path, value = NOT_PASSED, &block) 44 | @override_attribute_modifiers ||= [] 45 | if value != NOT_PASSED 46 | @override_attribute_modifiers << [ attribute_path, value ] 47 | elsif block 48 | @override_attribute_modifiers << [ attribute_path, block ] 49 | else 50 | raise "override_attribute requires either a value or a block" 51 | end 52 | end 53 | 54 | # Order matters--if two things here are in the wrong order, they will be flipped in the run list 55 | # recipe 'apache', 'mysql' 56 | # recipe 'recipe@version' 57 | # recipe 'recipe' 58 | # role '' 59 | attr_reader :run_list_modifiers 60 | attr_reader :run_list_removers 61 | def recipe(*recipes) 62 | if recipes.size == 0 63 | raise ArgumentError, "At least one recipe must be specified" 64 | end 65 | 66 | @run_list_modifiers ||= [] 67 | @run_list_modifiers += recipes.map { |recipe| Chef::RunList::RunListItem.new("recipe[#{recipe}]") } 68 | end 69 | 70 | def role(*roles) 71 | if roles.size == 0 72 | raise ArgumentError, "At least one role must be specified" 73 | end 74 | 75 | @run_list_modifiers ||= [] 76 | @run_list_modifiers += roles.map { |role| Chef::RunList::RunListItem.new("role[#{role}]") } 77 | end 78 | 79 | def remove_recipe(*recipes) 80 | if recipes.size == 0 81 | raise ArgumentError, "At least one recipe must be specified" 82 | end 83 | 84 | @run_list_removers ||= [] 85 | @run_list_removers += recipes.map { |recipe| Chef::RunList::RunListItem.new("recipe[#{recipe}]") } 86 | end 87 | 88 | def remove_role(*roles) 89 | if roles.size == 0 90 | raise ArgumentError, "At least one role must be specified" 91 | end 92 | 93 | @run_list_removers ||= [] 94 | @run_list_removers += roles.map { |recipe| Chef::RunList::RunListItem.new("role[#{role}]") } 95 | end 96 | 97 | action :create do 98 | differences = json_differences(current_json, new_json) 99 | 100 | if current_resource_exists? 101 | if differences.size > 0 102 | description = [ "update role #{new_resource.role_name} at #{rest.url}" ] + differences 103 | converge_by description do 104 | rest.put("roles/#{new_resource.role_name}", normalize_for_put(new_json)) 105 | end 106 | end 107 | else 108 | description = [ "create role #{new_resource.role_name} at #{rest.url}" ] + differences 109 | converge_by description do 110 | rest.post("roles", normalize_for_post(new_json)) 111 | end 112 | end 113 | end 114 | 115 | action :delete do 116 | if current_resource_exists? 117 | converge_by "delete role #{new_resource.role_name} at #{rest.url}" do 118 | rest.delete("roles/#{new_resource.role_name}") 119 | end 120 | end 121 | end 122 | 123 | action_class.class_eval do 124 | def load_current_resource 125 | @current_resource = json_to_resource(rest.get("roles/#{new_resource.role_name}")) 126 | rescue Net::HTTPClientException => e 127 | if e.response.code == "404" 128 | @current_resource = not_found_resource 129 | else 130 | raise 131 | end 132 | end 133 | 134 | def augment_new_json(json) 135 | # Apply modifiers 136 | json["run_list"] = apply_run_list_modifiers(new_resource.run_list_modifiers, new_resource.run_list_removers, json["run_list"]) 137 | json["default_attributes"] = apply_modifiers(new_resource.default_attribute_modifiers, json["default_attributes"]) 138 | json["override_attributes"] = apply_modifiers(new_resource.override_attribute_modifiers, json["override_attributes"]) 139 | json 140 | end 141 | 142 | # 143 | # Helpers 144 | # 145 | 146 | def resource_class 147 | Chef::Resource::ChefRole 148 | end 149 | 150 | def data_handler 151 | Chef::ChefFS::DataHandler::RoleDataHandler.new 152 | end 153 | 154 | def keys 155 | { 156 | "name" => :role_name, 157 | "description" => :description, 158 | "run_list" => :run_list, 159 | "env_run_lists" => :env_run_lists, 160 | "default_attributes" => :default_attributes, 161 | "override_attributes" => :override_attributes, 162 | } 163 | end 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/chef/resource/chef_user.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../cheffish" 2 | require_relative "../../cheffish/chef_actor_base" 3 | 4 | class Chef 5 | class Resource 6 | class ChefUser < Cheffish::ChefActorBase 7 | provides :chef_user, target_mode: true 8 | 9 | # Client attributes 10 | property :user_name, Cheffish::NAME_REGEX, name_property: true 11 | property :display_name, String 12 | property :admin, [TrueClass, FalseClass] 13 | property :email, String 14 | property :external_authentication_uid 15 | property :recovery_authentication_enabled, [TrueClass, FalseClass] 16 | property :password, String # Hmm. There is no way to idempotentize this. 17 | # property :salt # TODO server doesn't support sending or receiving these, but it's the only way to backup / restore a user 18 | # property :hashed_password 19 | # property :hash_type 20 | 21 | # Input key 22 | property :source_key # String or OpenSSL::PKey::* 23 | property :source_key_path, String 24 | property :source_key_pass_phrase 25 | 26 | # Output public key (if so desired) 27 | property :output_key_path, String 28 | property :output_key_format, %i{pem der openssh}, default: :openssh 29 | 30 | # Proc that runs just before the resource executes. Called with (resource) 31 | def before(&block) 32 | block ? @before = block : @before 33 | end 34 | 35 | # Proc that runs after the resource completes. Called with (resource, json, private_key, public_key) 36 | def after(&block) 37 | block ? @after = block : @after 38 | end 39 | 40 | action :create do 41 | create_actor 42 | end 43 | 44 | action :delete do 45 | delete_actor 46 | end 47 | 48 | action_class.class_eval do 49 | # 50 | # Helpers 51 | # 52 | # Gives us new_json, current_json, not_found_json, etc. 53 | 54 | def actor_type 55 | "user" 56 | end 57 | 58 | def actor_path 59 | "#{rest.root_url}/users" 60 | end 61 | 62 | def resource_class 63 | Chef::Resource::ChefUser 64 | end 65 | 66 | def data_handler 67 | Chef::ChefFS::DataHandler::UserDataHandler.new 68 | end 69 | 70 | def keys 71 | { 72 | "name" => :user_name, 73 | "username" => :user_name, 74 | "display_name" => :display_name, 75 | "admin" => :admin, 76 | "email" => :email, 77 | "password" => :password, 78 | "external_authentication_uid" => :external_authentication_uid, 79 | "recovery_authentication_enabled" => :recovery_authentication_enabled, 80 | "public_key" => :source_key, 81 | } 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/chef/resource/private_key.rb: -------------------------------------------------------------------------------- 1 | require "openssl/cipher" 2 | require_relative "../../cheffish/base_resource" 3 | require "openssl" unless defined?(OpenSSL) 4 | require_relative "../../cheffish/key_formatter" 5 | 6 | class Chef 7 | class Resource 8 | class PrivateKey < Cheffish::BaseResource 9 | provides :private_key, target_mode: true 10 | 11 | allowed_actions :create, :delete, :regenerate, :nothing 12 | default_action :create 13 | 14 | # Path to private key. Set to :none to create the key in memory and not on disk. 15 | property :path, [ String, :none ], name_property: true 16 | property :format, %i{pem der}, default: :pem 17 | property :type, %i{rsa dsa}, default: :rsa # TODO support :ec 18 | # These specify an optional public_key you can spit out if you want. 19 | property :public_key_path, String 20 | property :public_key_format, %i{openssh pem der}, default: :openssh 21 | # Specify this if you want to copy another private key but give it a different format / password 22 | property :source_key 23 | property :source_key_path, String 24 | property :source_key_pass_phrase 25 | 26 | # RSA and DSA 27 | property :size, Integer, default: 2048 28 | 29 | # RSA-only 30 | property :exponent, Integer # For RSA 31 | 32 | # PEM-only 33 | property :pass_phrase, String 34 | property :cipher, String, equal_to: OpenSSL::Cipher.ciphers.map(&:downcase), default: "des-ede3-cbc", coerce: proc { |x| x.downcase } 35 | 36 | # Set this to regenerate the key if it does not have the desired characteristics (like size, type, etc.) 37 | property :regenerate_if_different, [TrueClass, FalseClass] 38 | 39 | # Proc that runs after the resource completes. Called with (resource, private_key) 40 | def after(&block) 41 | block ? @after = block : @after 42 | end 43 | 44 | # We are not interested in Chef's cloning behavior here. 45 | def load_prior_resource(*args) 46 | Chef::Log.debug("Overloading #{resource_name}.load_prior_resource with NOOP") 47 | end 48 | 49 | action :create do 50 | create_key(false, :create) 51 | end 52 | 53 | action :regenerate do 54 | create_key(true, :regenerate) 55 | end 56 | 57 | action :delete do 58 | if current_resource.path 59 | converge_by "delete private key #{new_path}" do 60 | ::File.unlink(new_path) 61 | end 62 | end 63 | end 64 | 65 | action_class.class_eval do 66 | def create_key(regenerate, action) 67 | if @should_create_directory 68 | Cheffish.inline_resource(self, action) do 69 | directory run_context.config[:private_key_write_path] 70 | end 71 | end 72 | 73 | final_private_key = nil 74 | if new_source_key 75 | # 76 | # Create private key from source 77 | # 78 | desired_output = encode_private_key(new_source_key) 79 | if current_resource.path == :none || desired_output != IO.read(new_path) 80 | converge_by "reformat key at #{new_resource.source_key_path} to #{new_resource.format} private key #{new_path} (#{new_resource.pass_phrase ? ", #{new_resource.cipher} password" : ""})" do 81 | IO.binwrite(new_path, desired_output) 82 | end 83 | end 84 | 85 | final_private_key = new_source_key 86 | 87 | else 88 | # 89 | # Generate a new key 90 | # 91 | if current_resource.action == [ :delete ] || regenerate || 92 | (new_resource.regenerate_if_different && 93 | (!current_private_key || 94 | current_resource.size != new_resource.size || 95 | current_resource.type != new_resource.type)) 96 | case new_resource.type 97 | when :rsa 98 | if new_resource.exponent 99 | final_private_key = OpenSSL::PKey::RSA.generate(new_resource.size, new_resource.exponent) 100 | else 101 | final_private_key = OpenSSL::PKey::RSA.generate(new_resource.size) 102 | end 103 | when :dsa 104 | final_private_key = OpenSSL::PKey::DSA.generate(new_resource.size) 105 | end 106 | 107 | generated_key = true 108 | elsif !current_private_key 109 | raise "Could not read private key from #{current_resource.path}: missing pass phrase?" 110 | else 111 | final_private_key = current_private_key 112 | generated_key = false 113 | end 114 | 115 | if generated_key 116 | generated_description = " (#{new_resource.size} bits#{new_resource.pass_phrase ? ", #{new_resource.cipher} password" : ""})" 117 | 118 | if new_path != :none 119 | action = current_resource.path == :none ? "create" : "overwrite" 120 | converge_by "#{action} #{new_resource.type} private key #{new_path}#{generated_description}" do 121 | write_private_key(final_private_key) 122 | end 123 | else 124 | converge_by "generate private key#{generated_description}" do 125 | end 126 | end 127 | else 128 | # Warn if existing key has different characteristics than expected 129 | if current_resource.size != new_resource.size 130 | Chef::Log.warn("Mismatched key size! #{current_resource.path} is #{current_resource.size} bytes, desired is #{new_resource.size} bytes. Use action :regenerate to force key regeneration.") 131 | elsif current_resource.type != new_resource.type 132 | Chef::Log.warn("Mismatched key type! #{current_resource.path} is #{current_resource.type}, desired is #{new_resource.type} bytes. Use action :regenerate to force key regeneration.") 133 | end 134 | 135 | if current_resource.format != new_resource.format 136 | converge_by "change format of #{new_resource.type} private key #{new_path} from #{current_resource.format} to #{new_resource.format}" do 137 | write_private_key(current_private_key) 138 | end 139 | elsif RUBY_PLATFORM !~ /mswin|mingw|windows/ && (@current_file_mode & 0077) != 0 140 | new_mode = @current_file_mode & 07700 141 | converge_by "change mode of private key #{new_path} to #{new_mode.to_s(8)}" do 142 | ::File.chmod(new_mode, new_path) 143 | end 144 | end 145 | end 146 | end 147 | 148 | if new_resource.public_key_path 149 | public_key_path = new_resource.public_key_path 150 | public_key_format = new_resource.public_key_format 151 | Cheffish.inline_resource(self, action) do 152 | public_key public_key_path do 153 | source_key final_private_key 154 | format public_key_format 155 | end 156 | end 157 | end 158 | 159 | if new_resource.after 160 | new_resource.after.call(new_resource, final_private_key) 161 | end 162 | end 163 | 164 | def encode_private_key(key) 165 | key_format = {} 166 | key_format[:format] = new_resource.format if new_resource.format 167 | key_format[:pass_phrase] = new_resource.pass_phrase if new_resource.pass_phrase 168 | key_format[:cipher] = new_resource.cipher if new_resource.cipher 169 | Cheffish::KeyFormatter.encode(key, key_format) 170 | end 171 | 172 | def write_private_key(key) 173 | ::File.open(new_path, "wb") do |file| 174 | file.chmod(0600) 175 | file.write(encode_private_key(key)) 176 | end 177 | end 178 | 179 | def new_source_key 180 | @new_source_key ||= if new_resource.source_key.is_a?(String) 181 | source_key, _source_key_format = Cheffish::KeyFormatter.decode(new_resource.source_key, new_resource.source_key_pass_phrase) 182 | source_key 183 | elsif new_resource.source_key 184 | new_resource.source_key 185 | elsif new_resource.source_key_path 186 | source_key, _source_key_format = Cheffish::KeyFormatter.decode(IO.read(new_resource.source_key_path), new_resource.source_key_pass_phrase, new_resource.source_key_path) 187 | source_key 188 | else 189 | nil 190 | end 191 | end 192 | 193 | attr_reader :current_private_key 194 | 195 | def new_path 196 | new_key_with_path[1] 197 | end 198 | 199 | def new_key_with_path 200 | path = new_resource.path 201 | if path.is_a?(Symbol) 202 | [ nil, path ] 203 | elsif Pathname.new(path).relative? 204 | private_key, private_key_path = Cheffish.get_private_key_with_path(path, run_context.config) 205 | if private_key 206 | [ private_key, (private_key_path || :none) ] 207 | elsif run_context.config[:private_key_write_path] 208 | @should_create_directory = true 209 | path = ::File.join(run_context.config[:private_key_write_path], path) 210 | [ nil, path ] 211 | else 212 | raise "Could not find key #{path} and Chef::Config.private_key_write_path is not set." 213 | end 214 | elsif ::File.exist?(path) 215 | [ IO.read(path), path ] 216 | else 217 | [ nil, path ] 218 | end 219 | end 220 | 221 | def load_current_resource 222 | resource = Chef::Resource::PrivateKey.new(new_resource.name, run_context) 223 | 224 | new_key, new_path = new_key_with_path 225 | if new_path != :none && ::File.exist?(new_path) 226 | resource.path new_path 227 | @current_file_mode = ::File.stat(new_path).mode 228 | else 229 | resource.path :none 230 | end 231 | 232 | if new_key 233 | begin 234 | key, key_format = Cheffish::KeyFormatter.decode(new_key, new_resource.pass_phrase, new_path) 235 | if key 236 | @current_private_key = key 237 | resource.format key_format[:format] 238 | resource.type(key_format[:type]) if key_format[:type] 239 | resource.size(key_format[:size]) if key_format[:size] 240 | resource.exponent(key_format[:exponent]) if key_format[:exponent] 241 | resource.pass_phrase(key_format[:pass_phrase]) if key_format[:pass_phrase] 242 | resource.cipher(key_format[:cipher]) if key_format[:cipher] 243 | end 244 | rescue 245 | # If there's an error reading, we assume format and type are wrong and don't futz with them 246 | Chef::Log.warn("Error reading #{new_path}: #{$!}") 247 | end 248 | else 249 | resource.action :delete 250 | end 251 | @current_resource = resource 252 | end 253 | end 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /lib/chef/resource/public_key.rb: -------------------------------------------------------------------------------- 1 | require "openssl/cipher" 2 | require_relative "../../cheffish/base_resource" 3 | require "openssl" unless defined?(OpenSSL) 4 | require_relative "../../cheffish/key_formatter" 5 | 6 | class Chef 7 | class Resource 8 | class PublicKey < Cheffish::BaseResource 9 | provides :public_key, target_mode: true 10 | 11 | allowed_actions :create, :delete, :nothing 12 | default_action :create 13 | 14 | property :path, String, name_property: true 15 | property :format, %i{pem der openssh}, default: :openssh 16 | 17 | property :source_key 18 | property :source_key_path, String 19 | property :source_key_pass_phrase 20 | 21 | # We are not interested in Chef's cloning behavior here. 22 | def load_prior_resource(*args) 23 | Chef::Log.debug("Overloading #{resource_name}.load_prior_resource with NOOP") 24 | end 25 | 26 | action :create do 27 | unless new_source_key 28 | raise "No source key specified" 29 | end 30 | 31 | desired_output = encode_public_key(new_source_key) 32 | if Array(current_resource.action) == [ :delete ] || desired_output != IO.read(new_resource.path) 33 | converge_by "write #{new_resource.format} public key #{new_resource.path} from #{new_source_key_publicity} key #{new_resource.source_key_path}" do 34 | IO.binwrite(new_resource.path, desired_output) 35 | # TODO permissions on file? 36 | end 37 | end 38 | end 39 | 40 | action :delete do 41 | if Array(current_resource.action) == [ :create ] 42 | converge_by "delete public key #{new_resource.path}" do 43 | ::File.unlink(new_resource.path) 44 | end 45 | end 46 | end 47 | 48 | action_class.class_eval do 49 | def encode_public_key(key) 50 | key_format = {} 51 | key_format[:format] = new_resource.format if new_resource.format 52 | Cheffish::KeyFormatter.encode(key, key_format) 53 | end 54 | 55 | attr_reader :current_public_key 56 | attr_reader :new_source_key_publicity 57 | 58 | def new_source_key 59 | @new_source_key ||= begin 60 | if new_resource.source_key.is_a?(String) 61 | source_key, _source_key_format = Cheffish::KeyFormatter.decode(new_resource.source_key, new_resource.source_key_pass_phrase) 62 | elsif new_resource.source_key 63 | source_key = new_resource.source_key 64 | elsif new_resource.source_key_path 65 | source_key, _source_key_format = Cheffish::KeyFormatter.decode(IO.binread(new_resource.source_key_path), new_resource.source_key_pass_phrase, new_resource.source_key_path) 66 | else 67 | return nil 68 | end 69 | 70 | if source_key.private? 71 | @new_source_key_publicity = "private" 72 | source_key.public_key 73 | else 74 | @new_source_key_publicity = "public" 75 | source_key 76 | end 77 | end 78 | end 79 | 80 | def load_current_resource 81 | if ::File.exist?(new_resource.path) 82 | resource = Chef::Resource::PublicKey.new(new_resource.path, run_context) 83 | begin 84 | key, key_format = Cheffish::KeyFormatter.decode(IO.read(new_resource.path), nil, new_resource.path) 85 | if key 86 | @current_public_key = key 87 | resource.format key_format[:format] 88 | end 89 | rescue 90 | # If there is an error reading we assume format and such is broken 91 | end 92 | 93 | @current_resource = resource 94 | else 95 | not_found_resource = Chef::Resource::PublicKey.new(new_resource.path, run_context) 96 | not_found_resource.action :delete 97 | @current_resource = not_found_resource 98 | end 99 | end 100 | end 101 | 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/cheffish.rb: -------------------------------------------------------------------------------- 1 | module Cheffish 2 | NAME_REGEX = /^[.\-[:alnum:]_]+$/.freeze 3 | 4 | def self.inline_resource(provider, provider_action, *resources, &block) 5 | BasicChefClient.inline_resource(provider, provider_action, *resources, &block) 6 | end 7 | 8 | def self.default_chef_server(config = profiled_config) 9 | { 10 | chef_server_url: config[:chef_server_url], 11 | options: { 12 | client_name: config[:node_name], 13 | signing_key_filename: config[:client_key], 14 | }, 15 | } 16 | end 17 | 18 | def self.chef_server_api(chef_server = default_chef_server) 19 | # Pin the server api version to 0 until https://github.com/chef/cheffish/issues/56 20 | # gets the correct compatibility fix. 21 | chef_server[:options] ||= {} 22 | chef_server[:options][:api_version] = "0" 23 | Cheffish::ServerAPI.new(chef_server[:chef_server_url], chef_server[:options]) 24 | end 25 | 26 | def self.profiled_config(config = Chef::Config) 27 | if config.profile && config.profiles && config.profiles[config.profile] 28 | MergedConfig.new(config.profiles[config.profile], config) 29 | else 30 | config 31 | end 32 | end 33 | 34 | def self.load_chef_config(chef_config = Chef::Config) 35 | chef_config.config_file = if ::Gem::Version.new(::Chef::VERSION) >= ::Gem::Version.new("12.0.0") 36 | require "chef/workstation_config_loader" 37 | Chef::WorkstationConfigLoader.new(nil, Chef::Log).chef_config_dir 38 | else 39 | require "chef/knife" 40 | Chef::Knife.locate_config_file 41 | end 42 | config_fetcher = Chef::ConfigFetcher.new(chef_config.config_file, chef_config.config_file_jail) 43 | if chef_config.config_file.nil? 44 | Chef::Log.warn("No config file found or specified on command line, using command line options.") 45 | elsif config_fetcher.config_missing? 46 | Chef::Log.warn("Did not find config file: #{chef_config.config_file}, using command line options.") 47 | else 48 | config_content = config_fetcher.read_config 49 | config_file_path = chef_config.config_file 50 | begin 51 | chef_config.from_string(config_content, config_file_path) 52 | rescue Exception => error 53 | Chef::Log.fatal("Configuration error #{error.class}: #{error.message}") 54 | filtered_trace = error.backtrace.grep(/#{Regexp.escape(config_file_path)}/) 55 | filtered_trace.each { |line| Chef::Log.fatal(" " + line ) } 56 | Chef::Application.fatal!("Aborting due to error in '#{config_file_path}'", 2) 57 | end 58 | end 59 | Cheffish.profiled_config(chef_config) 60 | end 61 | 62 | def self.honor_local_mode(local_mode_default = true, &block) 63 | if !Chef::Config.key?(:local_mode) && !local_mode_default.nil? 64 | Chef::Config.local_mode = local_mode_default 65 | end 66 | if Chef::Config.local_mode && !Chef::Config.key?(:cookbook_path) && !Chef::Config.key?(:chef_repo_path) 67 | Chef::Config.chef_repo_path = Chef::Config.find_chef_repo_path(Dir.pwd) 68 | end 69 | begin 70 | require "chef/local_mode" 71 | Chef::LocalMode.with_server_connectivity(&block) 72 | 73 | rescue LoadError 74 | Chef::Application.setup_server_connectivity 75 | if block_given? 76 | begin 77 | yield 78 | ensure 79 | Chef::Application.destroy_server_connectivity 80 | end 81 | end 82 | end 83 | end 84 | 85 | def self.get_private_key(name, config = profiled_config) 86 | key, _key_path = get_private_key_with_path(name, config) 87 | key 88 | end 89 | 90 | def self.get_private_key_with_path(name, config = profiled_config) 91 | if config[:private_keys] && config[:private_keys][name] 92 | named_key = config[:private_keys][name] 93 | if named_key.is_a?(String) 94 | Chef::Log.info("Got key #{name} from Chef::Config.private_keys.#{name}, which points at #{named_key}. Reading key from there ...") 95 | return [ IO.read(named_key), named_key] 96 | else 97 | Chef::Log.info("Got key #{name} raw from Chef::Config.private_keys.#{name}.") 98 | return [ named_key.to_pem, nil ] 99 | end 100 | elsif config[:private_key_paths] 101 | config[:private_key_paths].each do |private_key_path| 102 | next unless File.exist?(private_key_path) 103 | 104 | Dir.entries(private_key_path).sort.each do |key| 105 | ext = File.extname(key) 106 | if key == name || ext == "" || ext == ".pem" 107 | key_name = key[0..-(ext.length + 1)] 108 | if key_name == name || key == name 109 | Chef::Log.info("Reading key #{name} from file #{private_key_path}/#{key}") 110 | return [ IO.read("#{private_key_path}/#{key}"), "#{private_key_path}/#{key}" ] 111 | end 112 | end 113 | end 114 | end 115 | end 116 | nil 117 | end 118 | 119 | def self.node_attributes(klass) 120 | klass.include Cheffish::NodeProperties 121 | end 122 | end 123 | 124 | # Include all recipe objects so require 'cheffish' brings in the whole recipe DSL 125 | require "chef/run_list/run_list_item" 126 | require_relative "cheffish/basic_chef_client" 127 | require_relative "cheffish/server_api" 128 | 129 | # Starting with the version below, knife is no longer in the chef gem and is 130 | # not available during a chef-client run. We'll keep it here for older versions 131 | # to retain backward-compatibility. 132 | if ::Gem::Version.new(::Chef::VERSION) < ::Gem::Version.new("17.0.178") 133 | require "chef/knife" 134 | end 135 | 136 | require "chef/config_fetcher" 137 | require "chef/log" 138 | require "chef/application" 139 | require_relative "cheffish/recipe_dsl" 140 | require_relative "cheffish/node_properties" 141 | -------------------------------------------------------------------------------- /lib/cheffish/array_property.rb: -------------------------------------------------------------------------------- 1 | require "chef/property" 2 | 3 | module Cheffish 4 | # A typical array property. Defaults to [], accepts multiple args to setter, accumulates values. 5 | class ArrayProperty < Chef::Property 6 | def initialize(**options) 7 | options[:is] ||= Array 8 | options[:default] ||= [] 9 | options[:coerce] ||= proc { |v| v.is_a?(Array) ? v : [ v ] } 10 | super 11 | end 12 | 13 | # Support my_property 'a', 'b', 'c'; my_property 'a'; and my_property ['a', 'b'] 14 | def emit_dsl 15 | declared_in.class_eval(<<-EOM, __FILE__, __LINE__ + 1) 16 | def #{name}(*values) 17 | property = self.class.properties[#{name.inspect}] 18 | if values.empty? 19 | property.get(self) 20 | elsif property.is_set?(self) 21 | property.set(self, property.get(self) + values.flatten) 22 | else 23 | property.set(self, values.flatten) 24 | end 25 | end 26 | EOM 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/cheffish/base_properties.rb: -------------------------------------------------------------------------------- 1 | require "chef/mixin/properties" 2 | require_relative "array_property" 3 | require_relative "../cheffish" 4 | 5 | module Cheffish 6 | module BaseProperties 7 | include Chef::Mixin::Properties 8 | 9 | def initialize(*args) 10 | super 11 | chef_server run_context.cheffish.current_chef_server 12 | end 13 | 14 | ArrayType = ArrayProperty.new 15 | 16 | property :chef_server, Hash 17 | property :raw_json, Hash 18 | property :complete, [TrueClass, FalseClass] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/cheffish/base_resource.rb: -------------------------------------------------------------------------------- 1 | require "chef/resource" 2 | require_relative "base_properties" 3 | 4 | module Cheffish 5 | class BaseResource < Chef::Resource 6 | include Cheffish::BaseProperties 7 | 8 | declare_action_class.class_eval do 9 | def rest 10 | @rest ||= Cheffish.chef_server_api(new_resource.chef_server) 11 | end 12 | 13 | def current_resource_exists? 14 | Array(current_resource.action) != [ :delete ] 15 | end 16 | 17 | def not_found_resource 18 | resource = resource_class.new(new_resource.name, run_context) 19 | resource.action :delete 20 | resource 21 | end 22 | 23 | def normalize_for_put(json) 24 | data_handler.normalize_for_put(json, fake_entry) 25 | end 26 | 27 | def normalize_for_post(json) 28 | data_handler.normalize_for_post(json, fake_entry) 29 | end 30 | 31 | def new_json 32 | @new_json ||= begin 33 | if new_resource.complete 34 | result = normalize(resource_to_json(new_resource)) 35 | else 36 | # If the resource is incomplete, we use the current json to fill any holes 37 | result = current_json.merge(resource_to_json(new_resource)) 38 | end 39 | augment_new_json(result) 40 | end 41 | end 42 | 43 | # Meant to be overridden 44 | def augment_new_json(json) 45 | json 46 | end 47 | 48 | def current_json 49 | @current_json ||= begin 50 | result = normalize(resource_to_json(current_resource)) 51 | result = augment_current_json(result) 52 | result 53 | end 54 | end 55 | 56 | # Meant to be overridden 57 | def augment_current_json(json) 58 | json 59 | end 60 | 61 | def resource_to_json(resource) 62 | json = resource.raw_json || {} 63 | keys.each do |json_key, resource_key| 64 | value = resource.send(resource_key) 65 | # This takes care of Chef ImmutableMash and ImmutableArray 66 | value = value.to_hash if value.is_a?(Hash) 67 | value = value.to_a if value.is_a?(Array) 68 | json[json_key] = value if value 69 | end 70 | json 71 | end 72 | 73 | def json_to_resource(json) 74 | resource = resource_class.new(new_resource.name, run_context) 75 | keys.each do |json_key, resource_key| 76 | resource.send(resource_key, json.delete(json_key)) 77 | end 78 | # Set the leftover to raw_json 79 | resource.raw_json json 80 | resource 81 | end 82 | 83 | def normalize(json) 84 | data_handler.normalize(json, fake_entry) 85 | end 86 | 87 | def json_differences(old_json, new_json, print_values = true, name = "", result = nil) 88 | result ||= [] 89 | json_differences_internal(old_json, new_json, print_values, name, result) 90 | result 91 | end 92 | 93 | def json_differences_internal(old_json, new_json, print_values, name, result) 94 | if old_json.is_a?(Hash) && new_json.is_a?(Hash) 95 | removed_keys = old_json.keys.inject({}) { |hash, key| hash[key] = true; hash } 96 | new_json.each_pair do |new_key, new_value| 97 | if old_json.key?(new_key) 98 | removed_keys.delete(new_key) 99 | if new_value != old_json[new_key] 100 | json_differences_internal(old_json[new_key], new_value, print_values, name == "" ? new_key : "#{name}.#{new_key}", result) 101 | end 102 | else 103 | if print_values 104 | result << " add #{name == "" ? new_key : "#{name}.#{new_key}"} = #{new_value.inspect}" 105 | else 106 | result << " add #{name == "" ? new_key : "#{name}.#{new_key}"}" 107 | end 108 | end 109 | end 110 | removed_keys.keys.each do |removed_key| 111 | result << " remove #{name == "" ? removed_key : "#{name}.#{removed_key}"}" 112 | end 113 | else 114 | old_json = old_json.to_s if old_json.is_a?(Symbol) 115 | new_json = new_json.to_s if new_json.is_a?(Symbol) 116 | if old_json != new_json 117 | if print_values 118 | result << " update #{name} from #{old_json.inspect} to #{new_json.inspect}" 119 | else 120 | result << " update #{name}" 121 | end 122 | end 123 | end 124 | end 125 | 126 | def apply_modifiers(modifiers, json) 127 | return json if !modifiers || modifiers.size == 0 128 | 129 | # If the attributes have nothing, set them to {} so we have something to add to 130 | if json 131 | json = Marshal.load(Marshal.dump(json)) # Deep copy 132 | else 133 | json = {} 134 | end 135 | 136 | modifiers.each do |path, value| 137 | path = [path] unless path.is_a?(Array) 138 | path = path.map(&:to_s) 139 | parent = 0.upto(path.size - 2).inject(json) do |hash, index| 140 | if hash.nil? 141 | nil 142 | elsif !hash.is_a?(Hash) 143 | raise "Attempt to set #{path} to #{value} when #{path[0..index - 1]} is not a hash" 144 | else 145 | hash[path[index]] 146 | end 147 | end 148 | if !parent.nil? && !parent.is_a?(Hash) 149 | raise "Attempt to set #{path} to #{value} when #{path[0..-2]} is not a hash" 150 | end 151 | 152 | existing_value = parent ? parent[path[-1]] : nil 153 | 154 | if value.is_a?(Proc) 155 | value = value.call(existing_value) 156 | end 157 | if value == :delete 158 | parent.delete(path[-1]) if parent 159 | else 160 | # Create parent if necessary, overwriting values 161 | parent = path[0..-2].inject(json) do |hash, path_part| 162 | hash[path_part] = {} unless hash[path_part] 163 | hash[path_part] 164 | end 165 | if path.size > 0 166 | parent[path[-1]] = value 167 | else 168 | json = value 169 | end 170 | end 171 | end 172 | json 173 | end 174 | 175 | def apply_run_list_modifiers(add_to_run_list, delete_from_run_list, run_list) 176 | return run_list if (!add_to_run_list || add_to_run_list.size == 0) && (!delete_from_run_list || !delete_from_run_list.size) 177 | 178 | delete_from_run_list ||= [] 179 | add_to_run_list ||= [] 180 | 181 | run_list = Chef::RunList.new(*run_list) 182 | 183 | result = [] 184 | add_to_run_list_index = 0 185 | run_list_index = 0 186 | while run_list_index < run_list.run_list_items.size 187 | # See if the desired run list has this item 188 | found_desired = add_to_run_list.index { |item| same_run_list_item(item, run_list[run_list_index]) } 189 | if found_desired 190 | # If so, copy all items up to that desired run list (to preserve order). 191 | # If a run list item is out of order (run_list = X, B, Y, A, Z and desired = A, B) 192 | # then this will give us X, A, B. When A is found later, nothing will be copied 193 | # because found_desired will be less than add_to_run_list_index. The result will 194 | # be X, A, B, Y, Z. 195 | if found_desired >= add_to_run_list_index 196 | result += add_to_run_list[add_to_run_list_index..found_desired].map(&:to_s) 197 | add_to_run_list_index = found_desired + 1 198 | end 199 | else 200 | # If not, just copy it in 201 | unless delete_from_run_list.index { |item| same_run_list_item(item, run_list[run_list_index]) } 202 | result << run_list[run_list_index].to_s 203 | end 204 | end 205 | run_list_index += 1 206 | end 207 | 208 | # Copy any remaining desired items at the end 209 | result += add_to_run_list[add_to_run_list_index..-1].map(&:to_s) 210 | result 211 | end 212 | 213 | def same_run_list_item(a, b) 214 | a_name = a.name 215 | b_name = b.name 216 | # Handle "a::default" being the same as "a" 217 | if a.type == :recipe && a_name =~ /(.+)::default$/ 218 | a_name = $1 219 | elsif b.type == :recipe && b_name =~ /(.+)::default$/ 220 | b_name = $1 221 | end 222 | 223 | a_name == b_name && a.type == b.type # We want to replace things with same name and different version 224 | end 225 | 226 | private 227 | 228 | # Needed to be able to use DataHandler classes 229 | def fake_entry 230 | FakeEntry.new("#{new_resource.send(keys.values.first)}.json") 231 | end 232 | 233 | class FakeEntry 234 | def initialize(name, parent = nil) 235 | @name = name 236 | @parent = parent 237 | @org = nil 238 | end 239 | 240 | attr_reader :name 241 | attr_reader :parent 242 | attr_reader :org 243 | end 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /lib/cheffish/basic_chef_client.rb: -------------------------------------------------------------------------------- 1 | require_relative "version" 2 | require "chef/dsl/recipe" 3 | require "chef/event_dispatch/base" 4 | require "chef/event_dispatch/dispatcher" 5 | require "chef/node" 6 | require "chef/run_context" 7 | require "chef/runner" 8 | require "forwardable" unless defined?(Forwardable) 9 | require "chef/providers" 10 | require "chef/resources" 11 | 12 | module Cheffish 13 | class BasicChefClient 14 | include Chef::DSL::Recipe 15 | 16 | def initialize(node = nil, events = nil, **chef_config) 17 | unless node 18 | node = Chef::Node.new 19 | node.name "basic_chef_client" 20 | node.automatic[:platform] = "basic_chef_client" 21 | node.automatic[:platform_version] = Cheffish::VERSION 22 | end 23 | 24 | # Decide on the config we want for this chef client 25 | @chef_config = chef_config 26 | 27 | with_chef_config do 28 | @cookbook_name = "basic_chef_client" 29 | @event_catcher = BasicChefClientEvents.new 30 | dispatcher = Chef::EventDispatch::Dispatcher.new(@event_catcher) 31 | case events 32 | when Array 33 | events.each { |e| dispatcher.register(e) } if events 34 | when !nil # rubocop: disable Lint/LiteralAsCondition 35 | dispatcher.register(events) 36 | end 37 | @run_context = Chef::RunContext.new(node, {}, dispatcher) 38 | @updated = [] 39 | @cookbook_name = "basic_chef_client" 40 | end 41 | end 42 | 43 | extend Forwardable 44 | 45 | # Stuff recipes need 46 | attr_reader :chef_config 47 | attr_reader :run_context 48 | attr_accessor :cookbook_name 49 | attr_accessor :recipe_name 50 | 51 | def add_resource(resource) 52 | with_chef_config do 53 | resource.run_context = run_context 54 | run_context.resource_collection.insert(resource) 55 | end 56 | end 57 | 58 | def load_block(&block) 59 | with_chef_config do 60 | @recipe_name = "block" 61 | instance_eval(&block) 62 | end 63 | end 64 | 65 | def converge 66 | with_chef_config do 67 | Chef::Runner.new(run_context).converge 68 | end 69 | end 70 | 71 | def updates 72 | @event_catcher.updates 73 | end 74 | 75 | def updated? 76 | @event_catcher.updates.size > 0 77 | end 78 | 79 | # Builds a resource sans context, which can be later used in a new client's 80 | # add_resource() method. 81 | def self.build_resource(type, name, created_at = nil, &resource_attrs_block) 82 | created_at ||= caller[0] 83 | BasicChefClient.new.tap do |client| 84 | client.with_chef_config do 85 | client.build_resource(type, name, created_at, &resource_attrs_block) 86 | end 87 | end 88 | end 89 | 90 | def self.inline_resource(provider, provider_action, *resources, &block) 91 | events = ProviderEventForwarder.new(provider, provider_action) 92 | client = BasicChefClient.new(provider.node, events) 93 | client.with_chef_config do 94 | resources.each do |resource| 95 | client.add_resource(resource) 96 | end 97 | end 98 | client.load_block(&block) if block 99 | client.converge 100 | client.updated? 101 | end 102 | 103 | def self.converge_block(node = nil, events = nil, &block) 104 | client = BasicChefClient.new(node, events) 105 | client.load_block(&block) 106 | client.converge 107 | client.updated? 108 | end 109 | 110 | def with_chef_config(&block) 111 | old_chef_config = Chef::Config.save 112 | if chef_config[:log_location] 113 | old_loggers = Chef::Log.loggers 114 | Chef::Log.init(chef_config[:log_location]) 115 | end 116 | if chef_config[:log_level] 117 | old_level = Chef::Log.level 118 | Chef::Log.level(chef_config[:log_level]) 119 | end 120 | # if chef_config[:stdout] 121 | # old_stdout = $stdout 122 | # $stdout = chef_config[:stdout] 123 | # end 124 | # if chef_config[:stderr] 125 | # old_stderr = $stderr 126 | # $stderr = chef_config[:stderr] 127 | # end 128 | begin 129 | deep_merge_config(chef_config, Chef::Config) 130 | yield 131 | ensure 132 | # $stdout = old_stdout if chef_config[:stdout] 133 | # $stderr = old_stderr if chef_config[:stderr] 134 | if old_loggers 135 | Chef::Log.logger = old_loggers.shift 136 | old_loggers.each { |l| Chef::Log.loggers.push(l) } 137 | elsif chef_config[:log_level] 138 | Chef::Log.level = old_level 139 | end 140 | Chef::Config.restore(old_chef_config) 141 | end 142 | end 143 | 144 | def deep_merge_config(src, dest) 145 | src.each do |name, value| 146 | if value.is_a?(Hash) && dest[name].is_a?(Hash) 147 | deep_merge_config(value, dest[name]) 148 | else 149 | dest[name] = value 150 | end 151 | end 152 | end 153 | 154 | class BasicChefClientEvents < Chef::EventDispatch::Base 155 | def initialize 156 | @updates = [] 157 | end 158 | 159 | attr_reader :updates 160 | 161 | # Called after a resource has been completely converged. 162 | def resource_updated(resource, action) 163 | updates << [ resource, action ] 164 | end 165 | end 166 | 167 | class ProviderEventForwarder < Chef::EventDispatch::Base 168 | def initialize(provider, provider_action) 169 | @provider = provider 170 | @provider_action = provider_action 171 | end 172 | 173 | attr_reader :provider 174 | attr_reader :provider_action 175 | 176 | def resource_update_applied(resource, action, update) 177 | provider.run_context.events.resource_update_applied(provider.new_resource, provider_action, update) 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/cheffish/chef_actor_base.rb: -------------------------------------------------------------------------------- 1 | require_relative "key_formatter" 2 | require_relative "base_resource" 3 | 4 | module Cheffish 5 | class ChefActorBase < Cheffish::BaseResource 6 | 7 | action_class.class_eval do 8 | def create_actor 9 | if new_resource.before 10 | new_resource.before.call(new_resource) 11 | end 12 | 13 | # Create or update the client/user 14 | current_public_key = new_json["public_key"] 15 | differences = json_differences(current_json, new_json) 16 | if current_resource_exists? 17 | # Update the actor if it's different 18 | if differences.size > 0 19 | description = [ "update #{actor_type} #{new_resource.name} at #{actor_path}" ] + differences 20 | converge_by description do 21 | result = rest.put("#{actor_path}/#{new_resource.name}", normalize_for_put(new_json)) 22 | current_public_key, _current_public_key_format = Cheffish::KeyFormatter.decode(result["public_key"]) if result["public_key"] 23 | end 24 | end 25 | else 26 | # Create the actor if it's missing 27 | unless new_public_key 28 | raise "You must specify a public key to create a #{actor_type}! Use the private_key resource to create a key, and pass it in with source_key_path." 29 | end 30 | 31 | description = [ "create #{actor_type} #{new_resource.name} at #{actor_path}" ] + differences 32 | converge_by description do 33 | result = rest.post((actor_path).to_s, normalize_for_post(new_json)) 34 | current_public_key, _current_public_key_format = Cheffish::KeyFormatter.decode(result["public_key"]) if result["public_key"] 35 | end 36 | end 37 | 38 | # Write out the public key 39 | if new_resource.output_key_path 40 | # TODO use inline_resource 41 | key_content = Cheffish::KeyFormatter.encode(current_public_key, { format: new_resource.output_key_format }) 42 | if !current_resource.output_key_path 43 | action = "create" 44 | elsif key_content != IO.read(current_resource.output_key_path) 45 | action = "overwrite" 46 | else 47 | action = nil 48 | end 49 | if action 50 | converge_by "#{action} public key #{new_resource.output_key_path}" do 51 | IO.write(new_resource.output_key_path, key_content) 52 | end 53 | end 54 | # TODO permissions? 55 | end 56 | 57 | if new_resource.after 58 | new_resource.after.call(self, new_json, server_private_key, server_public_key) 59 | end 60 | end 61 | 62 | def delete_actor 63 | if current_resource_exists? 64 | converge_by "delete #{actor_type} #{new_resource.name} at #{actor_path}" do 65 | rest.delete("#{actor_path}/#{new_resource.name}") 66 | Chef::Log.info("#{new_resource} deleted #{actor_type} #{new_resource.name} at #{rest.url}") 67 | end 68 | end 69 | if current_resource.output_key_path 70 | converge_by "delete public key #{current_resource.output_key_path}" do 71 | ::File.unlink(current_resource.output_key_path) 72 | end 73 | end 74 | end 75 | 76 | def new_public_key 77 | @new_public_key ||= if new_resource.source_key 78 | if new_resource.source_key.is_a?(String) 79 | key, _key_format = Cheffish::KeyFormatter.decode(new_resource.source_key) 80 | 81 | if key.private? 82 | key.public_key 83 | else 84 | key 85 | end 86 | elsif new_resource.source_key.private? 87 | new_resource.source_key.public_key 88 | else 89 | new_resource.source_key 90 | end 91 | elsif new_resource.source_key_path 92 | source_key_path = new_resource.source_key_path 93 | if Pathname.new(source_key_path).relative? 94 | source_key_str, source_key_path = Cheffish.get_private_key_with_path(source_key_path, run_context.config) 95 | else 96 | source_key_str = IO.read(source_key_path) 97 | end 98 | source_key, _source_key_format = Cheffish::KeyFormatter.decode(source_key_str, new_resource.source_key_pass_phrase, source_key_path) 99 | if source_key.private? 100 | source_key.public_key 101 | else 102 | source_key 103 | end 104 | else 105 | nil 106 | end 107 | end 108 | 109 | def augment_new_json(json) 110 | if new_public_key 111 | json["public_key"] = new_public_key.to_pem 112 | end 113 | json 114 | end 115 | 116 | def load_current_resource 117 | begin 118 | json = rest.get("#{actor_path}/#{new_resource.name}") 119 | @current_resource = json_to_resource(json) 120 | rescue Net::HTTPClientException => e 121 | if e.response.code == "404" 122 | @current_resource = not_found_resource 123 | else 124 | raise 125 | end 126 | end 127 | 128 | if new_resource.output_key_path && ::File.exist?(new_resource.output_key_path) 129 | current_resource.output_key_path = new_resource.output_key_path 130 | end 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/cheffish/chef_run.rb: -------------------------------------------------------------------------------- 1 | require_relative "basic_chef_client" 2 | 3 | module Cheffish 4 | class ChefRun 5 | # 6 | # @param chef_config A hash with symbol keys that looks suspiciously similar to `Chef::Config`. 7 | # Some possible options: 8 | # - stdout: - where to stream stdout to 9 | # - stderr: - where to stream stderr to 10 | # - log_level: :debug|:info|:warn|:error|:fatal 11 | # - log_location: - where to stream logs to 12 | # - verbose_logging: true|false - true if you want verbose logging in :debug 13 | # 14 | def initialize(chef_config = {}) 15 | @chef_config = chef_config || {} 16 | end 17 | 18 | attr_reader :chef_config 19 | 20 | class StringIOTee < StringIO 21 | def initialize(*streams) 22 | super() 23 | @streams = streams.flatten.select { |s| !s.nil? } 24 | end 25 | 26 | attr_reader :streams 27 | 28 | def write(*args, &block) 29 | super 30 | streams.each { |s| s.write(*args, &block) } 31 | end 32 | end 33 | 34 | def client 35 | @client ||= begin 36 | chef_config = self.chef_config.dup 37 | chef_config[:log_level] ||= :debug unless chef_config.key?(:log_level) 38 | chef_config[:verbose_logging] = false unless chef_config.key?(:verbose_logging) 39 | chef_config[:stdout] = StringIOTee.new(chef_config[:stdout]) 40 | chef_config[:stderr] = StringIOTee.new(chef_config[:stderr]) 41 | chef_config[:log_location] = StringIOTee.new(chef_config[:log_location]) 42 | @client = ::Cheffish::BasicChefClient.new(nil, 43 | [ event_sink, Chef::Formatters.new(:doc, chef_config[:stdout], chef_config[:stderr]) ], 44 | **chef_config) 45 | end 46 | end 47 | 48 | def event_sink 49 | @event_sink ||= EventSink.new 50 | end 51 | 52 | # 53 | # output 54 | # 55 | def stdout 56 | @client ? client.chef_config[:stdout].string : nil 57 | end 58 | 59 | def stderr 60 | @client ? client.chef_config[:stderr].string : nil 61 | end 62 | 63 | def logs 64 | @client ? client.chef_config[:log_location].string : nil 65 | end 66 | 67 | def logged_warnings 68 | logs.lines.select { |l| l =~ /^\[[^\]]*\] WARN:/ }.join("\n") 69 | end 70 | 71 | def logged_errors 72 | logs.lines.select { |l| l =~ /^\[[^\]]*\] ERROR:/ }.join("\n") 73 | end 74 | 75 | def logged_info 76 | logs.lines.select { |l| l =~ /^\[[^\]]*\] INFO:/ }.join("\n") 77 | end 78 | 79 | def resources 80 | client.run_context.resource_collection 81 | end 82 | 83 | def compile_recipe(&recipe) 84 | client.load_block(&recipe) 85 | end 86 | 87 | def converge 88 | client.converge 89 | @converged = true 90 | rescue RuntimeError => e 91 | @raised_exception = e 92 | raise 93 | end 94 | 95 | def reset 96 | @client = nil 97 | @converged = nil 98 | @stdout = nil 99 | @stderr = nil 100 | @logs = nil 101 | @raised_exception = nil 102 | end 103 | 104 | def converged? 105 | !!@converged 106 | end 107 | 108 | def converge_failed? 109 | @raised_exception.nil? ? false : true 110 | end 111 | 112 | def updated? 113 | client.updated? 114 | end 115 | 116 | def up_to_date? 117 | !client.updated? 118 | end 119 | 120 | def output_for_failure_message 121 | message = "" 122 | if stdout && !stdout.empty? 123 | message << "--- ---\n" 124 | message << "--- Chef Client Output ---\n" 125 | message << "--- ---\n" 126 | message << stdout 127 | message << "\n" unless stdout.end_with?("\n") 128 | end 129 | if stderr && !stderr.empty? 130 | message << "--- ---\n" 131 | message << "--- Chef Client Error Output ---\n" 132 | message << "--- ---\n" 133 | message << stderr 134 | message << "\n" unless stderr.end_with?("\n") 135 | end 136 | if logs && !logs.empty? 137 | message << "--- ---\n" 138 | message << "--- Chef Client Logs ---\n" 139 | message << "--- ---\n" 140 | message << logs 141 | end 142 | message 143 | end 144 | 145 | class EventSink 146 | def initialize 147 | @events = [] 148 | end 149 | 150 | attr_reader :events 151 | 152 | def method_missing(method, *args) 153 | @events << [ method, *args ] 154 | end 155 | 156 | def respond_to_missing?(method_name, include_private = false) 157 | # Chef::EventDispatch::Dispatcher calls #respond_to? to see (basically) if we'll accept an event; 158 | # obviously, per above #method_missing, we'll accept whatever we're given. if there's a problem, it 159 | # will surface higher up the stack. 160 | true 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/cheffish/chef_run_data.rb: -------------------------------------------------------------------------------- 1 | require "chef/config" 2 | require_relative "with_pattern" 3 | 4 | module Cheffish 5 | class ChefRunData 6 | def initialize(config) 7 | @local_servers = [] 8 | @current_chef_server = Cheffish.default_chef_server(config) 9 | end 10 | 11 | extend Cheffish::WithPattern 12 | with :data_bag 13 | with :environment 14 | with :data_bag_item_encryption 15 | with :chef_server 16 | 17 | attr_reader :local_servers 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/cheffish/chef_run_listener.rb: -------------------------------------------------------------------------------- 1 | require "chef/event_dispatch/base" 2 | 3 | module Cheffish 4 | class ChefRunListener < Chef::EventDispatch::Base 5 | def initialize(node) 6 | @node = node 7 | end 8 | 9 | attr_reader :node 10 | 11 | def run_complete(node) 12 | disconnect 13 | end 14 | 15 | def run_failed(exception) 16 | disconnect 17 | end 18 | 19 | private 20 | 21 | def disconnect 22 | # Stop the servers 23 | if node.run_context 24 | node.run_context.cheffish.local_servers.each(&:stop) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/cheffish/key_formatter.rb: -------------------------------------------------------------------------------- 1 | require "openssl" unless defined?(OpenSSL) 2 | require "net/ssh" unless defined?(Net::SSH) 3 | require "etc" unless defined?(Etc) 4 | require "socket" unless defined?(Socket) 5 | require "digest/md5" unless defined?(Digest::MD5) 6 | require "base64" unless defined?(Base64) 7 | 8 | module Cheffish 9 | class KeyFormatter 10 | # Returns nil or key, format 11 | def self.decode(str, pass_phrase = nil, filename = "") 12 | key_format = {} 13 | key_format[:format] = format_of(str) 14 | 15 | case key_format[:format] 16 | when :openssh 17 | key = decode_openssh_key(str, filename) 18 | else 19 | begin 20 | key = OpenSSL::PKey.read(str) { pass_phrase } 21 | rescue 22 | return nil 23 | end 24 | end 25 | 26 | key_format[:type] = type_of(key) if type_of(key) 27 | key_format[:size] = size_of(key) if size_of(key) 28 | key_format[:pass_phrase] = pass_phrase if pass_phrase 29 | # TODO cipher, exponent 30 | 31 | [key, key_format] 32 | end 33 | 34 | def self.encode(key, key_format) 35 | format = key_format[:format] || :pem 36 | case format 37 | when :openssh 38 | encode_openssh_key(key) 39 | when :pem 40 | if key_format[:pass_phrase] 41 | cipher = key_format[:cipher] || "DES-EDE3-CBC" 42 | key.to_pem(OpenSSL::Cipher.new(cipher), key_format[:pass_phrase]) 43 | else 44 | key.to_pem 45 | end 46 | when :der 47 | key.to_der 48 | when :fingerprint, :pkcs1md5fingerprint 49 | hexes = Digest::MD5.hexdigest(key.to_der) 50 | # Put : between every pair of hexes 51 | hexes.scan(/../).join(":") 52 | when :rfc4716md5fingerprint 53 | _type, base64_data, _etc = encode_openssh_key(key).split 54 | data = Base64.decode64(base64_data) 55 | hexes = Digest::MD5.hexdigest(data) 56 | hexes.scan(/../).join(":") 57 | when :pkcs8sha1fingerprint 58 | raise "PKCS8 SHA1 not supported by Ruby 2.0 and later" 59 | else 60 | raise "Unrecognized key format #{format}" 61 | end 62 | end 63 | 64 | def self.encode_openssh_key(key) 65 | # TODO there really isn't a method somewhere in net/ssh or openssl that does this?? 66 | type = key.ssh_type 67 | data = [ key.to_blob ].pack("m0") 68 | "#{type} #{data} #{Etc.getlogin}@#{Socket.gethostname}" 69 | end 70 | 71 | def self.decode_openssh_key(str, filename = "") 72 | Net::SSH::KeyFactory.load_data_public_key(str, filename) 73 | end 74 | 75 | def self.format_of(key_contents) 76 | if key_contents.start_with?("-----BEGIN ") 77 | :pem 78 | elsif key_contents.start_with?("ssh-rsa ", "ssh-dss ") 79 | :openssh 80 | else 81 | :der 82 | end 83 | end 84 | 85 | def self.type_of(key) 86 | case key.class 87 | when OpenSSL::PKey::RSA 88 | :rsa 89 | when OpenSSL::PKey::DSA 90 | :dsa 91 | else 92 | nil 93 | end 94 | end 95 | 96 | def self.size_of(key) 97 | case key.class 98 | when OpenSSL::PKey::RSA 99 | key.n.num_bytes * 8 100 | else 101 | nil 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/cheffish/merged_config.rb: -------------------------------------------------------------------------------- 1 | require "chef/mash" 2 | 3 | module Cheffish 4 | class MergedConfig 5 | def initialize(*configs) 6 | @configs = configs.map { |config| Mash.from_hash config.to_hash } 7 | @merge_arrays = Mash.new 8 | end 9 | 10 | include Enumerable 11 | 12 | attr_reader :configs 13 | def merge_arrays(*symbols) 14 | if symbols.size > 0 15 | symbols.each do |symbol| 16 | @merge_arrays[symbol] = true 17 | end 18 | else 19 | @merge_arrays 20 | end 21 | end 22 | 23 | def [](name) 24 | if @merge_arrays[name] 25 | configs.select { |c| !c[name].nil? }.collect_concat { |c| c[name] } 26 | else 27 | result_configs = [] 28 | configs.each do |config| 29 | value = config[name] 30 | unless value.nil? 31 | if value.respond_to?(:keys) 32 | result_configs << value 33 | elsif result_configs.size > 0 34 | return result_configs[0] 35 | else 36 | return value 37 | end 38 | end 39 | end 40 | if result_configs.size > 1 41 | MergedConfig.new(*result_configs) 42 | elsif result_configs.size == 1 43 | result_configs[0] 44 | else 45 | nil 46 | end 47 | end 48 | end 49 | 50 | def method_missing(name, *args) 51 | $stderr.puts "WARN: deprecated use of method_missing on a Cheffish::MergedConfig object at #{caller[0]}" 52 | if args.count > 0 53 | raise NoMethodError, "Unexpected method #{name} for MergedConfig with arguments #{args}" 54 | else 55 | self[name] 56 | end 57 | end 58 | 59 | def key?(name) 60 | configs.any? { |config| config.key?(name) } 61 | end 62 | 63 | alias_method :has_key?, :key? 64 | 65 | def keys 66 | configs.flat_map(&:keys).uniq 67 | end 68 | 69 | def values 70 | keys.map { |key| self[key] } 71 | end 72 | 73 | def empty? 74 | configs.empty? 75 | end 76 | 77 | def each_pair(&block) 78 | each(&block) 79 | end 80 | 81 | def each 82 | keys.each do |key| 83 | if block_given? 84 | yield key, self[key] 85 | end 86 | end 87 | end 88 | 89 | def to_hash 90 | result = {} 91 | each_pair do |key, value| 92 | result[key] = value 93 | end 94 | result 95 | end 96 | 97 | def to_h 98 | to_hash 99 | end 100 | 101 | def to_s 102 | to_hash.to_s 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/cheffish/node_properties.rb: -------------------------------------------------------------------------------- 1 | require_relative "base_properties" 2 | 3 | module Cheffish 4 | module NodeProperties 5 | include Cheffish::BaseProperties 6 | 7 | # Grab environment from with_environment 8 | def initialize(*args) 9 | super 10 | chef_environment run_context.cheffish.current_environment 11 | end 12 | 13 | property :node_properties_name, Cheffish::NAME_REGEX, name_property: true 14 | property :chef_environment, Cheffish::NAME_REGEX 15 | property :run_list, Array # We should let them specify it as a series of parameters too 16 | property :attributes, Hash 17 | 18 | # attribute 'ip_address', '127.0.0.1' 19 | # attribute [ 'pushy', 'port' ], '9000' 20 | # attribute 'ip_addresses' do |existing_value| 21 | # (existing_value || []) + [ '127.0.0.1' ] 22 | # end 23 | # attribute 'ip_address', :delete 24 | attr_accessor :attribute_modifiers 25 | def attribute(attribute_path, value = Chef::NOT_PASSED, &block) 26 | @attribute_modifiers ||= [] 27 | if value != Chef::NOT_PASSED 28 | @attribute_modifiers << [ attribute_path, value ] 29 | elsif block 30 | @attribute_modifiers << [ attribute_path, block ] 31 | else 32 | raise "attribute requires either a value or a block" 33 | end 34 | end 35 | 36 | # Patchy tags 37 | # tag 'webserver', 'apache', 'myenvironment' 38 | def tag(*tags) 39 | attribute "tags" do |existing_tags| 40 | existing_tags ||= [] 41 | tags.each do |tag| 42 | unless existing_tags.include?(tag.to_s) 43 | existing_tags << tag.to_s 44 | end 45 | end 46 | existing_tags 47 | end 48 | end 49 | 50 | def remove_tag(*tags) 51 | attribute "tags" do |existing_tags| 52 | if existing_tags 53 | tags.each do |tag| 54 | existing_tags.delete(tag.to_s) 55 | end 56 | end 57 | existing_tags 58 | end 59 | end 60 | 61 | # NON-patchy tags 62 | # tags :a, :b, :c # removes all other tags 63 | def tags(*tags) 64 | if tags.size == 0 65 | attribute("tags") 66 | else 67 | tags = tags[0] if tags.size == 1 && tags[0].is_a?(Array) 68 | attribute("tags", tags.map(&:to_s)) 69 | end 70 | end 71 | 72 | # Order matters--if two things here are in the wrong order, they will be flipped in the run list 73 | # recipe 'apache', 'mysql' 74 | # recipe 'recipe@version' 75 | # recipe 'recipe' 76 | # role '' 77 | attr_accessor :run_list_modifiers 78 | attr_accessor :run_list_removers 79 | def recipe(*recipes) 80 | if recipes.size == 0 81 | raise ArgumentError, "At least one recipe must be specified" 82 | end 83 | 84 | @run_list_modifiers ||= [] 85 | @run_list_modifiers += recipes.map { |recipe| Chef::RunList::RunListItem.new("recipe[#{recipe}]") } 86 | end 87 | 88 | def role(*roles) 89 | if roles.size == 0 90 | raise ArgumentError, "At least one role must be specified" 91 | end 92 | 93 | @run_list_modifiers ||= [] 94 | @run_list_modifiers += roles.map { |role| Chef::RunList::RunListItem.new("role[#{role}]") } 95 | end 96 | 97 | def remove_recipe(*recipes) 98 | if recipes.size == 0 99 | raise ArgumentError, "At least one recipe must be specified" 100 | end 101 | 102 | @run_list_removers ||= [] 103 | @run_list_removers += recipes.map { |recipe| Chef::RunList::RunListItem.new("recipe[#{recipe}]") } 104 | end 105 | 106 | def remove_role(*roles) 107 | if roles.size == 0 108 | raise ArgumentError, "At least one role must be specified" 109 | end 110 | 111 | @run_list_removers ||= [] 112 | @run_list_removers += roles.map { |role| Chef::RunList::RunListItem.new("role[#{role}]") } 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/cheffish/recipe_dsl.rb: -------------------------------------------------------------------------------- 1 | require_relative "../cheffish" 2 | 3 | require "chef/version" 4 | require "chef_zero/server" 5 | require "chef/chef_fs/chef_fs_data_store" 6 | require "chef/chef_fs/config" 7 | require_relative "chef_run_data" 8 | require_relative "chef_run_listener" 9 | require "chef/client" 10 | require "chef/config" 11 | require "chef_zero/version" 12 | require_relative "merged_config" 13 | require_relative "../chef/resource/chef_acl" 14 | require_relative "../chef/resource/chef_client" 15 | require_relative "../chef/resource/chef_container" 16 | require_relative "../chef/resource/chef_data_bag" 17 | require_relative "../chef/resource/chef_data_bag_item" 18 | require_relative "../chef/resource/chef_environment" 19 | require_relative "../chef/resource/chef_group" 20 | require_relative "../chef/resource/chef_mirror" 21 | require_relative "../chef/resource/chef_node" 22 | require_relative "../chef/resource/chef_organization" 23 | require_relative "../chef/resource/chef_role" 24 | require_relative "../chef/resource/chef_user" 25 | require_relative "../chef/resource/private_key" 26 | require_relative "../chef/resource/public_key" 27 | require "chef/util/path_helper" 28 | 29 | class Chef 30 | module DSL 31 | module Recipe 32 | def with_chef_data_bag(name) 33 | run_context.cheffish.with_data_bag(name, &block) 34 | end 35 | 36 | def with_chef_environment(name, &block) 37 | run_context.cheffish.with_environment(name, &block) 38 | end 39 | 40 | def with_chef_data_bag_item_encryption(encryption_options, &block) 41 | run_context.cheffish.with_data_bag_item_encryption(encryption_options, &block) 42 | end 43 | 44 | def with_chef_server(server_url, options = {}, &block) 45 | run_context.cheffish.with_chef_server({ chef_server_url: server_url, options: options }, &block) 46 | end 47 | 48 | def with_chef_local_server(options, &block) 49 | options[:host] ||= "127.0.0.1" 50 | options[:log_level] ||= Chef::Log.level 51 | options[:port] ||= ChefZero::VERSION.to_f >= 2.2 ? 8901.upto(9900) : 8901 52 | 53 | # Create the data store chef-zero will use 54 | options[:data_store] ||= begin 55 | unless options[:chef_repo_path] 56 | raise "chef_repo_path must be specified to with_chef_local_server" 57 | end 58 | 59 | # Ensure all paths are given 60 | %w{acl client cookbook container data_bag environment group node role}.each do |type| 61 | # Set the options as symbol keys and then copy to string keys 62 | string_key = "#{type}_path" 63 | symbol_key = "#{type}_path".to_sym 64 | 65 | options[symbol_key] ||= if options[:chef_repo_path].is_a?(String) 66 | Chef::Util::PathHelper.join(options[:chef_repo_path], "#{type}s") 67 | else 68 | options[:chef_repo_path].map { |path| Chef::Util::PathHelper.join(path, "#{type}s") } 69 | end 70 | 71 | # Copy over to string keys for things that use string keys (ChefFS)... 72 | # TODO: Fix ChefFS to take symbols or use something that is insensitive to the difference 73 | options[string_key] = options[symbol_key] 74 | end 75 | 76 | chef_fs = Chef::ChefFS::Config.new(options).local_fs 77 | chef_fs.write_pretty_json = true 78 | Chef::ChefFS::ChefFSDataStore.new(chef_fs) 79 | end 80 | 81 | # Start the chef-zero server 82 | Chef::Log.info("Starting chef-zero on port #{options[:port]} with repository at #{options[:data_store].chef_fs.fs_description}") 83 | chef_zero_server = ChefZero::Server.new(options) 84 | chef_zero_server.start_background 85 | 86 | run_context.cheffish.local_servers << chef_zero_server 87 | 88 | with_chef_server(chef_zero_server.url, &block) 89 | end 90 | 91 | def get_private_key(name) 92 | Cheffish.get_private_key(name, run_context.config) 93 | end 94 | end 95 | end 96 | 97 | class Config 98 | default(:profile) { ENV["CHEF_PROFILE"] || "default" } 99 | configurable(:private_keys) 100 | default(:private_key_paths) { [ Chef::Util::PathHelper.join(config_dir, "keys"), Chef::Util::PathHelper.join(user_home, ".ssh") ] } 101 | default(:private_key_write_path) { private_key_paths.first } 102 | end 103 | 104 | class RunContext 105 | def cheffish 106 | node.run_state[:cheffish] ||= begin 107 | run_data = Cheffish::ChefRunData.new(config) 108 | events.register(Cheffish::ChefRunListener.new(node)) 109 | run_data 110 | end 111 | end 112 | 113 | def config 114 | node.run_state[:chef_config] ||= Cheffish.profiled_config(Chef::Config) 115 | end 116 | end 117 | 118 | Chef::Client.when_run_starts do |run_status| 119 | # Pulling on cheffish_run_data makes it initialize right now 120 | run_status.node.run_state[:chef_config] = config = Cheffish.profiled_config(Chef::Config) 121 | run_status.node.run_state[:cheffish] = Cheffish::ChefRunData.new(config) 122 | run_status.events.register(Cheffish::ChefRunListener.new(run_status.node)) 123 | end 124 | 125 | end 126 | -------------------------------------------------------------------------------- /lib/cheffish/rspec.rb: -------------------------------------------------------------------------------- 1 | require_relative "rspec/chef_run_support" 2 | require_relative "rspec/repository_support" 3 | require_relative "rspec/matchers" 4 | 5 | module Cheffish 6 | module RSpec 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/chef_run_support.rb: -------------------------------------------------------------------------------- 1 | require "chef_zero/rspec" 2 | require "chef/server_api" 3 | require_relative "repository_support" 4 | require "uri" unless defined?(URI) 5 | require_relative "../chef_run" 6 | require_relative "recipe_run_wrapper" 7 | require_relative "matchers" 8 | 9 | module Cheffish 10 | module RSpec 11 | module ChefRunSupport 12 | include ChefZero::RSpec 13 | include RepositorySupport 14 | 15 | def self.extended(klass) 16 | klass.class_eval do 17 | include ChefRunSupportInstanceMethods 18 | end 19 | end 20 | 21 | def when_the_chef_12_server(*args, **options, &block) 22 | if Gem::Version.new(ChefZero::VERSION) >= Gem::Version.new("3.1") 23 | when_the_chef_server(*args, osc_compat: false, single_org: false, **options, &block) 24 | end 25 | end 26 | 27 | def with_converge(&recipe) 28 | before :each do 29 | r = recipe(&recipe) 30 | r.converge 31 | end 32 | end 33 | 34 | module ChefRunSupportInstanceMethods 35 | def rest 36 | ::Chef::ServerAPI.new(Chef::Config.chef_server_url, api_version: "0") 37 | end 38 | 39 | def get(path, *args) 40 | if path[0] == "/" 41 | path = URI.join(rest.url, path) 42 | end 43 | rest.get(path, *args) 44 | end 45 | 46 | def chef_config 47 | {} 48 | end 49 | 50 | def expect_recipe(str = nil, file = nil, line = nil, &recipe) 51 | r = recipe(str, file, line, &recipe) 52 | r.converge 53 | expect(r) 54 | end 55 | 56 | def expect_converge(str = nil, file = nil, line = nil, &recipe) 57 | expect { converge(str, file, line, &recipe) } 58 | end 59 | 60 | def recipe(str = nil, file = nil, line = nil, &recipe) 61 | unless recipe 62 | if file && line 63 | recipe = proc { eval(str, nil, file, line) } # rubocop:disable Security/Eval 64 | else 65 | recipe = proc { eval(str) } # rubocop:disable Security/Eval 66 | end 67 | end 68 | RecipeRunWrapper.new(chef_config, &recipe) 69 | end 70 | 71 | def converge(str = nil, file = nil, line = nil, &recipe) 72 | r = recipe(str, file, line, &recipe) 73 | r.converge 74 | r 75 | end 76 | 77 | def chef_client 78 | @chef_client ||= ChefRun.new(chef_config) 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/matchers.rb: -------------------------------------------------------------------------------- 1 | require_relative "matchers/have_updated" 2 | require_relative "matchers/be_idempotent" 3 | require_relative "matchers/partially_match" 4 | require_relative "matchers/emit_no_warnings_or_errors" 5 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/matchers/be_idempotent.rb: -------------------------------------------------------------------------------- 1 | require "rspec/matchers" 2 | 3 | RSpec::Matchers.define :be_idempotent do 4 | match do |recipe| 5 | @recipe = recipe 6 | recipe.reset 7 | recipe.converge 8 | recipe.up_to_date? 9 | end 10 | 11 | failure_message do 12 | "#{@recipe} is not idempotent! Converging it a second time caused updates.\n#{@recipe.output_for_failure_message}" 13 | end 14 | 15 | supports_block_expectations 16 | end 17 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/matchers/emit_no_warnings_or_errors.rb: -------------------------------------------------------------------------------- 1 | require "rspec/matchers" 2 | 3 | RSpec::Matchers.define :emit_no_warnings_or_errors do 4 | match do |recipe| 5 | @recipe = recipe 6 | @warn_err = recipe.logs.lines.select { |l| l =~ /warn|err/i }.join("\n") 7 | @warn_err.empty? 8 | end 9 | 10 | failure_message do 11 | "#{@recipe} emitted warnings and errors!\n#{@warn_err}" 12 | end 13 | 14 | supports_block_expectations 15 | end 16 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/matchers/have_updated.rb: -------------------------------------------------------------------------------- 1 | require "rspec/matchers" 2 | 3 | RSpec::Matchers.define :have_updated do |resource_name, *expected_actions| 4 | match do |recipe| 5 | @recipe = recipe 6 | actual = @recipe.event_sink.events 7 | actual_actions = actual.select { |event, resource, action| event == :resource_updated && resource.to_s == resource_name } 8 | .map { |event, resource, action| action } 9 | expect(actual_actions).to eq(expected_actions) 10 | end 11 | 12 | failure_message do 13 | actual = @recipe.event_sink.events 14 | updates = actual.select { |event, resource, action| event == :resource_updated }.to_a 15 | result = "expected that the chef_run would #{expected_actions.join(",")} #{resource_name}." 16 | if updates.size > 0 17 | result << " Actual updates were #{updates.map { |event, resource, action| "#{resource} => #{action.inspect}" }.join(", ")}" 18 | else 19 | result << " Nothing was updated." 20 | end 21 | result 22 | end 23 | 24 | failure_message_when_negated do 25 | actual = @recipe.event_sink.events 26 | updates = actual.select { |event, resource, action| event == :resource_updated }.to_a 27 | result = "expected that the chef_run would not #{expected_actions.join(",")} #{resource_name}." 28 | if updates.size > 0 29 | result << " Actual updates were #{updates.map { |event, resource, action| "#{resource} => #{action.inspect}" }.join(", ")}" 30 | else 31 | result << " Nothing was updated." 32 | end 33 | result 34 | end 35 | end 36 | 37 | RSpec::Matchers.define_negated_matcher :not_have_updated, :have_updated 38 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/matchers/partially_match.rb: -------------------------------------------------------------------------------- 1 | module Cheffish 2 | module RSpec 3 | module Matchers 4 | class PartiallyMatch 5 | include ::RSpec::Matchers::Composable 6 | 7 | def initialize(example, expected) 8 | @example = example 9 | @expected = expected 10 | end 11 | 12 | def matches?(actual) 13 | @actual = actual 14 | partially_matches_values(@expected, actual) 15 | end 16 | 17 | def failure_message 18 | "expected #{@actual} to match #{@expected}" 19 | end 20 | 21 | def failure_message_when_negated 22 | "expected #{@actual} not to match #{@expected}" 23 | end 24 | 25 | protected 26 | 27 | def partially_matches_values(expected, actual) 28 | if Hash === actual 29 | return partially_matches_hashes(expected, actual) if Hash === expected || Array === expected 30 | elsif Array === expected && Enumerable === actual && !(Struct === actual) 31 | return partially_matches_arrays(expected, actual) 32 | end 33 | 34 | return true if actual == expected 35 | 36 | begin 37 | expected === actual 38 | rescue ArgumentError 39 | # Some objects, like 0-arg lambdas on 1.9+, raise 40 | # ArgumentError for `expected === actual`. 41 | false 42 | end 43 | end 44 | 45 | def partially_matches_hashes(expected, actual) 46 | expected.all? { |key, value| partially_matches_values(value, actual[key]) } 47 | end 48 | 49 | def partially_matches_arrays(expected, actual) 50 | expected.all? { |e| actual.any? { |a| partially_matches_values(e, a) } } 51 | end 52 | end 53 | end 54 | end 55 | end 56 | 57 | module RSpec 58 | module Matchers 59 | def partially_match(expected) 60 | Cheffish::RSpec::Matchers::PartiallyMatch.new(self, expected) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/recipe_run_wrapper.rb: -------------------------------------------------------------------------------- 1 | require_relative "../chef_run" 2 | require "forwardable" unless defined?(Forwardable) 3 | 4 | module Cheffish 5 | module RSpec 6 | class RecipeRunWrapper < ChefRun 7 | def initialize(chef_config, example: nil, &recipe) 8 | super(chef_config) 9 | @recipe = recipe 10 | @example = example || recipe.binding.eval("self") 11 | end 12 | 13 | attr_reader :recipe 14 | attr_reader :example 15 | 16 | def client 17 | unless @client 18 | super 19 | example = self.example 20 | 21 | # 22 | # Support for both resources and rspec example's let variables: 23 | # 24 | # In 12.4, the elimination of a bunch of metaprogramming in 12.4 25 | # changed how Chef DSL is defined in code: resource methods are now 26 | # explicitly defined in `Chef::DSL::Recipe`. In 12.3, no actual 27 | # methods were defined and `respond_to?(:file)` would return false. 28 | # If we reach `method_missing` here, it means that we either have a 29 | # 12.3-ish resource or we want to call a `let` variable. 30 | # 31 | @client.instance_eval { @rspec_example = example } 32 | def @client.method_missing(name, *args, &block) # rubocop:disable Lint/NestedMethodDefinition 33 | # If there is a let variable, call it. This is because in 12.4, 34 | # the parent class is going to call respond_to?(name) to find out 35 | # if someone was doing weird things, and then call send(). This 36 | # would result in an infinite loop, coming right. Back. Here. 37 | # A fix to chef is incoming, but we still need this if we want to 38 | # work with Chef 12.4. 39 | if Gem::Version.new(Chef::VERSION) >= Gem::Version.new("12.4") 40 | if @rspec_example.respond_to?(name) 41 | return @rspec_example.public_send(name, *args, &block) 42 | end 43 | end 44 | 45 | # In 12.3 or below, method_missing was the only way to call 46 | # resources. If we are in 12.4, we still need to call the crazy 47 | # method_missing metaprogramming because backcompat. 48 | begin 49 | super 50 | rescue NameError 51 | if @rspec_example.respond_to?(name) 52 | @rspec_example.public_send(name, *args, &block) 53 | else 54 | raise 55 | end 56 | end 57 | end 58 | 59 | # This is called by respond_to?, and is required to make sure the 60 | # resource knows that we will in fact call the given method. 61 | def @client.respond_to_missing?(name, include_private = false) # rubocop:disable Lint/NestedMethodDefinition 62 | @rspec_example.respond_to?(name, include_private) || super 63 | end 64 | 65 | # Respond true to is_a?(Chef::Provider) so that Chef::Recipe::DSL.build_resource 66 | # will hook resources up to the example let variables as well (via 67 | # enclosing_provider). 68 | # Please don't hurt me 69 | def @client.is_a?(klass) # rubocop:disable Lint/NestedMethodDefinition 70 | klass == Chef::Provider || super(klass) 71 | end 72 | 73 | @client.load_block(&recipe) 74 | end 75 | @client 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/cheffish/rspec/repository_support.rb: -------------------------------------------------------------------------------- 1 | module Cheffish 2 | module RSpec 3 | module RepositorySupport 4 | def when_the_repository(desc, *tags, &block) 5 | context("when the chef repo #{desc}", *tags) do 6 | include_context "with a chef repo" 7 | extend WhenTheRepositoryClassMethods 8 | module_eval(&block) 9 | end 10 | end 11 | 12 | ::RSpec.shared_context "with a chef repo" do 13 | before :each do 14 | raise "Can only create one directory per test" if @repository_dir 15 | 16 | @repository_dir = Dir.mktmpdir("chef_repo") 17 | Chef::Config.chef_repo_path = @repository_dir 18 | %w{client cookbook data_bag environment node role user}.each do |object_name| 19 | Chef::Config.delete("#{object_name}_path".to_sym) 20 | end 21 | end 22 | 23 | after :each do 24 | if @repository_dir 25 | begin 26 | %w{client cookbook data_bag environment node role user}.each do |object_name| 27 | Chef::Config.delete("#{object_name}_path".to_sym) 28 | end 29 | Chef::Config.delete(:chef_repo_path) 30 | FileUtils.remove_entry_secure(@repository_dir) 31 | ensure 32 | @repository_dir = nil 33 | end 34 | end 35 | Dir.chdir(@old_cwd) if @old_cwd 36 | end 37 | 38 | def directory(relative_path, &block) 39 | old_parent_path = @parent_path 40 | @parent_path = path_to(relative_path) 41 | FileUtils.mkdir_p(@parent_path) 42 | instance_eval(&block) if block 43 | @parent_path = old_parent_path 44 | end 45 | 46 | def file(relative_path, contents) 47 | filename = path_to(relative_path) 48 | dir = File.dirname(filename) 49 | FileUtils.mkdir_p(dir) unless dir == "." 50 | File.open(filename, "w") do |file| 51 | raw = case contents 52 | when Hash, Array 53 | JSON.pretty_generate(contents) 54 | else 55 | contents 56 | end 57 | file.write(raw) 58 | end 59 | end 60 | 61 | def symlink(relative_path, relative_dest) 62 | filename = path_to(relative_path) 63 | dir = File.dirname(filename) 64 | FileUtils.mkdir_p(dir) unless dir == "." 65 | dest_filename = path_to(relative_dest) 66 | File.symlink(dest_filename, filename) 67 | end 68 | 69 | def path_to(relative_path) 70 | File.expand_path(relative_path, (@parent_path || @repository_dir)) 71 | end 72 | 73 | def cwd(relative_path) 74 | @old_cwd = Dir.pwd 75 | Dir.chdir(path_to(relative_path)) 76 | end 77 | 78 | module WhenTheRepositoryClassMethods 79 | def directory(*args, &block) 80 | before :each do 81 | directory(*args, &block) 82 | end 83 | end 84 | 85 | def file(*args, &block) 86 | before :each do 87 | file(*args, &block) 88 | end 89 | end 90 | 91 | def symlink(*args, &block) 92 | before :each do 93 | symlink(*args, &block) 94 | end 95 | end 96 | 97 | def path_to(*args, &block) 98 | before :each do 99 | file(*args, &block) 100 | end 101 | end 102 | end 103 | end 104 | end 105 | 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/cheffish/server_api.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: John Keiser () 3 | # Copyright:: Copyright (c) 2012 Opscode, Inc. 4 | # License:: Apache License, Version 2.0 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | require "chef/version" 20 | require "chef/http" 21 | require "chef/http/authenticator" 22 | require "chef/http/cookie_manager" 23 | require "chef/http/decompressor" 24 | require "chef/http/json_input" 25 | require "chef/http/json_output" 26 | if Gem::Version.new(Chef::VERSION) >= Gem::Version.new("11.12") 27 | require "chef/http/remote_request_id" 28 | end 29 | 30 | module Cheffish 31 | # Exactly like Chef::ServerAPI, but requires you to pass in what keys you want (no defaults) 32 | class ServerAPI < Chef::HTTP 33 | 34 | def initialize(url, options = {}) 35 | super(url, options) 36 | root_url = URI.parse(url) 37 | root_url.path = "" 38 | @root_url = root_url.to_s 39 | end 40 | 41 | attr_reader :root_url 42 | 43 | use Chef::HTTP::JSONInput 44 | use Chef::HTTP::JSONOutput 45 | use Chef::HTTP::CookieManager 46 | use Chef::HTTP::Decompressor 47 | use Chef::HTTP::Authenticator 48 | if Gem::Version.new(Chef::VERSION) >= Gem::Version.new("11.12") 49 | use Chef::HTTP::RemoteRequestID 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/cheffish/version.rb: -------------------------------------------------------------------------------- 1 | module Cheffish 2 | VERSION = "17.1.8".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/cheffish/with_pattern.rb: -------------------------------------------------------------------------------- 1 | module Cheffish 2 | module WithPattern 3 | def with(symbol) 4 | class_eval < "c1.1", "test2" => "c1.2" } 11 | c2 = { "test1" => "c2.1", "test3" => "c2.3" } 12 | Cheffish::MergedConfig.new(c1, c2) 13 | end 14 | 15 | let(:config_mismatch) do 16 | c1 = { test: { test: "val" } } 17 | c2 = { test: [2, 3, 4] } 18 | Cheffish::MergedConfig.new(c1, c2) 19 | end 20 | 21 | let(:config_hashes) do 22 | c1 = { test: { test: "val" } } 23 | c2 = { test: { test2: "val2" } } 24 | Cheffish::MergedConfig.new(c1, c2) 25 | end 26 | 27 | let(:nested_config) do 28 | c1 = { test: { test: "val" } } 29 | c2 = { test: { test2: "val2" } } 30 | mc = Cheffish::MergedConfig.new(c2) 31 | Cheffish::MergedConfig.new(c1, mc) 32 | end 33 | 34 | let(:empty_config) do 35 | Cheffish::MergedConfig.new 36 | end 37 | 38 | it "returns value in config" do 39 | expect(config.test).to eq("val") 40 | end 41 | 42 | it "raises a NoMethodError if calling an unknown method with arguments" do 43 | expect { config.merge({ some: "hash" }) }.to raise_error(NoMethodError) 44 | end 45 | 46 | it "has an informative string representation" do 47 | expect((config).to_s).to eq("{\"test\"=>\"val\"}") 48 | end 49 | 50 | it "has indifferent str/sym access" do 51 | expect(config["test"]).to eq("val") 52 | end 53 | 54 | it "respects precedence between the different configs" do 55 | expect(collision["test1"]).to eq("c1.1") 56 | expect(collision[:test1]).to eq("c1.1") 57 | end 58 | 59 | it "merges the configs" do 60 | expect(collision[:test2]).to eq("c1.2") 61 | expect(collision[:test3]).to eq("c2.3") 62 | end 63 | 64 | it "handle merged value type mismatch" do 65 | expect(config_mismatch[:test]).to eq("test" => "val") 66 | end 67 | 68 | it "merges values when they're hashes" do 69 | expect(config_hashes[:test].keys).to eq(%w{test test2}) 70 | end 71 | 72 | it "supports nested merged configs" do 73 | expect(nested_config[:test].keys).to eq(%w{test test2}) 74 | end 75 | 76 | it "supports empty?" do 77 | expect(empty_config.empty?).to eq true 78 | expect(nested_config.empty?).to eq false 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/functional/server_api_spec.rb: -------------------------------------------------------------------------------- 1 | require "cheffish" 2 | 3 | describe "api version" do 4 | 5 | let(:server_api) do 6 | Cheffish.chef_server_api({ chef_server_url: "my.chef.server" }) 7 | end 8 | 9 | it "is pinned to 0" do 10 | expect(Cheffish::ServerAPI).to receive(:new).with("my.chef.server", { api_version: "0" }) 11 | server_api 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/integration/chef_client_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | require "support/key_support" 4 | require "chef/resource/chef_client" 5 | 6 | repo_path = Dir.mktmpdir("chef_repo") 7 | 8 | describe Chef::Resource::ChefClient do 9 | extend Cheffish::RSpec::ChefRunSupport 10 | 11 | when_the_chef_12_server "is in multi-org mode" do 12 | organization "foo" 13 | 14 | before :each do 15 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 16 | end 17 | 18 | context "and is empty" do 19 | context "and we have a private key with a path" do 20 | with_converge do 21 | private_key "#{repo_path}/blah.pem" 22 | end 23 | 24 | context 'and we run a recipe that creates client "blah"' do 25 | it "the client gets created" do 26 | expect_recipe do 27 | chef_client "blah" do 28 | source_key_path "#{repo_path}/blah.pem" 29 | end 30 | end.to have_updated "chef_client[blah]", :create 31 | client = get("clients/blah") 32 | expect(client["name"]).to eq("blah") 33 | key, _format = Cheffish::KeyFormatter.decode(client["public_key"]) 34 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 35 | end 36 | end 37 | 38 | context 'and we run a recipe that creates client "blah" with output_key_path' do 39 | with_converge do 40 | chef_client "blah" do 41 | source_key_path "#{repo_path}/blah.pem" 42 | output_key_path "#{repo_path}/blah.pub" 43 | end 44 | end 45 | 46 | it "the output public key gets created" do 47 | expect(IO.read("#{repo_path}/blah.pub")).to start_with("ssh-rsa ") 48 | expect("#{repo_path}/blah.pub").to be_public_key_for("#{repo_path}/blah.pem") 49 | end 50 | end 51 | end 52 | 53 | context "and a private_key 'blah' resource" do 54 | before :each do 55 | Chef::Config.private_key_paths = [ repo_path ] 56 | end 57 | 58 | with_converge do 59 | private_key "blah" 60 | end 61 | 62 | context "and a chef_client 'foobar' resource with source_key_path 'blah'" do 63 | it "the client is accessible via the given private key" do 64 | expect_recipe do 65 | chef_client "foobar" do 66 | source_key_path "blah" 67 | end 68 | end.to have_updated "chef_client[foobar]", :create 69 | client = get("clients/foobar") 70 | key, _format = Cheffish::KeyFormatter.decode(client["public_key"]) 71 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 72 | 73 | private_key = Cheffish::KeyFormatter.decode(Cheffish.get_private_key("blah")) 74 | expect(key).to be_public_key_for(private_key) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | 81 | when_the_chef_server "is in OSC mode" do 82 | context "and is empty" do 83 | context "and we have a private key with a path" do 84 | with_converge do 85 | private_key "#{repo_path}/blah.pem" 86 | end 87 | 88 | context 'and we run a recipe that creates client "blah"' do 89 | it "the client gets created" do 90 | expect_recipe do 91 | chef_client "blah" do 92 | source_key_path "#{repo_path}/blah.pem" 93 | end 94 | end.to have_updated "chef_client[blah]", :create 95 | client = get("clients/blah") 96 | expect(client["name"]).to eq("blah") 97 | key, _format = Cheffish::KeyFormatter.decode(client["public_key"]) 98 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 99 | end 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/integration/chef_container_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Chef::Resource::ChefContainer do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | when_the_chef_12_server "is in multi-org mode" do 8 | organization "foo" 9 | 10 | before :each do 11 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 12 | end 13 | 14 | it 'Converging chef_container "x" creates the container' do 15 | expect_recipe do 16 | chef_container "x" 17 | end.to have_updated("chef_container[x]", :create) 18 | expect { get("containers/x") }.not_to raise_error 19 | end 20 | 21 | context "and already has a container named x" do 22 | container "x", {} 23 | 24 | it 'Converging chef_container "x" changes nothing' do 25 | expect_recipe do 26 | chef_container "x" 27 | end.not_to have_updated("chef_container[x]", :create) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/integration/chef_data_bag_item_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Chef::Resource::ChefDataBagItem do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | when_the_chef_12_server "foo" do 8 | organization "foo" 9 | 10 | before :each do 11 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 12 | end 13 | 14 | context 'when data bag "bag" exists' do 15 | with_converge { chef_data_bag "bag" } 16 | 17 | it 'runs a recipe that creates a chef_data_bag_item "bag/item"' do 18 | expect_recipe do 19 | chef_data_bag_item "bag/item" 20 | end.to have_updated "chef_data_bag_item[bag/item]", :create 21 | # expect(get('data_bags/bag')['name']).to eq('bag') 22 | # expect(get('data_bags/bag/item')['id']).to eq('item') 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/integration/chef_group_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Chef::Resource::ChefGroup do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | when_the_chef_12_server "is in multi-org mode" do 8 | organization "foo" 9 | 10 | before :each do 11 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 12 | end 13 | 14 | context "and is empty" do 15 | group "g", {} 16 | user "u", {} 17 | client "c", {} 18 | 19 | it 'Converging chef_group "x" creates the group with no members' do 20 | expect_recipe do 21 | chef_group "x" 22 | end.to have_updated("chef_group[x]", :create) 23 | expect(get("groups/x")).to eq({ 24 | "name" => "x", 25 | "groupname" => "x", 26 | "orgname" => "foo", 27 | "actors" => [], 28 | "groups" => [], 29 | "users" => [], 30 | "clients" => [], 31 | }) 32 | end 33 | 34 | it 'chef_group "x" action :delete does nothing' do 35 | expect_recipe do 36 | chef_group "x" do 37 | action :delete 38 | end 39 | end.to not_have_updated("chef_group[x]", :delete).and not_have_updated("chef_group[x]", :create) 40 | expect { get("groups/x") }.to raise_error(Net::HTTPClientException) 41 | end 42 | 43 | it 'Converging chef_group "x" creates the group with the given members' do 44 | expect_recipe do 45 | chef_group "x" do 46 | groups "g" 47 | users "u" 48 | clients "c" 49 | end 50 | end.to have_updated("chef_group[x]", :create) 51 | expect(get("groups/x")).to eq({ 52 | "name" => "x", 53 | "groupname" => "x", 54 | "orgname" => "foo", 55 | "actors" => %w{c u}, 56 | "groups" => %w{g}, 57 | "users" => %w{u}, 58 | "clients" => %w{c}, 59 | }) 60 | end 61 | end 62 | 63 | context "and has a group named x" do 64 | group "g", {} 65 | group "g2", {} 66 | group "g3", {} 67 | group "g4", {} 68 | user "u", {} 69 | user "u2", {} 70 | user "u3", {} 71 | user "u4", {} 72 | client "c", {} 73 | client "c2", {} 74 | client "c3", {} 75 | client "c4", {} 76 | 77 | group "x", { 78 | "users" => %w{u u2}, 79 | "clients" => %w{c c2}, 80 | "groups" => %w{g g2}, 81 | } 82 | 83 | it 'Converging chef_group "x" changes nothing' do 84 | expect_recipe do 85 | chef_group "x" 86 | end.not_to have_updated("chef_group[x]", :create) 87 | expect(get("groups/x")).to eq({ 88 | "name" => "x", 89 | "groupname" => "x", 90 | "orgname" => "foo", 91 | "actors" => %w{c c2 u u2}, 92 | "groups" => %w{g g2}, 93 | "users" => %w{u u2}, 94 | "clients" => %w{c c2}, 95 | }) 96 | end 97 | 98 | it 'chef_group "x" action :delete deletes the group' do 99 | expect_recipe do 100 | chef_group "x" do 101 | action :delete 102 | end 103 | end.to have_updated("chef_group[x]", :delete) 104 | expect { get("groups/x") }.to raise_error(Net::HTTPClientException) 105 | end 106 | 107 | it 'Converging chef_group "x" with existing users changes nothing' do 108 | expect_recipe do 109 | chef_group "x" do 110 | users "u" 111 | clients "c" 112 | groups "g" 113 | end 114 | end.not_to have_updated("chef_group[x]", :create) 115 | expect(get("groups/x")).to eq({ 116 | "name" => "x", 117 | "groupname" => "x", 118 | "orgname" => "foo", 119 | "actors" => %w{c c2 u u2}, 120 | "groups" => %w{g g2}, 121 | "users" => %w{u u2}, 122 | "clients" => %w{c c2}, 123 | }) 124 | end 125 | 126 | it 'Converging chef_group "x" adds new users' do 127 | expect_recipe do 128 | chef_group "x" do 129 | users "u3" 130 | clients "c3" 131 | groups "g3" 132 | end 133 | end.to have_updated("chef_group[x]", :create) 134 | expect(get("groups/x")).to eq({ 135 | "name" => "x", 136 | "groupname" => "x", 137 | "orgname" => "foo", 138 | "actors" => %w{c c2 c3 u u2 u3}, 139 | "groups" => %w{g g2 g3}, 140 | "users" => %w{u u2 u3}, 141 | "clients" => %w{c c2 c3}, 142 | }) 143 | end 144 | 145 | it 'Converging chef_group "x" with multiple users adds new users' do 146 | expect_recipe do 147 | chef_group "x" do 148 | users "u3", "u4" 149 | clients "c3", "c4" 150 | groups "g3", "g4" 151 | end 152 | end.to have_updated("chef_group[x]", :create) 153 | expect(get("groups/x")).to eq({ 154 | "name" => "x", 155 | "groupname" => "x", 156 | "orgname" => "foo", 157 | "actors" => %w{c c2 c3 c4 u u2 u3 u4}, 158 | "groups" => %w{g g2 g3 g4}, 159 | "users" => %w{u u2 u3 u4}, 160 | "clients" => %w{c c2 c3 c4}, 161 | }) 162 | end 163 | 164 | it 'Converging chef_group "x" with multiple users in an array adds new users' do 165 | expect_recipe do 166 | chef_group "x" do 167 | users %w{u3 u4} 168 | clients %w{c3 c4} 169 | groups %w{g3 g4} 170 | end 171 | end.to have_updated("chef_group[x]", :create) 172 | expect(get("groups/x")).to eq({ 173 | "name" => "x", 174 | "groupname" => "x", 175 | "orgname" => "foo", 176 | "actors" => %w{c c2 c3 c4 u u2 u3 u4}, 177 | "groups" => %w{g g2 g3 g4}, 178 | "users" => %w{u u2 u3 u4}, 179 | "clients" => %w{c c2 c3 c4}, 180 | }) 181 | end 182 | 183 | it 'Converging chef_group "x" with multiple users declarations adds new users' do 184 | expect_recipe do 185 | chef_group "x" do 186 | users "u3" 187 | users "u4" 188 | clients "c3" 189 | clients "c4" 190 | groups "g3" 191 | groups "g4" 192 | end 193 | end.to have_updated("chef_group[x]", :create) 194 | expect(get("groups/x")).to eq({ 195 | "name" => "x", 196 | "groupname" => "x", 197 | "orgname" => "foo", 198 | "actors" => %w{c c2 c3 c4 u u2 u3 u4}, 199 | "groups" => %w{g g2 g3 g4}, 200 | "users" => %w{u u2 u3 u4}, 201 | "clients" => %w{c c2 c3 c4}, 202 | }) 203 | end 204 | 205 | it 'Converging chef_group "x" removes desired users' do 206 | expect_recipe do 207 | chef_group "x" do 208 | remove_users "u2" 209 | remove_clients "c2" 210 | remove_groups "g2" 211 | end 212 | end.to have_updated("chef_group[x]", :create) 213 | expect(get("groups/x")).to eq({ 214 | "name" => "x", 215 | "groupname" => "x", 216 | "orgname" => "foo", 217 | "actors" => %w{c u}, 218 | "groups" => %w{g}, 219 | "users" => %w{u}, 220 | "clients" => %w{c}, 221 | }) 222 | end 223 | 224 | it 'Converging chef_group "x" with multiple users removes desired users' do 225 | expect_recipe do 226 | chef_group "x" do 227 | remove_users "u", "u2" 228 | remove_clients "c", "c2" 229 | remove_groups "g", "g2" 230 | end 231 | end.to have_updated("chef_group[x]", :create) 232 | expect(get("groups/x")).to eq({ 233 | "name" => "x", 234 | "groupname" => "x", 235 | "orgname" => "foo", 236 | "actors" => [], 237 | "groups" => [], 238 | "users" => [], 239 | "clients" => [], 240 | }) 241 | end 242 | 243 | it 'Converging chef_group "x" with multiple users in an array removes desired users' do 244 | expect_recipe do 245 | chef_group "x" do 246 | remove_users %w{u u2} 247 | remove_clients %w{c c2} 248 | remove_groups %w{g g2} 249 | end 250 | end.to have_updated("chef_group[x]", :create) 251 | expect(get("groups/x")).to eq({ 252 | "name" => "x", 253 | "groupname" => "x", 254 | "orgname" => "foo", 255 | "actors" => [], 256 | "groups" => [], 257 | "users" => [], 258 | "clients" => [], 259 | }) 260 | end 261 | 262 | it 'Converging chef_group "x" with multiple remove_ declarations removes desired users' do 263 | expect_recipe do 264 | chef_group "x" do 265 | remove_users "u" 266 | remove_users "u2" 267 | remove_clients "c" 268 | remove_clients "c2" 269 | remove_groups "g" 270 | remove_groups "g2" 271 | end 272 | end.to have_updated("chef_group[x]", :create) 273 | expect(get("groups/x")).to eq({ 274 | "name" => "x", 275 | "groupname" => "x", 276 | "orgname" => "foo", 277 | "actors" => [], 278 | "groups" => [], 279 | "users" => [], 280 | "clients" => [], 281 | }) 282 | end 283 | 284 | it 'Converging chef_group "x" adds and removes desired users' do 285 | expect_recipe do 286 | chef_group "x" do 287 | users "u3" 288 | clients "c3" 289 | groups "g3" 290 | remove_users "u" 291 | remove_clients "c" 292 | remove_groups "g" 293 | end 294 | end.to have_updated("chef_group[x]", :create) 295 | expect(get("groups/x")).to eq({ 296 | "name" => "x", 297 | "groupname" => "x", 298 | "orgname" => "foo", 299 | "actors" => %w{c2 c3 u2 u3}, 300 | "groups" => %w{g2 g3}, 301 | "users" => %w{u2 u3}, 302 | "clients" => %w{c2 c3}, 303 | }) 304 | end 305 | end 306 | end 307 | end 308 | -------------------------------------------------------------------------------- /spec/integration/chef_organization_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Chef::Resource::ChefOrganization do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | when_the_chef_12_server "is in multi-org mode" do 8 | context "and chef_server_url is pointed at the top level" do 9 | user "u", {} 10 | user "u2", {} 11 | 12 | it 'chef_organization "x" creates the organization' do 13 | expect_recipe do 14 | chef_organization "x" 15 | end.to have_updated("chef_organization[x]", :create) 16 | expect(get("/organizations/x")["full_name"]).to eq("x") 17 | end 18 | end 19 | 20 | context "and chef_server_url is pointed at /organizations/foo" do 21 | organization "foo" 22 | 23 | before :each do 24 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 25 | end 26 | 27 | context "and is empty" do 28 | user "u", {} 29 | user "u2", {} 30 | 31 | it 'chef_organization "x" creates the organization' do 32 | expect_recipe do 33 | chef_organization "x" 34 | end.to have_updated("chef_organization[x]", :create) 35 | expect(get("/organizations/x")["full_name"]).to eq("x") 36 | end 37 | 38 | it 'chef_organization "x" with full_name creates the organization' do 39 | expect_recipe do 40 | chef_organization "x" do 41 | full_name "Hi" 42 | end 43 | end.to have_updated("chef_organization[x]", :create) 44 | expect(get("/organizations/x")["full_name"]).to eq("Hi") 45 | end 46 | 47 | it 'chef_organization "x" and inviting users creates the invites' do 48 | expect_recipe do 49 | chef_organization "x" do 50 | invites "u", "u2" 51 | end 52 | end.to have_updated("chef_organization[x]", :create) 53 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{u u2}) 54 | end 55 | 56 | it 'chef_organization "x" adds members' do 57 | expect_recipe do 58 | chef_organization "x" do 59 | members "u", "u2" 60 | end 61 | end.to have_updated("chef_organization[x]", :create) 62 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{u u2}) 63 | end 64 | end 65 | 66 | context "and already has an organization named x" do 67 | user "u", {} 68 | user "u2", {} 69 | user "u3", {} 70 | user "member", {} 71 | user "member2", {} 72 | user "invited", {} 73 | user "invited2", {} 74 | organization "x", { "full_name" => "Lo" } do 75 | org_member "member", "member2" 76 | org_invite "invited", "invited2" 77 | end 78 | 79 | it 'chef_organization "x" changes nothing' do 80 | expect_recipe do 81 | chef_organization "x" 82 | end.not_to have_updated("chef_organization[x]", :create) 83 | expect(get("/organizations/x")["full_name"]).to eq("Lo") 84 | end 85 | 86 | it 'chef_organization "x" with "complete true" reverts the full_name' do 87 | expect_recipe do 88 | chef_organization "x" do 89 | complete true 90 | end 91 | end.to have_updated("chef_organization[x]", :create) 92 | expect(get("/organizations/x")["full_name"]).to eq("x") 93 | end 94 | 95 | it 'chef_organization "x" with new full_name updates the organization' do 96 | expect_recipe do 97 | chef_organization "x" do 98 | full_name "Hi" 99 | end 100 | end.to have_updated("chef_organization[x]", :create) 101 | expect(get("/organizations/x")["full_name"]).to eq("Hi") 102 | end 103 | 104 | context "invites and membership tests" do 105 | it 'chef_organization "x" and inviting users creates the invites' do 106 | expect_recipe do 107 | chef_organization "x" do 108 | invites "u", "u2" 109 | end 110 | end.to have_updated("chef_organization[x]", :create) 111 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited invited2 u u2}) 112 | end 113 | 114 | it 'chef_organization "x" adds members' do 115 | expect_recipe do 116 | chef_organization "x" do 117 | members "u", "u2" 118 | end 119 | end.to have_updated("chef_organization[x]", :create) 120 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2 u u2}) 121 | end 122 | 123 | it 'chef_organization "x" does nothing when inviting already-invited users and members' do 124 | expect_recipe do 125 | chef_organization "x" do 126 | invites "invited", "member" 127 | end 128 | end.not_to have_updated("chef_organization[x]", :create) 129 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited invited2}) 130 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2}) 131 | end 132 | 133 | it 'chef_organization "x" does nothing when adding members who are already members' do 134 | expect_recipe do 135 | chef_organization "x" do 136 | members "member" 137 | end 138 | end.not_to have_updated("chef_organization[x]", :create) 139 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited invited2}) 140 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2}) 141 | end 142 | 143 | it 'chef_organization "x" upgrades invites to members when asked' do 144 | expect_recipe do 145 | chef_organization "x" do 146 | members "invited" 147 | end 148 | end.to have_updated("chef_organization[x]", :create) 149 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{invited member member2}) 150 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited2}) 151 | end 152 | 153 | it 'chef_organization "x" removes members and invites when asked' do 154 | expect_recipe do 155 | chef_organization "x" do 156 | remove_members "invited", "member" 157 | end 158 | end.to have_updated("chef_organization[x]", :create) 159 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited2}) 160 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member2}) 161 | end 162 | 163 | it 'chef_organization "x" does nothing when asked to remove non-members' do 164 | expect_recipe do 165 | chef_organization "x" do 166 | remove_members "u", "u2" 167 | end 168 | end.not_to have_updated("chef_organization[x]", :create) 169 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited invited2}) 170 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2}) 171 | end 172 | 173 | it 'chef_organization "x" with "complete true" reverts the full_name but does not remove invites or members' do 174 | expect_recipe do 175 | chef_organization "x" do 176 | complete true 177 | end 178 | end.to have_updated("chef_organization[x]", :create) 179 | expect(get("/organizations/x")["full_name"]).to eq("x") 180 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited invited2}) 181 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2}) 182 | end 183 | 184 | it 'chef_organization "x" with members [] and "complete true" removes invites and members' do 185 | expect_recipe do 186 | chef_organization "x" do 187 | members [] 188 | complete true 189 | end 190 | end.to have_updated("chef_organization[x]", :create) 191 | expect(get("/organizations/x")["full_name"]).to eq("x") 192 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq([]) 193 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq([]) 194 | end 195 | 196 | it 'chef_organization "x" with invites [] and "complete true" removes invites but not members' do 197 | expect_recipe do 198 | chef_organization "x" do 199 | invites [] 200 | complete true 201 | end 202 | end.to have_updated("chef_organization[x]", :create) 203 | expect(get("/organizations/x")["full_name"]).to eq("x") 204 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq([]) 205 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member member2}) 206 | end 207 | 208 | it 'chef_organization "x" with invites, members and "complete true" removes all non-specified invites and members' do 209 | expect_recipe do 210 | chef_organization "x" do 211 | invites "invited", "u" 212 | members "member", "u2" 213 | complete true 214 | end 215 | end.to have_updated("chef_organization[x]", :create) 216 | expect(get("/organizations/x")["full_name"]).to eq("x") 217 | expect(get("/organizations/x/association_requests").map { |u| u["username"] }).to eq(%w{invited u}) 218 | expect(get("/organizations/x/users").map { |u| u["user"]["username"] }).to eq(%w{member u2}) 219 | end 220 | end 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /spec/integration/chef_role_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Chef::Resource::ChefRole do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | when_the_chef_12_server "is in multi-org mode" do 8 | organization "foo" 9 | 10 | before :each do 11 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 12 | end 13 | 14 | context "and is empty" do 15 | context 'and we run a recipe that creates role "blah"' do 16 | it "the role gets created" do 17 | expect_recipe do 18 | chef_role "blah" 19 | end.to have_updated "chef_role[blah]", :create 20 | expect(get("roles/blah")["name"]).to eq("blah") 21 | end 22 | end 23 | 24 | # TODO why-run mode 25 | 26 | context "and another chef server is running on port 8899" do 27 | before :each do 28 | @server = ChefZero::Server.new(port: 8899) 29 | @server.start_background 30 | end 31 | 32 | after :each do 33 | @server.stop 34 | end 35 | 36 | context 'and a recipe is run that creates role "blah" on the second chef server using with_chef_server' do 37 | 38 | it "the role is created on the second chef server but not the first" do 39 | expect_recipe do 40 | with_chef_server "http://127.0.0.1:8899" 41 | chef_role "blah" 42 | end.to have_updated "chef_role[blah]", :create 43 | expect { get("roles/blah") }.to raise_error(Net::HTTPClientException) 44 | expect(get("http://127.0.0.1:8899/roles/blah")["name"]).to eq("blah") 45 | end 46 | end 47 | 48 | context 'and a recipe is run that creates role "blah" on the second chef server using chef_server' do 49 | 50 | it "the role is created on the second chef server but not the first" do 51 | expect_recipe do 52 | chef_role "blah" do 53 | chef_server({ chef_server_url: "http://127.0.0.1:8899" }) 54 | end 55 | end.to have_updated "chef_role[blah]", :create 56 | expect { get("roles/blah") }.to raise_error(Net::HTTPClientException) 57 | expect(get("http://127.0.0.1:8899/roles/blah")["name"]).to eq("blah") 58 | end 59 | end 60 | end 61 | end 62 | end 63 | 64 | when_the_chef_server "is in OSC mode" do 65 | context "and is empty" do 66 | context 'and we run a recipe that creates role "blah"' do 67 | it "the role gets created" do 68 | expect_recipe do 69 | chef_role "blah" 70 | end.to have_updated "chef_role[blah]", :create 71 | expect(get("roles/blah")["name"]).to eq("blah") 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/integration/chef_user_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | require "support/key_support" 4 | 5 | repo_path = Dir.mktmpdir("chef_repo") 6 | 7 | describe Chef::Resource::ChefUser do 8 | extend Cheffish::RSpec::ChefRunSupport 9 | 10 | with_converge do 11 | private_key "#{repo_path}/blah.pem" 12 | end 13 | 14 | when_the_chef_server "is empty" do 15 | context 'and we run a recipe that creates user "blah"' do 16 | it "the user gets created" do 17 | expect_recipe do 18 | chef_user "blah" do 19 | source_key_path "#{repo_path}/blah.pem" 20 | end 21 | end.to have_updated "chef_user[blah]", :create 22 | user = get("/users/blah") 23 | expect(user["name"]).to eq("blah") 24 | key, _format = Cheffish::KeyFormatter.decode(user["public_key"]) 25 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 26 | end 27 | end 28 | 29 | context 'and we run a recipe that creates user "blah" with output_key_path' do 30 | with_converge do 31 | chef_user "blah" do 32 | source_key_path "#{repo_path}/blah.pem" 33 | output_key_path "#{repo_path}/blah.pub" 34 | end 35 | end 36 | 37 | it "the output public key gets created" do 38 | expect(IO.read("#{repo_path}/blah.pub")).to start_with("ssh-rsa ") 39 | expect("#{repo_path}/blah.pub").to be_public_key_for("#{repo_path}/blah.pem") 40 | end 41 | end 42 | end 43 | 44 | when_the_chef_12_server "is in multi-org mode" do 45 | context "and chef_server_url is pointed at the top level" do 46 | context 'and we run a recipe that creates user "blah"' do 47 | it "the user gets created" do 48 | expect_recipe do 49 | chef_user "blah" do 50 | source_key_path "#{repo_path}/blah.pem" 51 | end 52 | end.to have_updated "chef_user[blah]", :create 53 | user = get("/users/blah") 54 | expect(user["name"]).to eq("blah") 55 | key, _format = Cheffish::KeyFormatter.decode(user["public_key"]) 56 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 57 | end 58 | end 59 | end 60 | 61 | context "and chef_server_url is pointed at /organizations/foo" do 62 | organization "foo" 63 | 64 | before :each do 65 | Chef::Config.chef_server_url = URI.join(Chef::Config.chef_server_url, "/organizations/foo").to_s 66 | end 67 | 68 | context 'and we run a recipe that creates user "blah"' do 69 | it "the user gets created" do 70 | expect_recipe do 71 | chef_user "blah" do 72 | source_key_path "#{repo_path}/blah.pem" 73 | end 74 | end.to have_updated "chef_user[blah]", :create 75 | user = get("/users/blah") 76 | expect(user["name"]).to eq("blah") 77 | key, _format = Cheffish::KeyFormatter.decode(user["public_key"]) 78 | expect(key).to be_public_key_for("#{repo_path}/blah.pem") 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/integration/recipe_dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | require "tmpdir" 4 | 5 | describe "Cheffish Recipe DSL" do 6 | extend Cheffish::RSpec::ChefRunSupport 7 | 8 | context "when we include with_chef_local_server" do 9 | before :each do 10 | @tmp_repo = Dir.mktmpdir("chef_repo") 11 | end 12 | 13 | after :each do 14 | FileUtils.remove_entry_secure @tmp_repo 15 | end 16 | 17 | it "chef_nodes get put into said server" do 18 | tmp_repo = @tmp_repo 19 | expect_recipe do 20 | with_chef_local_server chef_repo_path: tmp_repo 21 | chef_node "blah" 22 | end.to have_updated "chef_node[blah]", :create 23 | expect(File).to exist("#{@tmp_repo}/nodes/blah.json") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/integration/rspec/converge_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe "Cheffish::RSpec::ChefRunSupport" do 5 | extend Cheffish::RSpec::ChefRunSupport 6 | 7 | let(:temp_file) do 8 | f = Tempfile.new("test") 9 | f.close 10 | f 11 | end 12 | 13 | context "#recipe" do 14 | it "recipe { file ... } updates the file" do 15 | result = recipe do 16 | file temp_file.path do 17 | content "test" 18 | end 19 | end 20 | expect(result.updated?).to be_falsey 21 | expect(IO.read(temp_file.path)).to eq "" 22 | end 23 | 24 | it "recipe 'file ...' does not update the file" do 25 | result = recipe <<-EOM 26 | file temp_file.path do 27 | content 'test' 28 | end 29 | EOM 30 | expect(result.updated?).to be_falsey 31 | expect(IO.read(temp_file.path)).to eq "" 32 | end 33 | 34 | it "recipe 'file ...' with file and line number does not update the file" do 35 | result = recipe(<<-EOM, __FILE__, __LINE__ + 1) 36 | file temp_file.path do 37 | content 'test' 38 | end 39 | EOM 40 | expect(result.updated?).to be_falsey 41 | expect(IO.read(temp_file.path)).to eq "" 42 | end 43 | end 44 | 45 | context "#converge" do 46 | it "converge { file ... } updates the file" do 47 | result = converge do 48 | file temp_file.path do 49 | content "test" 50 | end 51 | end 52 | expect(result.updated?).to be_truthy 53 | expect(IO.read(temp_file.path)).to eq "test" 54 | end 55 | 56 | it "converge 'file ...' updates the file" do 57 | result = converge <<-EOM 58 | file temp_file.path do 59 | content 'test' 60 | end 61 | EOM 62 | expect(result.updated?).to be_truthy 63 | expect(IO.read(temp_file.path)).to eq "test" 64 | end 65 | 66 | it "converge 'file ...' with file and line number updates the file" do 67 | result = converge(<<-EOM, __FILE__, __LINE__ + 1) 68 | file temp_file.path do 69 | content 'test' 70 | end 71 | EOM 72 | expect(result.updated?).to be_truthy 73 | expect(IO.read(temp_file.path)).to eq "test" 74 | end 75 | end 76 | 77 | context "#expect_recipe" do 78 | it "expect_recipe { file ... }.to be_updated updates the file, and be_idempotent does not fail" do 79 | expect_recipe do 80 | file temp_file.path do 81 | content "test" 82 | end 83 | end.to be_updated.and be_idempotent 84 | 85 | expect(IO.read(temp_file.path)).to eq "test" 86 | end 87 | 88 | it "expect_recipe 'file ...'.to be_updated updates the file, and be_idempotent does not fail" do 89 | expect_recipe(<<-EOM).to be_updated.and be_idempotent 90 | file temp_file.path do 91 | content 'test' 92 | end 93 | EOM 94 | 95 | expect(IO.read(temp_file.path)).to eq "test" 96 | end 97 | 98 | it "expect_recipe('file ...', file, line).to be_updated updates the file, and be_idempotent does not fail" do 99 | expect_recipe(<<-EOM, __FILE__, __LINE__ + 1).to be_updated.and be_idempotent 100 | file temp_file.path do 101 | content 'test' 102 | end 103 | EOM 104 | 105 | expect(IO.read(temp_file.path)).to eq "test" 106 | end 107 | 108 | it "expect_recipe { file ... }.to be_up_to_date fails" do 109 | expect do 110 | expect_recipe do 111 | file temp_file.path do 112 | content "test" 113 | end 114 | end.to be_up_to_date 115 | end.to raise_error RSpec::Expectations::ExpectationNotMetError 116 | end 117 | 118 | it "expect_recipe { }.to be_updated fails" do 119 | expect do 120 | expect_recipe {}.to be_updated 121 | end.to raise_error RSpec::Expectations::ExpectationNotMetError 122 | end 123 | 124 | it "expect_recipe { }.to be_up_to_date succeeds" do 125 | expect_recipe {}.to be_up_to_date 126 | end 127 | 128 | it "expect_recipe { }.to be_idempotent succeeds" do 129 | expect_recipe {}.to be_idempotent 130 | end 131 | end 132 | 133 | context "#expect_converge" do 134 | it "expect_converge { file ... }.not_to raise_error updates the file" do 135 | expect_converge do 136 | file temp_file.path do 137 | content "test" 138 | end 139 | end.not_to raise_error 140 | expect(IO.read(temp_file.path)).to eq "test" 141 | end 142 | 143 | it "expect_converge('file ...').not_to raise_error updates the file" do 144 | expect_converge(<<-EOM).not_to raise_error 145 | file temp_file.path do 146 | content 'test' 147 | end 148 | EOM 149 | expect(IO.read(temp_file.path)).to eq "test" 150 | end 151 | 152 | it "expect_converge('file ...', file, line).not_to raise_error updates the file" do 153 | expect_converge(<<-EOM, __FILE__, __LINE__ + 1).not_to raise_error 154 | file temp_file.path do 155 | content 'test' 156 | end 157 | EOM 158 | expect(IO.read(temp_file.path)).to eq "test" 159 | end 160 | 161 | it "expect_converge { raise 'oh no' }.to raise_error passes" do 162 | expect_converge do 163 | raise "oh no" 164 | end.to raise_error("oh no") 165 | end 166 | end 167 | 168 | context "when there is a let variable" do 169 | let(:let_variable) { "test" } 170 | 171 | it "converge { let_variable } accesses it" do 172 | # Capture the variable outside 173 | x = nil 174 | converge { x = let_variable } 175 | expect(x).to eq "test" 176 | end 177 | 178 | it "converge with a file resource referencing let_variable accesses let_variable" do 179 | converge do 180 | file temp_file.path do 181 | content let_variable 182 | end 183 | end 184 | expect(IO.read(temp_file.path)).to eq "test" 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /spec/support/key_support.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_public_key_for do |private_key, pass_phrase| 2 | match do |public_key| 3 | if public_key.is_a?(String) 4 | public_key, _public_key_format = Cheffish::KeyFormatter.decode(IO.binread(File.expand_path(public_key)), pass_phrase, public_key) 5 | end 6 | if private_key.is_a?(String) 7 | private_key, _private_key_format = Cheffish::KeyFormatter.decode(IO.binread(File.expand_path(private_key)), pass_phrase, private_key) 8 | end 9 | 10 | encrypted = public_key.public_encrypt("hi there") 11 | expect(private_key.private_decrypt(encrypted)).to eq("hi there") 12 | end 13 | end 14 | 15 | RSpec::Matchers.define :match_private_key do |expected, pass_phrase| 16 | match do |actual| 17 | if expected.is_a?(String) 18 | expected, _format = Cheffish::KeyFormatter.decode(IO.binread(File.expand_path(expected)), pass_phrase, expected) 19 | end 20 | if actual.is_a?(String) 21 | actual, _format = Cheffish::KeyFormatter.decode(IO.binread(File.expand_path(actual)), pass_phrase, actual) 22 | end 23 | 24 | encrypted = actual.public_encrypt("hi there") 25 | expect(expected.private_decrypt(encrypted)).to eq("hi there") 26 | encrypted = expected.public_encrypt("hi there") 27 | expect(actual.private_decrypt(encrypted)).to eq("hi there") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/spec_support.rb: -------------------------------------------------------------------------------- 1 | require "cheffish/rspec" 2 | 3 | require "cheffish" 4 | 5 | RSpec.configure do |config| 6 | config.filter_run focus: true 7 | config.run_all_when_everything_filtered = true 8 | 9 | config.before :each do 10 | Chef::Config.reset 11 | end 12 | end 13 | 14 | require "chef/providers" 15 | -------------------------------------------------------------------------------- /spec/unit/get_private_key_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | 4 | describe Cheffish do 5 | let(:directory_that_exists) do 6 | Dir.mktmpdir("cheffish-rspec") 7 | end 8 | 9 | let(:directory_that_does_not_exist) do 10 | dir = Dir.mktmpdir("cheffish-rspec") 11 | FileUtils.remove_entry dir 12 | dir 13 | end 14 | 15 | let(:private_key_contents) { "contents of private key" } 16 | 17 | let(:private_key_pem_contents) { "contents of private key pem" } 18 | 19 | let(:private_key_garbage_contents) { "da vinci virus" } 20 | 21 | def setup_key 22 | key_file = File.expand_path("ned_stark", directory_that_exists) 23 | File.open(key_file, "w+") do |f| 24 | f.write private_key_contents 25 | end 26 | key_file 27 | end 28 | 29 | def setup_arbitrarily_named_key 30 | key_file = File.expand_path("ned_stark.xxx", directory_that_exists) 31 | File.open(key_file, "w+") do |f| 32 | f.write private_key_contents 33 | end 34 | key_file 35 | end 36 | 37 | def setup_pem_key 38 | key_file = File.expand_path("ned_stark.pem", directory_that_exists) 39 | File.open(key_file, "w+") do |f| 40 | f.write private_key_pem_contents 41 | end 42 | key_file 43 | end 44 | 45 | def setup_garbage_key 46 | key_file = File.expand_path("ned_stark.pem.bak", directory_that_exists) 47 | File.open(key_file, "w+") do |f| 48 | f.write private_key_garbage_contents 49 | end 50 | key_file 51 | end 52 | 53 | shared_examples_for "returning the contents of the key file if it finds one" do 54 | it "returns nil if it cannot find the private key file" do 55 | expect(Cheffish.get_private_key("ned_stark", config)).to be_nil 56 | end 57 | 58 | it "returns the contents of the key if it doesn't have an extension" do 59 | setup_key 60 | expect(Cheffish.get_private_key("ned_stark", config)).to eq(private_key_contents) 61 | end 62 | 63 | it "returns the contents of the key if it has an extension" do 64 | setup_pem_key 65 | expect(Cheffish.get_private_key("ned_stark", config)).to eq(private_key_pem_contents) 66 | end 67 | 68 | it "returns the contents of arbitrarily named keys" do 69 | setup_arbitrarily_named_key 70 | expect(Cheffish.get_private_key("ned_stark.xxx", config)).to eq(private_key_contents) 71 | end 72 | 73 | # we arbitrarily prefer "ned_stark" over "ned_stark.pem" for deterministic behavior 74 | it "returns the contents of the key that does not have an extension if both exist" do 75 | setup_key 76 | setup_pem_key 77 | expect(Cheffish.get_private_key("ned_stark", config)).to eq(private_key_contents) 78 | end 79 | end 80 | 81 | describe "#get_private_key" do 82 | context "when private_key_paths has a directory which is empty" do 83 | let(:config) do 84 | { private_key_paths: [ directory_that_exists ] } 85 | end 86 | 87 | it_behaves_like "returning the contents of the key file if it finds one" 88 | 89 | context "when it also has a garbage file" do 90 | before { setup_garbage_key } 91 | 92 | it "does not return the da vinci virus if we find only the garbage file" do 93 | setup_garbage_key 94 | expect(Cheffish.get_private_key("ned_stark", config)).to be_nil 95 | end 96 | 97 | it_behaves_like "returning the contents of the key file if it finds one" 98 | end 99 | 100 | end 101 | 102 | context "when private_key_paths leads with a directory that does not exist and then an empty directory" do 103 | let(:config) do 104 | { private_key_paths: [ directory_that_does_not_exist, directory_that_exists ] } 105 | end 106 | 107 | it_behaves_like "returning the contents of the key file if it finds one" 108 | end 109 | 110 | context "when private_keys is empty" do 111 | let(:config) do 112 | { private_keys: {} } 113 | end 114 | 115 | it "returns nil" do 116 | expect(Cheffish.get_private_key("ned_stark", config)).to be_nil 117 | end 118 | end 119 | 120 | context "when private_keys contains the path to a key" do 121 | let(:name) { "ned_stark" } 122 | let(:config) do 123 | { private_keys: { name => setup_key } } 124 | end 125 | 126 | it "returns the contents of the key file" do 127 | setup_key 128 | expect(Cheffish.get_private_key(name, config)).to eq(private_key_contents) 129 | end 130 | end 131 | 132 | context "when private_keys contains the path to a key" do 133 | let(:name) { "ned_stark" } 134 | let(:key) { double("key", to_pem: private_key_contents) } 135 | let(:config) do 136 | { private_keys: { name => key } } 137 | end 138 | 139 | it "returns the contents of the key file" do 140 | expect(Cheffish.get_private_key(name, config)).to eq(private_key_contents) 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/unit/recipe_run_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/spec_support" 2 | require "cheffish/rspec/chef_run_support" 3 | # require 'cheffish/rspec/recipe_run_wrapper' 4 | 5 | module MyModule 6 | def respond_to_missing?(name, *args) 7 | if name == :allowable_method 8 | true 9 | else 10 | false 11 | end 12 | end 13 | end 14 | 15 | describe Cheffish::RSpec::RecipeRunWrapper do 16 | extend Cheffish::RSpec::ChefRunSupport 17 | 18 | let(:run_wrapper) do 19 | Cheffish::RSpec::RecipeRunWrapper.new(chef_config) do 20 | log "test recipe in specs" 21 | end 22 | end 23 | 24 | context "defines #respond_to_missing? on the client" do 25 | it "calls the new super.respond_to_missing" do 26 | run_wrapper.client.extend MyModule 27 | expect(run_wrapper.client.respond_to?(:allowable_method)).to be_truthy 28 | expect(run_wrapper.client.respond_to?(:not_an_allowable_method)).to be_falsey 29 | end 30 | end 31 | 32 | context "does not define #respond_to_missing? on the client" do 33 | it "calls the original super.respond_to_missing" do 34 | expect(run_wrapper.client.respond_to?(:nonexistent_method)).to be_falsey 35 | end 36 | end 37 | end 38 | --------------------------------------------------------------------------------