chef-core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,221 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "chef_core/target_host"
19
+ require "chef_core/error"
20
+
21
+ module ChefCore
22
+
23
+ # This class will resolve a provided target host name to
24
+ # one or more TargetHost instances. The target host name passed in
25
+ # to the constructor can contain up to two ranges in the form [0-9] and [a-z].
26
+ # Multiple target host names can be passed in asa comma-separated list.
27
+ #
28
+ # Use resolver_inst.targets to get the expanded list of TargetHost instances.
29
+ class TargetResolver
30
+ MAX_EXPANDED_TARGETS = 24
31
+ SUPPORTED_PROTOCOLS = %w{winrm ssh}
32
+ def initialize(target, default_protocol, conn_options, max_expanded_targets: MAX_EXPANDED_TARGETS)
33
+ @max_expanded_targets = max_expanded_targets
34
+ @default_proto = default_protocol
35
+ @unparsed_target = target
36
+ @split_targets = @unparsed_target.split(",")
37
+ @conn_options = conn_options.dup
38
+ @default_password = @conn_options.delete(:password)
39
+ @default_user = @conn_options.delete(:user)
40
+ end
41
+
42
+ # Returns the list of targets as an array of TargetHost instances
43
+ def targets
44
+ return @targets unless @targets.nil?
45
+ expanded_urls = []
46
+ @split_targets.each do |target|
47
+ expand_targets(target).each { |t| expanded_urls << t }
48
+ end
49
+
50
+ # Apply max_expanded_targets to the total list of resolved
51
+ # targets, since multiple targets can be comma-separated.
52
+ # Limiting it only in the expression resolver could still
53
+ # yield more than the maximum.
54
+ if expanded_urls.length > @max_expanded_targets
55
+ raise TooManyTargets.new(expanded_urls.length, @max_expanded_targets)
56
+ end
57
+
58
+ @targets = expanded_urls.map do |url|
59
+ config = @conn_options.merge(config_for_target(url))
60
+ TargetHost.new(config.delete(:url), config)
61
+ end
62
+ end
63
+
64
+ def config_for_target(url)
65
+ prefix, target = prefix_from_target(url)
66
+
67
+ inline_password = nil
68
+ inline_user = nil
69
+ host = target
70
+ # Default greedy-scan of the regex means that
71
+ # $2 will resolve to content after the final "@"
72
+ # URL credentials will take precedence over the default :user
73
+ # in @conn_opts
74
+ if target =~ /(.*)@(.*)/
75
+ inline_credentials = $1
76
+ host = $2
77
+ # We'll use a non-greedy match to grab everthinmg up to the first ':'
78
+ # as username if there is no :, credentials is just the username
79
+ if inline_credentials =~ /(.+?):(.*)/
80
+ inline_user = $1
81
+ inline_password = $2
82
+ else
83
+ inline_user = inline_credentials
84
+ end
85
+ end
86
+ user, password = make_credentials(inline_user, inline_password)
87
+ { url: "#{prefix}#{host}",
88
+ user: user,
89
+ password: password }
90
+ end
91
+
92
+ # Merge the inline user/pass with the default user/pass, giving
93
+ # precedence to inline.
94
+ def make_credentials(inline_user, inline_password)
95
+ user = inline_user || @default_user
96
+ user = nil if user && user.empty?
97
+ password = (inline_password || @default_password)
98
+ password = nil if password && password.empty?
99
+ [user, password]
100
+ end
101
+
102
+ def prefix_from_target(target)
103
+ if target =~ /^(.+?):\/\/(.*)/
104
+ # We'll store the existing prefix to avoid it interfering
105
+ # with the check further below.
106
+ if SUPPORTED_PROTOCOLS.include? $1.downcase
107
+ prefix = "#{$1}://"
108
+ target = $2
109
+ else
110
+ raise UnsupportedProtocol.new($1)
111
+ end
112
+ else
113
+ prefix = "#{@default_proto}://"
114
+ end
115
+ [prefix, target]
116
+ end
117
+
118
+ def expand_targets(target)
119
+ @current_target = target # Hold onto this for error reporting
120
+ do_parse([target.downcase])
121
+ end
122
+
123
+ private
124
+
125
+ # A string matching PREFIX[x:y]POSTFIX:
126
+ # POSTFIX can contain further ranges itself
127
+ # This uses a greedy match (.*) to get include every character
128
+ # up to the last "[" in PREFIX
129
+ # $1 - prefix; $2 - x, $3 - y, $4 unproccessed/remaining text
130
+ TARGET_WITH_RANGE = /^(.*)\[([\p{Alnum}]+):([\p{Alnum}]+)\](.*)/.freeze
131
+
132
+ def do_parse(targets, depth = 0)
133
+ raise TooManyRanges.new(@current_target) if depth > 2
134
+ new_targets = []
135
+ done = false
136
+ targets.each do |target|
137
+ if TARGET_WITH_RANGE =~ target
138
+ # $1 - prefix; $2 - x, $3 - y, $4 unprocessed/remaining text
139
+ expand_range(new_targets, $1, $2, $3, $4)
140
+ else
141
+ # Nothing more to expand
142
+ done = true
143
+ new_targets << target
144
+ end
145
+ end
146
+ if done
147
+ new_targets
148
+ else
149
+ do_parse(new_targets, depth + 1)
150
+ end
151
+ end
152
+
153
+ def expand_range(dest, prefix, start, stop, suffix)
154
+ prefix ||= ""
155
+ suffix ||= ""
156
+ start_is_int = Integer(start) >= 0 rescue false
157
+ stop_is_int = Integer(stop) >= 0 rescue false
158
+
159
+ if (start_is_int && !stop_is_int) || (stop_is_int && !start_is_int)
160
+ raise InvalidRange.new(@current_target, "[#{start}:#{stop}]")
161
+ end
162
+
163
+ # Ensure that a numeric range doesn't get created as a string, which
164
+ # would make the created Range further below fail to iterate for some values
165
+ # because of ASCII sorting.
166
+ if start_is_int
167
+ start = Integer(start)
168
+ end
169
+
170
+ if stop_is_int
171
+ stop = Integer(stop)
172
+ end
173
+
174
+ # For range to iterate correctly, the values must
175
+ # be low,high
176
+ if start > stop
177
+ temp = stop; stop = start; start = temp
178
+ end
179
+ Range.new(start, stop).each do |value|
180
+ # Ranges will resolve only numbers and letters,
181
+ # not other ascii characters that happen to fall between.
182
+ if start_is_int || /^[a-z0-9]/ =~ value
183
+ dest << "#{prefix}#{value}#{suffix}"
184
+ end
185
+ # Stop expanding as soon as we go over limit to prevent
186
+ # making the user wait for a massive accidental expansion
187
+ if dest.length > @max_expanded_targets
188
+ raise TooManyTargets.new(@split_targets.length, @max_expanded_targets)
189
+ end
190
+ end
191
+ end
192
+ # Provide an error base for all targetresolver errors, to simplify
193
+ # handling when caller is not concerned with the specific failure.
194
+ class TargetResolverError < ChefCore::Error; end
195
+
196
+ class InvalidRange < TargetResolverError
197
+ def initialize(unresolved_target, given_range)
198
+ super("CHEFRANGE001", unresolved_target, given_range)
199
+ end
200
+ end
201
+
202
+ class TooManyRanges < TargetResolverError
203
+ def initialize(unresolved_target)
204
+ super("CHEFRANGE002", unresolved_target)
205
+ end
206
+ end
207
+
208
+ class TooManyTargets < TargetResolverError
209
+ def initialize(num_top_level_targets, max_targets)
210
+ super("CHEFRANGE003", num_top_level_targets, max_targets)
211
+ end
212
+ end
213
+
214
+ class UnsupportedProtocol < TargetResolverError
215
+ def initialize(attempted_protocol)
216
+ super("CHEFVAL011", attempted_protocol,
217
+ ChefCore::TargetResolver::SUPPORTED_PROTOCOLS.join(" "))
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,32 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ class Telemetry
19
+ class Session
20
+ # The telemetry session data is normally kept in .chef, which we don't have.
21
+ def session_file
22
+ ChefCore::Config.telemetry_session_file.freeze
23
+ end
24
+ end
25
+
26
+ def deliver(data = {})
27
+ if ChefCore::Telemeter.instance.enabled?
28
+ payload = event.prepare(data)
29
+ client.await.fire(payload)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,123 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "telemetry"
19
+ require "chef_core/telemeter"
20
+ require "chef_core/telemeter/patch"
21
+ require "chef_core/log"
22
+ require "chef_core/version"
23
+
24
+ module ChefCore
25
+ class Telemeter
26
+ class Sender
27
+ attr_reader :session_files, :config
28
+
29
+ def self.start_upload_thread(config)
30
+ # Find the files before we spawn the thread - otherwise
31
+ # we may accidentally pick up the current run's session file if it
32
+ # finishes before the thread scans for new files
33
+ session_files = Sender.find_session_files(config)
34
+ sender = Sender.new(session_files, config)
35
+ Thread.new { sender.run }
36
+ end
37
+
38
+ def self.find_session_files(config)
39
+ ChefCore::Log.info("Looking for telemetry data to submit")
40
+ session_search = File.join(config[:payload_dir], "telemetry-payload-*.yml")
41
+ session_files = Dir.glob(session_search)
42
+ ChefCore::Log.info("Found #{session_files.length} sessions to submit")
43
+ session_files
44
+ end
45
+
46
+ def initialize(session_files, config)
47
+ @session_files = session_files
48
+ @config = config
49
+ end
50
+
51
+ def run
52
+ if ChefCore::Telemeter.enabled?
53
+ ChefCore::Log.info("Telemetry enabled, beginning upload of previous session(s)")
54
+ # dev mode telemetry gets sent to a different location
55
+
56
+ if config[:dev_mode]
57
+ ENV["CHEF_TELEMETRY_ENDPOINT"] ||= "https://telemetry-acceptance.chef.io"
58
+ end
59
+ session_files.each { |path| process_session(path) }
60
+ else
61
+ # If telemetry is not enabled, just clean up and return. Even though
62
+ # the telemetry gem will not send if disabled, log output saying that we're submitting
63
+ # it when it has been disabled can be alarming.
64
+ ChefCore::Log.info("Telemetry disabled, clearing any existing session captures without sending them.")
65
+ session_files.each { |path| FileUtils.rm_rf(path) }
66
+ end
67
+ FileUtils.rm_rf(config[:session_file])
68
+ ChefCore::Log.info("Terminating, nothing more to do.")
69
+ rescue => e
70
+ ChefCore::Log.fatal "Sender thread aborted: '#{e}' failed at #{e.backtrace[0]}"
71
+ end
72
+
73
+ def process_session(path)
74
+ ChefCore::Log.info("Processing telemetry entries from #{path}")
75
+ content = load_and_clear_session(path)
76
+ submit_session(content)
77
+ end
78
+
79
+ def submit_session(content)
80
+ # Each file contains the actions taken within a single run of the chef tool.
81
+ # Each run is one session, so we'll first remove remove the session file
82
+ # to force creating a new one.
83
+ FileUtils.rm_rf(config[:session_file])
84
+ # We'll use the version captured in the sesion file
85
+ entries = content["entries"]
86
+ cli_version = content["version"]
87
+ total = entries.length
88
+ telemetry = Telemetry.new(product: "chef-workstation",
89
+ origin: "command-line",
90
+ product_version: cli_version,
91
+ install_context: "omnibus")
92
+ total = entries.length
93
+ entries.each_with_index do |entry, x|
94
+ submit_entry(telemetry, entry, x + 1, total)
95
+ end
96
+ end
97
+
98
+ def submit_entry(telemetry, entry, sequence, total)
99
+ ChefCore::Log.info("Submitting telemetry entry #{sequence}/#{total}: #{entry} ")
100
+ telemetry.deliver(entry)
101
+ ChefCore::Log.info("Entry #{sequence}/#{total} submitted.")
102
+ rescue => e
103
+ # No error handling in telemetry lib, so at least track the failrue
104
+ ChefCore::Log.error("Failed to send entry #{sequence}/#{total}: #{e}")
105
+ ChefCore::Log.error("Backtrace: #{e.backtrace} ")
106
+ end
107
+
108
+ private
109
+
110
+ def load_and_clear_session(path)
111
+ content = File.read(path)
112
+ # We'll remove it now instead of after we parse or submit it -
113
+ # if we fail to deliver, we don't want to be stuck resubmitting it if the problem
114
+ # was due to payload. This is a trade-off - if we get a transient error, the
115
+ # payload will be lost.
116
+ # TODO: Improve error handling so we can intelligently decide whether to
117
+ # retry a failed load or failed submit.
118
+ FileUtils.rm_rf(path)
119
+ YAML.load(content)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,157 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2018 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ require "benchmark"
19
+ require "forwardable"
20
+ require "singleton"
21
+ require "json"
22
+ require "digest/sha1"
23
+ require "securerandom"
24
+ require "yaml"
25
+
26
+ module ChefCore
27
+
28
+ # This definites the Telemeter interface. Implementation thoughts for
29
+ # when we unstub it:
30
+ # - let's track the call sequence; most of our calls will be nested inside
31
+ # a main 'timed_capture', and it would be good to see ordering within nested calls.
32
+ class Telemeter
33
+ include Singleton
34
+ DEFAULT_INSTALLATION_GUID = "00000000-0000-0000-0000-000000000000".freeze
35
+
36
+ class << self
37
+ extend Forwardable
38
+ def_delegators :instance, :setup, :timed_capture, :capture, :commit, :timed_run_capture
39
+ def_delegators :instance, :pending_event_count, :last_event, :enabled?
40
+ def_delegators :instance, :make_event_payload, :config
41
+ end
42
+
43
+ attr_reader :events_to_send, :run_timestamp, :config
44
+
45
+ def setup(config)
46
+ # TODO validate required & correct keys
47
+ # :payload_dir #required
48
+ # :session_file # required
49
+ # :installation_identifier_file # required
50
+ # :enabled # false, not required
51
+ # :dev_mode # false, not required
52
+ config[:dev_mode] ||= false
53
+ config[:enabled] ||= false
54
+ require "chef_core/telemeter/sender"
55
+ @config = config
56
+ Sender.start_upload_thread(config)
57
+ end
58
+
59
+ def enabled?
60
+ require "telemetry/decision"
61
+ config[:enabled] && !Telemetry::Decision.env_opt_out?
62
+ end
63
+
64
+ def initialize
65
+ @events_to_send = []
66
+ @run_timestamp = Time.now.utc.strftime("%FT%TZ")
67
+ end
68
+
69
+ def timed_run_capture(arguments, &block)
70
+ timed_capture(:run, arguments: arguments, &block)
71
+ end
72
+
73
+ def timed_capture(name, data = {})
74
+ time = Benchmark.measure { yield }
75
+ data[:duration] = time.real
76
+ capture(name, data)
77
+ end
78
+
79
+ def capture(name, data = {})
80
+ # Adding it to the head of the list will ensure that the
81
+ # sequence of events is preserved when we send the final payload
82
+ payload = make_event_payload(name, data)
83
+ @events_to_send.unshift payload
84
+ end
85
+
86
+ def commit
87
+ if enabled?
88
+ session = convert_events_to_session
89
+ write_session(session)
90
+ end
91
+ @events_to_send = []
92
+ end
93
+
94
+ def make_event_payload(name, data)
95
+ {
96
+ event: name,
97
+ properties: {
98
+ installation_id: installation_id,
99
+ run_timestamp: run_timestamp,
100
+ host_platform: host_platform,
101
+ event_data: data,
102
+ },
103
+ }
104
+ end
105
+
106
+ def installation_id
107
+ @installation_id ||=
108
+ begin
109
+ File.read(config[:installation_identifier_file]).chomp
110
+ rescue
111
+ ChefCore::Log.info "could not read #{config[:installation_identifier_file]} - using default id"
112
+ DEFAULT_INSTALLATION_GUID
113
+ end
114
+ end
115
+
116
+ # For testing.
117
+ def pending_event_count
118
+ @events_to_send.length
119
+ end
120
+
121
+ def last_event
122
+ @events_to_send.last
123
+ end
124
+
125
+ private
126
+
127
+ def host_platform
128
+ @host_platform ||= case RUBY_PLATFORM
129
+ when /mswin|mingw|windows/
130
+ "windows"
131
+ else
132
+ RUBY_PLATFORM.split("-")[1]
133
+ end
134
+ end
135
+
136
+ def convert_events_to_session
137
+ YAML.dump({ "version" => ChefCore::VERSION,
138
+ "entries" => @events_to_send })
139
+ end
140
+
141
+ def write_session(session)
142
+ File.write(next_filename, convert_events_to_session)
143
+ end
144
+
145
+ def next_filename
146
+ id = 0
147
+ filename = ""
148
+ loop do
149
+ id += 1
150
+ filename = File.join(config[:payload_dir], "telemetry-payload-#{id}.yml")
151
+ break unless File.exist?(filename)
152
+ end
153
+ filename
154
+ end
155
+
156
+ end
157
+ end
@@ -0,0 +1,82 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2017 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module ChefCore
19
+ module Text
20
+ # Represents an error loaded from translation, with
21
+ # display attributes set.
22
+ class ErrorTranslation
23
+ ATTRIBUTES = [:decorations, :header, :footer, :stack, :log].freeze
24
+ attr_reader :message, *ATTRIBUTES
25
+
26
+ def initialize(id, params: [])
27
+ error_translation = error_translation_for_id(id)
28
+
29
+ options = YAML.load(Text.errors.display_defaults, "display_defaults",
30
+ symbolize_names: true)
31
+
32
+ # Display metadata is a string containing a YAML hash that is optionally under
33
+ # the error's 'options' attribute
34
+ # Note that we couldn't use :display, as that conflicts with a method on Object.
35
+ display_opts = if error_translation.methods.include?(:options)
36
+ YAML.load(error_translation.options, @id, symbolize_names: true)
37
+ else
38
+ {}
39
+ end
40
+
41
+ options = options.merge(display_opts) unless display_opts.nil?
42
+
43
+ @message = error_translation.text(*params)
44
+
45
+ ATTRIBUTES.each do |attribute|
46
+ instance_variable_set("@#{attribute}", options.delete(attribute))
47
+ end
48
+
49
+ if options.length > 0
50
+ # Anything not in ATTRIBUTES is not supported. This will also catch
51
+ # typos in attr names
52
+ raise InvalidDisplayAttributes.new(id, options)
53
+ end
54
+ end
55
+
56
+ def inspect
57
+ inspection = "#{self}: "
58
+ ATTRIBUTES.each do |attribute|
59
+ inspection << "#{attribute}: #{send(attribute.to_s)}; "
60
+ end
61
+ inspection << "message: #{message.gsub("\n", "\\n")}"
62
+ inspection
63
+ end
64
+
65
+ private
66
+
67
+ # This is split out for simplified unit testing of error formatting.
68
+ def error_translation_for_id(id)
69
+ Text.errors.send(id)
70
+ end
71
+
72
+ class InvalidDisplayAttributes < RuntimeError
73
+ attr_reader :invalid_attrs
74
+ def initialize(id, attrs)
75
+ @invalid_attrs = attrs
76
+ super("Invalid display attributes found for #{id}: #{attrs}")
77
+ end
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,87 @@
1
+ #
2
+ # Copyright:: Copyright (c) 2017 Chef Software Inc.
3
+ # License:: Apache License, Version 2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+
18
+ module ChefCore
19
+ module Text
20
+ # TextWrapper is a wrapper around R18n that returns all resolved values
21
+ # as Strings, and raise an error when a given i18n key is not found.
22
+ #
23
+ # This simplifies behaviors when interfacing with other libraries/components
24
+ # that don't play nicely with TranslatedString or Untranslated out of R18n.
25
+ class TextWrapper
26
+ def initialize(translation_tree)
27
+ @tree = translation_tree
28
+ @tree.translation_keys.each do |k|
29
+ # Integer keys are not translatable - they're quantity indicators in the key that
30
+ # are instead sent as arguments. If we see one here, it means it was not correctly
31
+ # labeled as plural with !!pl in the parent key
32
+ if k.class == Integer
33
+ raise MissingPlural.new(@tree.instance_variable_get(:@path), k)
34
+ end
35
+ k = k.to_sym
36
+ define_singleton_method k do |*args|
37
+ subtree = @tree.send(k, *args)
38
+ if subtree.methods.include?(:translation_keys) && !subtree.translation_keys.empty?
39
+ # If there are no more possible children, just return the translated value
40
+ TextWrapper.new(subtree)
41
+ else
42
+ subtree.to_s
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def method_missing(name, *args)
49
+ raise InvalidKey.new(@tree.instance_variable_get(:@path), name)
50
+ end
51
+
52
+ # TODO - make the checks for these conditions lint steps that run during build
53
+ # instead of part of the shipped product.
54
+ class TextError < RuntimeError
55
+ attr_accessor :line
56
+ def set_call_context
57
+ # TODO - this can vary (8 isn't always right) - inspect
58
+ @line = caller(8, 1).first
59
+ if @line =~ /.*\/lib\/(.*\.rb):(\d+)/
60
+ @line = "File: #{$1} Line: #{$2}"
61
+ end
62
+ end
63
+ end
64
+
65
+ class InvalidKey < TextError
66
+ def initialize(path, terminus)
67
+ set_call_context
68
+ # Calling back into Text here seems icky, this is an error
69
+ # that only engineering should see.
70
+ message = "i18n key #{path}.#{terminus} does not exist.\n"
71
+ message << " Referenced from #{line}"
72
+ super(message)
73
+ end
74
+ end
75
+
76
+ class MissingPlural < TextError
77
+ def initialize(path, terminus)
78
+ set_call_context
79
+ message = "i18n key #{path}.#{terminus} appears to reference a pluralization.\n"
80
+ message << " Please append the plural indicator '!!pl' to the end of #{path}.\n"
81
+ message << " Referenced from #{line}"
82
+ super(message)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end