batch-kit 0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/README.md +165 -0
  4. data/lib/batch-kit.rb +9 -0
  5. data/lib/batch-kit/arguments.rb +57 -0
  6. data/lib/batch-kit/config.rb +517 -0
  7. data/lib/batch-kit/configurable.rb +68 -0
  8. data/lib/batch-kit/core_ext/enumerable.rb +97 -0
  9. data/lib/batch-kit/core_ext/file.rb +69 -0
  10. data/lib/batch-kit/core_ext/file_utils.rb +103 -0
  11. data/lib/batch-kit/core_ext/hash.rb +17 -0
  12. data/lib/batch-kit/core_ext/numeric.rb +17 -0
  13. data/lib/batch-kit/core_ext/string.rb +88 -0
  14. data/lib/batch-kit/database.rb +133 -0
  15. data/lib/batch-kit/database/java_util_log_handler.rb +65 -0
  16. data/lib/batch-kit/database/log4r_outputter.rb +57 -0
  17. data/lib/batch-kit/database/models.rb +548 -0
  18. data/lib/batch-kit/database/schema.rb +229 -0
  19. data/lib/batch-kit/encryption.rb +7 -0
  20. data/lib/batch-kit/encryption/java_encryption.rb +178 -0
  21. data/lib/batch-kit/encryption/ruby_encryption.rb +175 -0
  22. data/lib/batch-kit/events.rb +157 -0
  23. data/lib/batch-kit/framework/acts_as_job.rb +197 -0
  24. data/lib/batch-kit/framework/acts_as_sequence.rb +123 -0
  25. data/lib/batch-kit/framework/definable.rb +169 -0
  26. data/lib/batch-kit/framework/job.rb +121 -0
  27. data/lib/batch-kit/framework/job_definition.rb +105 -0
  28. data/lib/batch-kit/framework/job_run.rb +145 -0
  29. data/lib/batch-kit/framework/runnable.rb +235 -0
  30. data/lib/batch-kit/framework/sequence.rb +87 -0
  31. data/lib/batch-kit/framework/sequence_definition.rb +38 -0
  32. data/lib/batch-kit/framework/sequence_run.rb +48 -0
  33. data/lib/batch-kit/framework/task_definition.rb +89 -0
  34. data/lib/batch-kit/framework/task_run.rb +53 -0
  35. data/lib/batch-kit/helpers/date_time.rb +54 -0
  36. data/lib/batch-kit/helpers/email.rb +198 -0
  37. data/lib/batch-kit/helpers/html.rb +175 -0
  38. data/lib/batch-kit/helpers/process.rb +101 -0
  39. data/lib/batch-kit/helpers/zip.rb +30 -0
  40. data/lib/batch-kit/job.rb +11 -0
  41. data/lib/batch-kit/lockable.rb +138 -0
  42. data/lib/batch-kit/loggable.rb +78 -0
  43. data/lib/batch-kit/logging.rb +169 -0
  44. data/lib/batch-kit/logging/java_util_logger.rb +87 -0
  45. data/lib/batch-kit/logging/log4r_logger.rb +71 -0
  46. data/lib/batch-kit/logging/null_logger.rb +35 -0
  47. data/lib/batch-kit/logging/stdout_logger.rb +96 -0
  48. data/lib/batch-kit/resources.rb +191 -0
  49. data/lib/batch-kit/sequence.rb +7 -0
  50. metadata +122 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a3d2112789225de19ecd9118d3cec6a9d5b88798
