batch-kit 0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+