kitchen-scribe 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -2,3 +2,4 @@ source :rubygems
2
2
 
3
3
  gem "chef"
4
4
  gem "rspec"
5
+ gem "kitchen-scribe"
data/Gemfile.lock CHANGED
@@ -25,6 +25,8 @@ GEM
25
25
  highline (1.6.15)
26
26
  ipaddress (0.8.0)
27
27
  json (1.6.1)
28
+ kitchen-scribe (0.1.0)
29
+ chef (>= 0.10.10)
28
30
  mime-types (1.19)
29
31
  mixlib-authentication (1.3.0)
30
32
  mixlib-log
@@ -70,4 +72,5 @@ PLATFORMS
70
72
 
71
73
  DEPENDENCIES
72
74
  chef
75
+ kitchen-scribe
73
76
  rspec
data/README.md CHANGED
@@ -4,33 +4,42 @@ Kitchen Scribe
4
4
  DESCRIPTION
5
5
  -----------
6
6
 
7
- This is a small Knife plugin for keeping track of various changes in your Chef environemnt.
7
+ This is a small Knife plugin for making and keeping track of various changes in your Chef environemnt.
8
8
 
9
- In it's core it pulls the configuration of all your environements, roles, and nodes then saves them into json files in the place of your choosing and commits it using a local git repository. It can also pull/push them to a remote repostory for safekeeping.
9
+ It performs two main functions.
10
10
 