4
+ data.tar.gz: 9e3df2077d4dd002c2238dc097643ed95258938c
5
+ SHA512:
6
+ metadata.gz: 78c81cba988369e2f053885f7fd8c3eebbfb963b8c6860e15241dc557ba39578bd84411936364b7ff5ea3206cd1b2827393b2ac0a70e5c7d909dc5002a3c9433
7
+ data.tar.gz: a77407066d3b7168ccc1c9af9cb0ffdc090bd16210dec372eac58ad9b615046ca4f9c94f2f5e139ae8036975142ac25a4147704fd2afb775fe29aa445544723a
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013, Adam Gardiner
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
17
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,165 @@
1
+ # Batch Kit
2
+
3
+ Batch Kit is a framework for creating batch jobs, i.e. small utility programs
4
+ that are often run in batch via a scheduler to perform some kind of repetitive
5
+ task. Using batch-kit, you can develop batch jobs from Ruby programs that are
6
+ simple to write, but which are:
7
+
8
+ - __Robust__: Batch jobs handle any uncaught exceptions gracefully, ensuring the
9
+ exception is reported, resources are freed, and the job itself exits with a
10
+ non-zero error code.
11
+ - __Configurable__: Batch Kit jobs make it easy to define the command-line
12
+ arguments the job can take, and also make it simple to use configuration files
13
+ in either property or YAML format.
14
+ - __Secure__: Configuration files support encryption of sensitive items such as
15
+ passwords.
16
+ - __Measured__: Batch jobs can use a database to gather statistics of runs, such
17
+ as the number of runs of each job, the average, minimum, and maximum duration,
18
+ arguments passed to the job, log output, etc.
19
+
20
+ To provide these capabilities, the batch kit framework provides:
21
+
22
+ - A job framework, which can be used to turn any class into a batch job. This
23
+ can be done either by extending the {BatchKit::Job} class, or by including the
24
+ {BatchKit::ActsAsJob} module. The job framework allows for new or existing job
25
+ and task methods to be created. Both job and task methods add
26
+ {https://en.wikipedia.org/wiki/Advice_(programming) advices} that wrap
27
+ the logic of the method with exception handlers, as well as gathering
28
+ statistics about the status and duration of the task or job. These can be
29
+ persisted to a database for job reporting.
30
+
31
+ - A facade over the Log4r and java.util.logging log frameworks, allowing
32
+ sophisticated logging with colour output to the console, and persistent
33
+ output to log files and to a database.
34
+
35
+ - A configuration class ({BatchKit::Config}), which supports either property or
36
+ YAML-based configuration files. The {BatchKit::Config} class extends Hash,
37
+ providing:
38
+
39
+ + __Flexible Access__: keys are case-insensitive, can be accessed using
40
+ either strings or symbols, and can be accessed using BatchKit::Config#[]
41
+ or accessor-style methods
42
+ + __Support for Placeholder Variables__: substitution variables can be used
43
+ in the configuration file, and will be expanded either from higher-level
44
+ properties in the configuration tree, or left to be resolved at a later
45
+ time.
46
+ + __Encryption__: any property value can be encrypted, and will be stored in
47
+ memory in encrypted form, and only be decrypted when accessed explicitly.
48
+ Encryption is performed using AES-128 bit encryption via a separate
49
+ master key (which should not be stored in the same configuration file).
50
+
51
+ - A resource manager class that can be used to ensure the cleanup of any
52
+ resource that has an acquire/release pair of methods. Use of the
53
+ {BatchiKit::ResourceManager} class ensures that a job releases all resources
54
+ in both success and error outcomes. Support is also provided for locking
55
+ resources, such that concurrent or discrete jobs that share a resource can
56
+ coordinate their use.
57
+
58
+ - Helpers: helper modules are provided for common batch tasks, such as zipping
59
+ files, sending emails, archiving files etc.
60
+
61
+ ## Example Usage
62
+
63
+ The simplest way to use the batch kit framework is to create a class for your job
64
+ that extends the {BatchKit::Job} class.
65
+
66
+ ```
67
+ require 'batch-kit/job'
68
+
69
+ class MyJob < BatchKit::Job
70
+ ```
71
+
72
+ Next, use the {BatchKit::Configurable::ClassMethods#configure configure} method
73
+ to add any configuration file(s) your job needs to read to load configuration
74
+ settings that control its behaviour:
75
+
76
+ ```
77
+ configure 'my_config.yaml'
78
+ ```
79
+
80
+ The job configuration is now available from both the class itself, and instances
81
+ of the class, via the {BatchKit::Job.config #config} method. Making the
82
+ configuration available from the class allows it to be used while defining the
83
+ class, e.g. when defining default values for command-line arguments your job
84
+ provides.
85
+
86
+ Command-line arguments are supported in batch kit jobs via the use of the
87
+ {https://github.com/agardiner/arg-parser arg-parser} gem. This provides a
88
+ DSL for defining various different kinds of command-line arguments:
89
+
90
+ ```
91
+ positional_arg :spec, 'A path to a specification file',
92
+ default: config.default_spec
93
+ flag_arg :verbose, 'Output more details during processing'
94
+ ```
95
+
96
+ When your job is run, you will be able to access the values supplied on the
97
+ command line via the job's provided {BatchKit::Job#arguments #arguments}
98
+ accessor.
99
+
100
+ ```
101
+ if arguments.verbose
102
+ # Do something verbosely
103
+ end
104
+ ```
105
+
106
+ We now come to the meat of our job - the tasks it is going to perform when
107
+ run. Tasks are simply methods, but have added functionality wrapped around
108
+ them. To define a task, there are two approaches that can be used:
109
+
110
+ ```
111
+ desc 'A one-line description for my task'
112
+ task :method1 do |param1|
113
+ # Define the steps this method is to perform
114
+ ...
115
+ end
116
+
117
+ def method2(param1, param2)
118
+ # Another task method
119
+ ...
120
+ end
121
+ task :method2, 'A short description for my task method'
122
+ ```
123
+
124
+ Both methods are equivalent, and both leave your class with a method named
125
+ for the task (which is then invoked like any other method) - so use
126
+ whichever appraoch you prefer.
127
+
128
+ While performing actions in your job, you can make use of the
129
+ {BatchKit::Job#log #log} method to output logging at various levels:
130
+
131
+ ```
132
+ log.config "Spec: #{arguments.spec}"
133
+ log.info "Doing some work now"
134
+ log.detail "Here are some more detailed messages about what we are doing"
135
+ log.warn "Oh-oh, looks like trouble ahead"
136
+ log.error "Oh no, an exception has occurred!"
137
+ ```
138
+
139
+ Finally, we need a method that will act as the main entry point to the job. We
140
+ define a job method much like a task, but there should only be one job method
141
+ in our class:
142
+
143
+ ```
144
+ job 'This job does XYZ' do
145
+ p1, p2, p3 = ...
146
+ method1(p1)
147
+ method2(p2, p3)
148
+ end
149
+ ```
150
+
151
+ As with tasks, we can use the {BatchKit::ActsAsJob#job #job} DSL method above to
152
+ define the main entry method, or we can pass a symbol identifying an existing
153
+ method in our class to be the job entry point.
154
+
155
+ Finally, to allow our job to run when it is passed as the main program to the
156
+ Ruby engine, we call the {Batch::Job.run run} method on our class at the end of
157
+ our script:
158
+
159
+ ```
160
+ MyJob.run
161
+ ```
162
+
163
+ This instructs the batch kit framework to instantiate our job class, parse any
164
+ command-line arguments, and then invoke our job entry method to start processing.
165
+
@@ -0,0 +1,9 @@
1
+ # BatchKit is the namespace for the framework of functionality that includes:
2
+ # - BatchKit:Job
3
+ # - BatchKit::Config
4
+ # - BatchKit::Database
5
+ # - Core Ruby class extensions
6
+ # - Helper methods for common job requirements
7
+ class BatchKit; end
8
+
9
+ require 'batch-kit/job'
@@ -0,0 +1,57 @@
1
+ require 'arg_parser'
2
+ require 'color-console'
3
+
4
+
5
+ class BatchKit
6
+
7
+ # Defines a module for adding argument parsing to a job via the arg-parser
8
+ # gem, and displaying help via the color-console gem.
9
+ module Arguments
10
+
11
+ include ArgParser::DSL
12
+
13
+
14
+ # Adds an arguments accessor that returns the results from parsing the
15
+ # command-line.
16
+ attr_reader :arguments
17
+
18
+
19
+ # Parse command-line arguments
20
+ def parse_arguments(args = ARGV, show_usage_on_error = true)
21
+ if defined?(BatchKit::Job) && self.is_a?(BatchKit::Job)
22
+ args_def.title ||= self.job.name.titleize
23
+ args_def.purpose ||= self.job.description
24
+ elsif defined?(BatchKit::Sequence) && self.is_a?(BatchKit::Sequence)
25
+ args_def.title ||= self.sequence.name.titleize
26
+ args_def.purpose ||= self.sequence.description
27
+ end
28
+ arg_parser = ArgParser::Parser.new(args_def)
29
+ @arguments = arg_parser.parse(args)
30
+ if @arguments == false
31
+ if arg_parser.show_help?
32
+ arg_parser.definition.show_help(nil, Console.width || 80).each do |line|
33
+ Console.puts line, :cyan
34
+ end
35
+ exit
36
+ else
37
+ arg_parser.errors.each{ |error| Console.puts error, :red }
38
+ if show_usage_on_error
39
+ arg_parser.definition.show_usage(nil, Console.width || 80).each do |line|
40
+ Console.puts line, :yellow
41
+ end
42
+ end
43
+ exit(99)
44
+ end
45
+ end
46
+ @arguments
47
+ end
48
+
49
+
50
+ # Add class methods when module is included
51
+ def self.included(base)
52
+ base.extend(ClassMethods)
53
+ end
54
+
55
+ end
56
+
57
+ end
@@ -0,0 +1,517 @@
1
+ class BatchKit
2
+
3
+ # Defines a class for managing configuration properties; essentially, this
4
+ # is a hash that is case and Strng/Symbol insensitive with respect to keys;
5
+ # this means a value can be retrieved using any mix of case using either a
6
+ # String or Symbol as the lookup key.
7
+ #
8
+ # In addition, there are some further conveniences added on:
9
+ # - Items can be accessed by either [] (using a String or Symbol key) or as
10
+ # methods on the Config object, i.e. the following are all equivalent:
11
+ #
12
+ # config['Foo']
13
+ # config[:foo]
14
+ # config.foo
15
+ #
16
+ # - Contents can be loaded from:
17
+ # - an existing Hash object
18
+ # - a properties file using [Section] and KEY=VALUE syntax
19
+ # - a YAML file
20
+ # - String values can contain placeholder variables that will be replaced
21
+ # when the item is added to the Config collection. Placeholder variables
22
+ # are denoted by ${<variable>} or %{<variable>} syntax, where <variable>
23
+ # is the name of another configuration variable or a key in a supplied
24
+ # expansion properties hash.
25
+ # - Support for encrypted values. These are decrypted on the fly, provided
26
+ # a decryption key has been set on the Config object.
27
+ #
28
+ # Internally, the case and String/Symbol insensitivity is managed by
29
+ # maintaining a second Hash that converts the lower-cased symbol value
30
+ # of all keys to the actual keys used to store the object. When looking up
31
+ # a value, we use this lookup Hash to find the actual key used, and then
32
+ # lookup the value.
33
+ #
34
+ # @note As this Config object is case and String/Symbol insensitive,
35
+ # different case and type keys that convert to the same lookup key are
36
+ # considered the same. The practical implication is that you can't have
37
+ # two different values in this Config object where the keys differ only
38
+ # in case and/or String/Symbol class.
39
+ class Config < Hash
40
+
41
+ # Create a new Config object, and initialize it from the specified
42
+ # +file+.
43
+ #
44
+ # @param file [String] A path to a properties or YAML file to load.
45
+ # @param props [Hash, Config] An optional Hash (or Config) object to
46
+ # seed this Config object with.
47
+ # @param options [Hash] An options hash.
48
+ # @option options [Boolean] :raise_on_unknown_var Whether to raise an
49
+ # error if an unrecognised placeholder variable is encountered in the
50
+ # file.
51
+ # @option options [Boolean] :use_erb If true, the contents of +file+
52
+ # is first run through ERB.
53
+ # @option options [Binding] :binding The binding to use when evaluating
54
+ # expressions in ERB
55
+ # @return [Config] A new Config object populated from +file+ and
56
+ # +props+, where placeholder variables have been expanded.
57
+ def self.load(file, props = nil, options = {})
58
+ cfg = self.new(props, options)
59
+ cfg.load(file, options)
60
+ cfg
61
+ end
62
+
63
+
64
+ # Converts a +str+ in the form of a properties file into a Hash.
65
+ def self.properties_to_hash(str)
66
+ hsh = props = {}
67
+ str.each_line do |line|
68
+ line.chomp!
69
+ if match = /^\s*\[([A-Za-z0-9_ ]+)\]\s*$/.match(line)
70
+ # Section heading
71
+ props = hsh[match[1]] = {}
72
+ elsif match = /^\s*([A-Za-z0-9_\.]+)\s*=\s*([^#]+)/.match(line)
73
+ # Property setting
74
+ val = match[2]
75
+ props[match[1]] = case val
76
+ when /^\d+$/ then val.to_i
77
+ when /^\d*\.\d+$/ then val.to_f
78
+ when /^:/ then val.intern
79
+ when /false/i then false
80
+ when /true/i then true
81
+ else val
82
+ end
83
+ end
84
+ end
85
+ hsh
86
+ end
87
+
88
+
89
+ # Expand any ${<variable>} or %{<variable>} placeholders in +str+ from
90
+ # either the supplied +props+ hash or the system environment variables.
91
+ # The props hash is assumed to contain string or symbol keys matching the
92
+ # variable name between ${ and } (or %{ and }) delimiters.
93
+ # If no match is found in the supplied props hash or the environment, the
94
+ # default behaviour returns the string with the placeholder variable still in
95
+ # place, but this behaviour can be overridden to cause an exception to be
96
+ # raised if desired.
97
+ #
98
+ # @param str [String] A String to be expanded from 0 or more placeholder
99
+ # substitutions
100
+ # @param properties [Hash, Array<Hash>] A properties Hash or array of Hashes
101
+ # from which placeholder variable values can be looked up.
102
+ # @param raise_on_unknown_var [Boolean] Whether or not an exception should
103
+ # be raised if no property is found for a placeholder expression. If false,
104
+ # unrecognised placeholder variables are left in the returned string.
105
+ # @return [String] A new string with placeholder variables replaced by
106
+ # the values in +props+.
107
+ def self.expand_placeholders(str, properties, raise_on_unknown_var = false)
108
+ chain = properties.is_a?(Hash) ? [properties] : properties.reverse
109
+ str.gsub(/(?:[$%])\{([a-zA-Z0-9_]+)\}/) do
110
+ case
111
+ when src = chain.find{ |props| props.has_key?($1) ||
112
+ props.has_key?($1.intern) ||
113
+ props.has_key?($1.downcase.intern) }
114
+ src[$1] || src[$1.intern] || src[$1.downcase.intern]
115
+ when ENV[$1] then ENV[$1]
116
+ when raise_on_unknown_var
117
+ raise KeyError, "No value supplied for placeholder variable '#{$&}'"
118
+ else
119
+ $&
120
+ end
121
+ end
122
+ end
123
+
124
+
125
+ # Create a Config object, optionally initialized from +hsh+.
126
+ #
127
+ # @param hsh [Hash] An optional Hash to seed this Config object with.
128
+ # @param options [Hash] An options hash.
129
+ def initialize(hsh = nil, options = {})
130
+ super(nil)
131
+ @lookup_keys = {}
132
+ @decryption_key = nil
133
+ merge!(hsh, options) if hsh
134
+ end
135
+
136
+
137
+ # Read a properties or YAML file at the path specified in +path+, and
138
+ # load the contents to this Config object.
139
+ #
140
+ # @param path [String] The path to the properties or YAML file to be
141
+ # loaded.
142
+ # @param options [Hash] An options hash.
143
+ # @option options [Boolean] @raise_on_unknown_var Whether to raise an
144
+ # error if an unrecognised placeholder variable is encountered in the
145
+ # file.
146
+ def load(path, options = {})
147
+ props = case File.extname(path)
148
+ when /\.yaml/i then self.load_yaml(path, options)
149
+ else self.load_properties(path, options)
150
+ end
151
+ end
152
+
153
+
154
+ # Process a property file, returning its contents as a Hash.
155
+ # Only lines of the form KEY=VALUE are processed, and # indicates the start
156
+ # of a comment. Property files can contain sections, denoted by [SECTION].
157
+ #
158
+ # @example
159
+ # If a properties file contains the following:
160
+ #
161
+ # FOO=Bar # This is a comment
162
+ # BAR=${FOO}\Baz
163
+ #
164
+ # [BAT]
165
+ # Car=Ford
166
+ #
167
+ # Then we would return a Config object containing the following:
168
+ #
169
+ # {'FOO' => 'Bar', 'BAR' => 'Bar\Baz', 'BAT' => {'Car' => 'Ford'}}
170
+ #
171
+ # This config content could be accessed via #[] or as properties of
172
+ # the Config object, e.g.
173
+ #
174
+ # cfg[:foo] # => 'Bar'
175
+ # cfg.bar # => 'Bar\Baz'
176
+ # cfg.bat.car # => 'Ford'
177
+ #
178
+ # @param prop_file [String] A path to the properties file to be parsed.
179
+ # @param options [Hash] An options hash.
180
+ # @option options [Boolean] :use_erb If true, the contents of +prop_file+
181
+ # is first passed through ERB before being processed. This allows for
182
+ # the use of <% %> and <%= %> directives in +prop_file+. The binding
183
+ # passed to ERB is the value of any :binding option specified, or else
184
+ # this Config object. If not specified, ERB is used if the file is
185
+ # found to contain the string '<%'.
186
+ # @option options [Binding] :binding The binding for ERB to use when
187
+ # processing expressions. Defaults to this Config instance if not
188
+ # specified.
189
+ # @return [Hash] The parsed contents of the file as a Hash.
190
+ def load_properties(prop_file, options = {})
191
+ str = read_file(prop_file, options)
192
+ hsh = self.class.properties_to_hash(str)
193
+ self.merge!(hsh, options)
194
+ end
195
+
196
+
197
+ # Load the YAML file at +yaml_file+.
198
+ #
199
+ # @param yaml_file [String] A path to a YAML file to be loaded.
200
+ # @param options [Hash] An options hash.
201
+ # @option options [Boolean] :use_erb If true, the contents of +yaml_file+
202
+ # are run through ERB before being parsed as YAML. This allows for use
203
+ # of <% %> and <%= %> directives in +yaml_file+. The binding passed to
204
+ # ERB is the value of any :binding option specified, or else this
205
+ # Config object. If not specified, ERB is used if the file is found to
206
+ # contain the string '<%'.
207
+ # @option options [Binding] :binding The binding for ERB to use when
208
+ # processing expressions. Defaults to this Config instance if not
209
+ # specified.
210
+ # @return [Object] The results of parsing the YAML contents of +yaml_file+.
211
+ def load_yaml(yaml_file, options = {})
212
+ require 'yaml'
213
+ str = read_file(yaml_file, options)
214
+ yaml = YAML.load(str)
215
+ self.merge!(yaml, options)
216
+ end
217
+
218
+
219
+ # Save this config object as a YAML file at +yaml_file+.
220
+ #
221
+ # @param yaml_file [String] A path to the YAML file to be saved.
222
+ # @param options [Hash] An options hash.
223
+ def save_yaml(yaml_file, options = {})
224
+ require 'yaml'
225
+ str = self.to_yaml
226
+ File.open(yaml_file, 'wb'){ |f| f.puts(str) }
227
+ end
228
+
229
+
230
+ # Ensure that Config objects are saved as normal hashes when writing
231
+ # YAML.
232
+ def encode_with(coder)
233
+ coder.represent_map nil, self
234
+ end
235
+
236
+
237
+ # Merge the contents of the specified +hsh+ into this Config object.
238
+ #
239
+ # @param hsh [Hash] The Hash object to merge into this Config object.
240
+ # @param options [Hash] An options hash.
241
+ # @option options [Boolean] :raise_on_unknown_var Whether to raise an
242
+ # exception if an unrecognised placeholder variable is encountered in
243
+ # +hsh+.
244
+ def merge!(hsh, options = {})
245
+ if hsh && !hsh.is_a?(Hash)
246
+ raise ArgumentError, "Only Hash objects can be merged into Config (got #{hsh.class.name})"
247
+ end
248
+ hsh && hsh.each do |key, val|
249
+ self[key] = convert_val(val, options[:raise_on_unknown_var])
250
+ end
251
+ if hsh.is_a?(Config)
252
+ @decryption_key = hsh.instance_variable_get(:@decryption_key) unless @decryption_key
253
+ end
254
+ self
255
+ end
256
+
257
+
258
+ # Merge the contents of the specified +hsh+ into a new Config object.
259
+ #
260
+ # @param hsh [Hash] The Hash object to merge with this Config object.
261
+ # @param options [Hash] An options hash.
262
+ # @option options [Boolean] @raise_on_unknown_var Whether to raise an
263
+ # error if an unrecognised placeholder variable is encountered in the
264
+ # file.
265
+ # @return A new Config object with the combined contents of this Config
266
+ # object plus the contents of +hsh+.
267
+ def merge(hsh, options = {})
268
+ cfg = self.dup
269
+ cfg.merge!(hsh, options)
270
+ cfg
271
+ end
272
+
273
+
274
+ # If set, encrypted strings (only) will be decrypted when accessed via
275
+ # #[] or #method_missing (for property-like access, e.g. +cfg.password+).
276
+ #
277
+ # @param key [String] The master encryption key used to encrypt sensitive
278
+ # values in this Config object.
279
+ def decryption_key=(key)
280
+ require_relative 'encryption'
281
+ self.each do |_, val|
282
+ val.decryption_key = key if val.is_a?(Config)
283
+ end
284
+ @decryption_key = key
285
+ end
286
+ alias_method :encryption_key=, :decryption_key=
287
+
288
+
289
+ # Recursively encrypts the values of all keys in this Config object that
290
+ # match +key_pat+.
291
+ #
292
+ # Note: +key_pat+ will be compared against the standardised key values of
293
+ # each object (i.e. lowercase, with spaces converted to _).
294
+ #
295
+ # @param key_pat [Regexp|String] A regular expression to be used to identify
296
+ # the keys that should be encrypted, e.g. /password/ would encrypt all
297
+ # values that have "password" in their key.
298
+ # @param master_key [String] The master key that should be used when
299
+ # encrypting. If not specified, uses the current value of the
300
+ # +decryption_key+ set for this Config object.
301
+ def encrypt(key_pat, master_key = @decryption_key)
302
+ key_pat = Regexp.new(key_pat, true) if key_pat.is_a?(String)
303
+ raise ArgumentError, "key_pat must be a Regexp or String" unless key_pat.is_a?(Regexp)
304
+ raise ArgumentError, "No master key has been set or passed" unless master_key
305
+ require_relative 'encryption'
306
+ self.each do |key, val|
307
+ if Config === val
308
+ val.encrypt(key_pat, master_key)
309
+ else
310
+ if @decryption_key && val.is_a?(String) && val =~ /!AES:([a-zA-Z0-9\/+=]+)!/
311
+ # Decrypt using old master key
312
+ val = self[key]
313
+ self[key] = val
314
+ end
315
+ if val.is_a?(String) && convert_key(key) =~ key_pat
316
+ self[key] = "!AES:#{Encryption.encrypt(master_key, val).strip}!"
317
+ end
318
+ end
319
+ end
320
+ @decryption_key = master_key
321
+ end
322
+
323
+
324
+ # Override #[] to be agnostic as to the case of the key, and whether it
325
+ # is a String or a Symbol.
326
+ def [](key)
327
+ key = @lookup_keys[convert_key(key)]
328
+ val = super(key)
329
+ if @decryption_key && val.is_a?(String) && val =~ /!AES:([a-zA-Z0-9\/+=]+)!/
330
+ begin
331
+ val = Encryption.decrypt(@decryption_key, $1)
332
+ rescue Exception => ex
333
+ raise "An error occurred while decrypting the value for key '#{key}': #{ex.message}"
334
+ end
335
+ end
336
+ val
337
+ end
338
+
339
+
340
+ # Override #[]= to be agnostic as to the case of the key, and whether it
341
+ # is a String or a Symbol.
342
+ def []=(key, val)
343
+ std_key = convert_key(key)
344
+ if @lookup_keys[std_key] != key
345
+ delete(key)
346
+ @lookup_keys[std_key] = key
347
+ end
348
+ super key, val
349
+ end
350
+
351
+
352
+ # Override #delete to be agnostic as to the case of the key, and whether
353
+ # it is a String or a Symbol.
354
+ def delete(key)
355
+ key = @lookup_keys.delete(convert_key(key))
356
+ super key
357
+ end
358
+
359
+
360
+ # Override #has_key? to be agnostic as to the case of the key, and whether
361
+ # it is a String or a Symbol.
362
+ def has_key?(key)
363
+ key = @lookup_keys[convert_key(key)]
364
+ super key
365
+ end
366
+ alias_method :include?, :has_key?
367
+
368
+
369
+ # Override #fetch to be agnostic as to the case of the key, and whether it
370
+ # is a String or a Symbol.
371
+ def fetch(key, *rest)
372
+ key = @lookup_keys[convert_key(key)] || key
373
+ super
374
+ end
375
+
376
+
377
+ # Override #clone to also clone contents of @lookup_keys.
378
+ def clone
379
+ copy = super
380
+ copy.instance_variable_set(:@lookup_keys, @lookup_keys.clone)
381
+ copy
382
+ end
383
+
384
+
385
+ # Override #dup to also clone contents of @lookup_keys.
386
+ def dup
387
+ copy = super
388
+ copy.instance_variable_set(:@lookup_keys, @lookup_keys.dup)
389
+ copy
390
+ end
391
+
392
+
393
+ # Override Hash core extension method #to_cfg and just return self.
394
+ def to_cfg
395
+ self
396
+ end
397
+
398
+
399
+ # Override method_missing to respond to method calls with the value of the
400
+ # property, if this Config object contains a property of the same name.
401
+ def method_missing(name, *args)
402
+ if name =~ /^(.+)\?$/
403
+ has_key?($1)
404
+ elsif has_key?(name)
405
+ self[name]
406
+ elsif has_key?(name.to_s.gsub('_', ''))
407
+ self[name.to_s.gsub('_', '')]
408
+ elsif name =~ /^(.+)=$/
409
+ self[$1]= args.first
410
+ else
411
+ raise ArgumentError, "No configuration entry for key '#{name}'"
412
+ end
413
+ end
414
+
415
+
416
+ # Override respond_to? to indicate which methods we will accept.
417
+ def respond_to?(name)
418
+ if name =~ /^(.+)\?$/
419
+ has_key?($1)
420
+ elsif has_key?(name)
421
+ true
422
+ elsif has_key?(name.to_s.gsub('_', ''))
423
+ true
424
+ elsif name =~ /^(.+)=$/
425
+ true
426
+ else
427
+ super
428
+ end
429
+ end
430
+
431
+
432
+ # Expand any ${<variable>} or %{<variable>} placeholders in +str+ from
433
+ # this Config object or the system environment variables.
434
+ # This Config object is assumed to contain string or symbol keys matching
435
+ # the variable name between ${ and } (or %{ and }) delimiters.
436
+ # If no match is found in the supplied props hash or the environment, the
437
+ # default behaviour is to raise an exception, but this can be overriden
438
+ # to leave the placeholder variable still in place if desired.
439
+ #
440
+ # @param str [String] A String to be expanded from 0 or more placeholder
441
+ # substitutions
442
+ # @param raise_on_unknown_var [Boolean] Whether or not an exception should
443
+ # be raised if no property is found for a placeholder expression. If false,
444
+ # unrecognised placeholder variables are left in the returned string.
445
+ # @return [String] A new string with placeholder variables replaced by
446
+ # the values in +props+.
447
+ def expand_placeholders(str, raise_on_unknown_var = true)
448
+ self.class.expand_placeholders(str, self, raise_on_unknown_var)
449
+ end
450
+
451
+
452
+ # Reads a template file at +template_name+, and expands any substitution
453
+ # variable placeholder strings from this Config object.
454
+ #
455
+ # @param template_name [String] The path to the template file containing
456
+ # placeholder variables to expand from this Config object.
457
+ # @return [String] The contents of the template file with placeholder
458
+ # variables replaced by the content of this Config object.
459
+ def read_template(template_name, raise_on_unknown_var = true)
460
+ template = IO.read(template_name)
461
+ expand_placeholders(template, raise_on_unknown_var)
462
+ end
463
+
464
+
465
+ private
466
+
467
+
468
+ # Reads the contents of +file+ into a String. The +file+ is passed
469
+ # through ERB if the :use_erb option is true, or if the options does
470
+ # not contain the :use_erb key and the file does contain the string
471
+ # '<%'.
472
+ def read_file(file, options)
473
+ str = IO.read(file)
474
+ if (options.has_key?(:use_erb) && options[:use_erb]) || str =~ /<%/
475
+ require 'erb'
476
+ str = ERB.new(str).result(options[:binding] || binding)
477
+ end
478
+ str
479
+ end
480
+
481
+
482
+ # Convert the supplied key to a lower-case symbol representation, which
483
+ # is the key to the @lookup_keys hash.
484
+ def convert_key(key)
485
+ key.to_s.downcase.gsub(' ', '_').intern
486
+ end
487
+
488
+
489
+ # Convert a value before merging it into the Config. This consists of
490
+ # these tasks:
491
+ # - Converting Hashes to Config objects
492
+ # - Propogating decryption keys to child Config objects
493
+ # - Expanding placeholder variables in strings
494
+ def convert_val(val, raise_on_unknown_var, parents = [self])
495
+ case val
496
+ when Config then val
497
+ when Hash
498
+ cfg = Config.new
499
+ cfg.instance_variable_set(:@decryption_key, @decryption_key)
500
+ new_parents = parents.clone
501
+ new_parents << cfg
502
+ val.each do |k, v|
503
+ cfg[k] = convert_val(v, raise_on_unknown_var, new_parents)
504
+ end
505
+ cfg
506
+ when Array
507
+ val.map{ |v| convert_val(v, raise_on_unknown_var, parents) }
508
+ when /[$%]\{[a-zA-Z0-9_]+\}/
509
+ self.class.expand_placeholders(val, parents, raise_on_unknown_var)
510
+ else val
511
+ end
512
+ end
513
+
514
+ end
515
+
516
+ end
517
+