├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── chef ├── __init__.py ├── acl.py ├── api.py ├── auth.py ├── base.py ├── client.py ├── data_bag.py ├── environment.py ├── exceptions.py ├── fabric.py ├── node.py ├── permissions.py ├── role.py ├── rsa.py ├── search.py ├── tests │ ├── __init__.py │ ├── client.pem │ ├── client_pub.pem │ ├── configs │ │ ├── basic.rb │ │ ├── basic_with_interpolated_values.rb │ │ ├── current_dir.rb │ │ └── env_values.rb │ ├── test_api.py │ ├── test_client.py │ ├── test_data_bag.py │ ├── test_environment.py │ ├── test_fabric.py │ ├── test_node.py │ ├── test_role.py │ ├── test_rsa.py │ └── test_search.py └── utils │ ├── __init__.py │ ├── file.py │ └── json.py ├── contrib └── python-chef.spec ├── docs ├── Makefile ├── api.rst ├── auth.rst ├── conf.py ├── fabric.rst ├── index.rst ├── make.bat └── requirements.txt ├── setup.py └── versiontools_support.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | .coverage 4 | htmlcov/* 5 | docs/_build/ 6 | build 7 | dist 8 | .venv 9 | 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - 2.7 5 | - 3.4 6 | - 3.5 7 | - 3.6 8 | install: pip install -e . 9 | script: python setup.py test 10 | -------------------------------------------------------------------------------- /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 | --- License for python-keystoneclient versions prior to 2.1 --- 177 | 178 | All rights reserved. 179 | 180 | Redistribution and use in source and binary forms, with or without 181 | modification, are permitted provided that the following conditions are met: 182 | 183 | 1. Redistributions of source code must retain the above copyright notice, 184 | this list of conditions and the following disclaimer. 185 | 186 | 2. Redistributions in binary form must reproduce the above copyright 187 | notice, this list of conditions and the following disclaimer in the 188 | documentation and/or other materials provided with the distribution. 189 | 190 | 3. Neither the name of this project nor the names of its contributors may 191 | be used to endorse or promote products derived from this software without 192 | specific prior written permission. 193 | 194 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 195 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 196 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 197 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 198 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 199 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 200 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 201 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 202 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 203 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 204 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versiontools_support.py 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyChef 2 | ====== 3 | 4 | .. image:: https://secure.travis-ci.org/coderanger/pychef.svg?branch=master 5 | :target: http://travis-ci.org/coderanger/pychef 6 | 7 | A Python API for interacting with a Chef server. 8 | 9 | Example 10 | ------- 11 | 12 | :: 13 | 14 | from chef import autoconfigure, Node 15 | 16 | api = autoconfigure() 17 | n = Node('web1') 18 | print n['fqdn'] 19 | n['myapp']['version'] = '1.0' 20 | n.save() 21 | 22 | Further Reading 23 | --------------- 24 | 25 | For more information check out http://pychef.readthedocs.org/en/latest/index.html 26 | -------------------------------------------------------------------------------- /chef/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2010 Noah Kantrowitz 2 | 3 | __version__ = (0, 3, 0) 4 | 5 | from chef.api import ChefAPI, autoconfigure 6 | from chef.client import Client 7 | from chef.data_bag import DataBag, DataBagItem 8 | from chef.exceptions import ChefError 9 | from chef.node import Node 10 | from chef.role import Role 11 | from chef.environment import Environment 12 | from chef.search import Search 13 | from chef.acl import Acl 14 | -------------------------------------------------------------------------------- /chef/acl.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | from chef.api import ChefAPI 3 | from chef.exceptions import ChefObjectTypeError 4 | from chef.permissions import Permissions 5 | 6 | 7 | class Acl(object): 8 | """ 9 | Acl class provides access to the Acl in the Chef 12 10 | 11 | Acl(object_type, name, api, skip_load=False) 12 | 13 | - object_type - type of the Chef object. Can be one of the following value: "clients", "containers", "cookbooks", 14 | "data", "environments", "groups", "nodes", "roles" 15 | - name - name of the Chef object (e.g. node name) 16 | - api - object of the ChefAPI class, configured to work with Chef server 17 | - skip_load - is skip_load is False, new object will be initialized with current Acl settings of the specified 18 | object 19 | 20 | Example:: 21 | 22 | from chef import ChefAPI 23 | from chef.acl import Acl 24 | 25 | api = ChefAPI('http://chef.com:4000', 'chef-developer.pem', 'chef-developer', '12.0.0') 26 | acl = Acl('nodes', 'i-022fcb0d', api) 27 | 28 | Each object of the Acl class contains the following properties: 29 | create, read, update, delete, grant 30 | each property represents corresponding access rights to the Chef object. 31 | each property contains the following fields (https://github.com/astryia/pychef/blob/acls/chef/permissions.py): 32 | - actors - list of the users, which have corresponding permissions 33 | - groups - list of the groups, which have corresponding permissions 34 | 35 | Example:: 36 | 37 | print acl.update.groups 38 | >>> ['admins', 'clients'] 39 | 40 | Each object of the class Acl contains the following methods: 41 | - reload() - reload current Acls from the Chef server 42 | - save() - save updated Acl object to the Chef server 43 | - is_supported() - return true if current Api version supports work with Acls 44 | 45 | Example:: 46 | 47 | from chef import ChefAPI 48 | from chef.acl import Acl 49 | 50 | api = ChefAPI('http://chef.com:4000', 'chef-developer.pem', 'chef-developer', '12.0.0') 51 | acl = Acl('nodes', 'i-022fcb0d', api) 52 | print acl.update.groups 53 | >>> ['admins'] 54 | acl.update.groups.append('clients') 55 | acl.save() 56 | acl.reload() 57 | print acl.update.groups 58 | >>> ['admins', 'clients'] 59 | 60 | Each class which represents Chef object contains method get_acl() method 61 | 62 | Example:: 63 | 64 | from chef import ChefAPI 65 | from chef.node import Node 66 | 67 | api = ChefAPI('http://chef.com:4000', 'chef-developer.pem', 'chef-developer', '12.0.0') 68 | node = Node('i-022fcb0d', api) 69 | acl = node.get_acl() 70 | print acl.read.groups 71 | >>> ['admins'] 72 | acl.save() 73 | 74 | Note about versions 75 | Chef server with version < 12 doesn't have Acl endpoint, so, I've introduced method is_supported() for Acl class. 76 | This method check if api version is greater than 12. 77 | So you should pass valid Chef server version to the ChefAPI constructor 78 | 79 | Example:: 80 | 81 | api = ChefAPI('http://chef.com:4000', 'chef-developer.pem', 'chef-developer', '12.0.0') 82 | acl = Acl('nodes', 'i-022fcb0d', api) 83 | print acl.is_supported() 84 | >>> True 85 | 86 | api = ChefAPI('http://chef.com:4000', 'chef-developer.pem', 'chef-developer', '11.2.0') 87 | acl = Acl('nodes', 'i-022fcb0d', api) 88 | print acl.is_supported() 89 | >>> False 90 | 91 | But if you pass string '12.0.0' when actual Chef server version is 11.2, you will receive an error when you try 92 | to build Acl object. 93 | """ 94 | 95 | ace_types = ["create", "read", "update", "delete", "grant"] 96 | object_types = ["clients", "containers", "cookbooks", "data", "environments", "groups", "nodes", "roles"] 97 | 98 | """ ALC API available only in Chef server from version 12.0""" 99 | version = pkg_resources.parse_version("12.0.0") 100 | 101 | def __init__(self, object_type, name, api, skip_load=False): 102 | self._check_object_type(object_type) 103 | 104 | self.object_type = object_type 105 | self.name = name 106 | self.url = "/%s/%s/_acl/" % (object_type, name) 107 | self.api = api or ChefAPI.get_global() 108 | 109 | self.attributes_map = {} 110 | for t in self.ace_types: 111 | self.attributes_map[t] = Permissions() 112 | 113 | if (not skip_load) and self.is_supported(): 114 | self.reload() 115 | 116 | @property 117 | def create(self): 118 | """ Gets Create permissions """ 119 | return self.attributes_map["create"] 120 | 121 | @property 122 | def read(self): 123 | """ Gets Read permissions """ 124 | return self.attributes_map["read"] 125 | 126 | @property 127 | def update(self): 128 | """ Gets Update permissions """ 129 | return self.attributes_map["update"] 130 | 131 | @property 132 | def delete(self): 133 | """ Gets Delete permissions """ 134 | return self.attributes_map["delete"] 135 | 136 | @property 137 | def grant(self): 138 | """ Gets Grant permissions """ 139 | return self.attributes_map["grant"] 140 | 141 | def save(self): 142 | """ Save updated permissions objects to the Chef server """ 143 | for t in self.ace_types: 144 | self.api.api_request('PUT', self.url + t, data={t: self[t]}) 145 | 146 | def __getitem__(self, key): 147 | if key in self.attributes_map.keys(): 148 | return self.attributes_map[key] 149 | else: 150 | return {} 151 | 152 | def reload(self): 153 | """ Load current permissions for the object """ 154 | data = self.api.api_request('GET', self.url) 155 | for t in self.ace_types: 156 | self[t].actors = data[t]['actors'] 157 | self[t].groups = data[t]['groups'] 158 | 159 | def to_dict(self): 160 | d = {} 161 | for t in self.ace_types: 162 | d[t] = self[t].to_dict() 163 | 164 | return d 165 | 166 | def _check_object_type(self, object_type): 167 | if object_type not in self.object_types: 168 | raise ChefObjectTypeError('Object type %s is not allowed' % object_type) 169 | 170 | def is_supported(self): 171 | return self.api.version_parsed >= self.version 172 | -------------------------------------------------------------------------------- /chef/api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import re 5 | import socket 6 | import subprocess 7 | import threading 8 | import weakref 9 | import six 10 | 11 | import pkg_resources 12 | 13 | import requests 14 | 15 | from chef.auth import sign_request 16 | from chef.exceptions import ChefServerError 17 | from chef.rsa import Key 18 | from chef.utils import json 19 | from chef.utils.file import walk_backwards 20 | 21 | api_stack = threading.local() 22 | log = logging.getLogger('chef.api') 23 | 24 | config_ruby_script = """ 25 | require 'chef' 26 | Chef::Config.from_file('%s') 27 | puts Chef::Config.configuration.to_json 28 | """.strip() 29 | 30 | def api_stack_value(): 31 | if not hasattr(api_stack, 'value'): 32 | api_stack.value = [] 33 | return api_stack.value 34 | 35 | 36 | class UnknownRubyExpression(Exception): 37 | """Token exception for unprocessed Ruby expressions.""" 38 | 39 | 40 | class ChefAPI(object): 41 | """The ChefAPI object is a wrapper for a single Chef server. 42 | 43 | .. admonition:: The API stack 44 | 45 | PyChef maintains a stack of :class:`ChefAPI` objects to be use with 46 | other methods if an API object isn't given explicitly. The first 47 | ChefAPI created will become the default, though you can set a specific 48 | default using :meth:`ChefAPI.set_default`. You can also use a ChefAPI 49 | as a context manager to create a scoped default:: 50 | 51 | with ChefAPI('http://localhost:4000', 'client.pem', 'admin'): 52 | n = Node('web1') 53 | """ 54 | 55 | ruby_value_re = re.compile(r'#\{([^}]+)\}') 56 | env_value_re = re.compile(r'ENV\[(.+)\]') 57 | ruby_string_re = re.compile(r'^\s*(["\'])(.*?)\1\s*$') 58 | 59 | def __init__(self, url, key, client, version='0.10.8', headers={}, ssl_verify=True): 60 | self.url = url.rstrip('/') 61 | self.parsed_url = six.moves.urllib.parse.urlparse(self.url) 62 | if not isinstance(key, Key): 63 | key = Key(key) 64 | if not key.key: 65 | raise ValueError("ChefAPI attribute 'key' was invalid.") 66 | self.key = key 67 | self.client = client 68 | self.version = version 69 | self.headers = dict((k.lower(), v) for k, v in six.iteritems(headers)) 70 | self.version_parsed = pkg_resources.parse_version(self.version) 71 | self.platform = self.parsed_url.hostname == 'api.opscode.com' 72 | self.ssl_verify = ssl_verify 73 | if not api_stack_value(): 74 | self.set_default() 75 | 76 | @classmethod 77 | def from_config_file(cls, path): 78 | """Load Chef API paraters from a config file. Returns None if the 79 | config can't be used. 80 | """ 81 | log.debug('Trying to load from "%s"', path) 82 | if not os.path.isfile(path) or not os.access(path, os.R_OK): 83 | # Can't even read the config file 84 | log.debug('Unable to read config file "%s"', path) 85 | return 86 | url = key_path = client_name = None 87 | ssl_verify = True 88 | for line in open(path): 89 | if not line.strip() or line.startswith('#'): 90 | continue # Skip blanks and comments 91 | parts = line.split(None, 1) 92 | if len(parts) != 2: 93 | continue # Not a simple key/value, we can't parse it anyway 94 | key, value = parts 95 | md = cls.ruby_string_re.search(value) 96 | if md: 97 | value = md.group(2) 98 | elif key == 'ssl_verify_mode': 99 | log.debug('Found ssl_verify_mode: %r', value) 100 | ssl_verify = (value.strip() != ':verify_none') 101 | log.debug('ssl_verify = %s', ssl_verify) 102 | else: 103 | # Not a string, don't even try 104 | log.debug('Value for {0} does not look like a string: {1}'.format(key, value)) 105 | continue 106 | def _ruby_value(match): 107 | expr = match.group(1).strip() 108 | if expr == 'current_dir': 109 | return os.path.dirname(path) 110 | envmatch = cls.env_value_re.match(expr) 111 | if envmatch: 112 | envmatch = envmatch.group(1).strip('"').strip("'") 113 | return os.environ.get(envmatch) or '' 114 | log.debug('Unknown ruby expression in line "%s"', line) 115 | raise UnknownRubyExpression 116 | try: 117 | value = cls.ruby_value_re.sub(_ruby_value, value) 118 | except UnknownRubyExpression: 119 | continue 120 | if key == 'chef_server_url': 121 | log.debug('Found URL: %r', value) 122 | url = value 123 | elif key == 'node_name': 124 | log.debug('Found client name: %r', value) 125 | client_name = value 126 | elif key == 'client_key': 127 | log.debug('Found key path: %r', value) 128 | key_path = value 129 | if not os.path.isabs(key_path): 130 | # Relative paths are relative to the config file 131 | key_path = os.path.abspath(os.path.join(os.path.dirname(path), key_path)) 132 | 133 | if not (url and client_name and key_path): 134 | # No URL, no chance this was valid, try running Ruby 135 | log.debug('No Chef server config found, trying Ruby parse') 136 | url = key_path = client_name = None 137 | proc = subprocess.Popen('ruby', stdin=subprocess.PIPE, stdout=subprocess.PIPE) 138 | script = config_ruby_script % path.replace('\\', '\\\\').replace("'", "\\'") 139 | out, err = proc.communicate(script.encode()) 140 | if proc.returncode == 0 and out.strip(): 141 | data = json.loads(out.decode()) 142 | log.debug('Ruby parse succeeded with %r', data) 143 | url = data.get('chef_server_url') 144 | client_name = data.get('node_name') 145 | key_path = data.get('client_key') 146 | if key_path and not os.path.isabs(key_path): 147 | # Relative paths are relative to the config file 148 | key_path = os.path.abspath(os.path.join(os.path.dirname(path), key_path)) 149 | else: 150 | log.debug('Ruby parse failed with exit code %s: %s', proc.returncode, out.strip()) 151 | if not url: 152 | # Still no URL, can't use this config 153 | log.debug('Still no Chef server URL found') 154 | return 155 | if not key_path: 156 | # Try and use ./client.pem 157 | key_path = os.path.join(os.path.dirname(path), 'client.pem') 158 | if not os.path.isfile(key_path) or not os.access(key_path, os.R_OK): 159 | # Can't read the client key 160 | log.debug('Unable to read key file "%s"', key_path) 161 | return 162 | if not client_name: 163 | client_name = socket.getfqdn() 164 | return cls(url, key_path, client_name, ssl_verify=ssl_verify) 165 | 166 | @staticmethod 167 | def get_global(): 168 | """Return the API on the top of the stack.""" 169 | while api_stack_value(): 170 | api = api_stack_value()[-1]() 171 | if api is not None: 172 | return api 173 | del api_stack_value()[-1] 174 | 175 | def set_default(self): 176 | """Make this the default API in the stack. Returns the old default if any.""" 177 | old = None 178 | if api_stack_value(): 179 | old = api_stack_value().pop(0) 180 | api_stack_value().insert(0, weakref.ref(self)) 181 | return old 182 | 183 | def __enter__(self): 184 | api_stack_value().append(weakref.ref(self)) 185 | return self 186 | 187 | def __exit__(self, type, value, traceback): 188 | del api_stack_value()[-1] 189 | 190 | def _request(self, method, url, data, headers): 191 | return requests.api.request(method, url, headers=headers, data=data, verify=self.ssl_verify) 192 | 193 | def request(self, method, path, headers={}, data=None): 194 | auth_headers = sign_request(key=self.key, http_method=method, 195 | path=self.parsed_url.path+path.split('?', 1)[0], body=data, 196 | host=self.parsed_url.netloc, timestamp=datetime.datetime.utcnow(), 197 | user_id=self.client) 198 | request_headers = {} 199 | request_headers.update(self.headers) 200 | request_headers.update(dict((k.lower(), v) for k, v in six.iteritems(headers))) 201 | request_headers['x-chef-version'] = self.version 202 | request_headers.update(auth_headers) 203 | try: 204 | response = self._request(method, self.url + path, data, dict( 205 | (k.capitalize(), v) for k, v in six.iteritems(request_headers))) 206 | except requests.ConnectionError as e: 207 | raise ChefServerError(e.message) 208 | 209 | if not response.ok: 210 | raise ChefServerError.from_error(response.reason, code=response.status_code) 211 | 212 | return response 213 | 214 | def api_request(self, method, path, headers={}, data=None): 215 | headers = dict((k.lower(), v) for k, v in six.iteritems(headers)) 216 | headers['accept'] = 'application/json' 217 | if data is not None: 218 | headers['content-type'] = 'application/json' 219 | data = json.dumps(data) 220 | response = self.request(method, path, headers, data) 221 | return response.json() 222 | 223 | def __getitem__(self, path): 224 | return self.api_request('GET', path) 225 | 226 | 227 | def autoconfigure(base_path=None): 228 | """Try to find a knife or chef-client config file to load parameters from, 229 | starting from either the given base path or the current working directory. 230 | 231 | The lookup order mirrors the one from Chef, first all folders from the base 232 | path are walked back looking for .chef/knife.rb, then ~/.chef/knife.rb, 233 | and finally /etc/chef/client.rb. 234 | 235 | The first file that is found and can be loaded successfully will be loaded 236 | into a :class:`ChefAPI` object. 237 | """ 238 | base_path = base_path or os.getcwd() 239 | # Scan up the tree for a knife.rb or client.rb. If that fails try looking 240 | # in /etc/chef. The /etc/chef check will never work in Win32, but it doesn't 241 | # hurt either. 242 | for path in walk_backwards(base_path): 243 | config_path = os.path.join(path, '.chef', 'knife.rb') 244 | api = ChefAPI.from_config_file(config_path) 245 | if api is not None: 246 | return api 247 | 248 | # The walk didn't work, try ~/.chef/knife.rb 249 | config_path = os.path.expanduser(os.path.join('~', '.chef', 'knife.rb')) 250 | api = ChefAPI.from_config_file(config_path) 251 | if api is not None: 252 | return api 253 | 254 | # Nothing in the home dir, try /etc/chef/client.rb 255 | config_path = os.path.join(os.path.sep, 'etc', 'chef', 'client.rb') 256 | api = ChefAPI.from_config_file(config_path) 257 | if api is not None: 258 | return api 259 | -------------------------------------------------------------------------------- /chef/auth.py: -------------------------------------------------------------------------------- 1 | import six.moves 2 | import base64 3 | import datetime 4 | import hashlib 5 | import re 6 | 7 | def _ruby_b64encode(value): 8 | """The Ruby function Base64.encode64 automatically breaks things up 9 | into 60-character chunks. 10 | """ 11 | b64 = base64.b64encode(value) 12 | for i in six.moves.range(0, len(b64), 60): 13 | yield b64[i:i + 60].decode() 14 | 15 | def ruby_b64encode(value): 16 | return '\n'.join(_ruby_b64encode(value)) 17 | 18 | def sha1_base64(value): 19 | """An implementation of Mixlib::Authentication::Digester.""" 20 | return ruby_b64encode(hashlib.sha1(value.encode()).digest()) 21 | 22 | class UTC(datetime.tzinfo): 23 | """UTC timezone stub.""" 24 | 25 | ZERO = datetime.timedelta(0) 26 | 27 | def utcoffset(self, dt): 28 | return self.ZERO 29 | 30 | def tzname(self, dt): 31 | return 'UTC' 32 | 33 | def dst(self, dt): 34 | return self.ZERO 35 | 36 | utc = UTC() 37 | 38 | def canonical_time(timestamp): 39 | if timestamp.tzinfo is not None: 40 | timestamp = timestamp.astimezone(utc).replace(tzinfo=None) 41 | return timestamp.replace(microsecond=0).isoformat() + 'Z' 42 | 43 | canonical_path_regex = re.compile(r'/+') 44 | def canonical_path(path): 45 | path = canonical_path_regex.sub('/', path) 46 | if len(path) > 1: 47 | path = path.rstrip('/') 48 | return path 49 | 50 | def canonical_request(http_method, path, hashed_body, timestamp, user_id): 51 | # Canonicalize request parameters 52 | http_method = http_method.upper() 53 | path = canonical_path(path) 54 | if isinstance(timestamp, datetime.datetime): 55 | timestamp = canonical_time(timestamp) 56 | hashed_path = sha1_base64(path) 57 | return ('Method:%(http_method)s\n' 58 | 'Hashed Path:%(hashed_path)s\n' 59 | 'X-Ops-Content-Hash:%(hashed_body)s\n' 60 | 'X-Ops-Timestamp:%(timestamp)s\n' 61 | 'X-Ops-UserId:%(user_id)s' % vars()) 62 | 63 | def sign_request(key, http_method, path, body, host, timestamp, user_id): 64 | """Generate the needed headers for the Opscode authentication protocol.""" 65 | timestamp = canonical_time(timestamp) 66 | hashed_body = sha1_base64(body or '') 67 | 68 | # Simple headers 69 | headers = { 70 | 'x-ops-sign': 'version=1.0', 71 | 'x-ops-userid': user_id, 72 | 'x-ops-timestamp': timestamp, 73 | 'x-ops-content-hash': hashed_body, 74 | } 75 | 76 | # Create RSA signature 77 | req = canonical_request(http_method, path, hashed_body, timestamp, user_id) 78 | sig = _ruby_b64encode(key.private_encrypt(req)) 79 | for i, line in enumerate(sig): 80 | headers['x-ops-authorization-%s'%(i+1)] = line 81 | return headers 82 | -------------------------------------------------------------------------------- /chef/base.py: -------------------------------------------------------------------------------- 1 | import six 2 | import collections 3 | 4 | import pkg_resources 5 | from chef.acl import Acl 6 | 7 | from chef.api import ChefAPI 8 | from chef.exceptions import * 9 | 10 | class ChefQuery(collections.Mapping): 11 | def __init__(self, obj_class, names, api): 12 | self.obj_class = obj_class 13 | self.names = names 14 | self.api = api 15 | 16 | def __len__(self): 17 | return len(self.names) 18 | 19 | def __contains__(self, key): 20 | return key in self.names 21 | 22 | def __iter__(self): 23 | return iter(self.names) 24 | 25 | def __getitem__(self, name): 26 | if name not in self: 27 | raise KeyError('%s not found'%name) 28 | return self.obj_class(name, api=self.api) 29 | 30 | 31 | class ChefObjectMeta(type): 32 | def __init__(cls, name, bases, d): 33 | super(ChefObjectMeta, cls).__init__(name, bases, d) 34 | if name != 'ChefObject': 35 | ChefObject.types[name.lower()] = cls 36 | cls.api_version_parsed = pkg_resources.parse_version(cls.api_version) 37 | 38 | 39 | class ChefObject(six.with_metaclass(ChefObjectMeta, object)): 40 | """A base class for Chef API objects.""" 41 | types = {} 42 | 43 | url = '' 44 | attributes = {} 45 | 46 | api_version = '0.9' 47 | 48 | def __init__(self, name, api=None, skip_load=False): 49 | self.name = name 50 | self.api = api or ChefAPI.get_global() 51 | self._check_api_version(self.api) 52 | 53 | self.url = self.__class__.url + '/' + self.name 54 | self.exists = False 55 | data = {} 56 | if not skip_load: 57 | try: 58 | data = self.api[self.url] 59 | except ChefServerNotFoundError: 60 | pass 61 | else: 62 | self.exists = True 63 | self._populate(data) 64 | 65 | def _populate(self, data): 66 | for name, cls in six.iteritems(self.__class__.attributes): 67 | if name in data: 68 | value = cls(data[name]) 69 | else: 70 | value = cls() 71 | setattr(self, name, value) 72 | 73 | @classmethod 74 | def from_search(cls, data, api=None): 75 | obj = cls(data.get('name'), api=api, skip_load=True) 76 | obj.exists = True 77 | obj._populate(data) 78 | return obj 79 | 80 | @classmethod 81 | def list(cls, api=None): 82 | """Return a :class:`ChefQuery` with the available objects of this type. 83 | """ 84 | api = api or ChefAPI.get_global() 85 | cls._check_api_version(api) 86 | names = [name for name, url in six.iteritems(api[cls.url])] 87 | return ChefQuery(cls, names, api) 88 | 89 | @classmethod 90 | def create(cls, name, api=None, **kwargs): 91 | """Create a new object of this type. Pass the initial value for any 92 | attributes as keyword arguments. 93 | """ 94 | api = api or ChefAPI.get_global() 95 | cls._check_api_version(api) 96 | obj = cls(name, api, skip_load=True) 97 | for key, value in six.iteritems(kwargs): 98 | setattr(obj, key, value) 99 | api.api_request('POST', cls.url, data=obj) 100 | return obj 101 | 102 | def save(self, api=None): 103 | """Save this object to the server. If the object does not exist it 104 | will be created. 105 | """ 106 | api = api or self.api 107 | try: 108 | api.api_request('PUT', self.url, data=self) 109 | except ChefServerNotFoundError as e: 110 | # If you get a 404 during a save, just create it instead 111 | # This mirrors the logic in the Chef code 112 | api.api_request('POST', self.__class__.url, data=self) 113 | 114 | def delete(self, api=None): 115 | """Delete this object from the server.""" 116 | api = api or self.api 117 | api.api_request('DELETE', self.url) 118 | 119 | def to_dict(self): 120 | d = { 121 | 'name': self.name, 122 | 'json_class': 'Chef::'+self.__class__.__name__, 123 | 'chef_type': self.__class__.__name__.lower(), 124 | } 125 | for attr in six.iterkeys(self.__class__.attributes): 126 | d[attr] = getattr(self, attr) 127 | return d 128 | 129 | def __str__(self): 130 | return self.name 131 | 132 | def __repr__(self): 133 | return '<%s %s>'%(type(self).__name__, self) 134 | 135 | @classmethod 136 | def _check_api_version(cls, api): 137 | # Don't enforce anything if api is None, since there is sometimes a 138 | # use for creating Chef objects without an API connection (just for 139 | # serialization perhaps). 140 | if api and cls.api_version_parsed > api.version_parsed: 141 | raise ChefAPIVersionError("Class %s is not compatible with API version %s" % (cls.__name__, api.version)) 142 | 143 | def get_acl(self): 144 | return Acl(self.__class__.url.strip('/'), self.name, self.api) 145 | -------------------------------------------------------------------------------- /chef/client.py: -------------------------------------------------------------------------------- 1 | from chef.api import ChefAPI 2 | from chef.base import ChefObject 3 | 4 | class Client(ChefObject): 5 | """A Chef client object.""" 6 | 7 | url = '/clients' 8 | 9 | def _populate(self, data): 10 | self.platform = self.api and self.api.platform 11 | self.private_key = None 12 | if self.platform: 13 | self.orgname = data.get('orgname') 14 | self.validator = bool(data.get('validator', False)) 15 | self.public_key = data.get('public_key') 16 | self.admin = False 17 | else: 18 | self.admin = bool(data.get('admin', False)) 19 | self.public_key = data.get('public_key') 20 | self.orgname = None 21 | self.validator = False 22 | 23 | @property 24 | def certificate(self): 25 | return self.public_key 26 | 27 | def to_dict(self): 28 | d = super(Client, self).to_dict() 29 | d['json_class'] = 'Chef::ApiClient' 30 | if self.platform: 31 | d.update({ 32 | 'orgname': self.orgname, 33 | 'validator': self.validator, 34 | 'public_key': self.certificate, 35 | 'clientname': self.name, 36 | }) 37 | else: 38 | d.update({ 39 | 'admin': self.admin, 40 | 'public_key': self.public_key, 41 | }) 42 | return d 43 | 44 | @classmethod 45 | def create(cls, name, api=None, admin=False): 46 | api = api or ChefAPI.get_global() 47 | obj = cls(name, api, skip_load=True) 48 | obj.admin = admin 49 | d = api.api_request('POST', cls.url, data=obj) 50 | obj.private_key = d['private_key'] 51 | obj.public_key = d['public_key'] 52 | return obj 53 | 54 | def rekey(self, api=None): 55 | api = api or self.api 56 | d_in = {'name': self.name, 'private_key': True} 57 | d_out = api.api_request('PUT', self.url, data=d_in) 58 | self.private_key = d_out['private_key'] 59 | -------------------------------------------------------------------------------- /chef/data_bag.py: -------------------------------------------------------------------------------- 1 | import six 2 | import abc 3 | import collections 4 | 5 | from chef.api import ChefAPI 6 | from chef.base import ChefObject, ChefQuery, ChefObjectMeta 7 | from chef.exceptions import ChefError, ChefServerNotFoundError 8 | 9 | class DataBagMeta(ChefObjectMeta, abc.ABCMeta): 10 | """A metaclass to allow DataBag to use multiple inheritance.""" 11 | 12 | 13 | class DataBag(six.with_metaclass(DataBagMeta, ChefObject, ChefQuery)): 14 | """A Chef data bag object. 15 | 16 | Data bag items are available via the mapping API. Evaluation works in the 17 | same way as :class:`ChefQuery`, so requesting only the names will not 18 | cause the items to be loaded:: 19 | 20 | bag = DataBag('versions') 21 | item = bag['web'] 22 | for name, item in six.iteritems(bag): 23 | print item['qa_version'] 24 | """ 25 | 26 | url = '/data' 27 | 28 | def _populate(self, data): 29 | self.names = list(data.keys()) 30 | 31 | def obj_class(self, name, api): 32 | return DataBagItem(self, name, api=api) 33 | 34 | 35 | class DataBagItem(six.with_metaclass(DataBagMeta, ChefObject, collections.MutableMapping)): 36 | """A Chef data bag item object. 37 | 38 | Data bag items act as normal dicts and can contain arbitrary data. 39 | """ 40 | 41 | url = '/data' 42 | attributes = { 43 | 'raw_data': dict, 44 | } 45 | 46 | def __init__(self, bag, name, api=None, skip_load=False): 47 | self._bag = bag 48 | super(DataBagItem, self).__init__(str(bag)+'/'+name, api=api, skip_load=skip_load) 49 | self.name = name 50 | 51 | @property 52 | def bag(self): 53 | """The :class:`DataBag` this item is a member of.""" 54 | if not isinstance(self._bag, DataBag): 55 | self._bag = DataBag(self._bag, api=self.api) 56 | return self._bag 57 | 58 | @classmethod 59 | def from_search(cls, data, api): 60 | bag = data.get('data_bag') 61 | if not bag: 62 | raise ChefError('No data_bag key in data bag item information') 63 | name = data.get('name') 64 | if not name: 65 | raise ChefError('No name key in the data bag item information') 66 | item = name[len('data_bag_item_' + bag + '_'):] 67 | obj = cls(bag, item, api=api, skip_load=True) 68 | obj.exists = True 69 | obj._populate(data) 70 | return obj 71 | 72 | def _populate(self, data): 73 | if 'json_class' in data: 74 | self.raw_data = data['raw_data'] 75 | else: 76 | self.raw_data = data 77 | 78 | def __len__(self): 79 | return len(self.raw_data) 80 | 81 | def __iter__(self): 82 | return iter(self.raw_data) 83 | 84 | def __getitem__(self, key): 85 | return self.raw_data[key] 86 | 87 | def __setitem__(self, key, value): 88 | self.raw_data[key] = value 89 | 90 | def __delitem__(self, key): 91 | del self.raw_data[key] 92 | 93 | @classmethod 94 | def create(cls, bag, name, api=None, **kwargs): 95 | """Create a new data bag item. Pass the initial value for any keys as 96 | keyword arguments.""" 97 | api = api or ChefAPI.get_global() 98 | obj = cls(bag, name, api, skip_load=True) 99 | for key, value in six.iteritems(kwargs): 100 | obj[key] = value 101 | obj['id'] = name 102 | api.api_request('POST', cls.url+'/'+str(bag), data=obj.raw_data) 103 | if isinstance(bag, DataBag) and name not in bag.names: 104 | # Mutate the bag in-place if possible, so it will return the new 105 | # item instantly 106 | bag.names.append(name) 107 | return obj 108 | 109 | def save(self, api=None): 110 | """Save this object to the server. If the object does not exist it 111 | will be created. 112 | """ 113 | api = api or self.api 114 | self['id'] = self.name 115 | try: 116 | api.api_request('PUT', self.url, data=self.raw_data) 117 | except ChefServerNotFoundError as e: 118 | api.api_request('POST', self.__class__.url+'/'+str(self._bag), data=self.raw_data) 119 | -------------------------------------------------------------------------------- /chef/environment.py: -------------------------------------------------------------------------------- 1 | from chef.base import ChefObject 2 | 3 | class Environment(ChefObject): 4 | """A Chef environment object. 5 | 6 | .. versionadded:: 0.2 7 | """ 8 | 9 | url = '/environments' 10 | 11 | api_version = '0.10' 12 | 13 | attributes = { 14 | 'description': str, 15 | 'cookbook_versions': dict, 16 | 'default_attributes': dict, 17 | 'override_attributes': dict, 18 | } 19 | -------------------------------------------------------------------------------- /chef/exceptions.py: -------------------------------------------------------------------------------- 1 | # Exception hierarchy for chef 2 | # Copyright (c) 2010 Noah Kantrowitz 3 | 4 | 5 | class ChefError(Exception): 6 | """Top-level Chef error.""" 7 | 8 | 9 | class ChefServerError(ChefError): 10 | """An error from a Chef server. May include a HTTP response code.""" 11 | 12 | def __init__(self, message, code=None): 13 | self.raw_message = message 14 | if isinstance(message, list): 15 | message = ', '.join(m for m in message if m) 16 | super(ChefError, self).__init__(message) 17 | self.code = code 18 | 19 | @staticmethod 20 | def from_error(message, code=None): 21 | cls = { 22 | 404: ChefServerNotFoundError, 23 | }.get(code, ChefServerError) 24 | return cls(message, code) 25 | 26 | 27 | class ChefServerNotFoundError(ChefServerError): 28 | """A 404 Not Found server error.""" 29 | 30 | 31 | class ChefAPIVersionError(ChefError): 32 | """An incompatible API version error""" 33 | 34 | 35 | class ChefObjectTypeError(ChefError): 36 | """An invalid object type error""" 37 | 38 | -------------------------------------------------------------------------------- /chef/fabric.py: -------------------------------------------------------------------------------- 1 | import six 2 | import functools 3 | 4 | from chef.api import ChefAPI, autoconfigure 5 | from chef.environment import Environment 6 | from chef.exceptions import ChefError, ChefAPIVersionError 7 | from chef.search import Search 8 | 9 | try: 10 | from fabric.api import env, task, roles, output 11 | except ImportError as e: 12 | env = {} 13 | task = lambda *args, **kwargs: lambda fn: fn 14 | roles = task 15 | 16 | __all__ = ['chef_roledefs', 'chef_environment', 'chef_query', 'chef_tags'] 17 | 18 | # Default environment name 19 | DEFAULT_ENVIRONMENT = '_default' 20 | 21 | # Default hostname attributes 22 | DEFAULT_HOSTNAME_ATTR = ['cloud.public_hostname', 'fqdn'] 23 | 24 | # Sentinel object to trigger defered lookup 25 | _default_environment = object() 26 | 27 | def _api(api): 28 | api = api or ChefAPI.get_global() or autoconfigure() 29 | if not api: 30 | raise ChefError('Unable to load Chef API configuration') 31 | return api 32 | 33 | 34 | class Roledef(object): 35 | """Represents a Fabric roledef for a Chef role.""" 36 | def __init__(self, query, api, hostname_attr, environment=None): 37 | self.query = query 38 | self.api = api 39 | self.hostname_attr = hostname_attr 40 | if isinstance(self.hostname_attr, six.string_types): 41 | self.hostname_attr = (self.hostname_attr,) 42 | self.environment = environment 43 | 44 | def __call__(self): 45 | query = self.query 46 | environment = self.environment 47 | if environment is _default_environment: 48 | environment = env.get('chef_environment', DEFAULT_ENVIRONMENT) 49 | if environment: 50 | query += ' AND chef_environment:%s' % environment 51 | for row in Search('node', query, api=self.api): 52 | if row: 53 | if callable(self.hostname_attr): 54 | val = self.hostname_attr(row.object) 55 | if val: 56 | yield val 57 | else: 58 | for attr in self.hostname_attr: 59 | try: 60 | val = row.object.attributes.get_dotted(attr) 61 | if val: # Don't ever give out '' or None, since it will error anyway 62 | yield val 63 | break 64 | except KeyError: 65 | pass # Move on to the next 66 | else: 67 | raise ChefError('Cannot find a usable hostname attribute for node %s', row.object) 68 | 69 | 70 | def chef_roledefs(api=None, hostname_attr=DEFAULT_HOSTNAME_ATTR, environment=_default_environment): 71 | """Build a Fabric roledef dictionary from a Chef server. 72 | 73 | Example:: 74 | 75 | from fabric.api import env, run, roles 76 | from chef.fabric import chef_roledefs 77 | 78 | env.roledefs = chef_roledefs() 79 | 80 | @roles('web_app') 81 | def mytask(): 82 | run('uptime') 83 | 84 | ``hostname_attr`` can either be a string that is the attribute in the chef 85 | node that holds the hostname or IP to connect to, an array of such keys to 86 | check in order (the first which exists will be used), or a callable which 87 | takes a :class:`~chef.Node` and returns the hostname or IP to connect to. 88 | 89 | To refer to a nested attribute, separate the levels with ``'.'`` e.g. ``'ec2.public_hostname'`` 90 | 91 | ``environment`` is the Chef :class:`~chef.Environment` name in which to 92 | search for nodes. If set to ``None``, no environment filter is added. If 93 | set to a string, it is used verbatim as a filter string. If not passed as 94 | an argument at all, the value in the Fabric environment dict is used, 95 | defaulting to ``'_default'``. 96 | 97 | .. note:: 98 | 99 | ``environment`` must be set to ``None`` if you are emulating Chef API 100 | version 0.9 or lower. 101 | 102 | .. versionadded:: 0.1 103 | 104 | .. versionadded:: 0.2 105 | Support for iterable and callable values for the``hostname_attr`` 106 | argument, and the ``environment`` argument. 107 | """ 108 | api = _api(api) 109 | if api.version_parsed < Environment.api_version_parsed and environment is not None: 110 | raise ChefAPIVersionError('Environment support requires Chef API 0.10 or greater') 111 | roledefs = {} 112 | for row in Search('role', api=api): 113 | name = row['name'] 114 | roledefs[name] = Roledef('roles:%s' % name, api, hostname_attr, environment) 115 | return roledefs 116 | 117 | 118 | @task(alias=env.get('chef_environment_task_alias', 'env')) 119 | def chef_environment(name, api=None): 120 | """A Fabric task to set the current Chef environment context. 121 | 122 | This task works alongside :func:`~chef.fabric.chef_roledefs` to set the 123 | Chef environment to be used in future role queries. 124 | 125 | Example:: 126 | 127 | from chef.fabric import chef_environment, chef_roledefs 128 | env.roledefs = chef_roledefs() 129 | 130 | .. code-block:: bash 131 | 132 | $ fab env:production deploy 133 | 134 | The task can be configured slightly via Fabric ``env`` values. 135 | 136 | ``env.chef_environment_task_alias`` sets the task alias, defaulting to "env". 137 | This value must be set **before** :mod:`chef.fabric` is imported. 138 | 139 | ``env.chef_environment_validate`` sets if :class:`~chef.Environment` names 140 | should be validated before use. Defaults to True. 141 | 142 | .. versionadded:: 0.2 143 | """ 144 | if env.get('chef_environment_validate', True): 145 | api = _api(api) 146 | chef_env = Environment(name, api=api) 147 | if not chef_env.exists: 148 | raise ChefError('Unknown Chef environment: %s' % name) 149 | env['chef_environment'] = name 150 | 151 | 152 | def chef_query(query, api=None, hostname_attr=DEFAULT_HOSTNAME_ATTR, environment=_default_environment): 153 | """A decorator to use an arbitrary Chef search query to find nodes to execute on. 154 | 155 | This is used like Fabric's ``roles()`` decorator, but accepts a Chef search query. 156 | 157 | Example:: 158 | 159 | from chef.fabric import chef_query 160 | 161 | @chef_query('roles:web AND tags:active') 162 | @task 163 | def deploy(): 164 | pass 165 | 166 | .. versionadded:: 0.2.1 167 | """ 168 | api = _api(api) 169 | if api.version_parsed < Environment.api_version_parsed and environment is not None: 170 | raise ChefAPIVersionError('Environment support requires Chef API 0.10 or greater') 171 | rolename = 'query_'+query 172 | env.setdefault('roledefs', {})[rolename] = Roledef(query, api, hostname_attr, environment) 173 | return lambda fn: roles(rolename)(fn) 174 | 175 | 176 | def chef_tags(*tags, **kwargs): 177 | """A decorator to use Chef node tags to find nodes to execute on. 178 | 179 | This is used like Fabric's ``roles()`` decorator, but accepts a list of tags. 180 | 181 | Example:: 182 | 183 | from chef.fabric import chef_tags 184 | 185 | @chef_tags('active', 'migrator') 186 | @task 187 | def migrate(): 188 | pass 189 | 190 | .. versionadded:: 0.2.1 191 | """ 192 | # Allow passing a single iterable 193 | if len(tags) == 1 and not isinstance(tags[0], six.string_types): 194 | tags = tags[0] 195 | query = ' AND '.join('tags:%s'%tag.strip() for tag in tags) 196 | return chef_query(query, **kwargs) 197 | -------------------------------------------------------------------------------- /chef/node.py: -------------------------------------------------------------------------------- 1 | import six 2 | import collections 3 | 4 | from chef.base import ChefObject 5 | from chef.exceptions import ChefError 6 | 7 | class NodeAttributes(collections.MutableMapping): 8 | """A collection of Chef :class:`~chef.Node` attributes. 9 | 10 | Attributes can be accessed like a normal python :class:`dict`:: 11 | 12 | print node['fqdn'] 13 | node['apache']['log_dir'] = '/srv/log' 14 | 15 | When writing to new attributes, any dicts required in the hierarchy are 16 | created automatically. 17 | 18 | .. versionadded:: 0.1 19 | """ 20 | 21 | def __init__(self, search_path=[], path=None, write=None): 22 | if not isinstance(search_path, collections.Sequence): 23 | search_path = [search_path] 24 | self.search_path = search_path 25 | self.path = path or () 26 | self.write = write 27 | 28 | def __iter__(self): 29 | keys = set() 30 | for d in self.search_path: 31 | keys |= set(six.iterkeys(d)) 32 | return iter(keys) 33 | 34 | def __len__(self): 35 | l = 0 36 | for key in self: 37 | l += 1 38 | return l 39 | 40 | def __getitem__(self, key): 41 | for d in self.search_path: 42 | if key in d: 43 | value = d[key] 44 | break 45 | else: 46 | raise KeyError(key) 47 | if not isinstance(value, dict): 48 | return value 49 | new_search_path = [] 50 | for d in self.search_path: 51 | new_d = d.get(key, {}) 52 | if not isinstance(new_d, dict): 53 | # Structural mismatch 54 | new_d = {} 55 | new_search_path.append(new_d) 56 | return self.__class__(new_search_path, self.path+(key,), write=self.write) 57 | 58 | def __setitem__(self, key, value): 59 | if self.write is None: 60 | raise ChefError('This attribute is not writable') 61 | dest = self.write 62 | for path_key in self.path: 63 | dest = dest.setdefault(path_key, {}) 64 | dest[key] = value 65 | 66 | def __delitem__(self, key): 67 | if self.write is None: 68 | raise ChefError('This attribute is not writable') 69 | dest = self.write 70 | for path_key in self.path: 71 | dest = dest.setdefault(path_key, {}) 72 | del dest[key] 73 | 74 | def has_dotted(self, key): 75 | """Check if a given dotted key path is present. See :meth:`.get_dotted` 76 | for more information on dotted paths. 77 | 78 | .. versionadded:: 0.2 79 | """ 80 | try: 81 | self.get_dotted(key) 82 | except KeyError: 83 | return False 84 | else: 85 | return True 86 | 87 | def get_dotted(self, key): 88 | """Retrieve an attribute using a dotted key path. A dotted path 89 | is a string of the form `'foo.bar.baz'`, with each `.` separating 90 | hierarcy levels. 91 | 92 | Example:: 93 | 94 | node.attributes['apache']['log_dir'] = '/srv/log' 95 | print node.attributes.get_dotted('apache.log_dir') 96 | """ 97 | value = self 98 | for k in key.split('.'): 99 | if not isinstance(value, NodeAttributes): 100 | raise KeyError(key) 101 | value = value[k] 102 | return value 103 | 104 | def set_dotted(self, key, value): 105 | """Set an attribute using a dotted key path. See :meth:`.get_dotted` 106 | for more information on dotted paths. 107 | 108 | Example:: 109 | 110 | node.attributes.set_dotted('apache.log_dir', '/srv/log') 111 | """ 112 | dest = self 113 | keys = key.split('.') 114 | last_key = keys.pop() 115 | for k in keys: 116 | if k not in dest: 117 | dest[k] = {} 118 | dest = dest[k] 119 | if not isinstance(dest, NodeAttributes): 120 | raise ChefError 121 | dest[last_key] = value 122 | 123 | def to_dict(self): 124 | merged = {} 125 | for d in reversed(self.search_path): 126 | merged.update(d) 127 | return merged 128 | 129 | 130 | class Node(ChefObject): 131 | """A Chef node object. 132 | 133 | The Node object can be used as a dict-like object directly, as an alias for 134 | the :attr:`.attributes` data:: 135 | 136 | >>> node = Node('name') 137 | >>> node['apache']['log_dir'] 138 | '/var/log/apache2' 139 | 140 | .. versionadded:: 0.1 141 | 142 | .. attribute:: attributes 143 | 144 | :class:`~chef.node.NodeAttributes` corresponding to the composite of all 145 | precedence levels. This only uses the stored data on the Chef server, 146 | it does not merge in attributes from roles or environments on its own. 147 | 148 | :: 149 | 150 | >>> node.attributes['apache']['log_dir'] 151 | '/var/log/apache2' 152 | 153 | .. attribute:: run_list 154 | 155 | The run list of the node. This is the unexpanded list in ``type[name]`` 156 | format. 157 | 158 | :: 159 | 160 | >>> node.run_list 161 | ['role[base]', 'role[app]', 'recipe[web]'] 162 | 163 | .. attribute:: chef_environment 164 | 165 | The name of the Chef :class:`~chef.Environment` this node is a member 166 | of. This value will still be present, even if communicating with a Chef 167 | 0.9 server, but will be ignored. 168 | 169 | .. versionadded:: 0.2 170 | 171 | .. attribute:: default 172 | 173 | :class:`~chef.node.NodeAttributes` corresponding to the ``default`` 174 | precedence level. 175 | 176 | .. attribute:: normal 177 | 178 | :class:`~chef.node.NodeAttributes` corresponding to the ``normal`` 179 | precedence level. 180 | 181 | .. attribute:: override 182 | 183 | :class:`~chef.node.NodeAttributes` corresponding to the ``override`` 184 | precedence level. 185 | 186 | .. attribute:: automatic 187 | 188 | :class:`~chef.node.NodeAttributes` corresponding to the ``automatic`` 189 | precedence level. 190 | """ 191 | 192 | url = '/nodes' 193 | attributes = { 194 | 'default': NodeAttributes, 195 | 'normal': lambda d: NodeAttributes(d, write=d), 196 | 'override': NodeAttributes, 197 | 'automatic': NodeAttributes, 198 | 'run_list': list, 199 | 'chef_environment': str 200 | } 201 | 202 | def has_key(self, key): 203 | return self.attributes.has_dotted(key) 204 | 205 | def get(self, key, default=None): 206 | return self.attributes.get(key, default) 207 | 208 | def __getitem__(self, key): 209 | return self.attributes[key] 210 | 211 | def __setitem__(self, key, value): 212 | self.attributes[key] = value 213 | 214 | def _populate(self, data): 215 | if not self.exists: 216 | # Make this exist so the normal<->attributes cross-link will 217 | # function correctly 218 | data['normal'] = {} 219 | data.setdefault('chef_environment', '_default') 220 | super(Node, self)._populate(data) 221 | self.attributes = NodeAttributes((data.get('automatic', {}), 222 | data.get('override', {}), 223 | data['normal'], # Must exist, see above 224 | data.get('default', {})), write=data['normal']) 225 | 226 | def cookbooks(self, api=None): 227 | api = api or self.api 228 | return api[self.url + '/cookbooks'] 229 | -------------------------------------------------------------------------------- /chef/permissions.py: -------------------------------------------------------------------------------- 1 | class Permissions(object): 2 | """ Represents single permissions object with list of actors and groups """ 3 | 4 | def __init__(self): 5 | self.actors = [] 6 | self.groups = [] 7 | 8 | def to_dict(self): 9 | d = { 10 | "actors": self.actors, 11 | "groups": self.groups 12 | } 13 | 14 | return d 15 | -------------------------------------------------------------------------------- /chef/role.py: -------------------------------------------------------------------------------- 1 | from chef.base import ChefObject 2 | 3 | class Role(ChefObject): 4 | """A Chef role object.""" 5 | 6 | url = '/roles' 7 | attributes = { 8 | 'description': str, 9 | 'run_list': list, 10 | 'default_attributes': dict, 11 | 'override_attributes': dict, 12 | 'env_run_lists': dict 13 | } 14 | -------------------------------------------------------------------------------- /chef/rsa.py: -------------------------------------------------------------------------------- 1 | import six 2 | import sys 3 | from ctypes import * 4 | from ctypes.util import find_library 5 | 6 | if sys.platform == 'win32' or sys.platform == 'cygwin': 7 | _eay = CDLL('libeay32.dll') 8 | else: 9 | _eay = CDLL(find_library('crypto')) 10 | 11 | #unsigned long ERR_get_error(void); 12 | ERR_get_error = _eay.ERR_get_error 13 | ERR_get_error.argtypes = [] 14 | ERR_get_error.restype = c_ulong 15 | 16 | #void ERR_error_string_n(unsigned long e, char *buf, size_t len); 17 | ERR_error_string_n = _eay.ERR_error_string_n 18 | ERR_error_string_n.argtypes = [c_ulong, c_char_p, c_size_t] 19 | ERR_error_string_n.restype = None 20 | 21 | class SSLError(Exception): 22 | """An error in OpenSSL.""" 23 | 24 | def __init__(self, message, *args): 25 | message = message%args 26 | err = ERR_get_error() 27 | if err: 28 | message += ':' 29 | while err: 30 | buf = create_string_buffer(120) 31 | ERR_error_string_n(err, buf, 120) 32 | message += '\n%s'%string_at(buf, 119) 33 | err = ERR_get_error() 34 | super(SSLError, self).__init__(message) 35 | 36 | 37 | #BIO * BIO_new(BIO_METHOD *type); 38 | BIO_new = _eay.BIO_new 39 | BIO_new.argtypes = [c_void_p] 40 | BIO_new.restype = c_void_p 41 | 42 | # BIO *BIO_new_mem_buf(void *buf, int len); 43 | BIO_new_mem_buf = _eay.BIO_new_mem_buf 44 | BIO_new_mem_buf.argtypes = [c_void_p, c_int] 45 | BIO_new_mem_buf.restype = c_void_p 46 | 47 | #BIO_METHOD *BIO_s_mem(void); 48 | BIO_s_mem = _eay.BIO_s_mem 49 | BIO_s_mem.argtypes = [] 50 | BIO_s_mem.restype = c_void_p 51 | 52 | #long BIO_ctrl(BIO *bp,int cmd,long larg,void *parg); 53 | BIO_ctrl = _eay.BIO_ctrl 54 | BIO_ctrl.argtypes = [c_void_p, c_int, c_long, c_void_p] 55 | BIO_ctrl.restype = c_long 56 | 57 | #define BIO_CTRL_RESET 1 /* opt - rewind/zero etc */ 58 | BIO_CTRL_RESET = 1 59 | ##define BIO_CTRL_INFO 3 /* opt - extra tit-bits */ 60 | BIO_CTRL_INFO = 3 61 | 62 | #define BIO_reset(b) (int)BIO_ctrl(b,BIO_CTRL_RESET,0,NULL) 63 | def BIO_reset(b): 64 | return BIO_ctrl(b, BIO_CTRL_RESET, 0, None) 65 | 66 | ##define BIO_get_mem_data(b,pp) BIO_ctrl(b,BIO_CTRL_INFO,0,(char *)pp) 67 | def BIO_get_mem_data(b, pp): 68 | return BIO_ctrl(b, BIO_CTRL_INFO, 0, pp) 69 | 70 | # int BIO_free(BIO *a) 71 | BIO_free = _eay.BIO_free 72 | BIO_free.argtypes = [c_void_p] 73 | BIO_free.restype = c_int 74 | def BIO_free_errcheck(result, func, arguments): 75 | if result == 0: 76 | raise SSLError('Unable to free BIO') 77 | BIO_free.errcheck = BIO_free_errcheck 78 | 79 | #RSA *PEM_read_bio_RSAPrivateKey(BIO *bp, RSA **x, 80 | # pem_password_cb *cb, void *u); 81 | PEM_read_bio_RSAPrivateKey = _eay.PEM_read_bio_RSAPrivateKey 82 | PEM_read_bio_RSAPrivateKey.argtypes = [c_void_p, c_void_p, c_void_p, c_void_p] 83 | PEM_read_bio_RSAPrivateKey.restype = c_void_p 84 | 85 | #RSA *PEM_read_bio_RSAPublicKey(BIO *bp, RSA **x, 86 | # pem_password_cb *cb, void *u); 87 | PEM_read_bio_RSAPublicKey = _eay.PEM_read_bio_RSAPublicKey 88 | PEM_read_bio_RSAPublicKey.argtypes = [c_void_p, c_void_p, c_void_p, c_void_p] 89 | PEM_read_bio_RSAPublicKey.restype = c_void_p 90 | 91 | #int PEM_write_bio_RSAPrivateKey(BIO *bp, RSA *x, const EVP_CIPHER *enc, 92 | # unsigned char *kstr, int klen, 93 | # pem_password_cb *cb, void *u); 94 | PEM_write_bio_RSAPrivateKey = _eay.PEM_write_bio_RSAPrivateKey 95 | PEM_write_bio_RSAPrivateKey.argtypes = [c_void_p, c_void_p, c_void_p, c_char_p, c_int, c_void_p, c_void_p] 96 | PEM_write_bio_RSAPrivateKey.restype = c_int 97 | 98 | #int PEM_write_bio_RSAPublicKey(BIO *bp, RSA *x); 99 | PEM_write_bio_RSAPublicKey = _eay.PEM_write_bio_RSAPublicKey 100 | PEM_write_bio_RSAPublicKey.argtypes = [c_void_p, c_void_p] 101 | PEM_write_bio_RSAPublicKey.restype = c_int 102 | 103 | #int RSA_private_encrypt(int flen, unsigned char *from, 104 | # unsigned char *to, RSA *rsa,int padding); 105 | RSA_private_encrypt = _eay.RSA_private_encrypt 106 | RSA_private_encrypt.argtypes = [c_int, c_void_p, c_void_p, c_void_p, c_int] 107 | RSA_private_encrypt.restype = c_int 108 | 109 | #int RSA_public_decrypt(int flen, unsigned char *from, 110 | # unsigned char *to, RSA *rsa, int padding); 111 | RSA_public_decrypt = _eay.RSA_public_decrypt 112 | RSA_public_decrypt.argtypes = [c_int, c_void_p, c_void_p, c_void_p, c_int] 113 | RSA_public_decrypt.restype = c_int 114 | 115 | RSA_PKCS1_PADDING = 1 116 | RSA_NO_PADDING = 3 117 | 118 | # int RSA_size(const RSA *rsa); 119 | RSA_size = _eay.RSA_size 120 | RSA_size.argtypes = [c_void_p] 121 | RSA_size.restype = c_int 122 | 123 | #RSA *RSA_generate_key(int num, unsigned long e, 124 | # void (*callback)(int,int,void *), void *cb_arg); 125 | RSA_generate_key = _eay.RSA_generate_key 126 | RSA_generate_key.argtypes = [c_int, c_ulong, c_void_p, c_void_p] 127 | RSA_generate_key.restype = c_void_p 128 | 129 | ##define RSA_F4 0x10001L 130 | RSA_F4 = 0x10001 131 | 132 | # void RSA_free(RSA *rsa); 133 | RSA_free = _eay.RSA_free 134 | RSA_free.argtypes = [c_void_p] 135 | 136 | class Key(object): 137 | """An OpenSSL RSA key.""" 138 | 139 | def __init__(self, fp=None): 140 | self.key = None 141 | self.public = False 142 | if not fp: 143 | return 144 | if isinstance(fp, six.binary_type) and fp.startswith(b'-----'): 145 | # PEM formatted text 146 | self.raw = fp 147 | elif isinstance(fp, six.string_types): 148 | self.raw = open(fp, 'rb').read() 149 | else: 150 | self.raw = fp.read() 151 | self._load_key() 152 | 153 | def _load_key(self): 154 | if b'\0' in self.raw: 155 | # Raw string has embedded nulls, treat it as binary data 156 | buf = create_string_buffer(self.raw, len(self.raw)) 157 | else: 158 | buf = create_string_buffer(self.raw) 159 | 160 | bio = BIO_new_mem_buf(buf, len(buf)) 161 | try: 162 | self.key = PEM_read_bio_RSAPrivateKey(bio, 0, 0, 0) 163 | if not self.key: 164 | BIO_reset(bio) 165 | self.public = True 166 | self.key = PEM_read_bio_RSAPublicKey(bio, 0, 0, 0) 167 | if not self.key: 168 | raise SSLError('Unable to load RSA key') 169 | finally: 170 | BIO_free(bio) 171 | 172 | @classmethod 173 | def generate(cls, size=1024, exp=RSA_F4): 174 | self = cls() 175 | self.key = RSA_generate_key(size, exp, None, None) 176 | return self 177 | 178 | def private_encrypt(self, value, padding=RSA_PKCS1_PADDING): 179 | if self.public: 180 | raise SSLError('private method cannot be used on a public key') 181 | if six.PY3 and not isinstance(value, bytes): 182 | buf = create_string_buffer(value.encode(), len(value)) 183 | else: 184 | buf = create_string_buffer(value, len(value)) 185 | size = RSA_size(self.key) 186 | output = create_string_buffer(size) 187 | ret = RSA_private_encrypt(len(buf), buf, output, self.key, padding) 188 | if ret <= 0: 189 | raise SSLError('Unable to encrypt data') 190 | return output.raw[:ret] 191 | 192 | def public_decrypt(self, value, padding=RSA_PKCS1_PADDING): 193 | if six.PY3 and not isinstance(value, bytes): 194 | buf = create_string_buffer(value.encode(), len(value)) 195 | else: 196 | buf = create_string_buffer(value, len(value)) 197 | size = RSA_size(self.key) 198 | output = create_string_buffer(size) 199 | ret = RSA_public_decrypt(len(buf), buf, output, self.key, padding) 200 | if ret <= 0: 201 | raise SSLError('Unable to decrypt data') 202 | if six.PY3 and isinstance(output.raw, bytes): 203 | return output.raw[:ret].decode() 204 | else: 205 | return output.raw[:ret] 206 | 207 | def private_export(self): 208 | if self.public: 209 | raise SSLError('private method cannot be used on a public key') 210 | out = BIO_new(BIO_s_mem()) 211 | PEM_write_bio_RSAPrivateKey(out, self.key, None, None, 0, None, None) 212 | buf = c_char_p() 213 | count = BIO_get_mem_data(out, byref(buf)) 214 | pem = string_at(buf, count) 215 | BIO_free(out) 216 | return pem 217 | 218 | def public_export(self): 219 | out = BIO_new(BIO_s_mem()) 220 | PEM_write_bio_RSAPublicKey(out, self.key) 221 | buf = c_char_p() 222 | count = BIO_get_mem_data(out, byref(buf)) 223 | pem = string_at(buf, count) 224 | BIO_free(out) 225 | return pem 226 | 227 | def __del__(self): 228 | if self.key and RSA_free: 229 | RSA_free(self.key) 230 | -------------------------------------------------------------------------------- /chef/search.py: -------------------------------------------------------------------------------- 1 | import six 2 | import collections 3 | import copy 4 | import six.moves.urllib.parse 5 | 6 | from chef.api import ChefAPI 7 | from chef.base import ChefQuery, ChefObject 8 | 9 | class SearchRow(dict): 10 | """A single row in a search result.""" 11 | 12 | def __init__(self, row, api): 13 | super(SearchRow, self).__init__(row) 14 | self.api = api 15 | self._object = None 16 | 17 | @property 18 | def object(self): 19 | if self._object is None: 20 | # Decode Chef class name 21 | chef_class = self.get('json_class', '') 22 | if chef_class.startswith('Chef::'): 23 | chef_class = chef_class[6:] 24 | if chef_class == 'ApiClient': 25 | chef_class = 'Client' # Special case since I don't match the Ruby name. 26 | cls = ChefObject.types.get(chef_class.lower()) 27 | if not cls: 28 | raise ValueError('Unknown class %s'%chef_class) 29 | self._object = cls.from_search(self, api=self.api) 30 | return self._object 31 | 32 | 33 | class Search(collections.Sequence): 34 | """A search of the Chef index. 35 | 36 | The only required argument is the index name to search (eg. node, role, etc). 37 | The second, optional argument can be any Solr search query, with the same semantics 38 | as Chef. 39 | 40 | Example:: 41 | 42 | for row in Search('node', 'roles:app'): 43 | print row['roles'] 44 | print row.object.name 45 | 46 | .. versionadded:: 0.1 47 | """ 48 | 49 | url = '/search' 50 | 51 | def __init__(self, index, q='*:*', rows=1000, start=0, api=None): 52 | self.name = index 53 | self.api = api or ChefAPI.get_global() 54 | self._args = dict(q=q, rows=rows, start=start) 55 | self.url = self.__class__.url + '/' + self.name + '?' + six.moves.urllib.parse.urlencode(self._args) 56 | 57 | @property 58 | def data(self): 59 | if not hasattr(self, '_data'): 60 | self._data = self.api[self.url] 61 | return self._data 62 | 63 | @property 64 | def total(self): 65 | return self.data['total'] 66 | 67 | def query(self, query): 68 | args = copy.copy(self._args) 69 | args['q'] = query 70 | return self.__class__(self.name, api=self.api, **args) 71 | 72 | def rows(self, rows): 73 | args = copy.copy(self._args) 74 | args['rows'] = rows 75 | return self.__class__(self.name, api=self.api, **args) 76 | 77 | def start(self, start): 78 | args = copy.copy(self._args) 79 | args['start'] = start 80 | return self.__class__(self.name, api=self.api, **args) 81 | 82 | def __len__(self): 83 | return len(self.data['rows']) 84 | 85 | def __getitem__(self, value): 86 | if isinstance(value, slice): 87 | if value.step is not None and value.step != 1: 88 | raise ValueError('Cannot use a step other than 1') 89 | return self.start(self._args['start']+value.start).rows(value.stop-value.start) 90 | if isinstance(value, six.string_types): 91 | return self[self.index(value)] 92 | row_value = self.data['rows'][value] 93 | # Check for null rows, just in case 94 | if row_value is None: 95 | return None 96 | return SearchRow(row_value, self.api) 97 | 98 | def __contains__(self, name): 99 | for row in self: 100 | if row.object.name == name: 101 | return True 102 | return False 103 | 104 | def index(self, name): 105 | for i, row in enumerate(self): 106 | if row.object.name == name: 107 | return i 108 | raise ValueError('%s not in search'%name) 109 | 110 | def __call__(self, query): 111 | return self.query(query) 112 | 113 | @classmethod 114 | def list(cls, api=None): 115 | api = api or ChefAPI.get_global() 116 | names = [name for name, url in six.iteritems(api[cls.url])] 117 | return ChefQuery(cls, names, api) 118 | -------------------------------------------------------------------------------- /chef/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import six.moves 2 | import os 3 | import random 4 | from functools import wraps 5 | 6 | import mock 7 | from unittest2 import TestCase, skipUnless 8 | 9 | from chef.api import ChefAPI 10 | from chef.exceptions import ChefError 11 | from chef.search import Search 12 | 13 | TEST_ROOT = os.path.dirname(os.path.abspath(__file__)) 14 | 15 | def skipSlowTest(): 16 | return skipUnless(os.environ.get('PYCHEF_SLOW_TESTS'), 'slow tests skipped, set $PYCHEF_SLOW_TESTS=1 to enable') 17 | 18 | class mockSearch(object): 19 | def __init__(self, search_data): 20 | self.search_data = search_data 21 | 22 | def __call__(self, fn): 23 | @wraps(fn) 24 | def wrapper(inner_self): 25 | return mock.patch('chef.search.Search', side_effect=self._search_inst)(fn)(inner_self) 26 | return wrapper 27 | 28 | def _search_inst(self, index, q='*:*', *args, **kwargs): 29 | data = self.search_data[index, q] 30 | if not isinstance(data, dict): 31 | data = {'total': len(data), 'rows': data} 32 | search = Search(index, q, *args, **kwargs) 33 | search._data = data 34 | return search 35 | 36 | 37 | def test_chef_api(**kwargs): 38 | return ChefAPI('https://api.opscode.com/organizations/pycheftest', os.path.join(TEST_ROOT, 'client.pem'), 'unittests', **kwargs) 39 | 40 | 41 | class ChefTestCase(TestCase): 42 | """Base class for Chef unittests.""" 43 | 44 | def setUp(self): 45 | super(ChefTestCase, self).setUp() 46 | self.api = test_chef_api() 47 | self.api.set_default() 48 | self.objects = [] 49 | 50 | def tearDown(self): 51 | for obj in self.objects: 52 | try: 53 | obj.delete() 54 | except ChefError as e: 55 | print(e) 56 | # Continue running 57 | 58 | def register(self, obj): 59 | self.objects.append(obj) 60 | 61 | def random(self, length=8, alphabet='0123456789abcdef'): 62 | return ''.join(random.choice(alphabet) for _ in six.moves.range(length)) 63 | -------------------------------------------------------------------------------- /chef/tests/client.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEA0ab5f7qe2ape2RNeFWB5xHRKKWbiMZHXptHozteOz5eA2y8D 3 | 8o/x4OhOUnc0FGSshyIxyGN09Ojfk00kWYEYC/l9h12vhj2fPABS/Z7Iz1WzEnOj 4 | 5LvDe9HujkDk3JZnID0csLUXtrvL4lbKrSdFWaSLZC8IGN2EexUH+MgMttmf7Aso 5 | LJezwHiqlZMDvFUQUCqOZj1CWHZQZ43zSm3xNIX4+ace8vK0Fs6fzsYofpEouRyN 6 | k17UQM7AwJIJ9rBgsQL9xCCeseTiX7+o797cDjPtmaz7u/NAi84iwpFCvKLigrZY 7 | GnL3hxJbtL4haRwg3UEB6VwHGTwoOLjo4i5CJQIDAQABAoIBAFRol7dXWbFlKL7Z 8 | T13n896mu10j8RnoEB04EjWFEBiAdP7KVRqJ3eahYTdOiwdS6SuXFtgJQwN/5tQV 9 | kPcARMA9eM7RZ2Py13N+5er6zPq9FFXvfSMQfcoSYaugcQRnAao3MJ/sqVmHDrVY 10 | IE3Kq99FomF5lhb8yOQNOaJuWMAc7/tBdTutyXAfJo4TmbhQKB940MXfX64Df5QX 11 | tXAOxKivep1DnbZ1mKYVgiAFR3w2SWqxa6gAz7LxtpsSMXiW9bg/LfkU/xqbVd2t 12 | UEGs/mw/xvXdtQB70KTXj0ZIguhPUq2++hO8x4ngX8wfnqN84skCuhRevN9WHDB9 13 | zs6VnRkCgYEA6KerrXVJ+t/P2URKJ9Ygs8/QIlZ1pUwYXcdvijKk1JTjoYUJWtRT 14 | gp+wEbPmmtsW42K4iHas60GBZNFOgFepQNc270w0Wni25FEtI43l54FcpPZHNqk7 15 | a+nWpyY8LWdTxPqZPTSWWMQf8nAZcYGNl6sdVIdYTkXujCeXjgvMpNMCgYEA5rBr 16 | 0NIrJwRRls1GsAvofI+OmQO6cT1P+w0Fsa+3/YYdwmKRb1eAmfXO3CJKnHfl3vOE 17 | nZ2SxO1b5bJxdk5wGSn8bdtrHDI/goUQAkI8A+z3skw6LfN9HzJe+9/Ilcz5Alr7 18 | mH6ahFiS4ykiPpeslyYENmKvjcRGVnBO3VO6gicCgYBR61gDx5y4/T2OXwFNbZQu 19 | PCopLRBXl6esvaCEpLhtMc/E+7cDiGevQtMYKKQ2Opagkg4v3rmcTIBnI1vkzPkH 20 | n7/0Gn0EriSX2A1wy3H8Rgx8+Uqx8Hy/zqKKUGg4BH32idaTOoUF1Gj7UIVk9h0J 21 | HnNBZDavuOf56abvmTABiQKBgAycsphNFTzh2JAVEvtG+2Pr+VDWSlgskPXZxWjs 22 | gXOj5HafKvJaZ1aDgNa6LTgWugORbrurRL3teCu7sMZWDXzitcFP0LBO8vfwzGpD 23 | MsLILtaZokim2j1dZKICnxXJiged78lriokXypgOxKeFZVMyKeLLTGvEwk+xfi5N 24 | iJHbAoGAJTLHBuMXp/9cXa0H8UFE7T3d5oKU1IN2NdhgwxRznCE7pw6cCDESrcF6 25 | +DlyEKrX+KH9O8t9IXwhBh0cOK58ojT9cLfFcU1srGgDHNnneGge4W/nKeYoHQ+Y 26 | EMg+8fG/1CbyZdY1rp5KHU3lEDHk/SW4EavlPbVDoZ66x1ahKMA= 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /chef/tests/client_pub.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PUBLIC KEY----- 2 | MIIBCgKCAQEA0ab5f7qe2ape2RNeFWB5xHRKKWbiMZHXptHozteOz5eA2y8D8o/x 3 | 4OhOUnc0FGSshyIxyGN09Ojfk00kWYEYC/l9h12vhj2fPABS/Z7Iz1WzEnOj5LvD 4 | e9HujkDk3JZnID0csLUXtrvL4lbKrSdFWaSLZC8IGN2EexUH+MgMttmf7AsoLJez 5 | wHiqlZMDvFUQUCqOZj1CWHZQZ43zSm3xNIX4+ace8vK0Fs6fzsYofpEouRyNk17U 6 | QM7AwJIJ9rBgsQL9xCCeseTiX7+o797cDjPtmaz7u/NAi84iwpFCvKLigrZYGnL3 7 | hxJbtL4haRwg3UEB6VwHGTwoOLjo4i5CJQIDAQAB 8 | -----END RSA PUBLIC KEY----- 9 | -------------------------------------------------------------------------------- /chef/tests/configs/basic.rb: -------------------------------------------------------------------------------- 1 | chef_server_url 'http://chef:4000' 2 | client_key '../client.pem' 3 | # Use both kind of quotes, also a comment for testing 4 | node_name "test_1" 5 | -------------------------------------------------------------------------------- /chef/tests/configs/basic_with_interpolated_values.rb: -------------------------------------------------------------------------------- 1 | key_name = 'client' 2 | chef_server_url "#{url}" 3 | client_key "../#{key_name}.pem" 4 | # Use both kind of quotes, also a comment for testing 5 | node_name "test_1" 6 | # test multiple line values 7 | client_name = { 8 | 'dev' =>'test_1' 9 | }['dev'] 10 | -------------------------------------------------------------------------------- /chef/tests/configs/current_dir.rb: -------------------------------------------------------------------------------- 1 | chef_server_url 'http://chef:4000' 2 | client_key '../client.pem' 3 | node_name "#{current_dir}/test_1" 4 | -------------------------------------------------------------------------------- /chef/tests/configs/env_values.rb: -------------------------------------------------------------------------------- 1 | chef_server_url 'http://chef:4000' 2 | client_key '../client.pem' 3 | node_name "#{ENV['_PYCHEF_TEST_']}" 4 | -------------------------------------------------------------------------------- /chef/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import mock 4 | import unittest2 5 | 6 | from chef.api import ChefAPI 7 | 8 | 9 | class APITestCase(unittest2.TestCase): 10 | def load(self, path): 11 | path = os.path.join(os.path.dirname(__file__), 'configs', path) 12 | return ChefAPI.from_config_file(path) 13 | 14 | @mock.patch('chef.api.subprocess.Popen') 15 | def test_config_with_interpolated_settings(self, mock_subproc_popen): 16 | process_mock = mock.Mock() 17 | output = b'{"chef_server_url": "http:///chef:4000", "client_key": "../client.pem",' \ 18 | b'"node_name": "test_1"}' 19 | attrs = { 20 | 'communicate.return_value': (output, 'error'), 21 | 'returncode': 0} 22 | process_mock.configure_mock(**attrs) 23 | mock_subproc_popen.return_value = process_mock 24 | 25 | api = self.load('basic_with_interpolated_values.rb') 26 | self.assertEqual(api.client, 'test_1') 27 | 28 | def test_basic(self): 29 | api = self.load('basic.rb') 30 | self.assertEqual(api.url, 'http://chef:4000') 31 | self.assertEqual(api.client, 'test_1') 32 | 33 | def test_current_dir(self): 34 | api = self.load('current_dir.rb') 35 | path = os.path.join(os.path.dirname(__file__), 'configs', 'test_1') 36 | self.assertEqual(os.path.normpath(api.client), path) 37 | 38 | def test_env_variables(self): 39 | try: 40 | os.environ['_PYCHEF_TEST_'] = 'foobar' 41 | api = self.load('env_values.rb') 42 | self.assertEqual(api.client, 'foobar') 43 | finally: 44 | del os.environ['_PYCHEF_TEST_'] 45 | 46 | def test_bad_key_raises(self): 47 | invalids = [None, ''] 48 | for item in invalids: 49 | self.assertRaises( 50 | ValueError, ChefAPI, 'foobar', item, 'user') 51 | -------------------------------------------------------------------------------- /chef/tests/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest2 2 | 3 | from chef import Client 4 | from chef.tests import ChefTestCase 5 | 6 | class ClientTestCase(ChefTestCase): 7 | def test_list(self): 8 | self.assertIn('test_1', Client.list()) 9 | 10 | def test_get(self): 11 | client = Client('test_1') 12 | self.assertTrue(client.platform) 13 | self.assertEqual(client.orgname, 'pycheftest') 14 | self.assertTrue(client.public_key) 15 | self.assertTrue(client.certificate) 16 | self.assertEqual(client.private_key, None) 17 | 18 | def test_create(self): 19 | name = self.random() 20 | client = Client.create(name) 21 | self.register(client) 22 | self.assertEqual(client.name, name) 23 | #self.assertEqual(client.orgname, 'pycheftest') # See CHEF-2019 24 | self.assertTrue(client.private_key) 25 | self.assertTrue(client.public_key) 26 | self.assertIn(name, Client.list()) 27 | 28 | client2 = Client(name) 29 | client2.rekey() 30 | self.assertEqual(client.public_key, client2.public_key) 31 | self.assertNotEqual(client.private_key, client2.private_key) 32 | 33 | def test_delete(self): 34 | name = self.random() 35 | client = Client.create(name) 36 | client.delete() 37 | self.assertNotIn(name, Client.list()) 38 | -------------------------------------------------------------------------------- /chef/tests/test_data_bag.py: -------------------------------------------------------------------------------- 1 | from chef import DataBag, DataBagItem, Search 2 | from chef.exceptions import ChefError 3 | from chef.tests import ChefTestCase 4 | 5 | class DataBagTestCase(ChefTestCase): 6 | def test_list(self): 7 | bags = DataBag.list() 8 | self.assertIn('test_1', bags) 9 | self.assertIsInstance(bags['test_1'], DataBag) 10 | 11 | def test_keys(self): 12 | bag = DataBag('test_1') 13 | self.assertItemsEqual(list(bag.keys()), ['item_1', 'item_2']) 14 | self.assertItemsEqual(iter(bag), ['item_1', 'item_2']) 15 | 16 | def test_item(self): 17 | bag = DataBag('test_1') 18 | item = bag['item_1'] 19 | self.assertEqual(item['test_attr'], 1) 20 | self.assertEqual(item['other'], 'foo') 21 | 22 | def test_search_item(self): 23 | self.assertIn('test_1', Search.list()) 24 | q = Search('test_1') 25 | self.assertIn('item_1', q) 26 | self.assertIn('item_2', q) 27 | self.assertEqual(q['item_1']['raw_data']['test_attr'], 1) 28 | item = q['item_1'].object 29 | self.assertIsInstance(item, DataBagItem) 30 | self.assertEqual(item['test_attr'], 1) 31 | 32 | def test_direct_item(self): 33 | item = DataBagItem('test_1', 'item_1') 34 | self.assertEqual(item['test_attr'], 1) 35 | self.assertEqual(item['other'], 'foo') 36 | 37 | def test_direct_item_bag(self): 38 | bag = DataBag('test_1') 39 | item = DataBagItem(bag, 'item_1') 40 | self.assertEqual(item['test_attr'], 1) 41 | self.assertEqual(item['other'], 'foo') 42 | 43 | def test_create_bag(self): 44 | name = self.random() 45 | bag = DataBag.create(name) 46 | self.register(bag) 47 | self.assertIn(name, DataBag.list()) 48 | 49 | def test_create_item(self): 50 | value = self.random() 51 | bag_name = self.random() 52 | bag = DataBag.create(bag_name) 53 | self.register(bag) 54 | item_name = self.random() 55 | item = DataBagItem.create(bag, item_name, foo=value) 56 | self.assertIn('foo', item) 57 | self.assertEqual(item['foo'], value) 58 | self.assertIn(item_name, bag) 59 | bag2 = DataBag(bag_name) 60 | self.assertIn(item_name, bag2) 61 | item2 = bag2[item_name] 62 | self.assertIn('foo', item) 63 | self.assertEqual(item['foo'], value) 64 | 65 | def test_set_item(self): 66 | value = self.random() 67 | value2 = self.random() 68 | bag_name = self.random() 69 | bag = DataBag.create(bag_name) 70 | self.register(bag) 71 | item_name = self.random() 72 | item = DataBagItem.create(bag, item_name, foo=value) 73 | item['foo'] = value2 74 | item.save() 75 | self.assertEqual(item['foo'], value2) 76 | item2 = DataBagItem(bag, item_name) 77 | self.assertEqual(item2['foo'], value2) 78 | -------------------------------------------------------------------------------- /chef/tests/test_environment.py: -------------------------------------------------------------------------------- 1 | from chef import Environment 2 | from chef.exceptions import ChefAPIVersionError 3 | from chef.tests import ChefTestCase, test_chef_api 4 | 5 | class EnvironmentTestCase(ChefTestCase): 6 | def test_version_error_list(self): 7 | with test_chef_api(version='0.9.0'): 8 | with self.assertRaises(ChefAPIVersionError): 9 | Environment.list() 10 | 11 | def test_version_error_create(self): 12 | with test_chef_api(version='0.9.0'): 13 | with self.assertRaises(ChefAPIVersionError): 14 | Environment.create(self.random()) 15 | 16 | def test_version_error_init(self): 17 | with test_chef_api(version='0.9.0'): 18 | with self.assertRaises(ChefAPIVersionError): 19 | Environment(self.random()) 20 | -------------------------------------------------------------------------------- /chef/tests/test_fabric.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from chef.fabric import chef_roledefs 4 | from chef.tests import ChefTestCase, mockSearch 5 | 6 | class FabricTestCase(ChefTestCase): 7 | @mock.patch('chef.search.Search') 8 | def test_roledef(self, MockSearch): 9 | search_data = { 10 | ('role', '*:*'): {}, 11 | } 12 | search_mock_memo = {} 13 | def search_mock(index, q='*:*', *args, **kwargs): 14 | data = search_data[index, q] 15 | search_mock_inst = search_mock_memo.get((index, q)) 16 | if search_mock_inst is None: 17 | search_mock_inst = search_mock_memo[index, q] = mock.Mock() 18 | search_mock_inst.data = data 19 | return search_mock_inst 20 | MockSearch.side_effect = search_mock 21 | print(MockSearch('role').data) 22 | 23 | 24 | @mockSearch({('role', '*:*'): {1:2}}) 25 | def test_roledef2(self, MockSearch): 26 | print(MockSearch('role').data) 27 | -------------------------------------------------------------------------------- /chef/tests/test_node.py: -------------------------------------------------------------------------------- 1 | from unittest2 import TestCase, skip 2 | 3 | from chef import Node 4 | from chef.exceptions import ChefError 5 | from chef.node import NodeAttributes 6 | from chef.tests import ChefTestCase 7 | 8 | class NodeAttributeTestCase(TestCase): 9 | def test_getitem(self): 10 | attrs = NodeAttributes([{'a': 1}]) 11 | self.assertEqual(attrs['a'], 1) 12 | 13 | def test_setitem(self): 14 | data = {'a': 1} 15 | attrs = NodeAttributes([data], write=data) 16 | attrs['a'] = 2 17 | self.assertEqual(attrs['a'], 2) 18 | self.assertEqual(data['a'], 2) 19 | 20 | def test_getitem_nested(self): 21 | attrs = NodeAttributes([{'a': {'b': 1}}]) 22 | self.assertEqual(attrs['a']['b'], 1) 23 | 24 | def test_set_nested(self): 25 | data = {'a': {'b': 1}} 26 | attrs = NodeAttributes([data], write=data) 27 | attrs['a']['b'] = 2 28 | self.assertEqual(attrs['a']['b'], 2) 29 | self.assertEqual(data['a']['b'], 2) 30 | 31 | def test_search_path(self): 32 | attrs = NodeAttributes([{'a': 1}, {'a': 2}]) 33 | self.assertEqual(attrs['a'], 1) 34 | 35 | def test_search_path_nested(self): 36 | data1 = {'a': {'b': 1}} 37 | data2 = {'a': {'b': 2}} 38 | attrs = NodeAttributes([data1, data2]) 39 | self.assertEqual(attrs['a']['b'], 1) 40 | 41 | def test_read_only(self): 42 | attrs = NodeAttributes([{'a': 1}]) 43 | with self.assertRaises(ChefError): 44 | attrs['a'] = 2 45 | 46 | def test_get(self): 47 | attrs = NodeAttributes([{'a': 1}]) 48 | self.assertEqual(attrs.get('a'), 1) 49 | 50 | def test_get_default(self): 51 | attrs = NodeAttributes([{'a': 1}]) 52 | self.assertEqual(attrs.get('b'), None) 53 | 54 | def test_getitem_keyerror(self): 55 | attrs = NodeAttributes([{'a': 1}]) 56 | with self.assertRaises(KeyError): 57 | attrs['b'] 58 | 59 | def test_iter(self): 60 | attrs = NodeAttributes([{'a': 1, 'b': 2}]) 61 | self.assertEqual(set(attrs), set(['a', 'b'])) 62 | 63 | def test_iter2(self): 64 | attrs = NodeAttributes([{'a': {'b': 1, 'c': 2}}]) 65 | self.assertEqual(set(attrs['a']), set(['b', 'c'])) 66 | 67 | def test_len(self): 68 | attrs = NodeAttributes([{'a': 1, 'b': 2}]) 69 | self.assertEqual(len(attrs), 2) 70 | 71 | def test_len2(self): 72 | attrs = NodeAttributes([{'a': {'b': 1, 'c': 2}}]) 73 | self.assertEqual(len(attrs), 1) 74 | self.assertEqual(len(attrs['a']), 2) 75 | 76 | def test_get_dotted(self): 77 | attrs = NodeAttributes([{'a': {'b': 1}}]) 78 | self.assertEqual(attrs.get_dotted('a.b'), 1) 79 | 80 | def test_get_dotted_keyerror(self): 81 | attrs = NodeAttributes([{'a': {'b': 1}}]) 82 | with self.assertRaises(KeyError): 83 | attrs.get_dotted('a.b.c') 84 | 85 | def test_set_dotted(self): 86 | data = {'a': {'b': 1}} 87 | attrs = NodeAttributes([data], write=data) 88 | attrs.set_dotted('a.b', 2) 89 | self.assertEqual(attrs['a']['b'], 2) 90 | self.assertEqual(attrs.get_dotted('a.b'), 2) 91 | self.assertEqual(data['a']['b'], 2) 92 | 93 | def test_set_dotted2(self): 94 | data = {'a': {'b': 1}} 95 | attrs = NodeAttributes([data], write=data) 96 | attrs.set_dotted('a.c.d', 2) 97 | self.assertEqual(attrs['a']['c']['d'], 2) 98 | self.assertEqual(attrs.get_dotted('a.c.d'), 2) 99 | self.assertEqual(data['a']['c']['d'], 2) 100 | 101 | 102 | class NodeTestCase(ChefTestCase): 103 | def setUp(self): 104 | super(NodeTestCase, self).setUp() 105 | self.node = Node('test_1') 106 | 107 | def test_default_attr(self): 108 | self.assertEqual(self.node.default['test_attr'], 'default') 109 | 110 | def test_normal_attr(self): 111 | self.assertEqual(self.node.normal['test_attr'], 'normal') 112 | 113 | def test_override_attr(self): 114 | self.assertEqual(self.node.override['test_attr'], 'override') 115 | 116 | def test_composite_attr(self): 117 | self.assertEqual(self.node.attributes['test_attr'], 'override') 118 | 119 | def test_getitem(self): 120 | self.assertEqual(self.node['test_attr'], 'override') 121 | 122 | def test_create(self): 123 | name = self.random() 124 | node = Node.create(name, run_list=['recipe[foo]']) 125 | self.register(node) 126 | self.assertEqual(node.run_list, ['recipe[foo]']) 127 | 128 | node2 = Node(name) 129 | self.assertTrue(node2.exists) 130 | self.assertEqual(node2.run_list, ['recipe[foo]']) 131 | 132 | def test_create_crosslink(self): 133 | node = Node.create(self.random()) 134 | self.register(node) 135 | node.normal['foo'] = 'bar' 136 | self.assertEqual(node['foo'], 'bar') 137 | node.attributes['foo'] = 'baz' 138 | self.assertEqual(node.normal['foo'], 'baz') 139 | -------------------------------------------------------------------------------- /chef/tests/test_role.py: -------------------------------------------------------------------------------- 1 | from chef import Role 2 | from chef.exceptions import ChefError 3 | from chef.tests import ChefTestCase 4 | 5 | class RoleTestCase(ChefTestCase): 6 | def test_get(self): 7 | r = Role('test_1') 8 | self.assertTrue(r.exists) 9 | self.assertEqual(r.description, 'Static test role 1') 10 | self.assertEqual(r.run_list, []) 11 | self.assertEqual(r.default_attributes['test_attr'], 'default') 12 | self.assertEqual(r.default_attributes['nested']['nested_attr'], 1) 13 | self.assertEqual(r.override_attributes['test_attr'], 'override') 14 | 15 | def test_create(self): 16 | name = self.random() 17 | r = Role.create(name, description='A test role', run_list=['recipe[foo]'], 18 | default_attributes={'attr': 'foo'}, override_attributes={'attr': 'bar'}) 19 | self.register(r) 20 | self.assertEqual(r.description, 'A test role') 21 | self.assertEqual(r.run_list, ['recipe[foo]']) 22 | self.assertEqual(r.default_attributes['attr'], 'foo') 23 | self.assertEqual(r.override_attributes['attr'], 'bar') 24 | 25 | r2 = Role(name) 26 | self.assertTrue(r2.exists) 27 | self.assertEqual(r2.description, 'A test role') 28 | self.assertEqual(r2.run_list, ['recipe[foo]']) 29 | self.assertEqual(r2.default_attributes['attr'], 'foo') 30 | self.assertEqual(r2.override_attributes['attr'], 'bar') 31 | 32 | def test_delete(self): 33 | name = self.random() 34 | r = Role.create(name) 35 | r.delete() 36 | for n in Role.list(): 37 | self.assertNotEqual(n, name) 38 | self.assertFalse(Role(name).exists) 39 | -------------------------------------------------------------------------------- /chef/tests/test_rsa.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import unittest2 4 | 5 | from chef.rsa import Key, SSLError 6 | from chef.tests import TEST_ROOT, skipSlowTest 7 | 8 | class RSATestCase(unittest2.TestCase): 9 | def test_load_private(self): 10 | key = Key(os.path.join(TEST_ROOT, 'client.pem')) 11 | self.assertFalse(key.public) 12 | 13 | def test_load_public(self): 14 | key = Key(os.path.join(TEST_ROOT, 'client_pub.pem')) 15 | self.assertTrue(key.public) 16 | 17 | def test_private_export(self): 18 | key = Key(os.path.join(TEST_ROOT, 'client.pem')) 19 | raw = open(os.path.join(TEST_ROOT, 'client.pem'), 'rb').read() 20 | self.assertTrue(key.private_export().strip(), raw.strip()) 21 | 22 | def test_public_export(self): 23 | key = Key(os.path.join(TEST_ROOT, 'client.pem')) 24 | raw = open(os.path.join(TEST_ROOT, 'client_pub.pem'), 'rb').read() 25 | self.assertTrue(key.public_export().strip(), raw.strip()) 26 | 27 | def test_private_export_pubkey(self): 28 | key = Key(os.path.join(TEST_ROOT, 'client_pub.pem')) 29 | with self.assertRaises(SSLError): 30 | key.private_export() 31 | 32 | def test_public_export_pubkey(self): 33 | key = Key(os.path.join(TEST_ROOT, 'client_pub.pem')) 34 | raw = open(os.path.join(TEST_ROOT, 'client_pub.pem'), 'rb').read() 35 | self.assertTrue(key.public_export().strip(), raw.strip()) 36 | 37 | def test_encrypt_decrypt(self): 38 | key = Key(os.path.join(TEST_ROOT, 'client.pem')) 39 | msg = 'Test string!' 40 | self.assertEqual(key.public_decrypt(key.private_encrypt(msg)), msg) 41 | 42 | def test_encrypt_decrypt_pubkey(self): 43 | key = Key(os.path.join(TEST_ROOT, 'client.pem')) 44 | pubkey = Key(os.path.join(TEST_ROOT, 'client_pub.pem')) 45 | msg = 'Test string!' 46 | self.assertEqual(pubkey.public_decrypt(key.private_encrypt(msg)), msg) 47 | 48 | def test_generate(self): 49 | key = Key.generate() 50 | msg = 'Test string!' 51 | self.assertEqual(key.public_decrypt(key.private_encrypt(msg)), msg) 52 | 53 | def test_generate_load(self): 54 | key = Key.generate() 55 | key2 = Key(key.private_export()) 56 | self.assertFalse(key2.public) 57 | key3 = Key(key.public_export()) 58 | self.assertTrue(key3.public) 59 | 60 | def test_load_pem_string(self): 61 | key = Key(open(os.path.join(TEST_ROOT, 'client.pem'), 'rb').read()) 62 | self.assertFalse(key.public) 63 | 64 | def test_load_public_pem_string(self): 65 | key = Key(open(os.path.join(TEST_ROOT, 'client_pub.pem'), 'rb').read()) 66 | self.assertTrue(key.public) 67 | -------------------------------------------------------------------------------- /chef/tests/test_search.py: -------------------------------------------------------------------------------- 1 | from unittest2 import skip 2 | 3 | from chef import Search, Node 4 | from chef.exceptions import ChefError 5 | from chef.tests import ChefTestCase, mockSearch 6 | 7 | class SearchTestCase(ChefTestCase): 8 | def test_search_all(self): 9 | s = Search('node') 10 | self.assertGreaterEqual(len(s), 3) 11 | self.assertIn('test_1', s) 12 | self.assertIn('test_2', s) 13 | self.assertIn('test_3', s) 14 | 15 | def test_search_query(self): 16 | s = Search('node', 'role:test_1') 17 | self.assertGreaterEqual(len(s), 2) 18 | self.assertIn('test_1', s) 19 | self.assertNotIn('test_2', s) 20 | self.assertIn('test_3', s) 21 | 22 | def test_list(self): 23 | searches = Search.list() 24 | self.assertIn('node', searches) 25 | self.assertIn('role', searches) 26 | 27 | def test_search_set_query(self): 28 | s = Search('node').query('role:test_1') 29 | self.assertGreaterEqual(len(s), 2) 30 | self.assertIn('test_1', s) 31 | self.assertNotIn('test_2', s) 32 | self.assertIn('test_3', s) 33 | 34 | def test_search_call(self): 35 | s = Search('node')('role:test_1') 36 | self.assertGreaterEqual(len(s), 2) 37 | self.assertIn('test_1', s) 38 | self.assertNotIn('test_2', s) 39 | self.assertIn('test_3', s) 40 | 41 | def test_rows(self): 42 | s = Search('node', rows=1) 43 | self.assertEqual(len(s), 1) 44 | self.assertGreaterEqual(s.total, 3) 45 | 46 | def test_start(self): 47 | s = Search('node', start=1) 48 | self.assertEqual(len(s), s.total-1) 49 | self.assertGreaterEqual(s.total, 3) 50 | 51 | def test_slice(self): 52 | s = Search('node')[1:2] 53 | self.assertEqual(len(s), 1) 54 | self.assertGreaterEqual(s.total, 3) 55 | 56 | s2 = s[1:2] 57 | self.assertEqual(len(s2), 1) 58 | self.assertGreaterEqual(s2.total, 3) 59 | self.assertNotEqual(s[0]['name'], s2[0]['name']) 60 | 61 | s3 = Search('node')[2:3] 62 | self.assertEqual(len(s3), 1) 63 | self.assertGreaterEqual(s3.total, 3) 64 | self.assertEqual(s2[0]['name'], s3[0]['name']) 65 | 66 | def test_object(self): 67 | s = Search('node', 'name:test_1') 68 | self.assertEqual(len(s), 1) 69 | node = s[0].object 70 | self.assertEqual(node.name, 'test_1') 71 | self.assertEqual(node.run_list, ['role[test_1]']) 72 | 73 | 74 | class MockSearchTestCase(ChefTestCase): 75 | @mockSearch({ 76 | ('node', '*:*'): [Node('fake_1', skip_load=True).to_dict()] 77 | }) 78 | def test_single_node(self, MockSearch): 79 | import chef.search 80 | s = chef.search.Search('node') 81 | self.assertEqual(len(s), 1) 82 | self.assertIn('fake_1', s) 83 | -------------------------------------------------------------------------------- /chef/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderanger/pychef/216cbb25579975bd3a4233641aa8a5ff5fff2168/chef/utils/__init__.py -------------------------------------------------------------------------------- /chef/utils/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def walk_backwards(path): 4 | while 1: 5 | yield path 6 | next_path = os.path.dirname(path) 7 | if path == next_path: 8 | break 9 | path = next_path 10 | -------------------------------------------------------------------------------- /chef/utils/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import types 3 | try: 4 | import json 5 | except ImportError: 6 | import simplejson as json 7 | 8 | def maybe_call(x): 9 | if callable(x): 10 | return x() 11 | return x 12 | 13 | class JSONEncoder(json.JSONEncoder): 14 | """Custom encoder to allow arbitrary classes.""" 15 | 16 | def default(self, obj): 17 | if hasattr(obj, 'to_dict'): 18 | return maybe_call(obj.to_dict) 19 | elif hasattr(obj, 'to_list'): 20 | return maybe_call(obj.to_list) 21 | elif isinstance(obj, types.GeneratorType): 22 | return list(obj) 23 | return super(JSONEncoder, self).default(obj) 24 | 25 | loads = json.loads 26 | dumps = lambda obj, **kwargs: json.dumps(obj, cls=JSONEncoder, **kwargs) 27 | -------------------------------------------------------------------------------- /contrib/python-chef.spec: -------------------------------------------------------------------------------- 1 | %{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print get_python_lib()")} 2 | %global pkgname chef 3 | 4 | Name: python-%{pkgname} 5 | Version: 0.2.1 6 | Release: 1%{?dist} 7 | Summary: A Python API for interacting with a Chef server 8 | 9 | Group: Development/Libraries 10 | License: BSD 11 | URL: http://github.com/coderanger/pychef 12 | Source0: coderanger-pychef-v0.2.1-0-g5b9a185.tar.gz 13 | BuildRoot: %(mktemp -ud %{_tmppath}/%{name}-%{version}-%{release}-XXXXXX) 14 | 15 | Requires: python openssl-devel 16 | BuildRequires: python python-devel python-setuptools 17 | 18 | %description 19 | A Python API for interacting with a Chef server. 20 | 21 | 22 | %prep 23 | %setup -q -n coderanger-pychef-g5b9a185 24 | 25 | 26 | %build 27 | %{__python} setup.py build 28 | 29 | 30 | %install 31 | rm -rf %{buildroot} 32 | 33 | PATH=$PATH:%{buildroot}%{python_sitelib}/%{pkgname} 34 | %{__python} setup.py install --root=%{buildroot} 35 | 36 | 37 | %clean 38 | rm -rf %{buildroot} 39 | 40 | 41 | %files 42 | %defattr(-,root,root,-) 43 | #%doc 44 | %dir %{python_sitelib}/PyChef-0.2.1-py2.6.egg-info/ 45 | %{python_sitelib}/PyChef-0.2.1-py2.6.egg-info/* 46 | %dir %{python_sitelib}/%{pkgname}/ 47 | %{python_sitelib}/%{pkgname}/*.py 48 | %{python_sitelib}/%{pkgname}/*.pyc 49 | %{python_sitelib}/%{pkgname}/*.pyo 50 | %dir %{python_sitelib}/%{pkgname}/tests/ 51 | %{python_sitelib}/%{pkgname}/tests/*.py 52 | %{python_sitelib}/%{pkgname}/tests/*.pyc 53 | %{python_sitelib}/%{pkgname}/tests/*.pyo 54 | %dir %{python_sitelib}/%{pkgname}/utils/ 55 | %{python_sitelib}/%{pkgname}/utils/*.py 56 | %{python_sitelib}/%{pkgname}/utils/*.pyc 57 | %{python_sitelib}/%{pkgname}/utils/*.pyo 58 | 59 | 60 | %changelog 61 | * Tue Jul 26 2011 Daniel Aharon - 0.2-1 62 | - Initial release 63 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 37 | 38 | clean: 39 | -rm -rf $(BUILDDIR)/* 40 | 41 | html: 42 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 43 | @echo 44 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 45 | 46 | dirhtml: 47 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 48 | @echo 49 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 50 | 51 | singlehtml: 52 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 53 | @echo 54 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 55 | 56 | pickle: 57 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 58 | @echo 59 | @echo "Build finished; now you can process the pickle files." 60 | 61 | json: 62 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 63 | @echo 64 | @echo "Build finished; now you can process the JSON files." 65 | 66 | htmlhelp: 67 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 68 | @echo 69 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 70 | ".hhp project file in $(BUILDDIR)/htmlhelp." 71 | 72 | qthelp: 73 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 74 | @echo 75 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 76 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 77 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyZen.qhcp" 78 | @echo "To view the help file:" 79 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyZen.qhc" 80 | 81 | devhelp: 82 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 83 | @echo 84 | @echo "Build finished." 85 | @echo "To view the help file:" 86 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyZen" 87 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyZen" 88 | @echo "# devhelp" 89 | 90 | epub: 91 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 92 | @echo 93 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 94 | 95 | latex: 96 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 97 | @echo 98 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 99 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 100 | "(use \`make latexpdf' here to do that automatically)." 101 | 102 | latexpdf: 103 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 104 | @echo "Running LaTeX files through pdflatex..." 105 | make -C $(BUILDDIR)/latex all-pdf 106 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 107 | 108 | text: 109 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 110 | @echo 111 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 112 | 113 | man: 114 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 115 | @echo 116 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 117 | 118 | changes: 119 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 120 | @echo 121 | @echo "The overview file is in $(BUILDDIR)/changes." 122 | 123 | linkcheck: 124 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 125 | @echo 126 | @echo "Link check complete; look for any errors in the above output " \ 127 | "or in $(BUILDDIR)/linkcheck/output.txt." 128 | 129 | texinfo: 130 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 131 | @echo 132 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 133 | @echo "Run \`make' in that directory to run these through makeinfo" \ 134 | "(use \`make info' here to do that automatically)." 135 | 136 | info: 137 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 138 | @echo "Running Texinfo files through makeinfo..." 139 | make -C $(BUILDDIR)/texinfo info 140 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 141 | 142 | doctest: 143 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 144 | @echo "Testing of doctests in the sources finished, look at the " \ 145 | "results in $(BUILDDIR)/doctest/output.txt." 146 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. module:: chef 2 | .. _api: 3 | 4 | API Reference 5 | ============= 6 | 7 | Chef API Interface 8 | ------------------ 9 | 10 | .. autoclass:: ChefAPI 11 | :members: 12 | 13 | .. autofunction:: autoconfigure 14 | 15 | Nodes 16 | ----- 17 | 18 | .. autoclass :: Node 19 | :members: 20 | :inherited-members: 21 | 22 | .. autoclass:: chef.node.NodeAttributes 23 | :members: 24 | 25 | Roles 26 | ----- 27 | 28 | .. autoclass :: Role 29 | :members: 30 | :inherited-members: 31 | 32 | Data Bags 33 | --------- 34 | 35 | .. autoclass :: DataBag 36 | :members: 37 | :inherited-members: 38 | 39 | .. autoclass :: DataBagItem 40 | :members: 41 | :inherited-members: 42 | 43 | Environments 44 | ------------ 45 | 46 | .. autoclass :: Environment 47 | :members: 48 | :inherited-members: 49 | 50 | Search 51 | ------ 52 | 53 | .. autoclass :: Search 54 | :members: 55 | :inherited-members: 56 | -------------------------------------------------------------------------------- /docs/auth.rst: -------------------------------------------------------------------------------- 1 | .. _auth: 2 | 3 | =============================== 4 | Opscode Authentication protocol 5 | =============================== 6 | 7 | The Opscode authentication protocol is a specification for an HTTP 8 | authentication method using RSA signatures. It is used with chef-server-api as 9 | well as the Opscode Platform service. 10 | 11 | .. _auth-keys: 12 | 13 | Keys 14 | ==== 15 | 16 | Every client to a Chef server requires an RSA private key. These are generated 17 | by the server (or Platform) and should be stored securely. Keys must be in PEM 18 | format as defined by OpenSSL. 19 | 20 | .. _auth-headers: 21 | 22 | Headers 23 | ======= 24 | 25 | Each request must include 5 headers: 26 | 27 | X-Ops-Sign 28 | Must be ``version=1.0``. 29 | X-Ops-Userid 30 | The name of the API client. 31 | X-Ops-Timestamp 32 | The current time. See :ref:`Timestamp `. 33 | X-Ops-Content-Hash 34 | The hash of the content of the request. See :ref:`Hashing `. 35 | X-Ops-Authorization-$N 36 | The lines of the RSA signature. See :ref:`Signature `. 37 | 38 | .. _auth-canonical: 39 | 40 | Canonicalization 41 | ================ 42 | 43 | Rules for canonicalizing request data. 44 | 45 | .. _auth-canonical-method: 46 | 47 | HTTP Method 48 | ----------- 49 | 50 | HTTP methods must be capitalized. Examples:: 51 | 52 | GET 53 | POST 54 | 55 | .. _auth-canonical-timestamp: 56 | 57 | Timestamp 58 | --------- 59 | 60 | All timestamps must in `ISO 8601`__ format using ``T`` as the separator. The 61 | timezone must be UTC, using ``Z`` as the indicator. Examples:: 62 | 63 | 2010-12-04T15:47:49Z 64 | 65 | __ http://en.wikipedia.org/wiki/ISO_8601 66 | 67 | .. _auth-canonical-path: 68 | 69 | Path 70 | ---- 71 | 72 | The path component of the URL must not have consecutive ``/`` characters. If 73 | it is not the root path (``^/$``), it must not end with a ``/`` character. 74 | Example:: 75 | 76 | / 77 | /nodes 78 | /nodes/example.com 79 | 80 | .. _auth-hash: 81 | 82 | Hashing 83 | ======= 84 | 85 | All hashes are Base64-encoded SHA1. The Base64 text must have line-breaks 86 | every 60 characters. The Base64 alphabet must be the standard alphabet 87 | defined in `RFC 3548`__ (``+/=``). 88 | 89 | __ http://tools.ietf.org/html/rfc3548.html 90 | 91 | .. _auth-sign: 92 | 93 | Signature 94 | ========= 95 | 96 | The ``X-Ops-Authorization-$N`` headers must be a Base64 hash of the output 97 | of ``RSA_private_encrypt``. Each line of the Base64 output is a new header, 98 | with the numbering starting at 1. 99 | 100 | Base String 101 | ----------- 102 | 103 | The signature base string is defined as:: 104 | 105 | Method:\n 106 | Hashed Path:\n 107 | X-Ops-Content-Hash:\n 108 | X-Ops-Timestamp:\n 109 | X-Ops-UserId: 110 | 111 | All values must be canonicalized using the above rules. 112 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PyChef documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Aug 14 18:14:46 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'PyChef' 44 | copyright = u'2010-2012, Noah Kantrowitz' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | import pkg_resources 50 | try: 51 | release = pkg_resources.get_distribution('PyChef').version 52 | except pkg_resources.DistributionNotFound: 53 | print 'To build the documentation, The distribution information of PyChef' 54 | print 'Has to be available. Either install the package into your' 55 | print 'development environment or run "setup.py develop" to setup the' 56 | print 'metadata. A virtualenv is recommended!' 57 | sys.exit(1) 58 | del pkg_resources 59 | 60 | if 'dev' in release: 61 | release = release.split('dev')[0] + 'dev' 62 | version = '.'.join(release.split('.')[:2]) 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | #language = None 67 | 68 | # There are two options for replacing |today|: either, you set today to some 69 | # non-false value, then it is used: 70 | #today = '' 71 | # Else, today_fmt is used as the format for a strftime call. 72 | #today_fmt = '%B %d, %Y' 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | exclude_patterns = ['_build'] 77 | 78 | # The reST default role (used for this markup: `text`) to use for all documents. 79 | #default_role = None 80 | 81 | # If true, '()' will be appended to :func: etc. cross-reference text. 82 | #add_function_parentheses = True 83 | 84 | # If true, the current module name will be prepended to all description 85 | # unit titles (such as .. function::). 86 | #add_module_names = True 87 | 88 | # If true, sectionauthor and moduleauthor directives will be shown in the 89 | # output. They are ignored by default. 90 | #show_authors = False 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = 'sphinx' 94 | 95 | # A list of ignored prefixes for module index sorting. 96 | #modindex_common_prefix = [] 97 | 98 | 99 | # -- Options for HTML output --------------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | html_theme = 'default' 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | #html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | #html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | #html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | #html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | #html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | #html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ['_static'] 133 | 134 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 135 | # using the given strftime format. 136 | #html_last_updated_fmt = '%b %d, %Y' 137 | 138 | # If true, SmartyPants will be used to convert quotes and dashes to 139 | # typographically correct entities. 140 | #html_use_smartypants = True 141 | 142 | # Custom sidebar templates, maps document names to template names. 143 | #html_sidebars = {} 144 | 145 | # Additional templates that should be rendered to pages, maps page names to 146 | # template names. 147 | #html_additional_pages = {} 148 | 149 | # If false, no module index is generated. 150 | #html_domain_indices = True 151 | 152 | # If false, no index is generated. 153 | #html_use_index = True 154 | 155 | # If true, the index is split into individual pages for each letter. 156 | #html_split_index = False 157 | 158 | # If true, links to the reST sources are added to the pages. 159 | #html_show_sourcelink = True 160 | 161 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 162 | #html_show_sphinx = True 163 | 164 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 165 | #html_show_copyright = True 166 | 167 | # If true, an OpenSearch description file will be output, and all pages will 168 | # contain a tag referring to it. The value of this option must be the 169 | # base URL from which the finished HTML is served. 170 | #html_use_opensearch = '' 171 | 172 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 173 | #html_file_suffix = None 174 | 175 | # Output file base name for HTML help builder. 176 | htmlhelp_basename = 'PyChefdoc' 177 | 178 | 179 | # -- Options for LaTeX output -------------------------------------------------- 180 | 181 | # The paper size ('letter' or 'a4'). 182 | #latex_paper_size = 'letter' 183 | 184 | # The font size ('10pt', '11pt' or '12pt'). 185 | #latex_font_size = '10pt' 186 | 187 | # Grouping the document tree into LaTeX files. List of tuples 188 | # (source start file, target name, title, author, documentclass [howto/manual]). 189 | latex_documents = [ 190 | ('index', 'PyChef.tex', u'PyChef Documentation', 191 | u'Noah Kantrowitz', 'manual'), 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | #latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | #latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | #latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | #latex_show_urls = False 207 | 208 | # Additional stuff for the LaTeX preamble. 209 | #latex_preamble = '' 210 | 211 | # Documents to append as an appendix to all manuals. 212 | #latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | #latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [ 223 | ('index', 'pychef', u'PyChef Documentation', 224 | [u'Noah Kantrowitz'], 1) 225 | ] 226 | -------------------------------------------------------------------------------- /docs/fabric.rst: -------------------------------------------------------------------------------- 1 | .. module:: chef.fabric 2 | .. _fabric: 3 | 4 | Fabric Integration 5 | ================== 6 | 7 | .. autofunction:: chef.fabric.chef_roledefs 8 | 9 | .. autofunction:: chef.fabric.chef_environment 10 | 11 | .. autofunction:: chef.fabric.chef_query 12 | 13 | .. autofunction:: chef.fabric.chef_tags 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyChef 2 | ====== 3 | 4 | Getting Started 5 | --------------- 6 | 7 | The first thing you have to do is load your Chef server credentials in to a 8 | :class:`~chef.ChefAPI` object. The easiest way to do this is with 9 | :func:`~chef.autoconfigure`:: 10 | 11 | import chef 12 | api = chef.autoconfigure() 13 | 14 | Then we can load an object from the Chef server:: 15 | 16 | node = chef.Node('node_1') 17 | 18 | And update it:: 19 | 20 | node.run_list.append('role[app]') 21 | node.save() 22 | 23 | .. toctree:: 24 | 25 | api 26 | fabric 27 | 28 | .. toctree:: 29 | :hidden: 30 | 31 | auth -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 47 | goto end 48 | ) 49 | 50 | if "%1" == "dirhtml" ( 51 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 52 | echo. 53 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 54 | goto end 55 | ) 56 | 57 | if "%1" == "singlehtml" ( 58 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 59 | echo. 60 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 61 | goto end 62 | ) 63 | 64 | if "%1" == "pickle" ( 65 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 66 | echo. 67 | echo.Build finished; now you can process the pickle files. 68 | goto end 69 | ) 70 | 71 | if "%1" == "json" ( 72 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 73 | echo. 74 | echo.Build finished; now you can process the JSON files. 75 | goto end 76 | ) 77 | 78 | if "%1" == "htmlhelp" ( 79 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 80 | echo. 81 | echo.Build finished; now you can run HTML Help Workshop with the ^ 82 | .hhp project file in %BUILDDIR%/htmlhelp. 83 | goto end 84 | ) 85 | 86 | if "%1" == "qthelp" ( 87 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 88 | echo. 89 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 90 | .qhcp project file in %BUILDDIR%/qthelp, like this: 91 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PyZen.qhcp 92 | echo.To view the help file: 93 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PyZen.ghc 94 | goto end 95 | ) 96 | 97 | if "%1" == "devhelp" ( 98 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 99 | echo. 100 | echo.Build finished. 101 | goto end 102 | ) 103 | 104 | if "%1" == "epub" ( 105 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 106 | echo. 107 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 108 | goto end 109 | ) 110 | 111 | if "%1" == "latex" ( 112 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 113 | echo. 114 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 115 | goto end 116 | ) 117 | 118 | if "%1" == "text" ( 119 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 120 | echo. 121 | echo.Build finished. The text files are in %BUILDDIR%/text. 122 | goto end 123 | ) 124 | 125 | if "%1" == "man" ( 126 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 127 | echo. 128 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 129 | goto end 130 | ) 131 | 132 | if "%1" == "changes" ( 133 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 134 | echo. 135 | echo.The overview file is in %BUILDDIR%/changes. 136 | goto end 137 | ) 138 | 139 | if "%1" == "linkcheck" ( 140 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 141 | echo. 142 | echo.Link check complete; look for any errors in the above output ^ 143 | or in %BUILDDIR%/linkcheck/output.txt. 144 | goto end 145 | ) 146 | 147 | if "%1" == "doctest" ( 148 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 149 | echo. 150 | echo.Testing of doctests in the sources finished, look at the ^ 151 | results in %BUILDDIR%/doctest/output.txt. 152 | goto end 153 | ) 154 | 155 | :end 156 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | versiontools 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: iso-8859-1 -*- 3 | import os 4 | 5 | from setuptools import setup, find_packages 6 | 7 | setup( 8 | name = 'PyChef', 9 | version = '0.3.0', 10 | packages = find_packages(), 11 | author = 'Noah Kantrowitz', 12 | author_email = 'noah@coderanger.net', 13 | description = 'Python implementation of a Chef API client.', 14 | long_description = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read(), 15 | license = 'Apache 2.0', 16 | keywords = '', 17 | url = 'http://github.com/coderanger/pychef', 18 | classifiers = [ 19 | #'Development Status :: 1 - Planning', 20 | #'Development Status :: 2 - Pre-Alpha', 21 | #'Development Status :: 3 - Alpha', 22 | #'Development Status :: 4 - Beta', 23 | 'Development Status :: 5 - Production/Stable', 24 | #'Development Status :: 6 - Mature', 25 | #'Development Status :: 7 - Inactive', 26 | 'License :: OSI Approved :: Apache License', 27 | 'Natural Language :: English', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | ], 31 | zip_safe = False, 32 | install_requires = ['six>=1.9.0','requests>=2.7.0'], 33 | tests_require = ['unittest2', 'mock'], 34 | test_suite = 'unittest2.collector', 35 | ) 36 | -------------------------------------------------------------------------------- /versiontools_support.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012 Linaro Limited 2 | # 3 | # Author: Zygmunt Krynicki 4 | # 5 | # This file is part of versiontools. 6 | # 7 | # versiontools is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License version 3 9 | # as published by the Free Software Foundation 10 | # 11 | # versiontools is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Lesser General Public License 17 | # along with versiontools. If not, see . 18 | 19 | """ 20 | versiontools.versiontools_support 21 | ================================= 22 | 23 | A small standalone module that allows any package to use versiontools. 24 | 25 | Typically you should copy this file verbatim into your source distribution. 26 | 27 | Historically versiontools was depending on a exotic feature of setuptools to 28 | work. Setuptools has so-called setup-time dependencies, that is modules that 29 | need to be downloaded and imported/interrogated for setup.py to run 30 | successfully. Versiontools supports this by installing a handler for the 31 | 'version' keyword of the setup() function. 32 | 33 | This approach was always a little annoying as this setuptools feature is rather 34 | odd and very few other packages made any use of it. In the future the standard 35 | tools for python packaging (especially in python3 world) this feature may be 36 | removed or have equivalent thus rendering versiontools completely broken. 37 | 38 | Currently the biggest practical issue is the apparent inability to prevent 39 | setuptools from downloading packages designated as setup_requires. This is 40 | discussed in this pip issue: https://github.com/pypa/pip/issues/410 41 | 42 | To counter this issue I've redesigned versiontools to be a little smarter. The 43 | old mode stays as-is for compatibility. The new mode works differently, without 44 | the need for using setup_requires in your setup() call. Instead it requires 45 | each package that uses versiontools to ship a verbatim copy of this module and 46 | to import it in their setup.py script. This module helps setuptools find 47 | package version in the standard PKG-INFO file that is created for all source 48 | distributions. Remember that you only need this mode when you don't want to add 49 | a dependency on versiontools. This will still allow you to use versiontools (in 50 | a limited way) in your setup.py file. 51 | 52 | Technically this module defines an improved version of one of 53 | distutils.dist.DistributionMetadata class and monkey-patches distutils to use 54 | it. To retain backward compatibility the new feature is only active when a 55 | special version string is passed to the setup() call. 56 | """ 57 | 58 | __version__ = (1, 0, 0, "final", 0) 59 | 60 | import distutils.dist 61 | import distutils.errors 62 | 63 | 64 | class VersiontoolsEnchancedDistributionMetadata(distutils.dist.DistributionMetadata): 65 | """ 66 | A subclass of distutils.dist.DistributionMetadata that uses versiontools 67 | 68 | Typically you would not instantiate this class directly. It is constructed 69 | by distutils.dist.Distribution.__init__() method. Since there is no other 70 | way to do it, this module monkey-patches distutils to override the original 71 | version of DistributionMetadata 72 | """ 73 | 74 | # Reference to the original class. This is only required because distutils 75 | # was created before the introduction of new-style classes to python. 76 | __base = distutils.dist.DistributionMetadata 77 | 78 | def get_version(self): 79 | """ 80 | Get distribution version. 81 | 82 | This method is enhanced compared to original distutils implementation. 83 | If the version string is set to a special value then instead of using 84 | the actual value the real version is obtained by querying versiontools. 85 | 86 | If versiontools package is not installed then the version is obtained 87 | from the standard section of the ``PKG-INFO`` file. This file is 88 | automatically created by any source distribution. This method is less 89 | useful as it cannot take advantage of version control information that 90 | is automatically loaded by versiontools. It has the advantage of not 91 | requiring versiontools installation and that it does not depend on 92 | ``setup_requires`` feature of ``setuptools``. 93 | """ 94 | if (self.name is not None and self.version is not None 95 | and self.version.startswith(":versiontools:")): 96 | return (self.__get_live_version() or self.__get_frozen_version() 97 | or self.__fail_to_get_any_version()) 98 | else: 99 | return self.__base.get_version(self) 100 | 101 | def __get_live_version(self): 102 | """ 103 | Get a live version string using versiontools 104 | """ 105 | try: 106 | import versiontools 107 | except ImportError: 108 | return None 109 | else: 110 | return str(versiontools.Version.from_expression(self.name)) 111 | 112 | def __get_frozen_version(self): 113 | """ 114 | Get a fixed version string using an existing PKG-INFO file 115 | """ 116 | try: 117 | return self.__base("PKG-INFO").version 118 | except IOError: 119 | return None 120 | 121 | def __fail_to_get_any_version(self): 122 | """ 123 | Raise an informative exception 124 | """ 125 | raise SystemExit( 126 | """This package requires versiontools for development or testing. 127 | 128 | See http://versiontools.readthedocs.org/ for more information about 129 | what versiontools is and why it is useful. 130 | 131 | To install versiontools now please run: 132 | $ pip install versiontools 133 | 134 | Note: versiontools works best when you have additional modules for 135 | integrating with your preferred version control system. Refer to 136 | the documentation for a full list of required modules.""") 137 | 138 | 139 | # If DistributionMetadata is not a subclass of 140 | # VersiontoolsEnhancedDistributionMetadata then monkey patch it. This should 141 | # prevent a (odd) case of multiple imports of this module. 142 | if not issubclass( 143 | distutils.dist.DistributionMetadata, 144 | VersiontoolsEnchancedDistributionMetadata): 145 | distutils.dist.DistributionMetadata = VersiontoolsEnchancedDistributionMetadata 146 | --------------------------------------------------------------------------------