11
- The plugin is in an early alpha (as indicated by the commit dates) so things may change rather drastically in the near future.
11
+ 1. _Documenting changes_. It pulls the configuration of all your environements, roles, and nodes then saves them into json files in the place of your choosing and commits the changes to a local git repository. It can also pull/push them to a remote repostory for safekeeping.
12
+
13
+ 2. _Making precise changes_. It can perform precise updates on your environments, roles and nodes by using json data structure describing the change. *[This feature is still being tested and it's not available through rubygems yet. If you would like to try it please install it driectly from the github repo]*
14
+
15
+ The philosophy behind using scribe to update your environments, roles an nodes is that you may want to make prepare some changes in advance, be able to test them and then have them applied to the final setup. Also it might be important to isolate those changes in a clear way so people who are not familiar with chef don't have to edit a huge json object to get them in. Lastly you can now automate applying your changes as well, and automation is what Chef is all about in the end:).
16
+
17
+ The plugin is still in the beta stage, I've tested it manualy to some extent, but I'm sure there are things I missed. Please submit any bugs you find through the github issue system and I promiss to take care of them as soon as possible.
12
18
 
13
19
  [![Code Climate](https://codeclimate.com/github/khozlov/kitchen-scribe.png)](https://codeclimate.com/github/khozlov/kitchen-scribe)
20
+ [![Gem Version](https://badge.fury.io/rb/kitchen-scribe.png)](http://badge.fury.io/rb/kitchen-scribe)
14
21
 
15
22
  USAGE
16
23
  -----
17
24
 
18
- Currently Kitchen Scribe can perform two actions.
25
+ Install as gem using `gem install kitchen-scribe`.
26
+
27
+ ### Documenting Changes
19
28
 
20
29
  First you need to hire a scribe using `knife scribe hire`. By default this will initialize a local directory called `.chronicle` where all backups will be performed. This task takes the following parameters:
21
30
 
22
- * `-p` or `--chronicle-path` followed by a path allows you to specify a different location for the backup dir.
23
- * `-r` or `--remote-url` followed by a git remote url will set up a remote in the backup dir pointing to the specified url. By default the name of the remote will be `origin`
24
- * `--remote-name` followed by a remote_name allows you to specify a name for the remote repository. If `-r` is not used this has no effect.
31
+ * `-p` or `--chronicle-path` followed by a path allows you to specify a different location for the backup dir.
32
+ * `-r` or `--remote-url` followed by a git remote url will set up a remote in the backup dir pointing to the specified url. By default the name of the remote will be `origin`
33
+ * `--remote-name` followed by a remote_name allows you to specify a name for the remote repository. If `-r` is not used this has no effect.
25
34
 
26
35
  `hire` action can be performed multiple times to set up additional remotes.
27
36
 
28
- Next you probably want to get your scribe to actually do something for a change. `knife scribe copy` will fully back up your `roles` and `environments` and partially backup your `nodes` (only `name`, `env`, `attributes` and `run_list`). It assumes that you already hired your scribe and by default will look for your chronicle at `.chronicle`, assume that your remote name is `origin`, your default branch name is `master` and you want each of your commit messages to say _"Commiting chef state as of [current date and time]"_. Again you may customize this using:
37
+ Next you probably want to get your scribe to actually do something for a change. `knife scribe copy` will fully back your `roles` and `environments` up and partially back your `nodes` up (only `name`, `env`, `attributes` and `run_list`). It assumes that you already hired your scribe and by default will look for your chronicle at `.chronicle`, assume that your remote name is `origin`, your default branch name is `master` and you want each of your commit messages to say _"Commiting chef state as of [current date and time]"_. Again you may customize this using:
29
38
 
30
- * `-p` or `--chronicle-path` followed by a path to specify a different location for the chronicle.
31
- * `--remote-name` followed by a remote_name to specify a different remote name.
32
- * `--branch` followed by a branch name to indicate that you want to use a different branch.
33
- * `-m` or `--commit-message` followed by a message to, suprise, suprise, specify your own custom message (use `%TIME%` anywhere in the message to get it substitued with current time).
39
+ * `-p` or `--chronicle-path` followed by a path to specify a different location for the chronicle.
40
+ * `--remote-name` followed by a remote_name to specify a different remote name.
41
+ * `--branch` followed by a branch name to indicate that you want to use a different branch.
42
+ * `-m` or `--commit-message` followed by a message to, suprise, suprise, specify your own custom message (use `%TIME%` anywhere in the message to get it substitued with current time).
34
43
 
35
44
  You can also specify all the params in your `knife.rb` not to type it in every time by putting a config hash in there:
36
45
 
@@ -38,15 +47,76 @@ You can also specify all the params in your `knife.rb` not to type it in every t
38
47
  :remote_name => "your_remote_name",
39
48
  :remote_url => "your_remote_url",
40
49
  :branch => "your_branch",
41
- :commit_message => "your_commit_message"
42
- }
43
- Have fun!
50
+ :commit_message => "your_commit_message"
51
+ }
52
+
53
+ ### Making Changes
54
+
55
+ `adjust` action is your friend here.
56
+
57
+ I takes any amount of filenames (but at least one) with the changes specified in a JSON object. Apart from `author_name`, `author_email` and `description` which aren't mandatory this object needs to contain a property called `adjustments` that will in turn be an array of JSON objects containing at least the following properties:
58
+
59
+ * `action` - The actual action to perform.
60
+ * `type` - What you are trying to update. It can be either `environment`, `role` or `node`
61
+ * `search` - the search query that will be used to figure out what to update. If a simple string is given (without a `:` character) scribe will assume it's a name and act accordingly
62
+ * `adjustment` - the hash containing the actual changes
63
+
64
+ The action to perform can be one of the following:
65
+
66
+ * `merge` - a deep merge that combines the chef object with the adjustment, adding new entries and updating values. Arrays will be combined.
67
+ * `hash_only_merge` - same as merge but arrays will be overwritten instead of combined (only in Chef version 11.0+).
68
+ * `overwrite` - a simple merge on the top level of the chef object. Usefull for overwriting run lists.
69
+ * `delete` - as the name suggests it can be used to delete parts of the config. The adjustment may be an integer or string in which case scribe will attempt to remove this key from the objec at the top level. It can be hash, which scribe treats as a map to the key that needs to be removed. Finally It can be an array which represents a set of changes that needs to be done on a single level. A quick example:
70
+
71
+ With a simple envrionment
44
72
 
45
- WHAT'S THE PLAN?
46
- ----------------
73
+ { "chef_type": "environment"
74
+ "cookbook_versions": { "apache2": "<= 1.1.8",
75
+ "apt": "<= 1.4.9"
76
+ },
77
+ "default_attributes": { "env" : "dev",
78
+ "ports" : [ 80, 8080 ],
79
+ "app" : { "storage_method" : "s3",
80
+ "storage_url" : "foo.bar"
81
+ }
82
+ }
83
+ }
47
84
 
48
- - Turn Scribe into a gem
49
- - Mysterious Next Step ;-)
85
+ Applying the folowing `delete` adjustment
86
+
87
+ { "default_attributes" : [ "env",
88
+ { "app" : "storage_method" },
89
+ { "ports" : 0 }
90
+ ],
91
+ "cookbook_versions" : "apt"
92
+ }
93
+
94
+ Will remove default_attributes/env, default_attributes/app/storage_method, cookbook_versions/apt keys and first port from the default_attributes/ports array. The final product will be:
95
+
96
+ { "chef_type": "environment"
97
+ "cookbook_versions": { "apache2": "<= 1.1.8" }
98
+ "default_attributes": { "ports" : [ 8080 ],
99
+ "app" : { "storage_url" : "foo.bar" }
100
+ }
101
+ }
102
+
103
+ You can use `adjust` with a `-g` or `--generate` option. It will then fill all the files specified with an adjustment template (overwriting any existing content of the files).
104
+
105
+ An additional option `-t` or `--type` allows you to decide what adjustment template should be used (possible variants are `environment`, `role` and `node` - `environment` being the default)
106
+
107
+ Runing `adjust` with a `--dryrun` option won't update any objects on the server, but will allow you to review the result of your adjustments in a diff format.
108
+
109
+ Lastly you can use the `-d` or `--document` option which will surround your adjustments with a `scribe copy` action (initializing the repo with `scribe hire` just in case). It will use the same configuration form your `knife.rb` file that a standalone call to `hire` or `copy` would. You can also use the same command line prameters (`--chronicle-path`, `--remote-name`, `--remote-url` and `--branch`) to set everything up.
110
+
111
+ **Important note:** When you're trying to apply a change to all your environemnts, don't use `*:*` as your search term. Chef will then try to apply those changes to the `_default` environemnt which is frozen and can't me modified. Try using `-name:_default` instead.
112
+
113
+ Have fun!
114
+
115
+ WHAT'S NEXT
116
+ -----------
117
+ * Refactoring, refactoring, refactoring
118
+ * Testing
119
+ * Bug fixes
50
120
 
51
121
  LICENSE
52
122
  -------
@@ -0,0 +1,325 @@
1
+ #
2
+ # Author:: Pawel Kozlowski (<pawel.kozlowski@u2i.com>)
3
+ # Copyright:: Copyright (c) 2013 Pawel Kozlowski
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'chef/mixin/deep_merge'
20
+ require 'chef/mixin/shell_out'
21
+
22
+ class Chef
23
+ class Knife
24
+ class ScribeAdjust < Chef::Knife
25
+
26
+ include Chef::Mixin::DeepMerge
27
+ include Chef::Mixin::ShellOut
28
+
29
+ deps do
30
+ require_relative 'scribe_hire'
31
+ require_relative 'scribe_copy'
32
+ end
33
+
34
+ TEMPLATE_HASH = { "author_name" => "",
35
+ "author_email" => "",
36
+ "description" => "",
37
+ "adjustments" => []
38
+ }
39
+
40
+ ENVIRONMENT_ADJUSTMENT_TEMPLATE = {
41
+ "adjustment" => { "default_attributes" => { },
42
+ "override_attributes" => { },
43
+ "cookbook_versions" => { }
44
+ }
45
+ }
46
+
47
+ ROLE_ADJUSTMENT_TEMPLATE = {
48
+ "adjustment" => { "default_attributes" => { },
49
+ "override_attributes" => { },
50
+ "run_list" => [ ]
51
+ }
52
+ }
53
+
54
+ NODE_ADJUSTMENT_TEMPLATE = {
55
+ "adjustment" => { "attributes" => { },
56
+ "run_list" => [ ]
57
+ }
58
+ }
59
+
60
+ banner "knife scribe adjust FILE [FILE..]"
61
+
62
+ option :generate,
63
+ :short => "-g",
64
+ :long => "--generate",
65
+ :description => "generate adjustment templates"
66
+
67
+ option :document,
68
+ :short => "-d",
69
+ :long => "--document",
70
+ :description => "document with copy copy"
71
+
72
+ option :dryrun,
73
+ :long => "--dryrun",
74
+ :description => "do a test run"
75
+
76
+
77
+ option :type,
78
+ :short => "-t TYPE",
79
+ :long => "--type TYPE",
80
+ :description => "generate adjustment templates [environemnt|node|role]",
81
+ :default => "environment"
82
+
83
+
84
+ option :chronicle_path,
85
+ :short => "-p PATH",
86
+ :long => "--chronicle-path PATH",
87
+ :description => "Path to the directory where the chronicle should be located",
88
+ :default => nil
89
+
90
+ option :remote_name,
91
+ :long => "--remote-name REMOTE_NAME",
92
+ :description => "Name of the remote chronicle repository",
93
+ :default => nil
94
+
95
+ option :remote_url,
96
+ :short => "-r REMOTE_URL",
97
+ :long => "--remote-url REMOTE_URL",
98
+ :description => "Url of the remote chronicle repository",
99
+ :default => nil
100
+
101
+ option :branch,
102
+ :long => "--branch BRANCH_NAME",
103
+ :description => "Name of the branch you want to use",
104
+ :default => nil
105
+
106
+
107
+ alias_method :action_merge, :merge
108
+ alias_method :action_hash_only_merge, :hash_only_merge if respond_to?(:hash_only_merge)
109
+
110
+ def changes
111
+ @changes ||= {}
112
+ end
113
+
114
+ def descriptions
115
+ @descriptions ||= []
116
+ end
117
+
118
+ def errors
119
+ @errors ||= []
120
+ end
121
+
122
+
123
+ def run
124
+ if @name_args[0].nil?
125
+ show_usage
126
+ ui.fatal("At least one adjustment file needs to be specified!")
127
+ exit 1
128
+ end
129
+ if config[:generate] == true
130
+ @name_args.each { |filename| generate_template(filename) }
131
+ else
132
+ parse_adjustments
133
+ end
134
+ end
135
+
136
+ def generate_templates
137
+
138
+ end
139
+
140
+ def parse_adjustments
141
+ @name_args.each do |filename|
142
+ errors.push({ "name" => filename, "general" => nil, "adjustments" => {} })
143
+ parse_adjustment_file(filename)
144
+ end
145
+ if errors?
146
+ print_errors
147
+ exit 1 unless config[:dryrun]
148
+ end
149
+ if config[:dryrun]
150
+ diff
151
+ else
152
+ if config[:document] == true
153
+ hire
154
+ record_state
155
+ end
156
+ write_adjustments
157
+ record_state(descriptions.join("\n").strip) if config[:document] == true
158
+ end
159
+ end
160
+
161
+ def generate_template(filename)
162
+ unless ["environment", "role", "node"].include?(config[:type])
163
+ ui.fatal("Incorrect adjustment type! Only 'node', 'environment' or 'role' allowed.")
164
+ exit 1
165
+ end
166
+ TEMPLATE_HASH["adjustments"] = [self.class.class_eval(config[:type].upcase + "_ADJUSTMENT_TEMPLATE")]
167
+ File.open(filename, "w") { |file| file.write(JSON.pretty_generate(TEMPLATE_HASH)) }
168
+ end
169
+
170
+ def parse_adjustment_file(filename)
171
+ if !File.exists?(filename)
172
+ errors.last["general"] = "File does not exist!"
173
+ else
174
+ begin
175
+ adjustment_file = File.open(filename, "r") { |file| JSON.load(file) }
176
+ if adjustment_file_valid? adjustment_file
177
+ adjustment_file["adjustments"].each_with_index do |adjustment, index|
178
+ apply_adjustment(adjustment) if adjustment_valid?(adjustment, index)
179
+ end
180
+ end
181
+ if adjustment_file["adjustments"].length > errors.last["adjustments"].length
182
+ description = adjustment_file["description"]
183
+ description += "[with errors]" if errors.last["adjustments"].size > 0
184
+ descriptions.push(description)
185
+ end
186
+ rescue JSON::ParserError
187
+ errors.last["general"] = "Malformed JSON!"
188
+ end
189
+ end
190
+ end
191
+
192
+ def apply_adjustment(adjustment)
193
+ query = adjustment["search"].include?(":") ? adjustment["search"] : "name:" + adjustment["search"]
194
+ Chef::Search::Query.new.search(adjustment["type"], query ) do |result|
195
+ result_hash = result.to_hash
196
+ key = result_hash["chef_type"] + ":" + result_hash["name"]
197
+ if changes.has_key? key
198
+ result_hash = changes[key]["adjusted"]
199
+ else
200
+ changes.store(key, { "original" => result_hash })
201
+ end
202
+ changes[key].store("adjusted", send(("action_" + adjustment["action"]).to_sym, result_hash, adjustment["adjustment"]))
203
+ end
204
+ end
205
+
206
+ def write_adjustments
207
+ changes.values.each do |change|
208
+ Chef.const_get(change["adjusted"]["chef_type"].capitalize).json_create(change["adjusted"]).save
209
+ end
210
+ end
211
+
212
+ def adjustment_file_valid? adjustment_file
213
+ unless adjustment_file.kind_of?(Hash)
214
+ errors.last["general"] = "Adjustment file must contain a JSON hash!"
215
+ return false
216
+ end
217
+
218
+ unless adjustment_file["adjustments"].kind_of?(Array)
219
+ errors.last["general"] = "Adjustment file must contain an array of adjustments!"
220
+ return false
221
+ end
222
+ true
223
+ end
224
+
225
+ def adjustment_valid?(adjustment, index)
226
+ unless adjustment.kind_of?(Hash)
227
+ errors.last["adjustments"].store(index,"Adjustment must be a JSON hash!")
228
+ return false
229
+ end
230
+
231
+ ["action", "type", "search", "adjustment"].each do |required_key|
232
+ unless adjustment.has_key?(required_key)
233
+ errors.last["adjustments"].store(index, "Adjustment hash must contain " + required_key + "!")
234
+ return false
235
+ end
236
+ end
237
+
238
+ unless respond_to?("action_" + adjustment["action"])
239
+ errors.last["adjustments"].store(index, "Incorrect action!")
240
+ return false
241
+ end
242
+ true
243
+ end
244
+
245
+ def hire
246
+ hired_scribe = Chef::Knife::ScribeHire.new
247
+ [:chronicle_path, :remote_url, :remote_name].each { |key| hired_scribe.config[key] = config[key] }
248
+ hired_scribe.run
249
+ end
250
+
251
+ def record_state(message = nil)
252
+ if @copyist.nil?
253
+ @copyist = Chef::Knife::ScribeCopy.new
254
+ [:chronicle_path, :remote_name, :branch].each { |key| @copyist.config[key] = config[key] }
255
+ end
256
+ @copyist.config[:message] = message
257
+ @copyist.run
258
+ end
259
+
260
+ def errors?
261
+ errors.each { |err| return true if !err["general"].nil? || (err["adjustments"].size > 0) }
262
+ false
263
+ end
264
+
265
+ def print_errors
266
+ ui.error("ERRORS OCCURED:")
267
+ errors.each do |err|
268
+ ui.error(err["name"]) if !err["general"].nil? || (err["adjustments"].size > 0)
269
+ ui.error("\t" + err["general"]) if !err["general"].nil?
270
+ err["adjustments"].each { |num, adj_err| ui.error("\t[Adjustment #{num}]: #{adj_err}") }
271
+ end
272
+ end
273
+
274
+ def action_overwrite(base, overwrite_with)
275
+ if base.kind_of?(Hash) && overwrite_with.kind_of?(Hash)
276
+ base.merge(overwrite_with)
277
+ elsif overwrite_with.nil?
278
+ base
279
+ else
280
+ overwrite_with
281
+ end
282
+ end
283
+ end
284
+
285
+ def deep_delete(delete_from, delete_spec)
286
+ deep_delete!(delete_from.dup, delete_spec.dup)
287
+ end
288
+
289
+ alias_method :action_delete, :deep_delete
290
+
291
+ def deep_delete!(delete_from, delete_spec)
292
+ if delete_from.kind_of?(Hash) || delete_from.kind_of?(Array)
293
+ if delete_spec.kind_of?(Array)
294
+ delete_spec.each { |item| deep_delete!(delete_from, item) }
295
+ elsif delete_spec.kind_of?(Hash)
296
+ delete_spec.each { |key,item| deep_delete!(delete_from[key], item) }
297
+ else
298
+ delete_from.kind_of?(Array) ? delete_from.delete_at(delete_spec) : delete_from.delete(delete_spec)
299
+ end
300
+ end
301
+ delete_from
302
+ end
303
+
304
+ def diff
305
+ original_file = Tempfile.new("original")
306
+ adjusted_file = Tempfile.new("adjusted")
307
+ begin
308
+ changes.each do |key, change|
309
+ ui.info("[#{key}]")
310
+ original_file.write(JSON.pretty_generate(change["original"]))
311
+ adjusted_file.write(JSON.pretty_generate(change["adjusted"]))
312
+ original_file.rewind
313
+ adjusted_file.rewind
314
+ diff_output = shell_out("diff -L original -L adjusted -u #{original_file.path} #{adjusted_file.path}")
315
+ ui.info(diff_output.stdout)
316
+ end
317
+ ensure
318
+ original_file.close
319
+ original_file.unlink
320
+ adjusted_file.close
321
+ adjusted_file.unlink
322
+ end
323
+ end
324
+ end
325
+ end